mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:50:50 +02:00
Compare commits
113 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6482325e26 | |||
| b8b2e0cd33 | |||
| bee22507b2 | |||
| 1f9a720625 | |||
| 55145e3faf | |||
| 0091e6d93b | |||
| 120f9716b6 | |||
| 322defbbd0 | |||
| eb69bc7db8 | |||
| b05c7265d6 | |||
| 6b4dfe6b9b | |||
| de54e4ea26 | |||
| 0d245c4ccb | |||
| 2907aba6ff | |||
| c07aed2449 | |||
| a3fb18a589 | |||
| 66da6cbc4b | |||
| 121c87b629 | |||
| dfe2f1ae1a | |||
| 41f1f07342 | |||
| 848c9cb29f | |||
| 87a6c03017 | |||
| f0d5ca570a | |||
| d5542f9518 | |||
| 063ce6e328 | |||
| c9c5a8901a | |||
| d86d387243 | |||
| 89fe722a62 | |||
| 180dd393ea | |||
| 67bbb3d44b | |||
| 4ee15c4f32 | |||
| 5a4979303b | |||
| a46f194009 | |||
| 301bd4c1f9 | |||
| 55bc993ed7 | |||
| 6080531b08 | |||
| 53fff766a4 | |||
| 00f2b0bb9a | |||
| c8555e6820 | |||
| 91d9a37e8f | |||
| 0d0ec51f51 | |||
| b50bbe0782 | |||
| 9f74ea7e73 | |||
| 442e9d26d5 | |||
| 6240b50cec | |||
| e14cf3de90 | |||
| 77e0f93315 | |||
| 20c9061625 | |||
| dde0879527 | |||
| 9e54656fe3 | |||
| 8ef4d2908f | |||
| ca10a7b860 | |||
| b2ccb14afa | |||
| 79f8a5dc4a | |||
| edb9ccfffe | |||
| b7ce0f21d5 | |||
| 7ce268e5a2 | |||
| ecb0b57d26 | |||
| 1915654a50 | |||
| 0e22393ec7 | |||
| f21b3ee597 | |||
| bddb9de0ba | |||
| 74cc08f1bf | |||
| 7b1ac9b1b6 | |||
| 08bcbf7629 | |||
| a3a712205b | |||
| 95771403d9 | |||
| 9542ba94b1 | |||
| f245e8808f | |||
| c79ecbb7ac | |||
| 70243a407b | |||
| c6b38f8d44 | |||
| f5970d8d9a | |||
| e3c13edda7 | |||
| 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 | |||
| 3eb62a5e00 |
@@ -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"
|
||||
+40
@@ -5,3 +5,43 @@ __pycache__/
|
||||
|
||||
# agent worktrees and local scratch
|
||||
.claude/
|
||||
|
||||
# ------- Pokemon Stadium data
|
||||
#
|
||||
# NONE OF THIS SHIPS, and none of it is in the repository. The battle models
|
||||
# are Pokemon Stadium's own data: what the mod carries is the READER for them
|
||||
# (lib/StadiumRom, StadiumFragment, StadiumFx, StadiumBuild) and the player
|
||||
# supplies the cartridge, exactly as this engine already asks them to supply
|
||||
# the Game Boy ROM it is a recompilation of.
|
||||
#
|
||||
# So the ROM itself, everything model_extract/pipeline extracts out of it, and
|
||||
# the packs tools/stadium_pack.py builds from those are all ignored. What IS
|
||||
# tracked is the pipeline, the notes, and the two READMEs that say where to
|
||||
# put a ROM.
|
||||
#
|
||||
# At runtime the packs are built on the player's own machine, on first run,
|
||||
# into the save directory -- never into the mod folder (see StadiumInstall).
|
||||
|
||||
# the cartridge, wherever it is dropped, and the checksum note that comes
|
||||
# with one. The Zone.Identifier pattern has no colon in it on purpose: it is
|
||||
# an NTFS alternate data stream, and the separator reaches git as U+F03A
|
||||
# rather than as ':' -- so matching on the suffix alone is what actually works
|
||||
*.z64
|
||||
*.n64
|
||||
*.v64
|
||||
*Zone.Identifier
|
||||
model_extract/baseroms/**/checksum.md5
|
||||
|
||||
# everything the pipeline extracts from it
|
||||
model_extract/glb/
|
||||
model_extract/js/
|
||||
model_extract/textures/
|
||||
model_extract/manifest.json
|
||||
model_extract/moves.json
|
||||
model_extract/viewer.html
|
||||
|
||||
# and the packed models built from those -- the local oracle the Lua
|
||||
# extractor is diffed against (tests/stadium_extract_test.lua), rebuilt with
|
||||
# tools/stadium_pack.py whenever it is wanted
|
||||
assets/stadium/
|
||||
pocket-voxel/
|
||||
@@ -4,6 +4,8 @@
|
||||
# The SDK suite and the probes it grew out of. A shipped test that requires
|
||||
# an engine module reads as a private require against the archive
|
||||
# (CONTRIBUTING-mods.md "What the PR must contain", 2).
|
||||
tests/arena_config.lua
|
||||
tests/arena_editor.lua
|
||||
tests/arena_pick.lua
|
||||
tests/battle_shots.lua
|
||||
tests/dramatic_shape_test.lua
|
||||
|
||||
+2033
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,12 @@
|
||||
# Dramatic Shape Voxel Mod
|
||||
|
||||
Redistribution of non-derivative code is expressly prohibited after v1.6.0 without permission.
|
||||
|
||||
A mod for the [Pokémon Gen 1 Recompilation
|
||||
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
|
||||
|
||||
The overworld as a 3D diorama. Terrain is extruded into real geometry,
|
||||
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.
|
||||
The overworld as a voxelized 3D diorama. Also supports experimental
|
||||
first-person, third-person and VR.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -29,26 +15,285 @@ menu.
|
||||
|
||||
| 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 → 3RD → 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 |
|
||||
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
|
||||
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
|
||||
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
|
||||
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 → 4 → 5 — bend the world over the horizon; 5 is a half sphere |
|
||||
| the **RENDER DIST** options row | FIT / WIDE / WIDER / WIDEST / OFF — how much of the map the camera bothers to draw. **FIT** is exactly the ground on screen and no more: the trapezoid a tilted camera really frames, which reaches well north of you and flares wide out there — not the square the flat game shows. A connected map falling entirely outside it is skipped before it is drawn, terrain, water, grass and shadows together, which is most of the frame's geometry at the high rungs. Below about 63° that is all the row does and the picture is untouched; at **75** the camera sees to the horizon, so something has to name a distance — FIT is the closest, the wider rungs push the world's edge out, **OFF** stops cutting. Not on **1ST** or **3RD**: you are standing in the world there, and the box opens out and away as the camera dives in. **FULL** sets it to FIT |
|
||||
| `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models |
|
||||
| `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 **SHADOWS** options row | ON / OFF — real cast shadows, thrown by rendering the whole scene a second time from the sun, so buildings, trees, ledges and people shadow whatever they land on: walls, roofs, each other. The most expensive pass in the mode after the geometry itself, and the first thing to turn off on a phone or an old machine. **OFF** is no shadow at all — the flat drop shadows under characters included — and the forest's light shafts go with it, since the beams are lit by the sun's own map. **FULL** leaves it alone |
|
||||
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
|
||||
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
|
||||
|
||||
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
|
||||
on the world whether or not the free-roam camera is pitched over.
|
||||
## Free-roam cameras (1ST / 3RD)
|
||||
|
||||
Two of the engine's own rows are taken away while this mod is installed:
|
||||
**TILT**, which is the flat fake of what this mode does for real, and **GBC
|
||||
FX**, a full-screen present pass over the top of the diorama. Both are held at
|
||||
off rather than merely hidden — a row that is not there cannot switch off a
|
||||
value an older save arrived with. Uninstall and both come back, at whatever
|
||||
they were last set to.
|
||||
The last two rungs of the **VOXEL** ladder are experimental, and they are
|
||||
the same camera: **1ST** stands it in the player's own eyes, **3RD** pulls
|
||||
it back onto a boom behind their shoulder. Both steer, and on both the grid
|
||||
walk is replaced by continuous camera-relative movement — push in any
|
||||
direction and you go there, at any angle, not just along the four compass
|
||||
lines. Collision, warps, ledges, encounters and scripts all still run
|
||||
through the engine's own machinery.
|
||||
|
||||
Everything the battle screen draws as a box — the two HUD blocks, the text
|
||||
box and the menus over it — sits on frosted glass rather than on the white
|
||||
field it used to have behind it: the world underneath, blurred and laid back
|
||||
down translucent, with the ink flipping white where the ground it lands on is
|
||||
dark. Nothing the engine draws inside a box moves; only the paper is gone.
|
||||
| control | does |
|
||||
| --- | --- |
|
||||
| mouse | look (the cursor is captured; left click is A, right click is B) |
|
||||
| right stick | look |
|
||||
| a touch drag off the overlay's controls | look |
|
||||
| left stick / touch d-pad / arrow keys | walk, relative to where the camera looks |
|
||||
| wheel, `Q` / `E`, pinch, or a stick click | **3RD only** — let the boom out and pull it in (`Q` and left stick click out, `E` and right stick click in) |
|
||||
|
||||
On an **orbit rung** the same wheel, `Q`/`E` and pinch drive the engine's own
|
||||
survey zoom. On **1ST** they do nothing at all: the eye is in your head, and
|
||||
there is no distance to change.
|
||||
|
||||
On **3RD** the boom shortens against whatever is behind you, so backing into
|
||||
a wall walks the camera in to your shoulders rather than through it — squeeze
|
||||
it all the way in and the view is 1ST until you step clear. The character
|
||||
turns to face where they are walking, and every sprite in the world — yours,
|
||||
the NPCs', the figures drawn into the furniture — turns to face the camera
|
||||
and shows the frame it would look like from where the camera actually
|
||||
stands, so walking behind someone shows you their back.
|
||||
|
||||
## The battle camera
|
||||
|
||||
A fight staged on the map (**3D-BTL**, on by default) is shot with a solved
|
||||
over-the-shoulder rig — and you can steer it.
|
||||
|
||||
| control | does |
|
||||
| --- | --- |
|
||||
| right stick, a touch drag, or the mouse | swing the shot around the arena (→) and raise the seat (↑) |
|
||||
| wheel, `Q` / `E`, pinch, or a stick click | the lens (`Q` / left stick click out, `E` / right stick click in) |
|
||||
|
||||
Both axes stop where the composition does. Left stops at the shot the rig was
|
||||
solved for — there is nothing to the left of it. Right ends **side-on**: the
|
||||
eye square to the arena's axis, both Pokémon at the same distance instead of
|
||||
one behind the other. Down stops at the rig's own low stance; up is 45° above
|
||||
it. The lens opens as you swing or climb, by exactly the amount the two
|
||||
Pokémon spread apart, so they stay framed at every angle. Move animations
|
||||
follow the pair's position *and* its separation, so a beam still lands on the
|
||||
Pokémon it was aimed at.
|
||||
|
||||
Where you leave the camera is where the next battle opens.
|
||||
|
||||
**BACK SPRITES locks it.** That setting pins your own Pokémon to the GB's slot
|
||||
on the menu while the foe stands out on the map, and no angle holds a
|
||||
composition that is half frame and half world — so with it on, the shot holds
|
||||
the one the rig was solved for.
|
||||
|
||||
## STADIUM battles
|
||||
|
||||
The **3D-BTL** row has five rungs, which are two choices — what is standing
|
||||
there, and where:
|
||||
|
||||
| rung | the fight |
|
||||
| --- | --- |
|
||||
| **2D-3D A** | staged on the map, with the Game Boy's own pics stood up on their tiles |
|
||||
| **2D-3D B** | those same pics on two discs against the sky, with no map drawn |
|
||||
| **STADIUM A** | staged on the map, with the Pokémon Stadium battle models |
|
||||
| **STADIUM B** | those models on the discs |
|
||||
| **OFF** | the engine's own battle screen |
|
||||
|
||||
**A** is the map — real ground, in that place's own weather and light. **B**
|
||||
is the carried stage, which works everywhere, including the caves and shop
|
||||
floors that have nowhere to put a fight. Only the STADIUM rungs need a ROM;
|
||||
**2D-3D B** is generated in Lua and uses the game's own art.
|
||||
|
||||
Skinned and animated, playing the animation the move being used actually
|
||||
calls for — the Stadium ROM's own per-species move table, so **DIG** really
|
||||
does put Diglett into the ground. Fainting plays the faint and holds there, a
|
||||
send-out grows the Pokémon out of the ball as it opens and plays the entrance,
|
||||
and between all of that the standby loop runs. Eyes blink and go dizzy;
|
||||
Charmander's tail flame and Weezing's gas are drawn over the body.
|
||||
|
||||
Taking damage plays nothing, because the set has no damage reaction in it —
|
||||
the slot that looked like one is each species' default attack, which is why
|
||||
being hit used to look like swinging. The engine's own screen flash, pic blink
|
||||
and HP drain are what say "that hurt".
|
||||
|
||||
148 of the 151 have models. Exeggutor, Tangela and Magmar come out of the ROM
|
||||
with corrupt standby loops and stand as their Game Boy battle sprites
|
||||
instead, on their own tile, in the same arena — the same per-Pokémon fallback
|
||||
a substitute doll and the pre-send-out trainer pic already take.
|
||||
|
||||
**B is for the maps that cannot host a fight.** Half of Kanto's interiors are
|
||||
furniture, a cave floor can be nothing but corridors, and a map where neither
|
||||
Pokémon can be *seen* from a low camera is declined outright — which drops you
|
||||
back to the flat battle screen. B carries its stage, so it works everywhere
|
||||
and looks the same every time. It is abstracted from the ground, not from the
|
||||
world: the sky behind the discs is the hour's own, and a fight in a cave is
|
||||
under that cave's void and its own flat light.
|
||||
|
||||
### Getting the models
|
||||
|
||||
**They are not in this mod, and they cannot be** — they are Pokémon Stadium's
|
||||
data. What ships is the reader; you supply the cartridge, exactly as this
|
||||
engine already asks you to supply the Game Boy ROM it is a recompilation of.
|
||||
|
||||
> **You must supply a Pokémon Stadium (US) 1.0 ROM.** Not Stadium 2, not
|
||||
> another region, not a later revision. Every offset in the reader was
|
||||
> measured against that one cartridge, and nothing else is promised: a
|
||||
> different file is either refused outright or builds models that are subtly
|
||||
> wrong. The mod checks, and says so — on the console, and on the loading
|
||||
> screen itself if it built from something unexpected.
|
||||
>
|
||||
> The reference dump is **md5 `ed1378bc12115f71209a77844965ba50`**, 32 MB.
|
||||
> The mod does not tell you where to get one, and none ships with it.
|
||||
|
||||
1. Open **OPTIONS** and press the **STADIUM ROM** row. It opens your system's
|
||||
file picker; choose your **Pokémon Stadium (US) 1.0** ROM. `.z64`, `.n64`
|
||||
and `.v64` all work — the byte order is detected, and the wrong file is
|
||||
refused with a reason rather than half-built.
|
||||
2. The 151 models are built on a loading screen that says so and shows a
|
||||
progress bar, in about ten seconds. The row then reads **READY**.
|
||||
|
||||
The ROM itself is **not kept** — it is read, built from, and forgotten, so
|
||||
the cartridge does not sit in your save directory alongside the models it
|
||||
produced. Press the row again any time to import a different one.
|
||||
|
||||
There is no picker on Android, or on a Linux install with neither `zenity`
|
||||
nor `kdialog`. Those keep the original route, which still works everywhere:
|
||||
|
||||
- Put the **US 1.0** ROM in a `baseroms/` folder beside the game — straight
|
||||
in it, not in a subfolder — and start the game.
|
||||
- In a packaged build (and on Android) `baseroms/` goes in the save
|
||||
directory; the mod logs the exact path on startup when it cannot find one.
|
||||
On Android that is the app's external-files folder, reachable over USB or
|
||||
any file manager without root.
|
||||
|
||||
Either way, the two STADIUM rungs appear on the 3D-BTL row when it's done.
|
||||
|
||||
The built models live in the save directory, not in the mod folder, and are
|
||||
rebuilt automatically if the format changes or the ROM does. Until they exist
|
||||
the STADIUM rungs are simply not on the row — skipped rather than shown and
|
||||
refused, because a setting you can select that then does nothing is worse than
|
||||
one that is not there.
|
||||
|
||||
**This works on mobile.** The extraction is pure Lua — no FFI, no native
|
||||
helper, no second process — so it runs anywhere LÖVE does. It peaks at about
|
||||
68 MB of Lua heap (32 MB of that the cartridge itself) with a working set that
|
||||
does not grow across the run, and `tests/stadium_budget_test.lua` fails if
|
||||
either stops being true. On Android the save directory is the app's
|
||||
external-files folder, so `baseroms/` there is reachable over USB or a file
|
||||
manager without root; the build is slower than a desktop's ~7 s but runs one
|
||||
species a frame behind the progress bar either way.
|
||||
|
||||
Developers can pre-build them with `tools/stadium_pack.py`, which reads the
|
||||
same ROM through `model_extract/pipeline`. That path is also the *oracle*:
|
||||
`tests/stadium_extract_test.lua` runs it and the in-game Lua extractor over
|
||||
the same cartridge and requires all 151 packed files to come out byte for byte
|
||||
identical.
|
||||
|
||||
## VR
|
||||
|
||||
The **VR** options row (OFF / STANDARD / DIORAMA / DIORAMA-MR, off by
|
||||
default) drives a PCVR headset through OpenXR on Windows — SteamVR,
|
||||
Oculus or WMR.
|
||||
|
||||
**STANDARD** follows the VOXEL ladder. Both free-roam rungs put the
|
||||
headset in the player's *head*: a boom that seats its wearer three cells
|
||||
behind their own body is a reliable way to make people ill, so **3RD** in
|
||||
VR is **1ST** in VR. The rung still changes the walk and the sprites the
|
||||
same way.
|
||||
|
||||
### DIORAMA
|
||||
|
||||
**DIORAMA** is one presentation instead of a ladder: the world is always a
|
||||
model on the table, and the model is a *thing in the room*.
|
||||
|
||||
- **A viewport.** Everything outside an invisible **box** centred on the
|
||||
view is not drawn — a square slab of Kanto sitting in the air rather
|
||||
than a map running off to a horizon, cut with a hard edge, because a
|
||||
flat world is a thing with sides and the sides are what say so. The sky
|
||||
behind is the same one the flat screen has.
|
||||
- **V-CURVE changes its shape.** With the bend on the world is not flat
|
||||
any more, and a square cut through a little globe is a lie about what is
|
||||
being looked at — so the box becomes a **ball** whose rim is a
|
||||
**gradient** dissolving into the sky. One click of the left stick throws
|
||||
the row and swaps between the two readings of the same model.
|
||||
- **A staged fight** ignores both and cuts a vertical pillar about the
|
||||
arena, always with the dissolved rim, which lifts the fight out of the
|
||||
map as a floating disc.
|
||||
- **The grips** take hold of it: one hand carries the model anywhere in
|
||||
the room, both hands turn it and open the viewport out to whatever you
|
||||
spread your hands to.
|
||||
- **The left stick's click** throws **V-CURVE** to its top rung and back,
|
||||
rather than stepping views — there is no 2D diorama and no first-person
|
||||
one, so the ladder is held on an orbit rung while the mode runs.
|
||||
|
||||
**DIORAMA-MR** is the same mode with the background keyed pure green, for
|
||||
a mixed-reality capture that composites the model into your own room.
|
||||
|
||||
### VR controls
|
||||
|
||||
Suggested onto Touch, Index and WMR controllers (rebindable in the
|
||||
runtime's own binding UI); pad, keyboard and mouse all keep working
|
||||
alongside.
|
||||
|
||||
| control | does |
|
||||
| --- | --- |
|
||||
| left stick | move — grid-walks the diorama, free-walks 1ST |
|
||||
| A / B (X / Y on the left hand) | A / B |
|
||||
| either trigger | START |
|
||||
| left stick click | *STANDARD* — step the VOXEL angle ladder (same as the "3" key); *DIORAMA* — throw **V-CURVE** to its top rung and back |
|
||||
| right stick up / down | *tabletop* — zoom the model |
|
||||
| right stick left / right | *1ST only* — snap-turn 45°, or turn smoothly with **SMOOTH TURN** on |
|
||||
| one grip squeezed | *STANDARD* — drag the table's height; *DIORAMA* — carry the model wherever that hand goes |
|
||||
| both grips squeezed | *DIORAMA only* — turn the model with your hands, and open or close the viewport by spreading them |
|
||||
| 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 |
|
||||
|
||||
## Licenses
|
||||
|
||||
It redistributes one third-party binary:
|
||||
|
||||
- **`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.
|
||||
|
||||
### Acknowledgements — pret/pokestadium
|
||||
|
||||
The STADIUM battle models are read out of the player's own Pokémon Stadium
|
||||
(US) 1.0 cartridge by original code in [`lib/`](lib) and
|
||||
[`model_extract/`](model_extract). **That code exists because of
|
||||
[pret/pokestadium](https://github.com/pret/pokestadium)**, the community
|
||||
decompilation of that game, which is the reference this mod's reader was
|
||||
written against. Specifically, it is where the following came from:
|
||||
|
||||
- the bone matrix chain, and the fact that scale is kept *out* of it and
|
||||
applied only at draw time (`func_800143C0`) — the single most important
|
||||
thing to get right in the whole rig, and not guessable from the data
|
||||
- the rotation basis and its row-vector `Rx·Ry·Rz` order
|
||||
(`func_8000F730`, `src/F420.c`)
|
||||
- the animation player's frame counter and loop-start behaviour
|
||||
(`func_80016FBC`), and the texture-animation sampler that *clamps* past
|
||||
the end of its stream rather than wrapping (`func_80017540`) — which is
|
||||
the difference between a Pokémon blinking and twitching
|
||||
- the battle system's per-species animation context slots and the routines
|
||||
that select them (`func_8432B0A4`, `func_8430506C`, `func_84305A74`)
|
||||
- the move-id constants the per-species move table is keyed by
|
||||
|
||||
**No code, data or asset from that project is included in or redistributed
|
||||
by this mod**, and none is needed to build or run it. What was taken is an
|
||||
understanding of the file formats, re-expressed in this mod's own Lua and
|
||||
Python. If you want to reuse anything from the decompilation itself, get it
|
||||
from upstream and follow that project's own terms.
|
||||
|
||||
No Pokémon Stadium ROM data ships here either. The models are built on the
|
||||
player's own machine, from a cartridge they supply, into their own save
|
||||
directory — see [Getting the models](#getting-the-models).
|
||||
|
||||
@@ -61,6 +61,10 @@ cues generalize:
|
||||
| Band containing window/door frames | Vertical facade | Straight extrusion |
|
||||
| Full-width band with a black underline sitting above an inset band | Ledge / awning overhang | Extrusion + protrusion |
|
||||
| Dark `#555` runs beside a facade under a taper | Shadow on the wall beneath an eave | Leave as wall — the geometry above produces the shadow's meaning |
|
||||
| Scattered light shapes on a dark field, bracketed by TWO full-width black rims, shallow band below the lower rim | The **inside of an open container** seen from above, with contents lying in it | Hollow tray: walls to the rims, floor slab, air between — never an extrusion |
|
||||
| Ellipse drawn wider than tall (e.g. 9x5) | A horizontal circle seen from above — a mouth, a lid, a pot rim | Cut face of a round hull; the aspect ratio is the proof of the top view |
|
||||
| Arcs above/below a round object's straight flanks, lowest point at the centre column, often a 1px #555 halo outside | The SAME circles seen curving — ground contact and mouth back-edge, i.e. depth, not narrowing | Strip them from the revolve; run the last body row's disc to the floor |
|
||||
| A side band shearing sideways as it descends (¾-view) | The projection sliding a receding wall, not the wall's position | Un-project: the wall goes where the plan says |
|
||||
|
||||
The band table for Red's house, which Blue's house shares verbatim:
|
||||
|
||||
@@ -156,9 +160,12 @@ Tooling: `voxel_build_verify.py` (builds, asserts, renders previews).
|
||||
|
||||
1. Obtain the sprite; sample to native resolution via block centers.
|
||||
2. Extract palette + silhouette (light-only flood fill, threshold 130);
|
||||
review the ASCII mask.
|
||||
3. Segment rows into bands using the Stage-2 cues; write the band table
|
||||
before writing any geometry code.
|
||||
review the ASCII mask — rendered large, not hand-counted.
|
||||
3. Name the real object first (including whether it is hollow, round or
|
||||
thin — see "Beyond the house"), then segment rows into bands using the
|
||||
Stage-2 cues; write the band table as prose, one line per row range
|
||||
with where each band lands, before writing any geometry code. The
|
||||
correct reading makes the row arithmetic land exactly.
|
||||
4. Measure taper rates from the mask; derive `T(x)`, `YTOP`, overhangs, `D`.
|
||||
5. Build: extrude verticals (de-outlined interiors) → ledges → recesses →
|
||||
flat top (mid-row cycling) → sloped solids (overwrite, then trim) →
|
||||
@@ -214,3 +221,75 @@ right for the raw GB palette but comes out white once the atlas is
|
||||
recoloured, turning every sloped end into a black-and-white zip. The
|
||||
drawing's own eave is black / `#555` / black, and using that reads correctly
|
||||
under every palette.
|
||||
|
||||
## Beyond the house: the forms later objects added
|
||||
|
||||
The house is all solid masses — every band either lies flat or extrudes.
|
||||
Later objects forced the taxonomy open, and each addition came from the
|
||||
same root move: **name the real 3D form first, then ask which surfaces the
|
||||
drawing shows.** The recurring failure at every step was the *extruded
|
||||
picture* — and it has a second-order form that survives re-segmentation.
|
||||
The Bike Shop's toolbox was re-read from "a prop" into "a cabinet with a
|
||||
pump beside it": named parts, correct plot, de-outlined sides, and still
|
||||
wrong, because the region read as a cabinet *front* was the inside of an
|
||||
open box seen from above. Naming the parts is not enough; every REGION
|
||||
must answer "what surface of the real object is this?" The reliable
|
||||
arbiter is arithmetic: the correct reading makes the drawn row counts land
|
||||
exactly (the toolbox: 1 back-wall rim + 6 interior rows + 1 front rim = 8
|
||||
= the one-tile plot depth). Forcing rows to fit means the reading is wrong.
|
||||
|
||||
**Hollow forms.** An open container is the one shape whose model must
|
||||
contain AIR, which no band table or extrusion can produce. The tray
|
||||
treatment builds four walls to the drawn rims, lays the top-view band on
|
||||
the floor of the cavity (its contents — a wrench — come along free, since
|
||||
they are just pixels of that band), and leaves the space between empty.
|
||||
Two rules only containers hit: the pane-recess pass must never run on a
|
||||
one-voxel wall (it deletes the front voxel to expose the one behind, and
|
||||
there is nothing behind — the wall becomes a hole), and the hollowness
|
||||
needs its own verification assert, because a later change that refills the
|
||||
cavity leaves every count looking plausible.
|
||||
|
||||
**Round forms.** A drawn ellipse wider than tall is a horizontal circle
|
||||
seen from above — that one aspect-ratio measurement settles the whole
|
||||
reading. Straight flanks give diameter and height at once (round in plan,
|
||||
so drawn width IS depth — the one depth never authored). The arcs above
|
||||
and below the straight run are the same top and base circles seen curving:
|
||||
ground contact and mouth edge, not narrowing — revolving them puts the
|
||||
object on a stem. The hull's chord representation stores one z-interval
|
||||
per column/row, so a taper is expressible (re-cut the chords, squeeze the
|
||||
art into the narrowed span so the rim outline survives) but a hollow ring
|
||||
needs a second chord. Voxel resolution bounds taste: on an 11-wide object
|
||||
a one-step taper reads as damage and two steps as a cone; pick the step
|
||||
count and derive the amount.
|
||||
|
||||
**Thin forms.** A line drawing cannot be thick. The air inside a bicycle's
|
||||
frame is what makes it read as a bicycle; extrude each stroke 5 voxels and
|
||||
the side faces of neighbouring strokes close every gap off-axis — six
|
||||
bikes become one dark mass. Standee thickness is a vocabulary
|
||||
(`PINNED_DEPTH`: 1 for paper, 2 for plates and side-on vehicles, 5 for
|
||||
silhouettes, 10 for objects with a body), and when a standee looks wrong
|
||||
the first move is to dump the detector's mask — if the mask is a clean
|
||||
object, thickness is the problem, not segmentation.
|
||||
|
||||
**Authored masks.** When a drawing shares its tiles and shades with what
|
||||
it is painted into, nothing automatic can separate them; the profile
|
||||
carries a pixel mask instead. A person becomes a `figures` card (flat,
|
||||
leaning with the camera, standing on its feet — because GB character art
|
||||
is face-on iconography); an object becomes a `mounted` slab (fixed in the
|
||||
world, holding the wall's plane, keeping its drawn elevation — because a
|
||||
side-on drawing is a plane parallel to the wall). And when the backdrop is
|
||||
a *regular* pattern, the mask should be MEASURED, not hand-drawn:
|
||||
composite the plain backdrop tile over the same grid and flood from the
|
||||
border through pixels that still match it — what the flood cannot reach is
|
||||
the object, sprite-pure and exact.
|
||||
|
||||
**Verification, extended.** Isometric previews miss what only the game
|
||||
shows: shoot in-game at both the ¾ rung and the low rung (front-face holes
|
||||
and proportion errors are invisible from above), crop and NEAREST-upscale
|
||||
before judging, and remember the flat rung renders no model at all. Two
|
||||
cheap renders beat argument: the front-most voxel per (x, y) laid beside
|
||||
the composited drawing catches anchoring and texel leaks instantly, and
|
||||
the same render with sunk voxels flagged turns the recess pass into
|
||||
something you look at. When shared builder code moves, a saved count
|
||||
baseline diffed after every edit (mind the line endings) is what proves a
|
||||
generalization is an identity for every model that already shipped.
|
||||
|
||||
@@ -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.
+88
-64
@@ -23,6 +23,20 @@
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/arenas \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_pick.lua love .
|
||||
--
|
||||
-- To change ONE map's spot, or to go over these by eye, run the editor
|
||||
-- instead -- it is the same choice made in front of the map rather than in a
|
||||
-- batch. It slides the arena around on a plan of the map with the fit, the
|
||||
-- clearance and the camera's own sightlines answering live, stages a real
|
||||
-- battle on the spot when asked, and writes this file back a map at a time,
|
||||
-- replacing only the lines that changed and leaving every comment here alone:
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/arena_editor.lua lovec .
|
||||
--
|
||||
-- Its export lands in .scratchpad/arena_editor/ to be diffed and copied over;
|
||||
-- ARENA_WRITE=1 points it at this file directly. A comment above an entry it
|
||||
-- moved describes where that spot USED to be, and it says so by name at
|
||||
-- export time -- those are the lines to re-word by hand.
|
||||
|
||||
-- `cam = "wide"` on an entry swaps the long default lens for the 44-degree
|
||||
-- one (BattleCam.RIGS). Both frame the same composition -- the two mons land
|
||||
@@ -49,14 +63,14 @@ return {
|
||||
-- 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_1"] = { x = 10, y = 16, shape = "narrow", turn = 90 },
|
||||
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
|
||||
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
|
||||
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
|
||||
["ROUTE_5"] = { x = 13, y = 24, shape = "wide" },
|
||||
["ROUTE_6"] = { x = 5, y = 17, shape = "narrow" },
|
||||
["ROUTE_7"] = { x = 8, y = 8, shape = "narrow" },
|
||||
["ROUTE_8"] = { x = 25, y = 7, shape = "wide" },
|
||||
["ROUTE_3"] = { x = 52, y = 10, shape = "wide", turn = 90 },
|
||||
["ROUTE_4"] = { x = 46, y = 4, shape = "wide" },
|
||||
["ROUTE_5"] = { x = 7, y = 0, shape = "wide", turn = 270 },
|
||||
["ROUTE_6"] = { x = 4, y = 15, shape = "narrow" },
|
||||
["ROUTE_7"] = { x = 4, y = 3, shape = "narrow", cam = "wide" },
|
||||
["ROUTE_8"] = { x = 24, y = 5, shape = "wide", turn = 90 },
|
||||
-- the whole route admits six bare wide arenas, all in the west cliff
|
||||
-- corridor; this is the best of them. A flower cluster crosses the far
|
||||
-- mon's hind legs, which the brief allows -- every alternative put a
|
||||
@@ -64,13 +78,13 @@ return {
|
||||
["ROUTE_9"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
|
||||
["ROUTE_10"] = { x = 7, y = 40, shape = "wide" },
|
||||
["ROUTE_11"] = { x = 9, y = 6, shape = "wide" },
|
||||
["ROUTE_12"] = { x = 0, y = 73, shape = "wide" },
|
||||
["ROUTE_13"] = { x = 50, y = 8, shape = "narrow" },
|
||||
["ROUTE_14"] = { x = 11, y = 25, shape = "wide" },
|
||||
["ROUTE_15"] = { x = 9, y = 10, shape = "wide" },
|
||||
["ROUTE_16"] = { x = 6, y = 10, shape = "wide" },
|
||||
["ROUTE_12"] = { x = 10, y = 71, shape = "wide", turn = 90 },
|
||||
["ROUTE_13"] = { x = 51, y = 5, shape = "narrow" },
|
||||
["ROUTE_14"] = { x = 11, y = 24, shape = "wide" },
|
||||
["ROUTE_15"] = { x = 30, y = 10, shape = "wide", turn = 90 },
|
||||
["ROUTE_16"] = { x = 8, y = 10, shape = "wide", turn = 90 },
|
||||
["ROUTE_17"] = { x = 14, y = 70, shape = "wide" },
|
||||
["ROUTE_18"] = { x = 11, y = 4, shape = "wide" },
|
||||
["ROUTE_18"] = { x = 11, y = 3, shape = "wide" },
|
||||
|
||||
-- ------- buildings and caves
|
||||
--
|
||||
@@ -78,10 +92,10 @@ return {
|
||||
-- or gravestones in it rarely holds a 3x6 clearing whose whole width is
|
||||
-- also SEEN, and giving up the apron is usually the difference between a
|
||||
-- fight in the open and one behind a console.
|
||||
["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide" },
|
||||
["POKEMON_MANSION_2F"] = { x = 15, y = 17, shape = "wide" },
|
||||
["POKEMON_MANSION_3F"] = { x = 23, y = 2, shape = "narrow", cam = "wide" },
|
||||
["POKEMON_MANSION_B1F"] = { x = 19, y = 10, shape = "wide" },
|
||||
["POKEMON_MANSION_1F"] = { x = 4, y = 12, shape = "wide", cam = "wide" },
|
||||
["POKEMON_MANSION_2F"] = { x = 10, y = 1, shape = "wide", cam = "wide" },
|
||||
["POKEMON_MANSION_3F"] = { x = 24, y = 3, shape = "narrow", cam = "wide" },
|
||||
["POKEMON_MANSION_B1F"] = { x = 6, y = 19, shape = "wide", turn = 90 },
|
||||
["POKEMON_TOWER_2F"] = { x = 4, y = 7, shape = "narrow" },
|
||||
["POKEMON_TOWER_3F"] = { x = 4, y = 6, shape = "wide" },
|
||||
-- every one of 4F's thirty candidate spots puts a gravestone through a
|
||||
@@ -89,73 +103,79 @@ return {
|
||||
-- is shot there
|
||||
["POKEMON_TOWER_4F"] = { map = "POKEMON_TOWER_3F", x = 4, y = 6,
|
||||
shape = "wide" },
|
||||
["POKEMON_TOWER_5F"] = { x = 10, y = 1, shape = "narrow" },
|
||||
["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow" },
|
||||
["POKEMON_TOWER_5F"] = { x = 8, y = 9, shape = "narrow", cam = "wide" },
|
||||
["POKEMON_TOWER_6F"] = { x = 14, y = 6, shape = "narrow", cam = "wide" },
|
||||
["POKEMON_TOWER_7F"] = { x = 9, y = 5, shape = "wide" },
|
||||
["POWER_PLANT"] = { x = 18, y = 5, shape = "narrow" },
|
||||
["ROCK_TUNNEL_1F"] = { x = 14, y = 15, shape = "wide" },
|
||||
["ROCK_TUNNEL_B1F"] = { x = 20, y = 17, shape = "wide" },
|
||||
["ROCKET_HIDEOUT_B1F"] = { x = 11, y = 6, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B2F"] = { x = 19, y = 7, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B3F"] = { x = 22, y = 11, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B4F"] = { x = 17, y = 3, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B1F"] = { x = 20, y = 18, shape = "narrow" },
|
||||
["ROCKET_HIDEOUT_B2F"] = { x = 20, y = 10, shape = "narrow", cam = "wide" },
|
||||
["ROCKET_HIDEOUT_B3F"] = { x = 24, y = 15,
|
||||
shape = "narrow", turn = 90, cam = "wide" },
|
||||
["ROCKET_HIDEOUT_B4F"] = { x = 19, y = 17, shape = "wide", cam = "wide" },
|
||||
["SAFARI_ZONE_CENTER"] = { x = 1, y = 8, shape = "wide" },
|
||||
["SAFARI_ZONE_EAST"] = { x = 21, y = 8, shape = "wide" },
|
||||
["SAFARI_ZONE_NORTH"] = { x = 19, y = 14, shape = "wide" },
|
||||
["SAFARI_ZONE_WEST"] = { x = 18, y = 3, shape = "wide" },
|
||||
["SAFARI_ZONE_EAST"] = { x = 16, y = 8,
|
||||
shape = "wide", turn = 90, cam = "wide" },
|
||||
["SAFARI_ZONE_NORTH"] = { x = 22, y = 12, shape = "wide", turn = 90 },
|
||||
["SAFARI_ZONE_WEST"] = { x = 20, y = 2, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_1F"] = { x = 14, y = 7, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B1F"] = { x = 11, y = 1, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B2F"] = { x = 16, y = 2, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B3F"] = { x = 25, y = 7, shape = "wide" },
|
||||
["SEAFOAM_ISLANDS_B4F"] = { x = 12, y = 6, shape = "narrow" },
|
||||
["SEAFOAM_ISLANDS_B1F"] = { x = 9, y = 8,
|
||||
shape = "wide", turn = 90, cam = "wide" },
|
||||
["SEAFOAM_ISLANDS_B2F"] = { x = 15, y = 9, shape = "wide", turn = 90 },
|
||||
["SEAFOAM_ISLANDS_B3F"] = { x = 26, y = 7, shape = "wide", turn = 180 },
|
||||
["SEAFOAM_ISLANDS_B4F"] = { x = 9, y = 7, shape = "narrow" },
|
||||
-- Silph Co is office floors partitioned into small rooms, so the long lens
|
||||
-- often lands outside the walls it is meant to be looking between; the
|
||||
-- floors that could not be framed any other way ask for the wide one.
|
||||
["SILPH_CO_2F"] = { x = 16, y = 8, shape = "narrow" },
|
||||
["SILPH_CO_4F"] = { x = 24, y = 2, shape = "narrow" },
|
||||
["SILPH_CO_2F"] = { x = 4, y = 9, shape = "narrow", turn = 270 },
|
||||
["SILPH_CO_4F"] = { x = 14, y = 14, shape = "narrow", turn = 90 },
|
||||
["SILPH_CO_5F"] = { x = 16, y = 7, shape = "wide" },
|
||||
["SILPH_CO_6F"] = { x = 10, y = 8, shape = "narrow" },
|
||||
["SILPH_CO_6F"] = { x = 19, y = 2,
|
||||
shape = "narrow", turn = 90, cam = "wide" },
|
||||
["SILPH_CO_7F"] = { x = 1, y = 2, shape = "wide", cam = "wide" },
|
||||
["SILPH_CO_8F"] = { x = 8, y = 6, shape = "narrow" },
|
||||
["SILPH_CO_9F"] = { x = 20, y = 11, shape = "wide", cam = "wide" },
|
||||
["SS_ANNE_1F_ROOMS"] = { x = 10, y = 1, shape = "narrow", cam = "wide" },
|
||||
["SS_ANNE_1F_ROOMS"] = { x = 11, y = 1, shape = "narrow", cam = "wide" },
|
||||
-- the ship is all two-cell corridors, so the wide arena shape fits nowhere
|
||||
-- aboard and the long lens always lands outside the hull
|
||||
["SS_ANNE_2F"] = { x = 36, y = 8, shape = "narrow", cam = "wide" },
|
||||
-- these two decks are byte-identical geometry, so they take the same spot
|
||||
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
|
||||
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow" },
|
||||
["SS_ANNE_2F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
|
||||
["SS_ANNE_B1F_ROOMS"] = { x = 11, y = 12, shape = "narrow", cam = "wide" },
|
||||
["SS_ANNE_BOW"] = { x = 8, y = 3, shape = "wide" },
|
||||
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide" },
|
||||
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide" },
|
||||
["VICTORY_ROAD_2F"] = { x = 16, y = 6, shape = "wide", cam = "wide" },
|
||||
["VICTORY_ROAD_3F"] = { x = 20, y = 1, shape = "wide", cam = "wide" },
|
||||
|
||||
-- ------- towns and the last interiors
|
||||
["CERULEAN_CITY"] = { x = 15, y = 16, shape = "wide" },
|
||||
["GAME_CORNER"] = { x = 8, y = 7, shape = "wide" },
|
||||
["GAME_CORNER"] = { x = 8, y = 5, shape = "wide" },
|
||||
-- the lab is ten cells by twelve, so the long lens is always off-map, and
|
||||
-- on it a desk clipped one mon and a pillar the other
|
||||
["OAKS_LAB"] = { x = 3, y = 2, shape = "narrow", cam = "wide" },
|
||||
["OAKS_LAB"] = { x = 1, y = 3, shape = "narrow", turn = 90, cam = "wide" },
|
||||
-- Saffron's gym is a grid of small walled cells: no wide shape exists
|
||||
-- anywhere in it, and the long lens sits inside a divider
|
||||
["SAFFRON_GYM"] = { x = 9, y = 7, shape = "narrow", cam = "wide" },
|
||||
["SILPH_CO_3F"] = { x = 18, y = 11, shape = "wide", cam = "wide" },
|
||||
["SILPH_CO_10F"] = { x = 1, y = 2, shape = "wide" },
|
||||
["SILPH_CO_11F"] = { x = 1, y = 11, shape = "wide", cam = "wide" },
|
||||
["SILPH_CO_10F"] = { x = 1, y = 1, shape = "wide", cam = "wide" },
|
||||
["SILPH_CO_11F"] = { x = 10, y = 6,
|
||||
shape = "wide", turn = 180, cam = "wide" },
|
||||
-- the upper corridor (cols 4-5, rows 1-4) is sealed at runtime by the
|
||||
-- gym's barrier, so arenas there silently fail the fit test
|
||||
["VERMILION_GYM"] = { x = 4, y = 11, shape = "narrow" },
|
||||
["VICTORY_ROAD_1F"] = { x = 11, y = 2, shape = "narrow" },
|
||||
["VERMILION_GYM"] = { x = 3, y = 2,
|
||||
shape = "narrow", turn = 90, cam = "wide" },
|
||||
["VICTORY_ROAD_1F"] = { x = 11, y = 5, shape = "narrow", cam = "wide" },
|
||||
["VIRIDIAN_FOREST"] = { x = 16, y = 34, shape = "narrow" },
|
||||
|
||||
-- ------- the remaining routes
|
||||
["ROUTE_19"] = { x = 8, y = 6, shape = "narrow" },
|
||||
["ROUTE_19"] = { x = 8, y = 31, shape = "narrow" },
|
||||
-- the two surf routes fight AFLOAT, in the middle of their own sea rather
|
||||
-- than on the rim of beach the land search would otherwise find
|
||||
["ROUTE_20"] = { x = 23, y = 7, shape = "wide" },
|
||||
["ROUTE_21"] = { x = 8, y = 46, shape = "wide" },
|
||||
["ROUTE_22"] = { x = 35, y = 7, shape = "wide" },
|
||||
["ROUTE_23"] = { x = 4, y = 36, shape = "wide" },
|
||||
["ROUTE_24"] = { x = 13, y = 15, shape = "wide" },
|
||||
["ROUTE_22"] = { x = 35, y = 7, shape = "wide", cam = "wide" },
|
||||
["ROUTE_23"] = { x = 2, y = 34, shape = "wide", cam = "wide" },
|
||||
["ROUTE_24"] = { x = 6, y = 9, shape = "wide", turn = 270, cam = "wide" },
|
||||
["ROUTE_25"] = { x = 32, y = 2, shape = "wide", cam = "wide" },
|
||||
|
||||
-- ------- caves, gyms and the Elite Four
|
||||
@@ -163,25 +183,29 @@ return {
|
||||
-- None of these tilesets has a grass tile at all, so the no-grass rule
|
||||
-- constrained nothing here; what constrains them is furniture, rock
|
||||
-- pillars and how small the rooms are.
|
||||
["AGATHAS_ROOM"] = { x = 2, y = 1, shape = "narrow", cam = "wide" },
|
||||
["BRUNOS_ROOM"] = { x = 3, y = 1, shape = "narrow" },
|
||||
["CELADON_GYM"] = { x = 0, y = 3, shape = "narrow" },
|
||||
["CERULEAN_CAVE_1F"] = { x = 1, y = 7, shape = "narrow" },
|
||||
["AGATHAS_ROOM"] = { x = 4, y = 2, shape = "narrow", cam = "wide" },
|
||||
["BRUNOS_ROOM"] = { x = 2, y = 1, shape = "wide", turn = 90 },
|
||||
["CELADON_GYM"] = { x = 3, y = 4,
|
||||
shape = "narrow", turn = 90, cam = "wide" },
|
||||
["CERULEAN_CAVE_1F"] = { x = 12, y = 8, shape = "narrow", cam = "wide" },
|
||||
-- 2F is a maze of one-cell rock corridors; all 24 of its candidate spots
|
||||
-- hide a mon, so it borrows the floor below -- the same cave
|
||||
["CERULEAN_CAVE_2F"] = { map = "CERULEAN_CAVE_B1F", x = 2, y = 0,
|
||||
shape = "wide" },
|
||||
["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide" },
|
||||
["CERULEAN_GYM"] = { x = 0, y = 1, shape = "narrow" },
|
||||
["CERULEAN_CAVE_B1F"] = { x = 2, y = 0, shape = "wide", cam = "wide" },
|
||||
["CERULEAN_GYM"] = { x = 4, y = 2, shape = "wide" },
|
||||
["CHAMPIONS_ROOM"] = { x = 2, y = 2, shape = "narrow", cam = "wide" },
|
||||
["CINNABAR_GYM"] = { x = 18, y = 10, shape = "narrow" },
|
||||
["DIGLETTS_CAVE"] = { x = 19, y = 16, shape = "wide" },
|
||||
["FIGHTING_DOJO"] = { x = 4, y = 1, shape = "narrow" },
|
||||
["LANCES_ROOM"] = { x = 5, y = 15, shape = "wide" },
|
||||
["LORELEIS_ROOM"] = { x = 5, y = 2, shape = "narrow" },
|
||||
["MT_MOON_1F"] = { x = 24, y = 17, shape = "wide" },
|
||||
["MT_MOON_B1F"] = { x = 5, y = 12, shape = "wide" },
|
||||
["MT_MOON_B2F"] = { x = 2, y = 16, shape = "wide" },
|
||||
["CINNABAR_GYM"] = { x = 9, y = 16,
|
||||
shape = "narrow", turn = 90, cam = "wide" },
|
||||
["DIGLETTS_CAVE"] = { x = 14, y = 26,
|
||||
shape = "wide", turn = 90, cam = "wide" },
|
||||
["FIGHTING_DOJO"] = { x = 3, y = 5,
|
||||
shape = "narrow", turn = 90, cam = "wide" },
|
||||
["LANCES_ROOM"] = { x = 4, y = 2, shape = "wide", cam = "wide" },
|
||||
["LORELEIS_ROOM"] = { x = 4, y = 2, shape = "narrow" },
|
||||
["MT_MOON_1F"] = { x = 25, y = 16, shape = "wide" },
|
||||
["MT_MOON_B1F"] = { x = 4, y = 11, shape = "wide" },
|
||||
["MT_MOON_B2F"] = { x = 8, y = 20, shape = "wide" },
|
||||
|
||||
-- The three gyms the default rig cannot stand back from. Five blocks is
|
||||
-- further than these rooms are wide, so the eye landed outside the map and
|
||||
@@ -190,7 +214,7 @@ return {
|
||||
-- BattleCam), which fits inside the room; the mons come out smaller and all
|
||||
-- three became stageable. It is asked for HERE, per map, so every area that
|
||||
-- does not ask keeps the long lens it was framed for.
|
||||
["FUCHSIA_GYM"] = { x = 7, y = 6, shape = "narrow", cam = "wide" },
|
||||
["PEWTER_GYM"] = { x = 4, y = 8, shape = "narrow", cam = "wide" },
|
||||
["FUCHSIA_GYM"] = { x = 8, y = 6, shape = "narrow", cam = "wide" },
|
||||
["PEWTER_GYM"] = { x = 4, y = 1, shape = "narrow", turn = 90, cam = "wide" },
|
||||
["VIRIDIAN_GYM"] = { x = 10, y = 8, shape = "narrow", cam = "wide" },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- What hangs in the air of each map.
|
||||
--
|
||||
-- One entry per map that has an ATMOSPHERE: a ground fog the scene shader
|
||||
-- folds every surface into, and volumetric god rays -- light let down
|
||||
-- through an INVISIBLE canopy hanging above the map's real geometry, as
|
||||
-- if the trees drawn are only the understorey of something taller. The
|
||||
-- rays are not placed: a per-pixel march (see ForestAtmos) reads the
|
||||
-- frame's own depth and the sun's own shadow map, so the beams stand
|
||||
-- exactly where light really breaks between the tree hulls, trees and
|
||||
-- characters carve dark columns through them, and a wind-blown leaf
|
||||
-- field at the canopy plane opens and closes them like foliage moving
|
||||
-- overhead. The light leans along the mod's fixed noon shear (the one
|
||||
-- light a canopy map ever gets -- see DayNight.CANOPY); only its COLOUR
|
||||
-- and STRENGTH follow the clock: gold spears of sun by day, silver moon
|
||||
-- rays after dark, pollen adrift in the day's beams and fireflies once
|
||||
-- they cool.
|
||||
--
|
||||
-- A map with no entry here has no atmosphere at all: no fog uniform is
|
||||
-- raised, no march runs, nothing is spent. That is the contract a new
|
||||
-- map opts into by adding a line, and what a stale entry degrades to if
|
||||
-- its map id ever stops existing.
|
||||
--
|
||||
-- The knobs, in world pixels unless said otherwise (a map cell is 16):
|
||||
--
|
||||
-- canopyY where the invisible canopy hangs. MUST clear the tallest
|
||||
-- real geometry under it (Viridian's carved tree hulls top
|
||||
-- at y = 32) -- a beam is alpha ZERO at this height and only
|
||||
-- fades in below it, so a canopy at or under the tree tops
|
||||
-- would cut every ray off before it cleared the leaves.
|
||||
-- fadeTo the height by which a descending ray reaches full strength.
|
||||
-- fog density how fast distance dissolves into the haze
|
||||
-- (1 - exp(-density * distance-past-start))
|
||||
-- start how many pixels out the dissolve begins
|
||||
-- heightK how quickly the fog thins with ALTITUDE
|
||||
-- (exp(-y * heightK): 0.02 halves it by y = 35)
|
||||
-- rays strength overall in-scatter gain on the march
|
||||
-- reach how far out the march walks, in world px
|
||||
-- motes count of pollen/dust flecks adrift in the daylight beams
|
||||
-- fireflies count of the night shift
|
||||
-- seed the xorshift seed the particle deal runs on
|
||||
--
|
||||
-- Two caveats for maps opting in later: the water pass has no fog term,
|
||||
-- so a lake under heavy haze stays clear-day sharp in its reflections;
|
||||
-- and the march runs after the water's mirror copy, so beams will not
|
||||
-- appear IN those reflections either. Neither can bite in a map without
|
||||
-- water.
|
||||
|
||||
return {
|
||||
["VIRIDIAN_FOREST"] = {
|
||||
canopyY = 56,
|
||||
fadeTo = 28,
|
||||
fog = { density = 0.0045, start = 64, heightK = 0.02 },
|
||||
-- strength is calibrated against the march's real integral: the
|
||||
-- under-canopy stretch of an orbit ray is short and thinly dense, so
|
||||
-- the raw accumulation for a fully lit beam core is a few percent --
|
||||
-- this gain lands it near +0.3 on screen. Halve it for a whisper,
|
||||
-- double it for cathedral light.
|
||||
rays = { strength = 16, reach = 380 },
|
||||
motes = { count = 96 },
|
||||
fireflies = { count = 48 },
|
||||
seed = 0x51D,
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1649
-102
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
|
||||
+76
-9
@@ -109,6 +109,57 @@ BattleArena.SHAPES = {
|
||||
{ id = "narrow", w = 1, h = 4, enemy = { 0, 0 }, player = { 0, 3 } },
|
||||
}
|
||||
|
||||
-- ------- which way round the fight stands
|
||||
--
|
||||
-- Both shapes above are drawn north-south, with the foe at the top and the
|
||||
-- player below it, and the camera is solved for that: it sits off the
|
||||
-- player's shoulder, low and back down the arena's own axis. `turn` swings
|
||||
-- the WHOLE staging a quarter at a time -- the footprint, the two cells, and
|
||||
-- the camera with them -- so the composition on screen is identical and only
|
||||
-- the ground under it is different.
|
||||
--
|
||||
-- It buys two things.
|
||||
--
|
||||
-- A footprint that FITS. The wide shape is three cells by six; an east-west
|
||||
-- corridor two cells deep has no room for it standing up and all the room in
|
||||
-- the world for it lying down. Half the maps in Kanto run the other way from
|
||||
-- the one shape this mode was drawn in.
|
||||
--
|
||||
-- And a BACKDROP. A quarter turn moves the camera to a different side of the
|
||||
-- same patch of ground, so the wall behind the pair becomes the window
|
||||
-- behind them, or the cliff becomes the valley. Nothing about the shot's
|
||||
-- geometry changes -- the mons land on the same two screen anchors at the
|
||||
-- same size -- so this is purely a choice about what is behind them, made
|
||||
-- per map by somebody looking at it.
|
||||
--
|
||||
-- Written in DEGREES in data/battle_arenas.lua (`turn = 90`) because that is
|
||||
-- what it is; handled as quarter turns everywhere below.
|
||||
local function quarters(turn)
|
||||
local q = math.floor(((tonumber(turn) or 0) / 90) + 0.5)
|
||||
return ((q % 4) + 4) % 4
|
||||
end
|
||||
|
||||
BattleArena.quarters = quarters
|
||||
|
||||
-- The footprint a shape covers once turned: a quarter or three of a turn
|
||||
-- swaps how far it reaches in each direction, which is the whole reason a
|
||||
-- corridor takes one and not the other.
|
||||
function BattleArena.extent(shape, turn)
|
||||
if quarters(turn) % 2 == 1 then return shape.h, shape.w end
|
||||
return shape.w, shape.h
|
||||
end
|
||||
|
||||
-- Where a cell offset inside the shape ends up under the same turn, measured
|
||||
-- from the turned footprint's own north-west corner -- so the corner an entry
|
||||
-- names stays the corner, whichever way the fight faces from it.
|
||||
local function spin(shape, turn, ox, oy)
|
||||
local q = quarters(turn)
|
||||
if q == 1 then return shape.h - 1 - oy, ox end
|
||||
if q == 2 then return shape.w - 1 - ox, shape.h - 1 - oy end
|
||||
if q == 3 then return oy, shape.w - 1 - ox end
|
||||
return ox, oy
|
||||
end
|
||||
|
||||
-- Whether a cell is open ground for the purpose above.
|
||||
--
|
||||
-- "Open" is the walk test the player themselves answer to, so an arena can
|
||||
@@ -174,12 +225,19 @@ end
|
||||
|
||||
-- Build the record the renderer reads: the two mons' cells and, in world
|
||||
-- pixels, the centre of each and of the pair.
|
||||
local function place(shape, x, y)
|
||||
local ex, ey = x + shape.enemy[1], y + shape.enemy[2]
|
||||
local px, py = x + shape.player[1], y + shape.player[2]
|
||||
local function place(shape, x, y, turn)
|
||||
local eox, eoy = spin(shape, turn, shape.enemy[1], shape.enemy[2])
|
||||
local pox, poy = spin(shape, turn, shape.player[1], shape.player[2])
|
||||
local ex, ey = x + eox, y + eoy
|
||||
local px, py = x + pox, y + poy
|
||||
local w, h = BattleArena.extent(shape, turn)
|
||||
local arena = {
|
||||
shape = shape.id,
|
||||
x = x, y = y, w = shape.w, h = shape.h,
|
||||
-- carried in degrees, so everything downstream that reasons about the
|
||||
-- shot -- the camera's base yaw above all -- reads the same number the
|
||||
-- data file was written with
|
||||
turn = quarters(turn) * 90,
|
||||
x = x, y = y, w = w, h = h,
|
||||
enemyCell = { ex, ey },
|
||||
playerCell = { px, py },
|
||||
-- world-pixel centres of the two cells a mon stands on
|
||||
@@ -242,7 +300,10 @@ end
|
||||
-- Whether both mons would be in plain view from the battle camera.
|
||||
function BattleArena.clearance(map, arena)
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local ok, rig = pcall(BattleCam.rig, arena, 0)
|
||||
-- the CANONICAL shot: whether a fight fits somewhere is a fact about the
|
||||
-- ground, so it must not depend on the drift's phase or on where the
|
||||
-- player last swung the camera (see BattleCam.rig's third argument)
|
||||
local ok, rig = pcall(BattleCam.rig, arena, 0, true)
|
||||
if not (ok and rig and rig.eye) then return true end
|
||||
local eye = rig.eye
|
||||
local H = BattleArena.MON_H
|
||||
@@ -299,8 +360,12 @@ function BattleArena.find(map, fromX, fromY, surfing)
|
||||
-- ocean rather than on a scrap of beach at the edge of the map. Land
|
||||
-- entries are unaffected: land passes the test either way.
|
||||
local grid, gw = openGrid(host, true)
|
||||
if fits(grid, gw, pick.x, pick.y, shape.w, shape.h) then
|
||||
local arena = place(shape, pick.x, pick.y)
|
||||
-- measured against the TURNED footprint: an entry that lies the arena
|
||||
-- down an east-west corridor covers different ground from the one that
|
||||
-- stands it up, and the fit test is the thing that has to know
|
||||
local fw, fh = BattleArena.extent(shape, pick.turn)
|
||||
if fits(grid, gw, pick.x, pick.y, fw, fh) then
|
||||
local arena = place(shape, pick.x, pick.y, pick.turn)
|
||||
arena.map = host
|
||||
-- which camera rig this spot is framed for; nil is the default long
|
||||
-- lens, "close" the short one small rooms need (see BattleCam)
|
||||
@@ -318,9 +383,11 @@ end
|
||||
-- The arena at a given north-west corner, whatever the map says about it.
|
||||
-- The authoring tool's manual override: a spot chosen by eye rather than by
|
||||
-- the search, so it can be photographed and judged before it is written down.
|
||||
function BattleArena.at(x, y, shapeId)
|
||||
function BattleArena.at(x, y, shapeId, turn)
|
||||
for _, shape in ipairs(BattleArena.SHAPES) do
|
||||
if shape.id == (shapeId or "wide") then return place(shape, x, y) end
|
||||
if shape.id == (shapeId or "wide") then
|
||||
return place(shape, x, y, turn)
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
+327
-6
@@ -124,12 +124,257 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
|
||||
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
|
||||
BattleCam.DOLLY_PERIOD = 37
|
||||
|
||||
-- ------- the player's own orbit
|
||||
--
|
||||
-- The drift above is the shot breathing. THIS is the player steering it:
|
||||
-- a right stick, a drag across the screen or the mouse walks the eye
|
||||
-- around the arena's axis, and it stops at both ends.
|
||||
--
|
||||
-- 0 is the shot the rig was solved for and the LEFT stop, because there is
|
||||
-- nothing to the left of it -- the composition below is what the whole
|
||||
-- module exists to land, and past it the two mons start swapping sides.
|
||||
--
|
||||
-- 1 is SIDE-ON: the eye swung round until it is square to the arena's
|
||||
-- north-south axis, where the two mons stand at the same distance instead
|
||||
-- of one behind the other. That is as far as the picture stays a battle
|
||||
-- rather than a diorama with two Pokemon in it, and it is a different angle
|
||||
-- for each rig -- the tele lens starts 28 degrees off the axis and the wide
|
||||
-- one 45 -- so the stop is COMPUTED from the rig rather than written down,
|
||||
-- and retuning either moves its own stop with it.
|
||||
--
|
||||
-- The input is deliberately not 1:1 with the pixels: it accumulates into
|
||||
-- `orbitGoal` and the live angle eases after it, so a flick reads as the
|
||||
-- camera being pushed rather than as the camera being dragged.
|
||||
BattleCam.ORBIT_TIME = 0.22 -- seconds for the eye to catch its goal
|
||||
BattleCam.ORBIT_DRAG = 1.15 -- fraction of the range per screen width
|
||||
BattleCam.ORBIT_STICK = 0.9 -- fraction of the range per second, full tilt
|
||||
BattleCam.ORBIT_MOUSE = 0.0011 -- fraction of the range per mouse count
|
||||
BattleCam.STICK_DEAD = 0.2
|
||||
|
||||
-- ------- and the height it is watched from
|
||||
--
|
||||
-- The same steering on the other axis, with the same shape of stop at each
|
||||
-- end: 0 is the rig's own stance -- the low, near-floor seat the whole
|
||||
-- composition is solved around, and the DOWN stop, because below it the
|
||||
-- camera starts looking up the arena's nose -- and 1 is 45 degrees above
|
||||
-- it, which is high enough to read the ground the fight is standing on
|
||||
-- without becoming the diorama's own top-down.
|
||||
--
|
||||
-- Raised about the FOCUS rather than about the eye, so the aim stays on
|
||||
-- the two mons and only the seat climbs; and at a constant radius, so
|
||||
-- climbing never changes how big anything is -- that is the zoom's job.
|
||||
BattleCam.PITCH_RANGE = math.rad(45)
|
||||
BattleCam.PITCH_TIME = 0.22
|
||||
BattleCam.PITCH_DRAG = 1.6 -- fraction of the range per screen HEIGHT
|
||||
BattleCam.PITCH_STICK = 0.9
|
||||
BattleCam.PITCH_MOUSE = 0.0016
|
||||
|
||||
-- ------- and the player's own zoom
|
||||
--
|
||||
-- How much world the frame holds, as a multiple of the rig's own frameH:
|
||||
-- BELOW one is zoomed in. It has to be the LENS rather than the distance,
|
||||
-- because the rig derives its field of view from frameH and the distance
|
||||
-- together -- so moving the eye alone changes the perspective and not the
|
||||
-- framing, which is exactly what the dolly breath above is for.
|
||||
BattleCam.ZOOM_MIN = 0.45 -- the pair filling the frame
|
||||
BattleCam.ZOOM_MAX = 2.0 -- the fight in its own landscape
|
||||
BattleCam.ZOOM_STEP = 1.15
|
||||
BattleCam.ZOOM_TIME = 0.18
|
||||
|
||||
BattleCam.orbit = 0
|
||||
BattleCam.orbitGoal = 0
|
||||
BattleCam.pitch = 0
|
||||
BattleCam.pitchGoal = 0
|
||||
BattleCam.zoom = 1
|
||||
BattleCam.zoomGoal = 1
|
||||
|
||||
-- Whether the player may steer at all. BACK SPRITES clears it: that
|
||||
-- setting pins the player's own mon to the GB's own slot on the menu
|
||||
-- (OverworldBattle.backPinned) instead of standing it out on the map, so
|
||||
-- half the picture is nailed to the frame and half of it is geometry. Swing
|
||||
-- the camera under that and the two halves come apart -- the foe walks
|
||||
-- around an arena its opponent is not standing in, and the move animations
|
||||
-- that reach between them stretch across the gap. There is no angle that
|
||||
-- composition survives, so the answer is not to allow one.
|
||||
--
|
||||
-- Only the STEER is withheld: the slow drift stays, because it was always
|
||||
-- there under BACK SPRITES and two degrees is not a composition problem.
|
||||
BattleCam.steerable = true
|
||||
|
||||
-- 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
|
||||
|
||||
-- Only the DRIFT's phase, so every fight opens on the same breath. Where
|
||||
-- the player last put the camera is deliberately NOT reset: an angle and a
|
||||
-- lens they chose are how they want to watch battles, not a thing about
|
||||
-- this battle, and having to re-find them every encounter would make them
|
||||
-- not worth setting. They are session state -- a fresh run opens on the
|
||||
-- rig's own shot, which is the one the composition is solved for.
|
||||
function BattleCam.reset()
|
||||
BattleCam.t = 0
|
||||
end
|
||||
|
||||
-- Back to the solved shot, for anything that wants the composition as
|
||||
-- authored rather than as steered.
|
||||
function BattleCam.recentre()
|
||||
BattleCam.orbit, BattleCam.orbitGoal = 0, 0
|
||||
BattleCam.pitch, BattleCam.pitchGoal = 0, 0
|
||||
BattleCam.zoom, BattleCam.zoomGoal = 1, 1
|
||||
end
|
||||
|
||||
-- How far the eye may swing, in radians, before it is square to the arena's
|
||||
-- axis. The rig's own stance decides it: `side` and `back` are the offset
|
||||
-- it starts at, so the bearing it starts on is atan2(side, back) and what
|
||||
-- is left to a quarter turn is the room the player has.
|
||||
function BattleCam.orbitRange(arena)
|
||||
local R = BattleCam.rigFor(arena)
|
||||
return math.max(0, math.pi / 2 - math.atan2(R.side, R.back))
|
||||
end
|
||||
|
||||
-- ------- what the player's inputs reach
|
||||
--
|
||||
-- All four take a signed amount and clamp; positive is RIGHTWARD, toward
|
||||
-- the side-on stop. Returning whether the goal actually moved lets a
|
||||
-- caller tell "steered" from "already against the stop".
|
||||
|
||||
-- Both axes go through here, so the "nothing while BACK SPRITES holds the
|
||||
-- composition" rule and the two stops live in one place each.
|
||||
local function setAxis(key, goal)
|
||||
if not BattleCam.steerable then return false end
|
||||
local was = BattleCam[key]
|
||||
BattleCam[key] = math.max(0, math.min(1, goal))
|
||||
return BattleCam[key] ~= was
|
||||
end
|
||||
|
||||
-- A drag, in fractions of the screen's width (orbit) or height (pitch).
|
||||
function BattleCam.dragOrbit(fraction)
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG)
|
||||
end
|
||||
|
||||
function BattleCam.dragPitch(fraction)
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + (fraction or 0) * BattleCam.PITCH_DRAG)
|
||||
end
|
||||
|
||||
-- Relative mouse motion, in counts.
|
||||
function BattleCam.mouseOrbit(dx)
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE)
|
||||
end
|
||||
|
||||
function BattleCam.mousePitch(dy)
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + (dy or 0) * BattleCam.PITCH_MOUSE)
|
||||
end
|
||||
|
||||
-- A stick held for `dt` seconds, as a rate with a squared response -- the
|
||||
-- first half of the throw aims and the rest travels, the same curve the
|
||||
-- free-roam look uses.
|
||||
local function curve(v)
|
||||
local a = math.abs(v or 0)
|
||||
if a < BattleCam.STICK_DEAD then return 0 end
|
||||
a = (a - BattleCam.STICK_DEAD) / (1 - BattleCam.STICK_DEAD)
|
||||
return ((v < 0) and -1 or 1) * a * a
|
||||
end
|
||||
|
||||
function BattleCam.stickOrbit(x, dt)
|
||||
local v = curve(x)
|
||||
if v == 0 then return false end
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + v * BattleCam.ORBIT_STICK * (dt or 0))
|
||||
end
|
||||
|
||||
function BattleCam.stickPitch(y, dt)
|
||||
local v = curve(y)
|
||||
if v == 0 then return false end
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + v * BattleCam.PITCH_STICK * (dt or 0))
|
||||
end
|
||||
|
||||
-- The zoom, in notches (positive pulls OUT, like every other zoom here).
|
||||
function BattleCam.stepZoom(notches)
|
||||
if not BattleCam.steerable then return false end
|
||||
local was = BattleCam.zoomGoal
|
||||
BattleCam.zoomGoal = math.max(BattleCam.ZOOM_MIN,
|
||||
math.min(BattleCam.ZOOM_MAX,
|
||||
was * (BattleCam.ZOOM_STEP ^ (notches or 0))))
|
||||
return BattleCam.zoomGoal ~= was
|
||||
end
|
||||
|
||||
-- How far apart the two mons READ from the current orbit, as a multiple of
|
||||
-- how far apart they read from the solved shot.
|
||||
--
|
||||
-- The arena's axis runs from one mon to the other, and the solved shot
|
||||
-- looks along it at a shallow 28 degrees, which foreshortens that gap to
|
||||
-- less than half its length. Swing round to square-on and the
|
||||
-- foreshortening is gone: the same two cells now read at their full
|
||||
-- separation, better than twice as wide. Left alone, that threw the pair
|
||||
-- out to the edges of the frame -- half of each mon off-screen at the
|
||||
-- side-on stop, which made the whole far end of the range unusable.
|
||||
--
|
||||
-- Climbing does the same thing on the other axis -- a raised camera looks
|
||||
-- less along the ground and more across it, which un-foreshortens the gap
|
||||
-- again -- so the correction has to answer to both.
|
||||
--
|
||||
-- What it measures is how much of the arena's axis survives projection:
|
||||
-- the axis runs due north-south, the view line points back at the arena at
|
||||
-- plan bearing `beta` and elevation `elev`, and the part of a unit axis
|
||||
-- that lands across the frame rather than along the view is the sine of
|
||||
-- the angle between them. The ratio of that to the solved shot's own is
|
||||
-- the factor the lens opens by -- 1 at the solved shot by construction,
|
||||
-- about 1.9 at side-on, about 1.7 fully raised.
|
||||
--
|
||||
-- Analytic rather than measured off the built rig, so nothing has to
|
||||
-- reason about a camera to ask the question, and so the sun's box (which
|
||||
-- asks through frameH) gets the identical number the lens does.
|
||||
--
|
||||
-- Measured off the STEER alone, deliberately: the drift's own two degrees
|
||||
-- moved this before and must keep moving it by exactly as much, or every
|
||||
-- battle shot that has ever been taken shifts.
|
||||
local function axisSpan(beta, elev)
|
||||
local c = math.cos(elev)
|
||||
local s = math.sin(beta) * c
|
||||
local v = math.sin(elev)
|
||||
return math.sqrt(s * s + v * v)
|
||||
end
|
||||
|
||||
function BattleCam.spread(arena)
|
||||
local R = BattleCam.rigFor(arena)
|
||||
local beta = math.atan2(R.side, R.back)
|
||||
local elev = math.atan2(R.height - R.lookY,
|
||||
math.sqrt((R.side - R.lookX) ^ 2 + R.back ^ 2))
|
||||
local home = axisSpan(beta, elev)
|
||||
if home < 1e-6 then return 1 end
|
||||
return axisSpan(beta + BattleCam.orbit * BattleCam.orbitRange(arena),
|
||||
elev + BattleCam.pitch * BattleCam.PITCH_RANGE) / home
|
||||
end
|
||||
|
||||
-- How much world the frame holds right now: the rig's own reach at the
|
||||
-- player's zoom and at whatever the orbit has done to the pair's spacing,
|
||||
-- or the rig's own alone whenever both are being withheld (VR's fixed
|
||||
-- seat, BACK SPRITES' pinned composition). The sun's box is fitted to this
|
||||
-- too, so a zoomed shot lights exactly the ground it shows -- which is why
|
||||
-- BattleScene asks this rather than multiplying for itself.
|
||||
function BattleCam.frameH(arena)
|
||||
local base = BattleCam.rigFor(arena).frameH
|
||||
if BattleCam.still or not BattleCam.steerable then return base end
|
||||
return base * BattleCam.zoom * BattleCam.spread(arena)
|
||||
end
|
||||
|
||||
local function chase(now, goal, dt, time)
|
||||
if now == goal then return goal end
|
||||
local v = now + (goal - now) * math.min(1, (dt or 0) / time)
|
||||
return (math.abs(goal - v) < 1e-4) and goal or v
|
||||
end
|
||||
|
||||
-- Real frame time, like every other presentational tween in this mod: a
|
||||
-- fast-forwarded battle must not spin the camera.
|
||||
function BattleCam.update(dt)
|
||||
@@ -138,6 +383,14 @@ function BattleCam.update(dt)
|
||||
-- float precision in the sines below
|
||||
local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD
|
||||
if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end
|
||||
-- and the steered three easing after whatever the player last asked for,
|
||||
-- which is what keeps a flick of the stick from being a cut
|
||||
BattleCam.orbit = chase(BattleCam.orbit, BattleCam.orbitGoal, dt,
|
||||
BattleCam.ORBIT_TIME)
|
||||
BattleCam.pitch = chase(BattleCam.pitch, BattleCam.pitchGoal, dt,
|
||||
BattleCam.PITCH_TIME)
|
||||
BattleCam.zoom = chase(BattleCam.zoom, BattleCam.zoomGoal, dt,
|
||||
BattleCam.ZOOM_TIME)
|
||||
end
|
||||
|
||||
local function phase(t, period)
|
||||
@@ -155,22 +408,83 @@ end
|
||||
--
|
||||
-- `groundY` is the height of the arena floor, so a fight staged on a ledge
|
||||
-- or a raised walkway is shot from above THAT rather than from inside it.
|
||||
function BattleCam.rig(arena, groundY)
|
||||
-- `canonical` asks for the shot the rig was SOLVED for -- no drift, no
|
||||
-- breath, no steer, no zoom -- from a caller that is reasoning about the
|
||||
-- arena rather than drawing it. BattleArena's clearance test is the one
|
||||
-- that needs it: whether a fight can be staged somewhere is a fact about
|
||||
-- the ground, and answering it through whatever angle the player happened
|
||||
-- to leave the last battle on would pick a different arena depending on
|
||||
-- where they had swung the camera an hour ago.
|
||||
function BattleCam.rig(arena, groundY, canonical)
|
||||
groundY = groundY or 0
|
||||
local R = BattleCam.rigFor(arena)
|
||||
local mx, mz = arena.mid[1], arena.mid[2]
|
||||
-- VR asks for the same stillness for its own reason (see BattleCam.still)
|
||||
local fixed = BattleCam.still or canonical
|
||||
-- and the steer is withheld a second way, on its own: BACK SPRITES holds
|
||||
-- the composition and the DRIFT still runs under it (see steerable)
|
||||
local steered = (not fixed) and BattleCam.steerable
|
||||
|
||||
local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
|
||||
-- The drift, plus wherever the player has steered to. The steer is
|
||||
-- NEGATIVE because the rotation below runs the other way from the bearing
|
||||
-- it turns: rotating (side, back) by +yaw carries the eye back toward the
|
||||
-- arena's own axis, and the room the player has is all on the far side of
|
||||
-- that -- out toward square-on. (orbitRange measures exactly that room.)
|
||||
local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
|
||||
-- ------- and the quarter turn the arena itself is standing at
|
||||
--
|
||||
-- An arena may be laid down any of the four ways (BattleArena's `turn`),
|
||||
-- and the rig is solved for ONE of them: eye off the player's shoulder,
|
||||
-- back down an axis that runs north-south. So the whole offset is turned
|
||||
-- with the ground under it, which leaves the camera in exactly the same
|
||||
-- place RELATIVE to the two mons -- same distance, same height, same
|
||||
-- angle -- and therefore lands them on the same two screen anchors at the
|
||||
-- same size. A turn is a fact about the map, never about the shot.
|
||||
--
|
||||
-- It goes in with the drift and the steer rather than beside them because
|
||||
-- it is the same rotation about the same point; the player's own orbit is
|
||||
-- then measured from wherever the arena starts, so both stops travel with
|
||||
-- it and side-on stays side-on.
|
||||
local base = math.rad(arena.turn or 0)
|
||||
local yaw = base + steer + (fixed and 0
|
||||
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
|
||||
local c, s = math.cos(yaw), math.sin(yaw)
|
||||
-- the breath scales the whole offset, height included, so the eye moves
|
||||
-- along its own line to the arena and the pitch of the shot never changes
|
||||
local k = 1 + BattleCam.PAN_DOLLY
|
||||
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
|
||||
local k = fixed and 1
|
||||
or 1 + BattleCam.PAN_DOLLY
|
||||
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
|
||||
local dx = (R.side * c - R.back * s) * k
|
||||
local dz = (R.side * s + R.back * c) * k
|
||||
|
||||
local eye = { mx + dx, groundY + R.height * k, mz + dz }
|
||||
local focus = { mx + R.lookX, groundY + R.lookY, mz }
|
||||
-- the aim's own offset turns with the arena too, and with the BASE alone --
|
||||
-- the drift and the steer swing the eye about the focus, so a focus that
|
||||
-- followed them would take the thing being orbited around with it
|
||||
local bc, bs = math.cos(base), math.sin(base)
|
||||
local focus = { mx + R.lookX * bc, groundY + R.lookY, mz + R.lookX * bs }
|
||||
|
||||
-- and the climb: the eye swung UP about the focus, at a constant radius.
|
||||
-- About the focus so the aim stays nailed to the two mons and only the
|
||||
-- seat moves, and at a constant radius so climbing never changes how big
|
||||
-- anything is -- that is the lens's job below, and a rig that did both at
|
||||
-- once would have no way to do either on purpose.
|
||||
local lift = steered and BattleCam.pitch * BattleCam.PITCH_RANGE or 0
|
||||
if lift > 0 then
|
||||
local vx, vy, vz = eye[1] - focus[1], eye[2] - focus[2], eye[3] - focus[3]
|
||||
local flat = math.sqrt(vx * vx + vz * vz)
|
||||
local r = math.sqrt(flat * flat + vy * vy)
|
||||
if flat > 1e-6 and r > 1e-6 then
|
||||
local a = math.atan2(vy, flat) + lift
|
||||
-- short of straight down, always: the placed camera's up vector is
|
||||
-- world up, which degenerates against a view looking exactly along it
|
||||
a = math.min(a, math.rad(85))
|
||||
local nf = r * math.cos(a)
|
||||
eye[1] = focus[1] + vx / flat * nf
|
||||
eye[3] = focus[3] + vz / flat * nf
|
||||
eye[2] = focus[2] + r * math.sin(a)
|
||||
end
|
||||
end
|
||||
|
||||
local ex = eye[1] - focus[1]
|
||||
local ey = eye[2] - focus[2]
|
||||
@@ -178,10 +492,17 @@ function BattleCam.rig(arena, groundY)
|
||||
local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez))
|
||||
local horiz = math.sqrt(ex * ex + ez * ez)
|
||||
|
||||
-- The lens carries the player's zoom: how much world the frame holds is
|
||||
-- the one thing that actually changes the framing here, because the field
|
||||
-- of view is DERIVED from that reach and the distance. Moving the eye
|
||||
-- instead would leave the picture the same size and only change its
|
||||
-- perspective -- which is what the dolly breath above is deliberately
|
||||
-- for, and is not what "zoom" means to anyone holding a wheel.
|
||||
local frameH = fixed and R.frameH or BattleCam.frameH(arena)
|
||||
return {
|
||||
eye = eye,
|
||||
focus = focus,
|
||||
fov = 2 * math.atan((R.frameH / 2) / dist),
|
||||
fov = 2 * math.atan((frameH / 2) / dist),
|
||||
-- the world curve is a free-roam flourish that bends the horizon away
|
||||
-- from the player; a fixed camera on a staged shot has no player to bend
|
||||
-- around, and the bend would tip the arena floor out from under the mons
|
||||
|
||||
+20
-197
@@ -12,15 +12,17 @@
|
||||
-- and an opaque slab in the corner of the frame is the white field back
|
||||
-- again by another name.
|
||||
--
|
||||
-- And the text flips. A panel over a sunlit meadow is bright and wants black
|
||||
-- glyphs; the same panel over a cave floor or a dark roof is not, and wants
|
||||
-- white ones. So the panel's average brightness is measured and the glyphs
|
||||
-- follow it, with hysteresis so a slow camera drift across the threshold
|
||||
-- cannot strobe them.
|
||||
-- The ink does NOT change. There was a pass here that measured each panel's
|
||||
-- average brightness and flipped the glyphs to white over a dark one, with
|
||||
-- hysteresis so a drifting camera could not strobe them. It worked, and it
|
||||
-- was still wrong: the battle menu is the one part of the frame the player
|
||||
-- reads constantly, and having its colour depend on what the camera happens
|
||||
-- to be pointing at makes it an unreliable piece of furniture. Gen 1's
|
||||
-- battle text is black, so it is black -- and the panel's tint is what
|
||||
-- earns that its contrast, on a cave floor as much as on a meadow.
|
||||
--
|
||||
-- The measurement is a one-pixel readback, which is a GPU stall, so it runs
|
||||
-- a few times a second rather than every frame. The camera drifts at about
|
||||
-- a pixel a second; brightness cannot outrun that.
|
||||
-- Removing it also took out a one-pixel GPU readback that ran several times
|
||||
-- a second purely to answer a question nothing asks any more.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
@@ -37,24 +39,13 @@ local BattleHud = {}
|
||||
BattleHud.FROST = 0.55
|
||||
BattleHud.TINT = 0.26
|
||||
|
||||
-- The luminance the glyphs flip at, with a dead band so a drift across it
|
||||
-- settles rather than strobes.
|
||||
BattleHud.DARK_ENTER = 0.44 -- below this, the panel is dark: white glyphs
|
||||
BattleHud.DARK_LEAVE = 0.56 -- above this, back to black ones
|
||||
|
||||
-- Frames between brightness readbacks.
|
||||
BattleHud.SAMPLE_EVERY = 12
|
||||
|
||||
-- The frost buffer's height; width follows the source's aspect. Small on
|
||||
-- purpose: the downscale is most of the blur, and what is read back for the
|
||||
-- brightness is one pixel of it.
|
||||
-- purpose: the downscale is most of the blur.
|
||||
BattleHud.FROST_H = 72
|
||||
|
||||
local frost, frostW, frostH = nil, 0, 0
|
||||
local blurA, blurB = nil, nil
|
||||
local probe = nil
|
||||
local frame = 0
|
||||
local luma = {} -- panel key -> { value, dark, at }
|
||||
|
||||
local SHADER = [[
|
||||
uniform vec2 dir;
|
||||
@@ -102,7 +93,6 @@ function BattleHud.build(src)
|
||||
frost = canvasOf(w, h)
|
||||
blurA = canvasOf(w, h)
|
||||
blurB = canvasOf(w, h)
|
||||
probe = probe or canvasOf(1, 1)
|
||||
if not (frost and blurA and blurB) then
|
||||
frost, blurA, blurB, frostW, frostH = nil, nil, nil, 0, 0
|
||||
return nil
|
||||
@@ -149,46 +139,6 @@ function BattleHud.frame()
|
||||
return frame
|
||||
end
|
||||
|
||||
-- Average luminance of the frost under `key`'s rect, in frost-canvas pixels.
|
||||
--
|
||||
-- Averaged by letting the GPU do it: the rect is drawn into a one-pixel
|
||||
-- canvas, which IS the mean, and that one pixel is read back. Cached for
|
||||
-- SAMPLE_EVERY frames because the readback synchronises the pipeline and
|
||||
-- nothing it measures moves faster than that.
|
||||
local function sampleLuma(key, fx, fy, fw, fh)
|
||||
local hit = luma[key]
|
||||
if hit and (frame - hit.at) < BattleHud.SAMPLE_EVERY then return hit.value end
|
||||
if not (frost and probe and frostW > 0) then return hit and hit.value end
|
||||
if fw <= 0 or fh <= 0 then return hit and hit.value end
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
local value = hit and hit.value or 1
|
||||
local ok = pcall(function()
|
||||
love.graphics.setCanvas(probe)
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH)
|
||||
love.graphics.draw(frost, quad, 0, 0, 0, 1 / fw, 1 / fh)
|
||||
love.graphics.setCanvas()
|
||||
local data = probe:newImageData()
|
||||
local r, g, b = data:getPixel(0, 0)
|
||||
if data.release then pcall(data.release, data) end
|
||||
value = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
end)
|
||||
|
||||
if prevCanvas then
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
else
|
||||
love.graphics.setCanvas()
|
||||
end
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
if not ok then return hit and hit.value end
|
||||
|
||||
luma[key] = { value = value, at = frame }
|
||||
return value
|
||||
end
|
||||
|
||||
-- Map a GB-frame rect onto the frost canvas, given where the letterbox sits
|
||||
-- in the source the frost was built from.
|
||||
local function frostRect(rect, box)
|
||||
@@ -218,44 +168,14 @@ local function mapper(world)
|
||||
return world and frostRectWorld or frostRect
|
||||
end
|
||||
|
||||
-- ------- the verdict
|
||||
--
|
||||
-- ONE answer for the whole frame, not one per panel. Both HUDs draw in a
|
||||
-- single pass and there is only one glyph colour to be had out of it -- and
|
||||
-- a frame with a black-lettered HUD in one corner and a white-lettered one
|
||||
-- in the other would read as a bug rather than as adaptation. The DARKER
|
||||
-- panel decides, because it is the one that cannot afford to be wrong, and
|
||||
-- the tint below then commits both panels to that reading.
|
||||
local wasDark = false
|
||||
|
||||
function BattleHud.verdict(rects, box, world)
|
||||
if not (frost and box and box.scale and box.scale > 0) then return false end
|
||||
local toFrost = mapper(world)
|
||||
local darkest = nil
|
||||
for key, rect in pairs(rects) do
|
||||
local fx, fy, fw, fh = toFrost(rect, box)
|
||||
local v = sampleLuma(key, fx, fy, fw, fh)
|
||||
if v and (not darkest or v < darkest) then darkest = v end
|
||||
end
|
||||
if not darkest then return wasDark end
|
||||
-- hysteresis: it takes a clear move past the far threshold to flip back,
|
||||
-- so a camera drifting across the boundary settles instead of strobing
|
||||
if wasDark then
|
||||
wasDark = darkest < BattleHud.DARK_LEAVE
|
||||
else
|
||||
wasDark = darkest < BattleHud.DARK_ENTER
|
||||
end
|
||||
return wasDark
|
||||
end
|
||||
|
||||
-- Draw one HUD panel into the current target, in that target's own
|
||||
-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true)
|
||||
-- for a panel laid straight onto the world image.
|
||||
--
|
||||
-- The tint always pushes AWAY from the glyph colour that is about to be
|
||||
-- used, so the contrast is guaranteed rather than hoped for: a dark panel
|
||||
-- gets darker under white text, a bright one brighter under black text.
|
||||
function BattleHud.panel(rect, box, dark, world)
|
||||
-- The tint always pushes toward WHITE, away from the black ink that is about
|
||||
-- to land on it, so the contrast is guaranteed rather than hoped for -- and
|
||||
-- it is the whole of what makes a fixed ink colour workable over any ground.
|
||||
function BattleHud.panel(rect, box, world)
|
||||
if not (frost and box and box.scale and box.scale > 0) then return false end
|
||||
local fx, fy, fw, fh = mapper(world)(rect, box)
|
||||
local ok = pcall(function()
|
||||
@@ -263,93 +183,13 @@ function BattleHud.panel(rect, box, dark, world)
|
||||
love.graphics.setColor(1, 1, 1, BattleHud.FROST)
|
||||
love.graphics.draw(frost, quad, rect[1], rect[2], 0,
|
||||
rect[3] / fw, rect[4] / fh)
|
||||
local shade = dark and 0 or 1
|
||||
love.graphics.setColor(shade, shade, shade, BattleHud.TINT)
|
||||
love.graphics.setColor(1, 1, 1, BattleHud.TINT)
|
||||
love.graphics.rectangle("fill", rect[1], rect[2], rect[3], rect[4])
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ------- flipping the glyphs
|
||||
--
|
||||
-- Over a dark panel the HUD's black text has to go white, and it cannot be
|
||||
-- done by setting a draw colour: LOVE MULTIPLIES by it, and a black glyph
|
||||
-- times white is still black. The colour channel has to be REPLACED.
|
||||
--
|
||||
-- So the HUD is drawn into a scratch layer and that layer is composited back
|
||||
-- through a shader that whitens whatever is nearly black and leaves the rest
|
||||
-- alone. "Nearly black" is the text, the tick marks and the bar's outline --
|
||||
-- everything the HUD draws as ink -- while the HP bar's own greens and reds
|
||||
-- are well clear of the threshold and come through untouched.
|
||||
--
|
||||
-- Composited back into whatever the caller had bound, which is what makes it
|
||||
-- work in both pipelines without knowing which one it is in: in the colorized
|
||||
-- one that target is the grayscale BG canvas, where white IS shade 0 and the
|
||||
-- zone pass then colours the flipped glyphs like every other lightest-shade
|
||||
-- surface; in the flat fallback it is the screen, where white is white.
|
||||
local INK = 0.35 -- luminance at or under which a pixel counts as ink
|
||||
|
||||
local FLIP = [[
|
||||
uniform float ink;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec4 p = Texel(tex, tc);
|
||||
float luma = dot(p.rgb, vec3(0.299, 0.587, 0.114));
|
||||
if (p.a > 0.0 && luma <= ink * p.a) p.rgb = vec3(p.a);
|
||||
return p * color;
|
||||
}
|
||||
]]
|
||||
|
||||
local flipShader = nil
|
||||
local layer = nil
|
||||
|
||||
local function getFlip()
|
||||
if flipShader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, FLIP)
|
||||
flipShader = (ok and sh) or false
|
||||
end
|
||||
return flipShader or nil
|
||||
end
|
||||
|
||||
-- Whether the flip pass can run at all, for the shot driver's log.
|
||||
function BattleHud.flipReady()
|
||||
return getFlip() ~= nil
|
||||
end
|
||||
|
||||
-- Run `fn` with its ink whitened. Falls back to running it plainly when the
|
||||
-- scratch layer or the shader is unavailable, so a driver that cannot do
|
||||
-- either gets the vanilla black HUD rather than no HUD.
|
||||
function BattleHud.flipGlyphs(w, h, fn)
|
||||
local sh = getFlip()
|
||||
if not sh then return fn() end
|
||||
if not layer or layer:getWidth() ~= w or layer:getHeight() ~= h then
|
||||
layer = canvasOf(w, h, "nearest")
|
||||
if not layer then return fn() end
|
||||
end
|
||||
|
||||
local prevCanvas = love.graphics.getCanvas()
|
||||
local prevBlend, prevAlpha = love.graphics.getBlendMode()
|
||||
local ok, err = pcall(function()
|
||||
love.graphics.setCanvas(layer)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
fn()
|
||||
end)
|
||||
if prevCanvas then
|
||||
love.graphics.setCanvas(prevCanvas)
|
||||
else
|
||||
love.graphics.setCanvas()
|
||||
end
|
||||
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
if not ok then error(err, 0) end
|
||||
|
||||
love.graphics.setShader(sh)
|
||||
pcall(sh.send, sh, "ink", INK)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(layer, 0, 0)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
-- ------- the whole HUD layer as a texture
|
||||
--
|
||||
-- The two blocks do not sit in the same place any more: each is snapped to its
|
||||
@@ -358,13 +198,9 @@ end
|
||||
-- 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)
|
||||
function BattleHud.layerTexture(w, h, 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
|
||||
@@ -377,9 +213,7 @@ function BattleHud.layerTexture(w, h, dark, fn)
|
||||
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
|
||||
fn()
|
||||
end)
|
||||
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
||||
g.setBlendMode(prevBlend or "alpha", prevAlpha)
|
||||
@@ -388,21 +222,10 @@ function BattleHud.layerTexture(w, h, dark, fn)
|
||||
return hudLayer
|
||||
end
|
||||
|
||||
-- The last luminance measured, for the shot driver's log.
|
||||
function BattleHud.lastLuma()
|
||||
local best = nil
|
||||
for _, hit in pairs(luma) do
|
||||
if not best or hit.value < best then best = hit.value end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
function BattleHud.invalidate()
|
||||
frost, blurA, blurB, probe = nil, nil, nil, nil
|
||||
frost, blurA, blurB = nil, nil, nil
|
||||
frostW, frostH = 0, 0
|
||||
luma = {}
|
||||
wasDark = false
|
||||
layer, hudLayer = nil, nil
|
||||
hudLayer = nil
|
||||
end
|
||||
|
||||
return BattleHud
|
||||
|
||||
+122
-26
@@ -57,6 +57,26 @@
|
||||
-- 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.
|
||||
--
|
||||
@@ -71,14 +91,23 @@ local V = ...
|
||||
|
||||
local BattlePics = {}
|
||||
|
||||
-- Cached by the image the engine handed over. Weak keys, so a pic that goes
|
||||
-- out of scope takes its filled twin with it rather than pinning a texture
|
||||
-- for the session.
|
||||
local cache = setmetatable({}, { __mode = "k" })
|
||||
-- Cached by the image the engine handed over, one table per bottom rule --
|
||||
-- the same pic answers differently sealed and unsealed, and a single table
|
||||
-- would hand the wrong twin back to whichever caller asked second. Weak keys,
|
||||
-- so a pic that goes out of scope takes its filled twin with it rather than
|
||||
-- pinning a texture for the session.
|
||||
local function newCache()
|
||||
return {
|
||||
[false] = setmetatable({}, { __mode = "k" }),
|
||||
[true] = setmetatable({}, { __mode = "k" }),
|
||||
}
|
||||
end
|
||||
local cache = newCache()
|
||||
|
||||
-- What an enclosed hole is filled with. White, because white is what the
|
||||
-- battle field was: this restores the pixel the artist drew and the engine
|
||||
-- then keyed away, it does not invent a new one.
|
||||
-- What an enclosed hole is filled with when the pic itself offers nothing
|
||||
-- better. White, because white is what the battle field was: this restores the
|
||||
-- pixel the artist drew and the engine then keyed away, it does not invent a
|
||||
-- new one.
|
||||
BattlePics.FILL = { 1, 1, 1, 1 }
|
||||
|
||||
-- Anything at or under this alpha counts as keyed-out rather than drawn.
|
||||
@@ -88,6 +117,21 @@ local CUT = 0.5
|
||||
-- its data back, so it is drawn into a canvas of its own size and the canvas
|
||||
-- is read -- which is also what makes this work for every path that produces
|
||||
-- a pic, without knowing which one produced this one.
|
||||
--
|
||||
-- The canvas is forced to dpiscale = 1, and that is the whole difference
|
||||
-- between a pic and a MONSTER. love.graphics.newCanvas defaults its dpiscale
|
||||
-- to the surface's, conf.lua turns highdpi on for Android and iOS, and
|
||||
-- Android's density is routinely 2.75 -- so newCanvas(56, 56) hands back a
|
||||
-- 154x154 texture there, the pic is drawn into it magnified to fill it, and
|
||||
-- newImageData reads the magnified copy back at its own PIXEL size. The image
|
||||
-- built from that is 2.75x the artwork, drawPicsLayer draws it at 1:1 because
|
||||
-- it trusts getWidth(), and the mon stands on the map nearly three times the
|
||||
-- size of the square it is supposed to cover. Desktop never saw it: dpiscale
|
||||
-- is already 1 there. Nor did every species, because only a pic with an
|
||||
-- enclosed hole in it comes back through here at all (see `changed` below) --
|
||||
-- so a Pidgey came out giant and the mon beside it did not, which is what
|
||||
-- makes this read as a sprite bug rather than a scale one. See the engine's
|
||||
-- own src/render/PixelCanvas.lua, which exists for exactly this reason.
|
||||
local function readBack(img)
|
||||
local w, h = img:getDimensions()
|
||||
if w <= 0 or h <= 0 then return nil end
|
||||
@@ -96,7 +140,7 @@ local function readBack(img)
|
||||
local prevR, prevG, prevB, prevA = love.graphics.getColor()
|
||||
local data = nil
|
||||
local ok = pcall(function()
|
||||
local canvas = love.graphics.newCanvas(w, h)
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setBlendMode("replace", "premultiplied")
|
||||
@@ -139,6 +183,42 @@ local function inkBounds(data, w, h)
|
||||
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
|
||||
@@ -146,7 +226,9 @@ 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.
|
||||
-- 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.
|
||||
@@ -154,7 +236,7 @@ BattlePics.DRAIN = 6
|
||||
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
|
||||
-- pixels and a keyed-out background is most of them, which is a deeper call
|
||||
-- chain than is worth risking for no gain.
|
||||
local function markOutside(data, w, h, x0, y0, x1, y1)
|
||||
local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||
local outside = {}
|
||||
local stack, top = {}, 0
|
||||
local function clear(x, y)
|
||||
@@ -176,17 +258,21 @@ local function markOutside(data, w, h, x0, y0, x1, y1)
|
||||
push(x1, y)
|
||||
end
|
||||
-- the bottom, run by run: a wide one is the gap between two legs and lets
|
||||
-- the world through, a narrow one is where a belly ran out and is sealed
|
||||
local x = x0
|
||||
while x <= x1 do
|
||||
if clear(x, y1) then
|
||||
local from = x
|
||||
while x <= x1 and clear(x, y1) do x = x + 1 end
|
||||
if (x - from) > BattlePics.DRAIN then
|
||||
for k = from, x - 1 do push(k, y1) end
|
||||
-- the world through, a narrow one is where a belly ran out and is sealed.
|
||||
-- Skipped whole for a pic on the box, where even the widest of them has
|
||||
-- white paper behind it rather than arena.
|
||||
if not sealBottom then
|
||||
local x = x0
|
||||
while x <= x1 do
|
||||
if clear(x, y1) then
|
||||
local from = x
|
||||
while x <= x1 and clear(x, y1) do x = x + 1 end
|
||||
if (x - from) > BattlePics.DRAIN then
|
||||
for k = from, x - 1 do push(k, y1) end
|
||||
end
|
||||
else
|
||||
x = x + 1
|
||||
end
|
||||
else
|
||||
x = x + 1
|
||||
end
|
||||
end
|
||||
while top > 0 do
|
||||
@@ -204,9 +290,15 @@ end
|
||||
-- The pic with its enclosed holes filled, or the pic itself when that could
|
||||
-- not be done (no pixel access, a driver that refused the readback). Never
|
||||
-- nil for a non-nil argument: a caller must always have something to draw.
|
||||
function BattlePics.filled(img)
|
||||
--
|
||||
-- sealBottom for a pic pinned to the text box rather than standing on the map:
|
||||
-- see the header. A caller that does not say defaults to the map, which is
|
||||
-- where all but one of this mod's pics are.
|
||||
function BattlePics.filled(img, sealBottom)
|
||||
if not img then return img end
|
||||
local hit = cache[img]
|
||||
sealBottom = sealBottom and true or false
|
||||
local slot = cache[sealBottom]
|
||||
local hit = slot[img]
|
||||
if hit ~= nil then return hit or img end
|
||||
|
||||
local made = nil
|
||||
@@ -216,8 +308,12 @@ function BattlePics.filled(img)
|
||||
local w, h = data:getDimensions()
|
||||
local x0, y0, x1, y1 = inkBounds(data, w, h)
|
||||
if not x0 then return end -- a pic with nothing drawn in it
|
||||
local outside = markOutside(data, w, h, x0, y0, x1, y1)
|
||||
local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
|
||||
local fill = BattlePics.FILL
|
||||
local pr, pg, pb = paperColor(data, x0, y0, x1, y1)
|
||||
local fr = pr or fill[1]
|
||||
local fg = pg or fill[2]
|
||||
local fb = pb or fill[3]
|
||||
local changed = false
|
||||
-- only inside the box: everything beyond it is frame the artist never
|
||||
-- reached, and filling that would put the mon in a white rectangle
|
||||
@@ -227,7 +323,7 @@ function BattlePics.filled(img)
|
||||
if not outside[row + x] then
|
||||
local _, _, _, a = data:getPixel(x, y)
|
||||
if a <= CUT then
|
||||
data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
|
||||
data:setPixel(x, y, fr, fg, fb, fill[4])
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
@@ -240,12 +336,12 @@ function BattlePics.filled(img)
|
||||
made = out
|
||||
end)
|
||||
|
||||
cache[img] = (ok and made) or false
|
||||
slot[img] = (ok and made) or false
|
||||
return made or img
|
||||
end
|
||||
|
||||
function BattlePics.invalidate()
|
||||
cache = setmetatable({}, { __mode = "k" })
|
||||
cache = newCache()
|
||||
end
|
||||
|
||||
return BattlePics
|
||||
|
||||
+398
-37
@@ -42,11 +42,35 @@ local BattleCam = V.require("BattleCam")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local BattleScene = {}
|
||||
|
||||
-- ------- LET'S GO capture mode's stake in this scene
|
||||
--
|
||||
-- One table while a capture session runs, nil otherwise (see
|
||||
-- lib/CatchThrow.lua, which owns it):
|
||||
--
|
||||
-- hidePlayer the player's side stays out of the shot entirely -- no
|
||||
-- card here, no model (Stadium reads this same table), no
|
||||
-- pinned back pic (OverworldBattle reads it too)
|
||||
-- shrink the foe's scale while the ball drinks it in, applied
|
||||
-- about its chest so it collapses toward the beam
|
||||
-- draw(pull) the Poke Ball, drawn after the Stadium models -- same
|
||||
-- depth buffer, same flash window, same camera
|
||||
-- cast(sm) the same ball into the sun's pass
|
||||
-- sig() a term for the cached shadow signature, so a ball in
|
||||
-- flight re-casts and a resting scene does not
|
||||
-- drawGB(b) the 2D layer (ring, labels), drawn by OverworldBattle's
|
||||
-- BattleState:draw wrap in the GB frame
|
||||
--
|
||||
-- It lives HERE, not on OverworldBattle, because every consumer below
|
||||
-- already requires BattleScene and the one file that writes it requires
|
||||
-- both -- this is the spot with no require cycle.
|
||||
BattleScene.capture = nil
|
||||
|
||||
-- The GB frame the battle screen is drawn in, and the frame BattleCam's rig
|
||||
-- is solved against.
|
||||
BattleScene.GB_W = 160
|
||||
@@ -141,9 +165,10 @@ local function prefetchArena(state, host)
|
||||
for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end
|
||||
ChunkMesher.setLive(live)
|
||||
TerrainAtlas.setLive(live)
|
||||
local terrain = ChunkMesher.request(host, false, nil, true)
|
||||
or ChunkMesher.peek(host, true)
|
||||
return terrain, {}
|
||||
ChunkMesher.request(host, false, nil, true)
|
||||
local terrain, water = ChunkMesher.pair(host, false)
|
||||
if not terrain then terrain, water = ChunkMesher.pair(host, true) end
|
||||
return terrain, {}, water, {}
|
||||
end
|
||||
|
||||
-- ------- the sun
|
||||
@@ -193,14 +218,29 @@ end
|
||||
local function monCards(arena, groundY, textures)
|
||||
local out = {}
|
||||
if not textures then return out end
|
||||
local cap = BattleScene.capture
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local tex = textures[side]
|
||||
local cell = (side == "player") and arena.player or arena.enemy
|
||||
-- capture mode: the player's side is out of the shot (the seat looks
|
||||
-- over an empty shoulder), and OverworldBattle.textures already
|
||||
-- skipped rendering it -- this is the belt to that suspender
|
||||
if side == "player" and cap and cap.hidePlayer then tex = nil end
|
||||
if tex and tex.canvas and cell then
|
||||
local mirror = (side == "player") and not tex.trainer
|
||||
out[#out + 1] = { tex = tex.canvas,
|
||||
model = monMatrix(tex, cell[1], groundY, cell[2],
|
||||
mirror) }
|
||||
local model = monMatrix(tex, cell[1], groundY, cell[2], mirror)
|
||||
-- the foe drinking into the ball: scaled about its own chest, in
|
||||
-- world space so the composed card matrix needs no decomposition
|
||||
if side == "enemy" and cap and cap.shrink then
|
||||
local k = cap.shrink
|
||||
local ax, ay, az = cell[1], groundY + 8, cell[2]
|
||||
model = Mat4.mul(
|
||||
Mat4.mul(Mat4.translate(ax, ay, az),
|
||||
Mat4.mul(Mat4.scale(k, k, k),
|
||||
Mat4.translate(-ax, -ay, -az))),
|
||||
model)
|
||||
end
|
||||
out[#out + 1] = { tex = tex.canvas, model = model }
|
||||
end
|
||||
end
|
||||
return out
|
||||
@@ -208,6 +248,91 @@ end
|
||||
|
||||
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
|
||||
-- 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
|
||||
@@ -216,27 +341,60 @@ BattleScene.monCards = monCards
|
||||
-- first drawn in.
|
||||
local function shadowSignature(state, arena, terrain, nbMesh, token)
|
||||
local host = arena.map or state.map
|
||||
-- `turn` is in the signature with the corner and the shape: the same corner
|
||||
-- turned a quarter is a different footprint standing on different ground,
|
||||
-- and a cast kept from the other one freezes the shadows across it
|
||||
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
|
||||
tostring(arena.turn 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) }
|
||||
-- a capture session's ball moves through the sun's world too; its term
|
||||
-- is quantised inside sig() so the cache re-renders on real movement
|
||||
-- and not on every frame the ball rests
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.sig then
|
||||
local okSig, sig = pcall(cap.sig)
|
||||
parts[#parts + 1] = okSig and sig or "cap"
|
||||
end
|
||||
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
|
||||
return table.concat(parts, ",")
|
||||
end
|
||||
|
||||
local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
||||
atlasFor, cards, token, host, neighbors)
|
||||
atlasFor, cards, token, host, neighbors,
|
||||
water, nbWater, groundY)
|
||||
if not ShadowMap.available() then return end
|
||||
local sig = shadowSignature(state, arena, terrain, nbMesh, token)
|
||||
if not ShadowMap.stale(sig) then return end
|
||||
if not ShadowMap.begin(cx, cy, vw, vh) then return end
|
||||
|
||||
-- A DISC RUNG: the two discs are the only ground there is, so they are the
|
||||
-- only thing the sun has to see besides the Pokemon themselves. Everything
|
||||
-- below this is a map that is not in the shot.
|
||||
if arena.discs then
|
||||
pcall(function()
|
||||
V.require("StadiumStage").cast(ShadowMap, arena, groundY or 0)
|
||||
end)
|
||||
pcall(function() V.require("Stadium").cast(ShadowMap) end)
|
||||
ShadowMap.finish(sig)
|
||||
return
|
||||
end
|
||||
|
||||
ShadowMap.draw(terrain, atlasFor(host), nil)
|
||||
for i, nb in ipairs(neighbors) do
|
||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- the water surface is its own reflective pass now (see Water) and so is
|
||||
-- no longer inside the terrain mesh; the sun still has to see it, or the
|
||||
-- light's map has a hole at every lake
|
||||
ShadowMap.draw(water, atlasFor(host), nil)
|
||||
for i, nb in ipairs(neighbors) do
|
||||
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows
|
||||
-- keep contact with their bases instead of starting a bias-width away
|
||||
ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host),
|
||||
@@ -249,10 +407,26 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh,
|
||||
-- the mons themselves, as the same cards the camera will see. Their alpha
|
||||
-- is the silhouette, so what lands on the ground is the shape of the
|
||||
-- Pokemon rather than a blob standing in for one.
|
||||
-- marked as the CAST, so a fight staged at the water's edge does not lay a
|
||||
-- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's
|
||||
-- own floor still takes them, which is the shadow that matters here
|
||||
ShadowMap.sprites(true)
|
||||
for _, card in ipairs(cards or {}) do
|
||||
ShadowMap.draw(BattleBillboard.mesh(), card.tex,
|
||||
ShadowMap.snug(card.model))
|
||||
end
|
||||
ShadowMap.sprites(false)
|
||||
-- and the STADIUM models, when that rung is the one running. NOT marked
|
||||
-- as sprites: that flag exists so a flat card's cut-out is kept off the
|
||||
-- water (see ShadowMap.sprites), and these are real geometry standing in
|
||||
-- the world -- a Gyarados at the water's edge should put a Gyarados on
|
||||
-- the water. Un-snugged for the same reason: snug is a bias for a card
|
||||
-- rooted to the ground plane, and a model has thickness of its own.
|
||||
pcall(function() V.require("Stadium").cast(ShadowMap) end)
|
||||
-- the capture session's ball, by the same reasoning: real geometry, its
|
||||
-- shadow is half of what sells the arc
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.cast then pcall(cap.cast, ShadowMap) end
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
end
|
||||
@@ -262,6 +436,11 @@ end
|
||||
-- the one nearer the camera and therefore the one a mismatch would show up
|
||||
-- against.
|
||||
function BattleScene.groundY(map, arena)
|
||||
-- A disc rung's discs are carried, not found: their tops ARE the ground
|
||||
-- plane, so there is no terrain height to read and reading one would put
|
||||
-- the stage at whatever elevation the map happens to have at a spot the
|
||||
-- fight is not actually happening on
|
||||
if arena and arena.discs then return 0 end
|
||||
local ok, h = pcall(VoxelScene.groundAt, map,
|
||||
arena.playerCell[1], arena.playerCell[2])
|
||||
return (ok and h) or 0
|
||||
@@ -299,9 +478,36 @@ end
|
||||
BattleScene.FLASH_COLOR = { 1, 1, 1 }
|
||||
BattleScene.FLASH_STRENGTH = 0.5
|
||||
|
||||
-- ------- the tile clock, while the overworld is not the one drawing
|
||||
--
|
||||
-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE
|
||||
-- only advances it from OverworldState:drawWorld -- which runs under dialogs
|
||||
-- and menus, but not under a battle, because a battle draws instead of the
|
||||
-- overworld rather than over it. So for the length of a staged fight the
|
||||
-- counter stood still: the water tiles stopped rotating their pixels and the
|
||||
-- wave field, which is driven off the same number so the two cannot drift
|
||||
-- (see Water), stopped with them. A lake in the background of a battle was a
|
||||
-- photograph.
|
||||
--
|
||||
-- Ticked HERE rather than from the mod's update hook, because here is the
|
||||
-- one place that means "a staged battle is drawing this frame, and the
|
||||
-- overworld is not". From the update hook the condition would have to be
|
||||
-- guessed at, and a frame where both ran would double the rate.
|
||||
local function tickTiles()
|
||||
local Game = require("src.core.Game")
|
||||
local ow = Game and Game.overworld
|
||||
local top = Game and Game.stack and Game.stack:top()
|
||||
-- during the wipe INTO a battle the overworld can still be the one
|
||||
-- drawing, and it is ticking the clock itself; two ticks in a frame would
|
||||
-- run the water at double speed
|
||||
if top and ow and top == ow then return end
|
||||
pcall(require("src.render.TileRenderer").tick)
|
||||
end
|
||||
|
||||
function BattleScene.render(state, arena, textures, token)
|
||||
if not (state and state.map and arena) then return nil end
|
||||
if not Voxel3D.available() then return nil end
|
||||
tickTiles()
|
||||
|
||||
-- the floor the fight is staged on: normally the player's own, sometimes
|
||||
-- another floor of the same cave or building (see BattleArena)
|
||||
@@ -323,11 +529,37 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- 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
|
||||
-- the host floor's atmosphere reaches the staged shot at HALF density --
|
||||
-- a fight in Viridian Forest sits in the same haze the walk there did,
|
||||
-- thinned so neither mon goes soft -- and its god rays stay out of it:
|
||||
-- this camera is low and long, and a bright blade across a combatant
|
||||
-- reads as a rendering fault, not weather. nil almost everywhere.
|
||||
local ForestAtmos = V.require("ForestAtmos")
|
||||
local atmos = ForestAtmos.frame(host)
|
||||
Voxel3D.fog = atmos and { color = atmos.fog.color,
|
||||
density = atmos.fog.density * 0.5,
|
||||
start = atmos.fog.start,
|
||||
heightK = atmos.fog.heightK } or nil
|
||||
|
||||
-- A B RUNG stands the fight on two carried discs against the sky, with no
|
||||
-- map in the shot at all (see StadiumStage). Everything below still runs --
|
||||
-- the letterbox, the camera solve, the sun, the pins, the tint, the depth
|
||||
-- of field -- because none of it is about the terrain; what changes is
|
||||
-- which geometry the two passes draw.
|
||||
local discs = arena.discs and true or false
|
||||
|
||||
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
|
||||
-- exactly the meshes walking around would have and nothing extra
|
||||
local terrain, nbMesh = prefetchArena(state, host)
|
||||
if not terrain then return nil end
|
||||
local terrain, nbMesh, water, nbWater
|
||||
if discs then
|
||||
-- and nothing is meshed for a disc fight, which is the other half of why
|
||||
-- the rung works everywhere: there is no waiting for a chunk to build, so
|
||||
-- the first frame of the first battle on a cold map is the finished shot
|
||||
nbMesh, water, nbWater = {}, nil, {}
|
||||
else
|
||||
terrain, nbMesh, water, nbWater = prefetchArena(state, host)
|
||||
if not terrain then return nil end
|
||||
end
|
||||
|
||||
local lx, ly, s, pw, ph = BattleScene.letterbox()
|
||||
if not (pw > 0 and ph > 0 and s > 0) then return nil end
|
||||
@@ -338,13 +570,35 @@ function BattleScene.render(state, arena, textures, token)
|
||||
end
|
||||
|
||||
local groundY = BattleScene.groundY(host, arena)
|
||||
local cam, pitch = BattleCam.rig(arena, groundY)
|
||||
-- A capture session brings a camera of its own: the head-on seat, on
|
||||
-- the arena's axis looking straight at the foe, in place of the solved
|
||||
-- over-the-shoulder shot. Everything downstream -- the letterbox fov,
|
||||
-- the pins, the sun, the cards yawing to the eye -- is generic over
|
||||
-- whichever camera this is.
|
||||
local cam, pitch, capFrameH
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.rig then
|
||||
local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
|
||||
-- The pitch is off STRAIGHT DOWN, like Voxel.angle and like the one
|
||||
-- BattleCam.rig hands back -- the only thing downstream reads it is the
|
||||
-- grass and flower pull below. A seat that declines to say stands in
|
||||
-- for a near-LEVEL one rather than a top-down one, which is what every
|
||||
-- staged seat actually is: the pull grows toward straight down, and a
|
||||
-- default that guessed the wrong end of that would spend tens of world
|
||||
-- pixels of bias on a camera standing two cells from its subject.
|
||||
if okRig and c then cam, pitch, capFrameH = c, p or math.rad(80), fh end
|
||||
end
|
||||
if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
|
||||
cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
|
||||
|
||||
local cx, cy = arena.mid[1], arena.mid[2]
|
||||
-- the world extents the sun frustum is fitted to; the camera itself is
|
||||
-- framed by cam.fov, so these only have to describe the ground in shot
|
||||
local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s)
|
||||
-- the player's zoom is part of this: the sun's box is fitted to what the
|
||||
-- frame holds, so a shot pulled wide has to light the ground it just
|
||||
-- brought into view rather than the ground the rig alone would have
|
||||
local vh = (capFrameH or BattleCam.frameH(arena)) * ph
|
||||
/ (BattleScene.GB_H * s)
|
||||
local vw = vh * pw / ph
|
||||
|
||||
-- the cards need the camera's eye to face it, so the rig has to be live
|
||||
@@ -356,7 +610,7 @@ function BattleScene.render(state, arena, textures, token)
|
||||
local cards = monCards(arena, groundY, textures)
|
||||
Voxel3D.camera = nil
|
||||
castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor,
|
||||
cards, token, host, neighbors)
|
||||
cards, token, host, neighbors, water, nbWater, groundY)
|
||||
|
||||
-- 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
|
||||
@@ -365,6 +619,18 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- geometry stops.
|
||||
local sky = VoxelScene.skyColor(host, 1)
|
||||
or VoxelScene.skyShade(INDOOR_SHADE, 1)
|
||||
-- On a disc rung the void is not a backdrop behind the scenery -- it IS the
|
||||
-- scenery, because the map is not drawn. So outdoors it gets the full
|
||||
-- treatment the free-roam camera gets: the banded gradient and the hour's
|
||||
-- own sun or moon hanging in it (Voxel3D.beginScene paints those when the
|
||||
-- sky it is handed carries bands). Indoors there is nothing to dress: a
|
||||
-- room's void is one flat shade, which is what a room looks like past the
|
||||
-- wall, and the disc fight in a cave is lit and coloured as that cave.
|
||||
if discs and VoxelScene.skyColor(host, 1) then
|
||||
local Sky = V.require("Sky")
|
||||
local okDress, dressed = pcall(Sky.dress, sky)
|
||||
if okDress and dressed then sky = dressed end
|
||||
end
|
||||
|
||||
Voxel3D.camera = cam
|
||||
-- the sun is turned up for the arena and put back afterwards, so the
|
||||
@@ -385,14 +651,47 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- its own canvas slot: this renders at the window's pixel size and the
|
||||
-- free-roam pass does too, but the two are alive at different moments
|
||||
-- and a shared slot would reallocate on every battle entry and exit
|
||||
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
|
||||
--
|
||||
-- AA, if the row asks for it, renders it larger still and folds it back
|
||||
-- to pw x ph below (see AntiAlias). The framing is untouched by that:
|
||||
-- the lens was widened by the window's RATIO to the letterbox and the
|
||||
-- rig solved in the GB's own frame, so a bigger canvas is more samples
|
||||
-- of the identical shot -- which is why the pins below still measure in
|
||||
-- pw and ph, and why the HUDs and the depth of field, drawn onto the
|
||||
-- folded canvas afterwards, stay the chunky GB art they are.
|
||||
local rw, rh = AntiAlias.expand(pw, ph)
|
||||
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
|
||||
return
|
||||
end
|
||||
if discs then
|
||||
-- discs: the two platforms, and nothing else. No terrain, no
|
||||
-- neighbouring maps, no water, no grass and no flowers -- see the
|
||||
-- matching skips further down. What is behind them is the sky the
|
||||
-- clear painted.
|
||||
V.require("StadiumStage").draw(arena, groundY)
|
||||
else
|
||||
Voxel3D.draw(terrain, atlasFor(host), nil)
|
||||
for i, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
-- and the water over it -- PLAIN, always: the flat animated tiles, never
|
||||
-- the reflective pass, whatever the WATER row says. The reflection is
|
||||
-- tuned for the overworld's ladder of cameras; this shot's is PLACED --
|
||||
-- low, tilted and framed like a picture -- and under it the pass reads
|
||||
-- wrong: Fresnel opens all the way up, the leaned sky lands on bands the
|
||||
-- framing never shows, and a lake-sized arena comes out as murk wearing
|
||||
-- the tile art. The battle is a stage set, and stage water is painted.
|
||||
-- (No mirror also means the mons need no second draw into one -- they
|
||||
-- just composite over the water below, like everything else on the set.)
|
||||
if water then Voxel3D.draw(water, atlasFor(host)) end
|
||||
for i, nb in ipairs(neighbors) do
|
||||
if nbWater and nbWater[i] then
|
||||
Voxel3D.draw(nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
end
|
||||
end
|
||||
-- The mons, standing on their tiles. Depth-tested like everything else,
|
||||
-- so a ledge or a tree between the camera and a Pokemon really is in
|
||||
-- front of it, and the alpha discard cuts the sprite's own outline out of
|
||||
@@ -423,26 +722,66 @@ function BattleScene.render(state, arena, textures, token)
|
||||
end
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
-- and the STADIUM models, inside the same flash window and with the
|
||||
-- same camera-ward pull, so a Pokemon standing on its tile still wins
|
||||
-- the depth test against the tile. They manage the wireframe and the
|
||||
-- glass mask around their own draws (StadiumRig), which is why this
|
||||
-- sits outside the pair above rather than inside it.
|
||||
local okStadium, stadiumErr = pcall(function()
|
||||
V.require("Stadium").draw(BattleBillboard.PULL)
|
||||
end)
|
||||
if not okStadium then V.require("Stadium").report(stadiumErr) end
|
||||
-- the capture session's Poke Ball, still inside the flash window and
|
||||
-- with the mons' own camera-ward pull, so a ball crossing in front of
|
||||
-- a card wins the depth test the way a nearer thing should
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.draw then pcall(cap.draw, BattleBillboard.PULL) end
|
||||
-- and a shiny's arrival sparkle, last of the three so its stars add
|
||||
-- over the mon they belong to rather than under it, and still inside
|
||||
-- the flash window so a burst during a hit is lit like everything else
|
||||
pcall(function()
|
||||
V.require("ShinyFx").draw(arena, groundY, BattleBillboard.PULL)
|
||||
end)
|
||||
if flashing then Voxel3D.flatten(nil) end
|
||||
-- grass and flowers ride the same camera-ward pull the free-roam pass
|
||||
-- gives them, measured against THIS camera's pitch rather than the
|
||||
-- orbit's -- there is no character here for them to overdraw, but the
|
||||
-- pull is also what keeps a tuft from z-fighting the floor it stands on
|
||||
local pull = VoxelScene.pull(math.max(pitch, 0.05))
|
||||
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
if not discs then
|
||||
-- and the WIND blowing through it, exactly as the free-roam pass
|
||||
-- switches on around its own grass draws (VoxelScene). Without this
|
||||
-- the uniform sits at the per-frame default beginScene sends -- zero,
|
||||
-- meaning "no wind" -- and the tall grass a fight is standing in goes
|
||||
-- dead still for the length of the battle while the same tufts one
|
||||
-- frame earlier, and one frame after, were moving. A staged fight is
|
||||
-- shot on the MAP, in that place's own weather and light; a frozen
|
||||
-- field is the one thing that reads as a photograph of it rather than
|
||||
-- the place itself.
|
||||
--
|
||||
-- No contact point goes with it (grassWind's px/pz are left nil, which
|
||||
-- sends the far-away sentinel): that push is a WALKER parting the grass
|
||||
-- they are stepping through, and there is nobody walking here -- the
|
||||
-- two mons stand still on their own tiles for the whole shot.
|
||||
Voxel3D.grassWind(true)
|
||||
Voxel3D.draw(ChunkMesher.grass(host), atlasFor(host), nil, pull)
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
end
|
||||
-- off again before the flowers, which are not grass and have no sway
|
||||
-- of their own -- the same order the free-roam pass draws them in
|
||||
Voxel3D.grassWind(false)
|
||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
|
||||
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
end
|
||||
local fpull = math.max(0, pull - 8 * math.sin(math.max(pitch, 0.05)))
|
||||
Voxel3D.draw(ChunkMesher.flowers(host), atlasFor(host), nil, fpull,
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(neighbors) do
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
local canvas = Voxel3D.endScene()
|
||||
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
|
||||
if not canvas then return end
|
||||
|
||||
local vp = Voxel3D.vp
|
||||
@@ -454,25 +793,47 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- How wide one overworld square is on screen where each mon stands, in
|
||||
-- GB pixels. This is what the pics are scaled to: a mon covers its own
|
||||
-- square and no more, at whatever the drift has done to the distance.
|
||||
--
|
||||
-- Measured along BOTH map axes and answered as the larger, as a full
|
||||
-- 2D screen distance. One axis alone breaks the moment a camera looks
|
||||
-- ALONG it: the capture seat stands on the arena's own axis, and on a
|
||||
-- quarter-turned arena that axis is world X -- the ±X probe points
|
||||
-- then project to the same pixel and the span reads zero, which
|
||||
-- collapsed the ring and blew up the throw's world-per-pixel mapping.
|
||||
local half = BattleScene.CELL / 2
|
||||
local pl = BattleScene.toGB(vp, arena.player[1] - half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local pr = BattleScene.toGB(vp, arena.player[1] + half, groundY,
|
||||
arena.player[2], lx, ly, s, pw, ph)
|
||||
local el = BattleScene.toGB(vp, arena.enemy[1] - half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
local er = BattleScene.toGB(vp, arena.enemy[1] + half, groundY,
|
||||
arena.enemy[2], lx, ly, s, pw, ph)
|
||||
if not (pl and pr and el and er) then return end
|
||||
local function cellSpan(wx, wz)
|
||||
local x1, y1 = BattleScene.toGB(vp, wx - half, groundY, wz,
|
||||
lx, ly, s, pw, ph)
|
||||
local x2, y2 = BattleScene.toGB(vp, wx + half, groundY, wz,
|
||||
lx, ly, s, pw, ph)
|
||||
local x3, y3 = BattleScene.toGB(vp, wx, groundY, wz - half,
|
||||
lx, ly, s, pw, ph)
|
||||
local x4, y4 = BattleScene.toGB(vp, wx, groundY, wz + half,
|
||||
lx, ly, s, pw, ph)
|
||||
if not (x1 and x2 and x3 and x4) then return nil end
|
||||
local ew = math.sqrt((x2 - x1) ^ 2 + (y2 - y1) ^ 2)
|
||||
local ns = math.sqrt((x4 - x3) ^ 2 + (y4 - y3) ^ 2)
|
||||
return math.max(ew, ns)
|
||||
end
|
||||
local pSpan = cellSpan(arena.player[1], arena.player[2])
|
||||
local eSpan = cellSpan(arena.enemy[1], arena.enemy[2])
|
||||
if not (pSpan and eSpan) then return end
|
||||
out = {
|
||||
canvas = canvas,
|
||||
player = { pmx, pmy },
|
||||
enemy = { emx, emy },
|
||||
playerSpan = math.abs(pr - pl),
|
||||
enemySpan = math.abs(er - el),
|
||||
playerSpan = pSpan,
|
||||
enemySpan = eSpan,
|
||||
-- the letterbox, so the depth-of-field pass can put its sharp band on
|
||||
-- the two marks rather than on a fraction of the window
|
||||
lx = lx, ly = ly, scale = s, pw = pw, ph = ph,
|
||||
-- the camera and its combined matrix, for anything that reasons
|
||||
-- about this shot from outside the render -- the capture mode's
|
||||
-- throw is solved in these (aim errors along this eye's own right
|
||||
-- and forward, contact judged through this vp)
|
||||
eye = { cam.eye[1], cam.eye[2], cam.eye[3] },
|
||||
focus = { cam.focus[1], cam.focus[2], cam.focus[3] },
|
||||
vp = vp,
|
||||
-- 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
|
||||
|
||||
+868
-25
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
-- The player's own camera controls: zoom everywhere, and the battle's orbit.
|
||||
--
|
||||
-- This mod has four cameras, and by the time a wheel notch arrives they all
|
||||
-- want it. So one module owns the INPUTS and answers the only question that
|
||||
-- matters -- which camera is this aimed at -- rather than each camera
|
||||
-- growing its own wheel handler and racing the others for the event:
|
||||
--
|
||||
-- a staged battle the camera the fight is shot with (BattleCam): the
|
||||
-- wheel and Q/E work its lens, and the right stick,
|
||||
-- a drag or the mouse walk it around the arena.
|
||||
--
|
||||
-- the 3RD rung the boom behind the player's shoulder
|
||||
-- (ThirdPerson): the wheel, Q/E and a pinch let it
|
||||
-- out and pull it in.
|
||||
--
|
||||
-- an orbit rung the engine's own survey zoom, which the wheel has
|
||||
-- always driven -- so here the module mostly gets
|
||||
-- out of the way, and only ADDS the two keys and the
|
||||
-- pinch that the engine has no handler for.
|
||||
--
|
||||
-- the 1ST rung nothing. The eye is in the player's head; there is
|
||||
-- no distance to change, and a pinch there would
|
||||
-- silently wind the survey zoom for whenever they
|
||||
-- stepped back out. Inputs pass through untouched.
|
||||
--
|
||||
-- Every claim is answered by a GATE rather than by a mode flag, and every
|
||||
-- wrap forwards whatever it does not claim -- so with voxel mode off, and
|
||||
-- on every screen that is not the overworld or a battle, each byte flows
|
||||
-- exactly where it always did.
|
||||
--
|
||||
-- Installed AFTER FirstPerson (see main.lua), which makes these wraps the
|
||||
-- outer ones: a battle's controls get first refusal on the mouse and the
|
||||
-- touch screen, which is right, because while a fight is staged the
|
||||
-- free-roam look is not driving anyway.
|
||||
|
||||
-- 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 FirstPerson = V.require("FirstPerson")
|
||||
local ThirdPerson = V.require("ThirdPerson")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
|
||||
local CamControl = {}
|
||||
|
||||
-- ------- tuning
|
||||
--
|
||||
-- PINCH_SLACK is how far apart two fingers must travel, as a ratio of
|
||||
-- their starting gap, before the gesture counts as a pinch at all -- below
|
||||
-- it a two-finger tap wobbles rather than zooms.
|
||||
--
|
||||
-- SURVEY_PINCH is how many of the engine's integer survey steps one
|
||||
-- doubling of the finger gap is worth. The survey ladder is coarse (whole
|
||||
-- pixels per world pixel), so a pinch has to be geared down or the first
|
||||
-- centimetre of travel crosses the whole range.
|
||||
CamControl.PINCH_SLACK = 0.02
|
||||
CamControl.SURVEY_PINCH = 2.2
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- A fight staged on the map, drawn and on screen. Asked of the shot rather
|
||||
-- than of the battle state, because the shot is exactly "there is a 3D
|
||||
-- battle in front of the player right now" -- with 3D-BTL off, or on a map
|
||||
-- with no arena, the engine's own flat battle screen is up and its camera
|
||||
-- is not ours to steer.
|
||||
-- BACK SPRITES also closes it, through BattleCam.steerable: that setting
|
||||
-- nails the player's own mon to the GB's slot on the menu while the foe
|
||||
-- stands out on the map, and no camera angle holds a composition that is
|
||||
-- half frame and half world (see BattleCam.steerable, which is where the
|
||||
-- reasoning lives and which the RIG answers to as well -- so a stored
|
||||
-- angle from before the setting was switched on stands down with it).
|
||||
local function battleLive()
|
||||
local ok, shot = pcall(function()
|
||||
return V.require("OverworldBattle").shot()
|
||||
end)
|
||||
return (ok and shot and BattleCam.steerable) and true or false
|
||||
end
|
||||
|
||||
CamControl.battleLive = battleLive
|
||||
|
||||
-- The free-roam overworld, with the 3D pass carrying it: the gate every
|
||||
-- zoom that is not a battle's answers to.
|
||||
local function roaming()
|
||||
return Voxel.active() and Voxel3D.available() and FirstPerson.onTop()
|
||||
end
|
||||
|
||||
-- Which camera a zoom is aimed at: "battle", "boom", "survey", or nil for
|
||||
-- nothing that zooms (1ST, or a screen with no camera of ours behind it).
|
||||
function CamControl.zoomTarget()
|
||||
if battleLive() then return "battle" end
|
||||
if not roaming() then return nil end
|
||||
if Voxel.isThirdPerson(Voxel.level) then return "boom" end
|
||||
if Voxel.isFirstPerson(Voxel.level) then return nil end
|
||||
return "survey"
|
||||
end
|
||||
|
||||
-- ------- zoom
|
||||
--
|
||||
-- `notches` is signed the way every zoom in this file is: POSITIVE pulls
|
||||
-- the camera OUT. The engine's own survey step runs the other way, and is
|
||||
-- negated at the one place it is called rather than everywhere else being
|
||||
-- bent to match it.
|
||||
--
|
||||
-- Returns true when the input was ours, which is what tells a wrap to stop
|
||||
-- rather than forward.
|
||||
|
||||
local function surveyStep(notches)
|
||||
local ok = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
Game:zoomStep(notches > 0 and -1 or 1)
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
|
||||
function CamControl.zoomBy(notches)
|
||||
if not notches or notches == 0 then return false end
|
||||
local target = CamControl.zoomTarget()
|
||||
if target == "battle" then
|
||||
BattleCam.stepZoom(notches)
|
||||
return true
|
||||
elseif target == "boom" then
|
||||
ThirdPerson.stepZoom(notches)
|
||||
return true
|
||||
elseif target == "survey" then
|
||||
-- one call per notch: the engine's ladder is integer rungs, and a
|
||||
-- wheel spun hard should climb them all rather than one
|
||||
for _ = 1, math.min(8, math.abs(notches)) do surveyStep(notches) end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- A pinch's own scale: > 1 is fingers spreading, which means zoom IN
|
||||
-- (pull the world closer), which is a NEGATIVE notch count.
|
||||
function CamControl.pinchBy(factor)
|
||||
if not (factor and factor > 0) then return false end
|
||||
local target = CamControl.zoomTarget()
|
||||
if target == "boom" then
|
||||
return ThirdPerson.scaleZoom(1 / factor)
|
||||
elseif target == "battle" then
|
||||
-- battles take a pinch too: the wheel and the keys reach this camera
|
||||
-- and a phone has neither, so without it the lens would be the one
|
||||
-- control a touch screen could not work
|
||||
return BattleCam.stepZoom(math.log(1 / factor)
|
||||
/ math.log(BattleCam.ZOOM_STEP))
|
||||
elseif target == "survey" then
|
||||
CamControl.surveyAccum = (CamControl.surveyAccum or 0)
|
||||
+ math.log(factor) / math.log(2) * CamControl.SURVEY_PINCH
|
||||
local moved = false
|
||||
while CamControl.surveyAccum >= 1 do
|
||||
CamControl.surveyAccum = CamControl.surveyAccum - 1
|
||||
surveyStep(-1)
|
||||
moved = true
|
||||
end
|
||||
while CamControl.surveyAccum <= -1 do
|
||||
CamControl.surveyAccum = CamControl.surveyAccum + 1
|
||||
surveyStep(1)
|
||||
moved = true
|
||||
end
|
||||
return moved
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
CamControl.surveyAccum = 0
|
||||
|
||||
-- ------- the battle's orbit
|
||||
--
|
||||
-- Only ever the battle's: the free-roam rungs already steer their own look
|
||||
-- through FirstPerson, and these wraps sit outside it precisely so a fight
|
||||
-- can borrow the same devices without either of them growing a mode check.
|
||||
|
||||
-- The right stick, read as a rate off the axes FirstPerson's own wrap is
|
||||
-- already recording (it records whatever the rung, so a battle can read
|
||||
-- them without a second wrap on the same seam). Ticked from
|
||||
-- OverworldBattle.update, which runs whatever is on top of the stack.
|
||||
--
|
||||
-- X walks the shot round the arena, Y raises the seat. The Y is NEGATED:
|
||||
-- a stick pushed forward reads as negative on SDL's axis, and pushing
|
||||
-- forward should send the camera UP and over -- the same "push the camera
|
||||
-- where you want it" the drag and the mouse below use.
|
||||
function CamControl.tick(dt)
|
||||
if not battleLive() then return end
|
||||
local x, y = FirstPerson.stickX(), FirstPerson.stickY()
|
||||
if x ~= 0 then BattleCam.stickOrbit(x, dt) end
|
||||
if y ~= 0 then BattleCam.stickPitch(-y, dt) end
|
||||
end
|
||||
|
||||
-- ------- the wraps
|
||||
|
||||
local installed = false
|
||||
|
||||
function CamControl.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
-- ------- the wheel
|
||||
--
|
||||
-- The engine's own handler is the survey zoom, so the wrap only has to
|
||||
-- take the notch away when some OTHER camera wants it; "survey" falls
|
||||
-- through to exactly the code that always ran.
|
||||
do
|
||||
local inner = Game.wheelmoved
|
||||
function Game:wheelmoved(dx, dy)
|
||||
local target = CamControl.zoomTarget()
|
||||
if (target == "battle" or target == "boom") and dy and dy ~= 0 then
|
||||
CamControl.zoomBy(dy > 0 and -1 or 1)
|
||||
return
|
||||
end
|
||||
return inner(self, dx, dy)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the stick clicks
|
||||
--
|
||||
-- Q and E, on the pad: the left stick's click pulls the camera out and the
|
||||
-- right stick's pulls it in. A controller has no wheel and no number row,
|
||||
-- and the two clicks are the only buttons a Gen 1 pad layout leaves free
|
||||
-- (SELECT already walks the angle ladder).
|
||||
--
|
||||
-- Claimed for the two cameras a pad player can actually be looking at
|
||||
-- while pressing them -- the third-person boom and a staged battle's lens
|
||||
-- -- and forwarded untouched everywhere else, so a player who has rebound
|
||||
-- either click keeps it on every other screen, a rebind capture included.
|
||||
-- Not on the orbit rungs: the survey zoom has the OPTIONS row and the
|
||||
-- wheel already, and taking a pad button for it would be taking one from
|
||||
-- a player who never asked.
|
||||
local CLICK_ZOOMS = { boom = true, battle = true }
|
||||
do
|
||||
local inner = Game.gamepadpressed
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
if (button == "leftstick" or button == "rightstick")
|
||||
and CLICK_ZOOMS[CamControl.zoomTarget() or ""] then
|
||||
CamControl.zoomBy(button == "leftstick" and 1 or -1)
|
||||
return
|
||||
end
|
||||
return inner(self, joystick, button)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the mouse
|
||||
--
|
||||
-- Battle only. The free-roam look already owns relative motion through
|
||||
-- FirstPerson's own wrap (this one is outside it, so what is claimed here
|
||||
-- never reaches it) and a fight is exactly when that look is not driving.
|
||||
--
|
||||
-- Bare motion, no button held: moving the mouse moves the shot.
|
||||
--
|
||||
-- Each event's contribution is CLAMPED, though, because not every motion
|
||||
-- event is a hand moving. The pointer entering the window, a warp back to
|
||||
-- centre, an alt-tab -- each arrives as ONE event carrying the whole
|
||||
-- distance from wherever the cursor was last seen, and in testing that
|
||||
-- was a couple of hundred counts: enough to swing the shot a quarter of
|
||||
-- the way to side-on before the player had touched anything. A real hand
|
||||
-- delivers its travel as a stream of small events and is unaffected; a
|
||||
-- teleport delivers it as one and is cut down to the size of a flick.
|
||||
local MOUSE_STEP = 40
|
||||
local function clamp(v)
|
||||
return math.max(-MOUSE_STEP, math.min(MOUSE_STEP, v or 0))
|
||||
end
|
||||
do
|
||||
local inner = love.mousemoved
|
||||
love.mousemoved = function(x, y, dx, dy, istouch)
|
||||
if battleLive() and not istouch then
|
||||
-- dy is NEGATED for the same reason the stick's is: moving the
|
||||
-- mouse away from you sends the camera up and over
|
||||
if dx and dx ~= 0 then BattleCam.mouseOrbit(clamp(dx)) end
|
||||
if dy and dy ~= 0 then BattleCam.mousePitch(-clamp(dy)) end
|
||||
-- forwarded anyway: the cursor still has UI to point at, and the
|
||||
-- steer is a read of the motion rather than a claim on it
|
||||
end
|
||||
if inner then return inner(x, y, dx, dy, istouch) end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the touch screen
|
||||
--
|
||||
-- Two gestures, told apart by how many fingers are down on OPEN screen
|
||||
-- (the overlay's own d-pad and buttons are never either):
|
||||
--
|
||||
-- one finger, in a battle drags the shot around the arena
|
||||
-- two fingers pinch to zoom, wherever zooming means
|
||||
-- something -- and while they are down the
|
||||
-- free-roam look stands aside, so a pinch in
|
||||
-- 3RD does not also spin the view
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local free = {} -- id -> {x, y} for every finger on open screen
|
||||
local pinch = nil -- { a, b, gap } while two of them are pinching
|
||||
|
||||
local function freeCount()
|
||||
local n = 0
|
||||
for _ in pairs(free) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
local function gapOf(a, b)
|
||||
local dx, dy = free[a].x - free[b].x, free[a].y - free[b].y
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
end
|
||||
|
||||
-- Two free fingers and a camera that zooms: start measuring. The look
|
||||
-- drag is dropped for the duration -- FirstPerson never sees the moves
|
||||
-- below -- and re-seated on whichever finger survives, so the view does
|
||||
-- not jump by however far the pinch travelled.
|
||||
local function startPinch()
|
||||
if pinch or freeCount() < 2 then return end
|
||||
local ids = {}
|
||||
for id in pairs(free) do ids[#ids + 1] = id end
|
||||
local gap = gapOf(ids[1], ids[2])
|
||||
if gap < 16 then return end
|
||||
pinch = { a = ids[1], b = ids[2], gap = gap }
|
||||
CamControl.surveyAccum = 0
|
||||
pcall(FirstPerson.dropLook)
|
||||
end
|
||||
|
||||
local function endPinch(lifted)
|
||||
if not pinch then return end
|
||||
local survivor = nil
|
||||
for id in pairs(free) do
|
||||
if id ~= lifted then survivor = id break end
|
||||
end
|
||||
pinch = nil
|
||||
if survivor and free[survivor] then
|
||||
pcall(FirstPerson.reseatLook, survivor,
|
||||
free[survivor].x, free[survivor].y)
|
||||
end
|
||||
end
|
||||
|
||||
local function onControl(x, y)
|
||||
local hit = nil
|
||||
pcall(function() hit = TouchControls:hitTest(x, y) end)
|
||||
return hit
|
||||
end
|
||||
|
||||
-- Whether this module has any interest in touches at all this frame.
|
||||
-- Kept deliberately wide -- a battle, or anything that zooms -- because
|
||||
-- the wrap forwards everything it does not claim regardless.
|
||||
local function wantsTouch()
|
||||
return battleLive() or CamControl.zoomTarget() ~= nil
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchpressed
|
||||
function Game:touchpressed(id, x, y)
|
||||
if wantsTouch() and not onControl(x, y) then
|
||||
free[id] = { x = x, y = y }
|
||||
if CamControl.zoomTarget() then startPinch() end
|
||||
-- forwarded even so: a single free finger is the free-roam look's
|
||||
-- to claim (FirstPerson's wrap is inside this one), and in a
|
||||
-- battle it is nobody's until it MOVES
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchmoved
|
||||
function Game:touchmoved(id, x, y)
|
||||
local f = free[id]
|
||||
if f then
|
||||
local px, py = f.x, f.y
|
||||
f.x, f.y = x, y
|
||||
if pinch and (id == pinch.a or id == pinch.b) then
|
||||
local gap = gapOf(pinch.a, pinch.b)
|
||||
local factor = gap / math.max(1, pinch.gap)
|
||||
if math.abs(factor - 1) > CamControl.PINCH_SLACK then
|
||||
CamControl.pinchBy(factor)
|
||||
pinch.gap = gap
|
||||
end
|
||||
return -- claimed: never a look drag too
|
||||
end
|
||||
if battleLive() and not pinch then
|
||||
local w, h = 1280, 720
|
||||
pcall(function()
|
||||
w, h = love.graphics.getWidth(), love.graphics.getHeight()
|
||||
end)
|
||||
BattleCam.dragOrbit((x - px) / math.max(320, w))
|
||||
-- dragged UP sends the camera up and over, the same way the
|
||||
-- stick and the mouse do
|
||||
BattleCam.dragPitch(-(y - py) / math.max(240, h))
|
||||
return
|
||||
end
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchreleased
|
||||
function Game:touchreleased(id, x, y)
|
||||
if free[id] then
|
||||
if pinch and (id == pinch.a or id == pinch.b) then endPinch(id) end
|
||||
free[id] = nil
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
-- a reset that drops held input state drops ours with it, exactly as the
|
||||
-- free-roam look's does
|
||||
do
|
||||
local inner = Game.focus
|
||||
function Game:focus(f)
|
||||
free, pinch = {}, nil
|
||||
CamControl.surveyAccum = 0
|
||||
return inner(self, f)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return CamControl
|
||||
+1770
File diff suppressed because it is too large
Load Diff
+145
-28
@@ -52,6 +52,7 @@ local V = ...
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Structures = V.require("Structures")
|
||||
local Buildings = V.require("Buildings")
|
||||
local TileShape = V.require("TileShape")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local Budget = V.require("BuildBudget")
|
||||
@@ -102,6 +103,12 @@ local SIDES = {
|
||||
{ 0, -1, 6 }, -- -Z north
|
||||
}
|
||||
|
||||
-- How far sideways a face reaches for ordinary wall when the column it
|
||||
-- stands over draws a doorway or a sign (see wallTile), nearest ring
|
||||
-- first. Left before right at each distance is arbitrary and only decides
|
||||
-- symmetric cases.
|
||||
local SPAN = { { -1, 1 }, { -2, 2 } }
|
||||
|
||||
local function keyOf(tx, ty)
|
||||
return (ty + 64) * 4096 + (tx + 64)
|
||||
end
|
||||
@@ -221,8 +228,18 @@ end
|
||||
-- Kept free of any GPU call so it can be exercised headless -- the
|
||||
-- geometry is the part with the interesting invariants, and a suite that
|
||||
-- needed a real GL context to check them would never run in CI.
|
||||
local function runGeometry(map, bodyOnly, masks, sink)
|
||||
-- `waterSink`, when given, takes the WATER SURFACE quads instead of the
|
||||
-- main sink -- the one class in this world that is drawn as its own pass
|
||||
-- (see Water: a mirror cannot be drawn until what it reflects exists).
|
||||
-- Nothing else moves: the quads are the same quads, emitted by the same
|
||||
-- corner and uv arithmetic at the same recessed height, and the shoreline
|
||||
-- faces around them still belong to the GROUND that exposes them.
|
||||
--
|
||||
-- Omitted, water stays in the terrain mesh exactly as it always did, which
|
||||
-- is what the headless geometry() below and the sun's own pass both want.
|
||||
local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
local push = sink.push
|
||||
local waterPush = waterSink and waterSink.push or nil
|
||||
local tileset = map.tileset
|
||||
local S = Structures.forMap(map)
|
||||
local perRow = tileset.tilesPerRow or 16
|
||||
@@ -238,6 +255,32 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
return s and s.h or 0
|
||||
end
|
||||
|
||||
-- The tiles that belong on a DRAWN FACADE and nowhere else -- doorways,
|
||||
-- shop signs, the gyms' lettering (data/voxel_heights.lua `frontOnly`).
|
||||
-- A volume folds its column's drawing up all four sides, so without this
|
||||
-- a house's back and flanks each carry their own copy of its front door.
|
||||
-- The face keeps the same map row and reaches sideways for an ordinary
|
||||
-- column instead, which is the neighbouring course of the same wall.
|
||||
-- The search stays inside the structure -- a neighbour column with no run
|
||||
-- of its own is the ground beside the building, and a doorway that
|
||||
-- borrowed grass would be a hole. Two columns is as far as it needs to
|
||||
-- reach: every doorway in the game is two tiles wide.
|
||||
local frontOnly = Buildings.frontOnly(tileset.id)
|
||||
local function wallTile(tx, ty)
|
||||
local tile = map:tileAt(tx, ty)
|
||||
if not (frontOnly and frontOnly[tile]) then return tile end
|
||||
for d = 1, 2 do
|
||||
for _, nx in ipairs(SPAN[d]) do
|
||||
nx = tx + nx
|
||||
if S.runs[keyOf(nx, ty)] then
|
||||
local n = map:tileAt(nx, ty)
|
||||
if not frontOnly[n] then return n end
|
||||
end
|
||||
end
|
||||
end
|
||||
return tile
|
||||
end
|
||||
|
||||
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
|
||||
local function uvRect(tile, vTop, vBot)
|
||||
local ax = (tile % perRow) * 8
|
||||
@@ -358,12 +401,14 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
return aoSide
|
||||
end
|
||||
|
||||
local function topQuad(x0, z0, h, tile, shade)
|
||||
-- `to` routes the quad somewhere other than the main sink -- the water
|
||||
-- surface is the only caller that ever does (see runGeometry's header).
|
||||
local function topQuad(x0, z0, h, tile, shade, to)
|
||||
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
|
||||
push({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
||||
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
||||
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
||||
aoShades(x0 / 8, z0 / 8, h, shade))
|
||||
;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
||||
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
||||
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
||||
aoShades(x0 / 8, z0 / 8, h, shade))
|
||||
end
|
||||
|
||||
-- vertical quad for face direction `d` of the tile column at (x0, z0),
|
||||
@@ -558,8 +603,14 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
end
|
||||
topTile = S.tileAt[keyOf(tx, row)]
|
||||
end
|
||||
-- water's surface, and only water's: the recessed sheet itself,
|
||||
-- never the ground's shoreline bands around it. A cell an object
|
||||
-- stands on took the branch above and paints synthesized GROUND,
|
||||
-- which is right -- a sign at the waterline stands on a plot, not
|
||||
-- on the pond.
|
||||
topQuad(x0, z0, h, topTile,
|
||||
s.art == "upright" and VOLUME_TOP_SHADE or 1)
|
||||
s.art == "upright" and VOLUME_TOP_SHADE or 1,
|
||||
(s.class == "water") and waterPush or nil)
|
||||
end
|
||||
|
||||
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
||||
@@ -587,13 +638,16 @@ local function runGeometry(map, bodyOnly, masks, sink)
|
||||
-- drawing itself (full brightness); the other sides wear
|
||||
-- the same rows darkened, so a building's flank matches
|
||||
-- its face instead of smearing one tile
|
||||
local sy
|
||||
if d == 6 then
|
||||
src = map:tileAt(tx, math.min(run.front,
|
||||
run.north + band))
|
||||
sy = math.min(run.front, run.north + band)
|
||||
else
|
||||
src = map:tileAt(tx, math.max(run.north,
|
||||
run.front - band))
|
||||
sy = math.max(run.north, run.front - band)
|
||||
end
|
||||
-- the south face IS the drawing and keeps every tile of
|
||||
-- it; the back and the flanks are the same wall seen from
|
||||
-- somewhere the door and the sign are not
|
||||
src = (d == 5) and map:tileAt(tx, sy) or wallTile(tx, sy)
|
||||
if d == 5 then shade = 1 end
|
||||
elseif s.art == "upright" then
|
||||
-- profile-authored upright (a pinned wall or furniture
|
||||
@@ -764,32 +818,55 @@ end
|
||||
-- The raw geometry for `map`: (vertex list, triangle index list, quad
|
||||
-- count). Synchronous and GPU-free -- the headless suite and the probes
|
||||
-- exercise the invariants through this.
|
||||
function ChunkMesher.geometry(map, bodyOnly, masks)
|
||||
--
|
||||
-- `split` lifts the water surface out, as it is lifted out for the
|
||||
-- reflective pass, and appends that sink's own three values -- so the suite
|
||||
-- can check the same separation the GPU path relies on without a GPU.
|
||||
-- Without it the water is in the first list, which is what every existing
|
||||
-- caller reads.
|
||||
function ChunkMesher.geometry(map, bodyOnly, masks, split)
|
||||
local sink = newTableSink()
|
||||
runGeometry(map, bodyOnly, masks, sink)
|
||||
return sink.results()
|
||||
local waterSink = split and newTableSink() or nil
|
||||
runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
if not waterSink then return sink.results() end
|
||||
local v, i, n = sink.results()
|
||||
local wv, wi, wn = waterSink.results()
|
||||
return v, i, n, wv, wi, wn
|
||||
end
|
||||
|
||||
-- Build the mesh for `map` synchronously. Returns nil when there is
|
||||
-- nothing to draw or meshes are unavailable (headless).
|
||||
function ChunkMesher.build(map, bodyOnly, masks)
|
||||
--
|
||||
-- `split` asks for the water surface as a SECOND mesh, returned after the
|
||||
-- terrain one -- the shape the reflective pass needs (see Water). Without
|
||||
-- it the water is inside the terrain mesh, which is the historical
|
||||
-- contract and what every other caller still wants.
|
||||
function ChunkMesher.build(map, bodyOnly, masks, split)
|
||||
local sink = newSink()
|
||||
runGeometry(map, bodyOnly, masks, sink)
|
||||
return sink.finish()
|
||||
local waterSink = split and newSink() or nil
|
||||
runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
return sink.finish(), waterSink and waterSink.finish() or nil
|
||||
end
|
||||
|
||||
local function quadsMesh(quads)
|
||||
local function quadsMesh(quads, grass)
|
||||
if #quads == 0 then return nil end
|
||||
local verts, indices, n = {}, {}, 0
|
||||
for _, q in ipairs(quads) do
|
||||
for i = 1, 4 do
|
||||
local c = q[i]
|
||||
local uv = q.uv and q.uv[i] or { q.u, q.v }
|
||||
verts[#verts + 1] = { c[1], c[2], c[3], uv[1], uv[2], q.shade }
|
||||
local v = { c[1], c[2], c[3], uv[1], uv[2], q.shade }
|
||||
if grass then
|
||||
v[7], v[8], v[9], v[10] = q.sway or 0, q.cx or 0,
|
||||
q.cz or 0,
|
||||
q.firefly and 2 or (q.leaf and 1 or 0)
|
||||
end
|
||||
verts[#verts + 1] = v
|
||||
end
|
||||
Voxel3D.pushQuad(indices, n)
|
||||
n = n + 1
|
||||
end
|
||||
if grass then return Voxel3D.newGrassMesh(verts, indices) end
|
||||
return Voxel3D.newMesh(verts, indices)
|
||||
end
|
||||
|
||||
@@ -798,7 +875,7 @@ end
|
||||
-- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary
|
||||
-- terrain could never do this).
|
||||
local function buildGrassMesh(map)
|
||||
return quadsMesh(Structures.forMap(map).grassQuads)
|
||||
return quadsMesh(Structures.forMap(map).grassQuads, true)
|
||||
end
|
||||
|
||||
-- The flower billboards as their own mesh, for the same reason as the
|
||||
@@ -819,14 +896,24 @@ end
|
||||
-- character card (VoxelScene). A figure baked into the terrain mesh could
|
||||
-- not lean, and a shared mesh could not carry per-figure placement.
|
||||
--
|
||||
-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or
|
||||
-- none, so the loop that draws them is shorter than the terrain's.
|
||||
-- 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
|
||||
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y }
|
||||
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
|
||||
@@ -858,8 +945,17 @@ local function entry(id)
|
||||
return c
|
||||
end
|
||||
|
||||
-- The water surface that came out of a terrain slot's own build. Kept
|
||||
-- beside it rather than in a slot of its own because the two are ONE
|
||||
-- answer: a full mesh drawn beside a body build's water would draw the
|
||||
-- ring's ponds twice and miss the body's own.
|
||||
local function waterSlot(slot)
|
||||
return slot .. "Water"
|
||||
end
|
||||
|
||||
local function releaseEntry(c)
|
||||
for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do
|
||||
for _, slot in ipairs({ "full", "body", "fullWater", "bodyWater",
|
||||
"grass", "flowers" }) do
|
||||
local mesh = c[slot]
|
||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||
c[slot] = nil
|
||||
@@ -924,13 +1020,17 @@ local function runJob(job)
|
||||
if c.stale then c.stale.aux = nil end
|
||||
end
|
||||
local sink = newSink()
|
||||
runGeometry(map, job.slot == "body", job.masks, sink)
|
||||
local waterSink = newSink()
|
||||
runGeometry(map, job.slot == "body", job.masks, sink, waterSink)
|
||||
local mesh = sink.finish()
|
||||
local water = waterSink.finish()
|
||||
if (gen[job.id] or 0) ~= job.gen then
|
||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||
if water and water.release then pcall(water.release, water) end
|
||||
return
|
||||
end
|
||||
swapSlot(c, job.slot, mesh or false)
|
||||
swapSlot(c, waterSlot(job.slot), water or false)
|
||||
if c.stale then
|
||||
c.stale[job.slot] = nil
|
||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
||||
@@ -1032,12 +1132,14 @@ function ChunkMesher.get(map, bodyOnly, masks)
|
||||
if c.stale then c.stale.aux = nil end
|
||||
end
|
||||
if c[slot] == nil or (c.stale and c.stale[slot]) then
|
||||
local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks)
|
||||
local ok, mesh, water = pcall(ChunkMesher.build, map, bodyOnly, masks,
|
||||
true)
|
||||
if not ok then
|
||||
print("[warn] voxel mesh build failed for " .. tostring(map.id)
|
||||
.. ": " .. tostring(mesh))
|
||||
end
|
||||
swapSlot(c, slot, (ok and mesh) or false)
|
||||
swapSlot(c, waterSlot(slot), (ok and water) or false)
|
||||
if c.stale then
|
||||
c.stale[slot] = nil
|
||||
if not (c.stale.full or c.stale.body or c.stale.aux) then
|
||||
@@ -1058,6 +1160,21 @@ function ChunkMesher.peek(map, bodyOnly)
|
||||
return mesh or nil
|
||||
end
|
||||
|
||||
-- A slot's terrain mesh AND the water surface lifted out of it, as one
|
||||
-- answer. Never builds, like peek.
|
||||
--
|
||||
-- Both or neither, always from the SAME slot: the water was cut out of that
|
||||
-- exact geometry, so pairing a full mesh with a body build's water would
|
||||
-- draw the border ring's ponds twice and leave the body's as holes. Callers
|
||||
-- that fall back from one variant to the other fall back through this, so
|
||||
-- there is nowhere for the two to be chosen separately.
|
||||
function ChunkMesher.pair(map, bodyOnly)
|
||||
local c = cache[map.id]
|
||||
if not c then return nil, nil end
|
||||
local slot = bodyOnly and "body" or "full"
|
||||
return c[slot] or nil, c[waterSlot(slot)] or nil
|
||||
end
|
||||
|
||||
function ChunkMesher.grass(map)
|
||||
local c = cache[map.id]
|
||||
return c and c.grass or nil
|
||||
@@ -1068,8 +1185,8 @@ function ChunkMesher.flowers(map)
|
||||
return c and c.flowers or nil
|
||||
end
|
||||
|
||||
-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its
|
||||
-- own leaning matrix at draw time, so they cannot share one mesh.
|
||||
-- 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
|
||||
|
||||
+344
@@ -0,0 +1,344 @@
|
||||
-- The DIORAMA modes: Kanto as a model you can pick up.
|
||||
--
|
||||
-- STANDARD VR presents whatever rung the player is on -- the orbit rungs
|
||||
-- become a tabletop, 1ST stands you inside the world (see lib/VR.lua).
|
||||
-- DIORAMA is a different promise, and it is one promise rather than a
|
||||
-- ladder: the world is ALWAYS the model on the table, seen from outside,
|
||||
-- and what the headset adds is that the model is a THING IN THE ROOM --
|
||||
-- grab it, turn it, set it down somewhere else, decide how much of it you
|
||||
-- want to be holding.
|
||||
--
|
||||
-- Two pieces make that read, and this file owns both.
|
||||
--
|
||||
-- THE VIEWPORT. Everything outside an invisible BOX centred on the view
|
||||
-- is simply not drawn -- the Final Fantasy Tactics read, a square slab
|
||||
-- of the world sitting in the air rather than a map running off to a
|
||||
-- horizon. A square cut with a HARD edge, because a flat world is a
|
||||
-- thing with sides and the sides are what say so.
|
||||
--
|
||||
-- V-CURVE is what changes its shape. With the bend on, the world is not
|
||||
-- flat any more -- it is a little globe curling away over its own
|
||||
-- horizon -- and a square cut through a globe is a lie about what is
|
||||
-- being looked at. So the box becomes a BALL, and its rim becomes a
|
||||
-- GRADIENT that dissolves into the sky rather than an edge that
|
||||
-- guillotines it. One click of the left stick (which throws V-CURVE --
|
||||
-- see lib/VR) swaps between the two readings of the same model.
|
||||
--
|
||||
-- A staged fight ignores both and cuts a vertical PILLAR about the
|
||||
-- arena, which lifts the fight out of the map as a floating disc.
|
||||
--
|
||||
-- (A BASE was built under all this once -- the ground extruded a tile
|
||||
-- deep, cut to the viewport's shape, wearing Mt Moon's cave floor down
|
||||
-- its sides -- and it was REMOVED at the user's request. The cut ends at
|
||||
-- the ground plane now; don't put a plinth back under it.)
|
||||
--
|
||||
-- THE GRIP. Squeeze one and the model follows that hand through the
|
||||
-- room; squeeze both and it turns with them and the viewport resizes
|
||||
-- to whatever you open your hands to. All of it is arithmetic on the
|
||||
-- XR-to-world mapping lib/VRRig already had (an anchor, a yaw and a
|
||||
-- scale), so nothing about the world's own geometry knows this is
|
||||
-- happening.
|
||||
--
|
||||
-- DIORAMA-MR is the same mode with the background keyed pure green, for
|
||||
-- a mixed-reality capture that composites the model into the room the
|
||||
-- player is actually standing in.
|
||||
--
|
||||
-- Nothing here reaches the flat screen: every field is set by lib/VR for
|
||||
-- the length of one headset frame and cleared with the session.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Diorama = {}
|
||||
|
||||
-- ------- the viewport
|
||||
--
|
||||
-- The half-size at rest, as a fraction of the view height the flat screen
|
||||
-- frames. Sized off the VIEW rather than fixed in world pixels so the zoom
|
||||
-- rows keep meaning what they mean -- a zoomed-in rung frames less world
|
||||
-- and gets a smaller model, exactly as it frames a smaller picture.
|
||||
--
|
||||
-- The BOX takes half of it, so the square is exactly the view the standard
|
||||
-- rung would have shown, edge to edge. The BALL takes rather more: a ball
|
||||
-- inscribed in that square holds noticeably less world (its corners are
|
||||
-- the four biggest pieces of it), and the point of the V-CURVE throw is to
|
||||
-- see the same model curl, not to lose a quarter of it.
|
||||
Diorama.BOX_FRAC = 0.5
|
||||
Diorama.BALL_FRAC = 0.62
|
||||
|
||||
-- How far the grips may open and close it, as a multiplier on that.
|
||||
Diorama.SCALE_MIN = 0.3
|
||||
Diorama.SCALE_MAX = 4
|
||||
|
||||
-- The rim under V-CURVE, as a fraction of the radius: where the world
|
||||
-- starts fading and where it has finished. Wide enough to read as a
|
||||
-- dissolve rather than an edge, narrow enough that the middle of the model
|
||||
-- is solid. The BOX has no fade at all -- see fadeFor.
|
||||
Diorama.FADE_FRAC = 0.16
|
||||
|
||||
-- The staged fight's disc: the arena's own half-length plus an apron, in
|
||||
-- world pixels (a map cell is 16). The two mons stand three cells apart,
|
||||
-- so this is a disc about seven cells across -- the fight, the ground it
|
||||
-- is fought on, and nothing else.
|
||||
Diorama.ARENA_APRON = 32
|
||||
|
||||
-- ------- what the live frame is
|
||||
--
|
||||
-- All three set by lib/VR for the length of one headset frame, and by
|
||||
-- nothing else. `on` is the whole mode's gate; VoxelScene reads it once
|
||||
-- per frame and every diorama-shaped thing hangs off that read.
|
||||
Diorama.on = false
|
||||
Diorama.keyed = false
|
||||
Diorama.cull = nil -- { x, y, z, r, invFade, kind }
|
||||
|
||||
-- Chroma green, and PURE green deliberately: a keyer wants the one colour
|
||||
-- nothing in the picture can accidentally be, and no palette this mod can
|
||||
-- paint the world in reaches 0,255,0.
|
||||
Diorama.KEY_COLOR = { 0, 1, 0 }
|
||||
|
||||
-- ------- what the grips have done to it
|
||||
--
|
||||
-- Kept across frames (this is where the model IS, as far as the player is
|
||||
-- concerned) and cleared only when the session ends. `offset` is in LOCAL
|
||||
-- metres and rides the mapping's anchor, `yaw` turns the mapping, `zoom`
|
||||
-- multiplies the viewport's radius.
|
||||
Diorama.offset = { 0, 0, 0 }
|
||||
Diorama.yaw = 0
|
||||
Diorama.zoom = 1
|
||||
|
||||
function Diorama.reset()
|
||||
Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
|
||||
Diorama.offset = { 0, 0, 0 }
|
||||
Diorama.yaw, Diorama.zoom = 0, 1
|
||||
Diorama.release()
|
||||
end
|
||||
|
||||
-- ------- which way a staged fight lies on the table
|
||||
--
|
||||
-- The disc's bearing while a battle is up: the player's own hand-turn, with
|
||||
-- the ARENA's quarter turn taken back out of it.
|
||||
--
|
||||
-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
|
||||
-- the promise that field makes everywhere else is that turning it changes the
|
||||
-- GROUND under the fight and never the fight itself -- the two Pokemon land
|
||||
-- on the same marks, seen the same way round. Every other camera keeps that
|
||||
-- promise by construction: the flat shot and the standard VR mount are both
|
||||
-- built from BattleCam's eye, which turns with the arena, so the composition
|
||||
-- follows it round.
|
||||
--
|
||||
-- This one is not built from that eye. It is a disc of map lifted onto the
|
||||
-- table, and its bearing is the arena's bearing in the WORLD -- so a fight
|
||||
-- staged on a turned arena arrived on the table lying across the head that
|
||||
-- was looking at it, while the same fight on an unturned one faced properly.
|
||||
-- Same fight, same composition everywhere else, sideways here.
|
||||
--
|
||||
-- So the turn comes back out. Subtracted, matching the sign the standard
|
||||
-- mount already lands on: its yaw is atan2 of (eye - focus), and rotating
|
||||
-- that pair by +turn takes the bearing to (bearing - turn). One rule, two
|
||||
-- seats.
|
||||
--
|
||||
-- The hand-turn stays on top of it, because that is the player moving the
|
||||
-- model and is theirs to keep.
|
||||
function Diorama.battleYaw(arena)
|
||||
local turn = (arena and arena.turn) or 0
|
||||
if turn == 0 then return Diorama.yaw end
|
||||
local yaw = Diorama.yaw - math.rad(turn)
|
||||
-- kept in (-pi, pi] like the grips leave it, so nothing downstream has to
|
||||
-- care which way round it came
|
||||
return (yaw + math.pi) % (2 * math.pi) - math.pi
|
||||
end
|
||||
|
||||
-- Open a diorama frame. `mode` is VR.mode()'s answer; anything that is
|
||||
-- not a diorama mode closes it.
|
||||
function Diorama.begin(mode)
|
||||
Diorama.on = (mode == "diorama" or mode == "diorama-mr")
|
||||
Diorama.keyed = Diorama.on and mode == "diorama-mr"
|
||||
if not Diorama.on then Diorama.cull = nil end
|
||||
return Diorama.on
|
||||
end
|
||||
|
||||
function Diorama.stop()
|
||||
Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
|
||||
end
|
||||
|
||||
-- What the world's background must be cleared to, or nil to leave the sky
|
||||
-- alone. Only ever a colour in DIORAMA-MR, and only while a frame is open.
|
||||
function Diorama.keyColor()
|
||||
if not (Diorama.on and Diorama.keyed) then return nil end
|
||||
return Diorama.KEY_COLOR
|
||||
end
|
||||
|
||||
-- ------- the viewport, as the shaders take it
|
||||
--
|
||||
-- `kind` is the shader's own switch: 0 no cut, 1 the box, 2 the ball, 3
|
||||
-- the fight's pillar. `invFade` is one over the fade band in world pixels,
|
||||
-- so the rim is a single multiply out there -- and a hard edge is simply a
|
||||
-- band under a pixel wide, which costs the shader no branch of its own.
|
||||
Diorama.BOX = 1
|
||||
Diorama.BALL = 2
|
||||
Diorama.PILLAR = 3
|
||||
|
||||
-- The half-size the viewport stands at right now, for a view `vh` world
|
||||
-- pixels tall -- the flat framing this rung would have shown -- and for
|
||||
-- the shape it is currently in.
|
||||
function Diorama.radius(vh, curved)
|
||||
local frac = curved and Diorama.BALL_FRAC or Diorama.BOX_FRAC
|
||||
return math.max(24, (vh or 288) * frac * Diorama.zoom)
|
||||
end
|
||||
|
||||
-- Whether the world is BENT right now, which is the whole of what decides
|
||||
-- the viewport's shape: a square cut suits a flat slab of map, and a
|
||||
-- curved world rolling away over its own horizon wants a ball with a
|
||||
-- dissolve. Asked of the row rather than remembered, so the V-CURVE the
|
||||
-- stick click throws (and the "7" key, and the OPTIONS row) all reach it.
|
||||
function Diorama.curved()
|
||||
local ok, on = pcall(function()
|
||||
return V.require("WorldCurve").active()
|
||||
end)
|
||||
return ok and on or false
|
||||
end
|
||||
|
||||
-- The fade band for a cut of half-size `r`: the curve's dissolve, or a
|
||||
-- hard edge (band 0) for the box.
|
||||
function Diorama.fadeFor(r, curved)
|
||||
if not curved then return 0 end
|
||||
return math.max(1, r * Diorama.FADE_FRAC)
|
||||
end
|
||||
|
||||
local function volume(kind, x, y, z, r, fade)
|
||||
return { x = x, y = y, z = z, r = r,
|
||||
-- The BOX kind is rectangular in the shader, because the flat
|
||||
-- screen's box is the WINDOW's own footprint and a window is not
|
||||
-- square (lib/ViewBox). A headset's model has no window to be
|
||||
-- shaped like, so this one is: the same half-size twice.
|
||||
rx = r, rz = r,
|
||||
-- a zero band is a hard edge: half a pixel of ramp, which is
|
||||
-- one pixel of antialiasing rather than a stair
|
||||
invFade = 1 / math.max(fade or 0, 0.5), kind = kind }
|
||||
end
|
||||
|
||||
-- The viewport this frame, centred on the world point the model is pinned
|
||||
-- by: the BOX ordinarily, and the BALL while the world is curved.
|
||||
function Diorama.viewport(cx, cy, vh)
|
||||
local curved = Diorama.curved()
|
||||
local r = Diorama.radius(vh, curved)
|
||||
Diorama.cull = volume(curved and Diorama.BALL or Diorama.BOX,
|
||||
cx, 0, cy, r, Diorama.fadeFor(r, curved))
|
||||
return Diorama.cull
|
||||
end
|
||||
|
||||
-- The staged fight's disc: a vertical pillar about the arena's midpoint,
|
||||
-- wide enough for both mons and their apron. Vertical means UNBOUNDED --
|
||||
-- a tree standing on the disc keeps all of its height, which is what
|
||||
-- makes the cut read as the ground having been lifted out rather than as
|
||||
-- the world having been sliced through at eye level.
|
||||
function Diorama.pillar(arena)
|
||||
if not (arena and arena.mid) then return nil end
|
||||
local mx, mz = arena.mid[1], arena.mid[2]
|
||||
local r = Diorama.ARENA_APRON
|
||||
if arena.player and arena.enemy then
|
||||
local dx = arena.player[1] - mx
|
||||
local dz = arena.player[2] - mz
|
||||
r = r + math.sqrt(dx * dx + dz * dz)
|
||||
end
|
||||
-- Round whatever the curve is doing -- a fight is a disc, and a square
|
||||
-- arena tile floating in the air is not the picture -- and ALWAYS
|
||||
-- dissolved at the rim, curve or no curve. The box's hard edge is there
|
||||
-- to say "this is a flat slab of map with sides"; a fight is a thing
|
||||
-- lifted out of the world and hanging in the air, and a hard edge on it
|
||||
-- reads as a cookie cutter rather than as a piece of ground.
|
||||
Diorama.cull = volume(Diorama.PILLAR, mx, 0, mz, r,
|
||||
Diorama.fadeFor(r, true))
|
||||
return Diorama.cull
|
||||
end
|
||||
|
||||
-- ------- the grips
|
||||
--
|
||||
-- One hand carries the model; two turn it and open the viewport. The
|
||||
-- gesture is measured as a DELTA per frame rather than from where the
|
||||
-- squeeze started, so letting go and taking hold again never snaps
|
||||
-- anything -- the model simply stops following and starts again.
|
||||
|
||||
Diorama.GRIP = 0.6 -- squeezed past this counts as holding on
|
||||
Diorama.SPREAD_MIN = 0.08 -- hands closer than this give no scale
|
||||
|
||||
local lastOne = nil -- the carrying hand's position, last frame
|
||||
local lastMid = nil -- both hands' midpoint
|
||||
local lastAngle = nil -- and the bearing of the line between them
|
||||
local lastSpread = nil -- and its length
|
||||
|
||||
local function clearGrab()
|
||||
lastOne, lastMid, lastAngle, lastSpread = nil, nil, nil, nil
|
||||
end
|
||||
|
||||
Diorama.releaseGrab = clearGrab
|
||||
|
||||
-- Advance the grab from this frame's controller state (lib/VRXR's table:
|
||||
-- gripL/gripR in 0..1, handl/handr as { pos, quat } when tracked).
|
||||
-- Returns true while the model is being held.
|
||||
function Diorama.gesture(ctl)
|
||||
if not ctl then
|
||||
clearGrab()
|
||||
return false
|
||||
end
|
||||
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
|
||||
local hl = (gl > Diorama.GRIP) and ctl.handl or nil
|
||||
local hr = (gr > Diorama.GRIP) and ctl.handr or nil
|
||||
|
||||
if hl and hr then
|
||||
lastOne = nil
|
||||
local lp, rp = hl.pos, hr.pos
|
||||
local mid = { (lp[1] + rp[1]) / 2, (lp[2] + rp[2]) / 2,
|
||||
(lp[3] + rp[3]) / 2 }
|
||||
local dx, dy, dz = rp[1] - lp[1], rp[2] - lp[2], rp[3] - lp[3]
|
||||
local spread = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
-- the bearing of the line between the hands, in the same convention
|
||||
-- the mapping's yaw turns through (see VRRig.eyeCamera): atan2 of the
|
||||
-- x component over the z one, so a hand-over-hand turn and the model's
|
||||
-- turn are the same number
|
||||
local angle = math.atan2(dx, dz)
|
||||
if lastMid then
|
||||
for i = 1, 3 do
|
||||
Diorama.offset[i] = Diorama.offset[i] + (mid[i] - lastMid[i])
|
||||
end
|
||||
end
|
||||
if lastAngle then
|
||||
local d = (angle - lastAngle + math.pi) % (2 * math.pi) - math.pi
|
||||
Diorama.yaw = (Diorama.yaw + d + math.pi) % (2 * math.pi) - math.pi
|
||||
end
|
||||
if lastSpread and lastSpread > Diorama.SPREAD_MIN
|
||||
and spread > Diorama.SPREAD_MIN then
|
||||
Diorama.zoom = math.max(Diorama.SCALE_MIN,
|
||||
math.min(Diorama.SCALE_MAX,
|
||||
Diorama.zoom * (spread / lastSpread)))
|
||||
end
|
||||
lastMid, lastAngle, lastSpread = mid, angle, spread
|
||||
return true
|
||||
end
|
||||
|
||||
lastMid, lastAngle, lastSpread = nil, nil, nil
|
||||
local one = hl or hr
|
||||
if one then
|
||||
if lastOne then
|
||||
for i = 1, 3 do
|
||||
Diorama.offset[i] = Diorama.offset[i] + (one.pos[i] - lastOne[i])
|
||||
end
|
||||
end
|
||||
lastOne = { one.pos[1], one.pos[2], one.pos[3] }
|
||||
return true
|
||||
end
|
||||
lastOne = nil
|
||||
return false
|
||||
end
|
||||
|
||||
-- Nothing here owns a GPU object any more (the base did, and it is gone --
|
||||
-- see the header), so this is only the grab's own hand-to-hand state: a
|
||||
-- window resize or a hot reload should not leave the model following a
|
||||
-- delta measured against a frame that no longer exists.
|
||||
function Diorama.release()
|
||||
clearGrab()
|
||||
end
|
||||
|
||||
Diorama.invalidate = Diorama.release
|
||||
|
||||
return Diorama
|
||||
@@ -0,0 +1,98 @@
|
||||
-- LET'S GO: the whole party's experience, on one card.
|
||||
--
|
||||
-- Sharing experience to everybody has a cost the original game never had
|
||||
-- to pay: six Pokemon means six "X gained N EXP. Points!" boxes for every
|
||||
-- knockout, each needing its own press. That is the same information the
|
||||
-- player wanted, delivered in the most tiring possible way -- and it is
|
||||
-- worse than a wall of text, because the numbers arrive one at a time so
|
||||
-- the one thing a shared payout is FOR (comparing them: the little one
|
||||
-- gained five times what the big one did) can never be seen at once.
|
||||
--
|
||||
-- So the per-Pokemon lines are suppressed and this card is shown in their
|
||||
-- place: one box, one press, every gain side by side, with a level-up
|
||||
-- called out on the row it happened to. What the card cannot cover still
|
||||
-- plays as it always did -- "X grew to level 6!", the stats window, and
|
||||
-- any move learned -- because those are events, not a tally.
|
||||
--
|
||||
-- Drawn with the engine's own font and box, in the GB's own frame, so it
|
||||
-- sits in a staged 3D battle exactly like every other battle panel.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ExpPanel = {}
|
||||
ExpPanel.__index = ExpPanel
|
||||
|
||||
local Font = nil
|
||||
local function font()
|
||||
if Font ~= nil then return Font or nil end
|
||||
local ok, F = pcall(require, "src.render.Font")
|
||||
Font = ok and F or false
|
||||
return Font or nil
|
||||
end
|
||||
|
||||
-- the GB frame, in 8-pixel tiles
|
||||
local TILE = 8
|
||||
local COLS, ROWS = 20, 18
|
||||
local ROW_H = 12 -- pixels between listed Pokemon
|
||||
|
||||
-- `rows` is filled by the award loop and read HERE, at draw time: the
|
||||
-- loop runs to completion long before the battle queue reaches this
|
||||
-- panel, so the table is always complete by the time it is shown.
|
||||
function ExpPanel.new(game, rows)
|
||||
return setmetatable({ game = game, rows = rows or {}, t = 0 }, ExpPanel)
|
||||
end
|
||||
|
||||
function ExpPanel:update(dt)
|
||||
self.t = (self.t or 0) + (dt or 0)
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
-- a beat of deafness: the press that dismissed whatever came before
|
||||
-- must not dismiss this card in the same breath
|
||||
if self.t < 0.12 then return end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
function ExpPanel:draw()
|
||||
local F = font()
|
||||
if not F then return end
|
||||
local rows = self.rows or {}
|
||||
local n = #rows
|
||||
if n == 0 then return end
|
||||
|
||||
-- bottom-anchored and only as tall as it needs to be, so a two-Pokemon
|
||||
-- party does not black out the fight behind it
|
||||
local th = 3 + math.ceil(n * ROW_H / TILE)
|
||||
th = math.min(th, ROWS - 1)
|
||||
local ty = ROWS - th
|
||||
F.drawBox(0, ty, COLS, th)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local x0 = TILE
|
||||
local y0 = (ty + 1) * TILE + 2
|
||||
F.draw("EXP GAINED", x0, y0)
|
||||
|
||||
for i, r in ipairs(rows) do
|
||||
local y = y0 + ROW_H + (i - 1) * ROW_H
|
||||
if y > (ROWS - 1) * TILE then break end
|
||||
local mon = r.mon
|
||||
local name = mon.nickname
|
||||
or (self.game.data.pokemon[mon.species] or {}).name
|
||||
or tostring(mon.species)
|
||||
F.draw(name, x0, y)
|
||||
-- a level-up is called out where it happened rather than left to the
|
||||
-- message that follows, so the card reads as the whole story
|
||||
if (r.to or 0) > (r.from or 0) then
|
||||
local up = ("L%d"):format(r.to)
|
||||
F.draw(up, 108 - F.width(up), y)
|
||||
end
|
||||
local amt = ("+%d"):format(r.gained or 0)
|
||||
F.draw(amt, 152 - F.width(amt), y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return ExpPanel
|
||||
@@ -0,0 +1,919 @@
|
||||
-- Voxel world mode: the free-roam camera -- the 1ST and 3RD rungs.
|
||||
--
|
||||
-- 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.
|
||||
--
|
||||
-- 3RD is that same rig with the eye pulled back onto a boom behind the
|
||||
-- player's shoulder (lib/ThirdPerson.lua). Everything in this file is
|
||||
-- already general over where the eye stands -- the attitude, the look
|
||||
-- inputs, the move intent, the cards that turn to face the eye -- so the
|
||||
-- third-person rung is one number applied at the very end of frame(),
|
||||
-- rather than a second camera to keep in step with this one.
|
||||
--
|
||||
-- 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 ThirdPerson = V.require("ThirdPerson")
|
||||
|
||||
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 a free-roam rung -- 1ST or 3RD -- is selected and the 3D pass can
|
||||
-- carry it. Both stand the camera with the player, so both read the look
|
||||
-- inputs, both walk free, and both turn the cards; how far behind the head
|
||||
-- the eye ends up is ThirdPerson's business alone.
|
||||
function FirstPerson.engaged()
|
||||
return Voxel.isFreeCam(Voxel.level) and Voxel3D.available()
|
||||
end
|
||||
|
||||
-- Whether the overworld is what the player is looking at: nothing pushed
|
||||
-- over it, so the buttons are free-roam's. Shared with everything else in
|
||||
-- the mod that asks the same question of the same stack (CamControl's
|
||||
-- zooms above all), rather than each restating the pcall.
|
||||
function FirstPerson.onTop()
|
||||
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
|
||||
|
||||
-- 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()
|
||||
return FirstPerson.engaged() and FirstPerson.onTop()
|
||||
end
|
||||
|
||||
-- The right stick's live X, for a camera that is not this one: while a
|
||||
-- battle is staged the free-roam look is not driving, but the axes are
|
||||
-- still arriving on the wrap below (which records whatever the rung), and
|
||||
-- the battle's orbit wants them. Reading them here rather than wrapping
|
||||
-- the same seam twice.
|
||||
function FirstPerson.stickX()
|
||||
return stick.x or 0
|
||||
end
|
||||
|
||||
function FirstPerson.stickY()
|
||||
return stick.y or 0
|
||||
end
|
||||
|
||||
-- ------- lending the look finger out
|
||||
--
|
||||
-- A pinch needs both fingers on the screen, and one of them is very likely
|
||||
-- the finger this module claimed as the look drag. Rather than have the
|
||||
-- pinch fight for it, CamControl asks for it: dropLook while the gesture
|
||||
-- runs, reseatLook on whichever finger survives it. Re-seating rather than
|
||||
-- simply releasing is what stops the view snapping by however far the
|
||||
-- pinch travelled -- the finger carries on as the look drag from where it
|
||||
-- now is, which is what it looks like it should do.
|
||||
function FirstPerson.dropLook()
|
||||
lookTouch = nil
|
||||
end
|
||||
|
||||
function FirstPerson.reseatLook(id, x, y)
|
||||
if id == nil then lookTouch = nil return end
|
||||
lookTouch = { id = id, x = x, y = y }
|
||||
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.
|
||||
--
|
||||
-- Never while 3RD's boom is genuinely out, whatever the blend: the whole
|
||||
-- point of a boom is that the character it is booming away from is on
|
||||
-- screen. (Nor the silhouette that rides the same answer -- seeing your own
|
||||
-- outline through the building you just walked behind is what a
|
||||
-- third-person camera owes the player.) A boom SQUEEZED into the head by a
|
||||
-- wall answers false there and the card comes out again, because at that
|
||||
-- range it is the first-person problem word for word.
|
||||
function FirstPerson.hidePlayer()
|
||||
if ThirdPerson.showsPlayer() then return false end
|
||||
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
|
||||
|
||||
-- A bearing as one of the grid's four directions -- the 45-degree
|
||||
-- quantisation every facing in this file is made with, in one place so the
|
||||
-- compass, the body and the card frames can never disagree about where a
|
||||
-- boundary is.
|
||||
local function facingOf(a)
|
||||
local s, c = math.sin(a), math.cos(a)
|
||||
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 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()
|
||||
return facingOf(FirstPerson.yaw)
|
||||
end
|
||||
|
||||
-- Which way the BODY points, as a continuous world bearing, given the
|
||||
-- world-space direction it is walking (0, 0 while standing). In the head,
|
||||
-- the body is the head: you face what you look at. On the boom you can see
|
||||
-- yourself, and a character sliding sideways while facing the lens reads as
|
||||
-- a bug rather than as a strafe -- so a walking body turns to face its own
|
||||
-- travel, and a standing one comes back round to the camera's bearing,
|
||||
-- which is the one A talks along.
|
||||
function FirstPerson.bodyBearing(wx, wz)
|
||||
if ThirdPerson.extended() and wx and wz and (wx ~= 0 or wz ~= 0) then
|
||||
return math.atan2(wx, wz)
|
||||
end
|
||||
return FirstPerson.yaw
|
||||
end
|
||||
|
||||
-- The same answer as one of the four facings, which is what the grid game
|
||||
-- (and the sprite sheet) reasons in.
|
||||
function FirstPerson.bodyFacing(wx, wz)
|
||||
return facingOf(FirstPerson.bodyBearing(wx, wz))
|
||||
end
|
||||
|
||||
-- ------- the body's live bearing
|
||||
--
|
||||
-- The bearing the player's own body is actually pointing along RIGHT NOW,
|
||||
-- or nil whenever the free walk is not the thing pointing it (a scripted
|
||||
-- move, a cutscene, the grid walk with the rung off). FreeMove maintains
|
||||
-- it; only the player's own card reads it.
|
||||
--
|
||||
-- It exists because the card's frame is chosen by the angle BETWEEN the
|
||||
-- body and the eye, and quantising the body to a compass direction first
|
||||
-- throws away exactly the precision that choice needs. A standing body is
|
||||
-- pointed along the camera's own yaw, so the true angle between them is a
|
||||
-- flat 180 degrees and the card should show its back and nothing else --
|
||||
-- but snap the body to one of four directions on the game tick, then
|
||||
-- measure it against an eye that has kept turning since, and the pair can
|
||||
-- read as 135 degrees and pick the PROFILE frame instead. Spin the camera
|
||||
-- fast and the character flicks to a mirrored side view for a frame or
|
||||
-- two. Keeping the bearing continuous gives the measurement a full 45
|
||||
-- degrees of slack before it can cross a boundary, which no frame's worth
|
||||
-- of turning comes close to spending.
|
||||
FirstPerson.bodyYaw = nil
|
||||
|
||||
-- Point the body along the direction it is walking (or, standing, along
|
||||
-- the camera): records the continuous bearing and hands back the compass
|
||||
-- facing the caller wants for p.facing.
|
||||
function FirstPerson.pointBody(wx, wz)
|
||||
FirstPerson.bodyYaw = FirstPerson.bodyBearing(wx, wz)
|
||||
return facingOf(FirstPerson.bodyYaw)
|
||||
end
|
||||
|
||||
-- Hand the body back to whatever else is turning it.
|
||||
function FirstPerson.releaseBody()
|
||||
FirstPerson.bodyYaw = nil
|
||||
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 a body at world bearing `phi` shows an
|
||||
-- eye looking at (wx, wz): the bearing rotated into the viewer's own frame,
|
||||
-- quantised. nil when there is no rig to be seen from.
|
||||
local function frameFor(phi, wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
if not (eye and phi) then return nil end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return nil 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
|
||||
|
||||
-- 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.
|
||||
--
|
||||
-- An NPC's facing IS one of the four and nothing finer, so this is the
|
||||
-- whole story for everyone in the world except the one body the camera is
|
||||
-- attached to -- see playerFacing.
|
||||
function FirstPerson.apparentFacing(facing, wx, wz)
|
||||
return frameFor(FACING_ANGLE[facing], wx, wz) or facing
|
||||
end
|
||||
|
||||
-- The PLAYER's own card, which is the one case where the body's bearing is
|
||||
-- known to better than a compass point (bodyYaw, above) -- and the one case
|
||||
-- where it matters, because the eye is derived FROM that bearing rather
|
||||
-- than independent of it. Measured continuously, a standing body reads as
|
||||
-- a flat 180 degrees from its own camera and shows its back, steadily,
|
||||
-- however fast the camera is spun. Falls back to the four-direction answer
|
||||
-- whenever something other than the free walk is turning the body.
|
||||
function FirstPerson.playerFacing(facing, wx, wz)
|
||||
return frameFor(FirstPerson.bodyYaw, wx, wz)
|
||||
or FirstPerson.apparentFacing(facing, wx, wz)
|
||||
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
|
||||
|
||||
-- the boom, on the same tick and for the same reason: it has to keep
|
||||
-- easing after 3RD is left, and it needs the blend to know whether a
|
||||
-- change of rung is a SLIDE (already inside the world, 1ST <-> 3RD) or
|
||||
-- part of the dive in from the orbit, which carries the eye anyway
|
||||
ThirdPerson.update(dt, FirstPerson.blend)
|
||||
|
||||
-- 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 }
|
||||
|
||||
-- 3RD: the eye walks back off the head along the very direction it looks,
|
||||
-- as far as the world allows. Fully in (1ST, and every frame of the
|
||||
-- diorama) this hands back the head and the focus untouched, so the two
|
||||
-- rungs are one rig with one number between them.
|
||||
local camEye, camFocus = ThirdPerson.place(head, lx, ly, lz, fpFocus)
|
||||
|
||||
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, camEye),
|
||||
focus = mix(oFocus, camFocus),
|
||||
fov = oFov + (FirstPerson.FOV - oFov) * e,
|
||||
up = up,
|
||||
curve = k,
|
||||
}
|
||||
Voxel3D.camera = rig
|
||||
|
||||
-- the scene centre walks from the orbit's view centre to the head, so
|
||||
-- the curve's focus, the depth reference and the glint's travel follow
|
||||
-- the camera that is actually in charge
|
||||
local sx = cx + (head[1] - cx) * e
|
||||
local sy = cy + (head[3] - cy) * e
|
||||
return rig, sx, sy
|
||||
end
|
||||
|
||||
-- Where the shadow pass should centre its box: pushed along the flat look
|
||||
-- so the fitted frustum -- built for an orbit that always looks north --
|
||||
-- covers the ground THIS camera sees. The push is strongest looking
|
||||
-- south (the direction the orbit's box barely reaches) and scales with
|
||||
-- the blend.
|
||||
function FirstPerson.shadowCenter(sx, sy, vh)
|
||||
local e = FirstPerson.cardBlend()
|
||||
if e <= 0 then return sx, sy end
|
||||
local fx, fz = FirstPerson.lookFlat()
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local cap = (ShadowMap.FAR_CAP or 2.5) * vh
|
||||
return sx + fx * 0.6 * vh * e,
|
||||
sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e
|
||||
end
|
||||
|
||||
-- The first-person facts a shadow signature has to include: the sun's
|
||||
-- box is fitted around this camera, so turning the head or walking the
|
||||
-- blend has to re-fit it even standing still.
|
||||
function FirstPerson.signature()
|
||||
local b = FirstPerson.blend
|
||||
if b <= 0 then return "" end
|
||||
return table.concat({
|
||||
math.floor(b * 64),
|
||||
math.floor(FirstPerson.yaw * 64),
|
||||
math.floor(FirstPerson.pitch * 64),
|
||||
-- and how far back the boom stands the eye: a wall shortening it moves
|
||||
-- the camera the sun's box is fitted around, standing still or not
|
||||
ThirdPerson.signature(),
|
||||
}, ",")
|
||||
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,886 @@
|
||||
-- The air under the canopy: fog, god rays, and what drifts through them.
|
||||
--
|
||||
-- Some maps have an ATMOSPHERE (data/map_atmosphere.lua -- Viridian Forest
|
||||
-- today, any map that adds a line tomorrow): a ground haze the scene shader
|
||||
-- folds every surface into (see Voxel3D.fog), and VOLUMETRIC light let down
|
||||
-- through an INVISIBLE canopy hanging above the map's real trees, as if the
|
||||
-- carved hulls on screen were only the understorey of something taller.
|
||||
--
|
||||
-- The rays are not placed geometry. A fullscreen pass marches every
|
||||
-- pixel's eye ray through the air, stops at the frame's own depth buffer
|
||||
-- (the same detach-and-read Water runs), and asks two questions of every
|
||||
-- step of air on the way:
|
||||
--
|
||||
-- * the SUN'S question -- the shadow map. Air behind a tree hull is
|
||||
-- dark air; air in a real gap glows. A trunk stands in a column of
|
||||
-- its own shade, a character walks through the beams and blocks
|
||||
-- them, and every shaft on screen agrees with the light already on
|
||||
-- the floor, because it is read from the same map.
|
||||
--
|
||||
-- * the CANOPY'S question -- where this thread of sun pierced the
|
||||
-- invisible leaf layer. Every step of air on one sun ray shares that
|
||||
-- point, which is what makes a shaft a SHAFT, and the point samples
|
||||
-- a wind-blown noise field: the dapple drifts and shivers like
|
||||
-- leaves moving overhead, opening and closing the beams as it goes.
|
||||
--
|
||||
-- The shafts lean along the fixed noon shear, deliberately: a canopy
|
||||
-- map's rig is pinned to noon (see DayNight.CANOPY), so light that
|
||||
-- followed the sun's arc would part company with every shadow on the
|
||||
-- floor. What follows the clock is colour and strength -- gold spears of
|
||||
-- sun by day, silver moon rays after dark, dying back through the
|
||||
-- twilights -- plus the crew each shift brings: pollen adrift in the
|
||||
-- day's beams, fireflies once they cool. A forward-scattering phase term
|
||||
-- brightens the beams for a camera looking up into the light, which is
|
||||
-- most of what makes them read as light in air rather than paint on it.
|
||||
--
|
||||
-- Everything is deterministic: placement and the leaf field are dealt by
|
||||
-- a seeded xorshift (StadiumFx's generator), motion is a pure function
|
||||
-- of one `time` uniform, so a pinned ForestAtmos.time reproduces a frame
|
||||
-- exactly (see tests/forest_fog_shots). Every shader compiles lazily
|
||||
-- behind pcall -- nil untried, false unavailable -- and each refusal
|
||||
-- subtracts only itself: no march without readable depth, no beams
|
||||
-- without a shadow map, and the fog rides the scene shader whatever
|
||||
-- happens here.
|
||||
|
||||
local V = ...
|
||||
|
||||
local DayNight = V.require("DayNight")
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max
|
||||
|
||||
local ForestAtmos = {}
|
||||
|
||||
-- The viewport, as both shaders here take it (see Voxel3D.cull: the field
|
||||
-- is set for a headset's diorama frame and for an orbit rung's window box,
|
||||
-- and nil for every other, where kind 0 means "no cut"). Read through
|
||||
-- V.require rather than held as an upvalue because this file loads before
|
||||
-- Voxel3D on some paths.
|
||||
local function cullAt()
|
||||
local c = V.require("Voxel3D").cull
|
||||
return c and { c.x, c.y, c.z } or { 0, 0, 0 }
|
||||
end
|
||||
|
||||
local function cullShape()
|
||||
local c = V.require("Voxel3D").cull
|
||||
return c and { c.r, c.invFade, c.kind } or { 0, 0, 0 }
|
||||
end
|
||||
|
||||
local function cullRect()
|
||||
local c = V.require("Voxel3D").cull
|
||||
return c and { c.rx or c.r, c.rz or c.r } or { 0, 0 }
|
||||
end
|
||||
|
||||
-- FULL is the point; LOW halves the march and drops the particles, for
|
||||
-- hardware that minds a per-pixel loop under 4X supersampling.
|
||||
--
|
||||
-- On ANDROID the ladder itself is shorter: LOW and OFF, with LOW the
|
||||
-- default. The march needs a depth texture it can READ, and no driver on
|
||||
-- the phones this runs on has granted one (see newDepth in Voxel3D) --
|
||||
-- so FULL would be a rung with nothing behind it, which reads as a
|
||||
-- broken mod rather than a missing feature. LOW there is the haze, the
|
||||
-- one part of the atmosphere that rides the scene shader and works
|
||||
-- everywhere. A desktop save opened on a phone stores FULL still;
|
||||
-- ModSetting's unknown-value fallback lands it on LOW, and putting the
|
||||
-- save back on the desktop restores the choice.
|
||||
local function onAndroid()
|
||||
if not (love and love.system and love.system.getOS) then return false end
|
||||
local ok, os = pcall(love.system.getOS)
|
||||
return ok and os == "Android"
|
||||
end
|
||||
|
||||
ForestAtmos.setting = onAndroid()
|
||||
and ModSetting.new("atmos", "FOREST FX", { "low", "off" },
|
||||
{ "LOW", "OFF" })
|
||||
or ModSetting.new("atmos", "FOREST FX", { "full", "low", "off" },
|
||||
{ "FULL", "LOW", "OFF" })
|
||||
|
||||
-- the animation clock: ticked by main.lua's always-running update hook,
|
||||
-- pinnable (frozen = true) so a screenshot driver can hold a frame still
|
||||
ForestAtmos.time = 0
|
||||
ForestAtmos.frozen = false
|
||||
|
||||
function ForestAtmos.update(dt)
|
||||
if ForestAtmos.frozen then return end
|
||||
ForestAtmos.time = ForestAtmos.time + (dt or 0)
|
||||
end
|
||||
|
||||
-- ------- the authored table
|
||||
--
|
||||
-- Same shape as BattleArena's: the data file behind a pcall with a false
|
||||
-- sentinel, and an overrides table a tuning driver can stage a candidate
|
||||
-- through before anything is written down. `~= nil` on the override,
|
||||
-- because false is meaningful -- "this map has no atmosphere, whatever
|
||||
-- the file says".
|
||||
|
||||
local authored = nil
|
||||
local overrides = {}
|
||||
|
||||
local function configFor(mapId)
|
||||
if not mapId then return nil end
|
||||
local forced = overrides[mapId]
|
||||
if forced ~= nil then return forced or nil end
|
||||
if authored == nil then
|
||||
local ok, list = pcall(V.data, "map_atmosphere")
|
||||
authored = (ok and type(list) == "table") and list or false
|
||||
end
|
||||
if not authored then return nil end
|
||||
return authored[mapId]
|
||||
end
|
||||
|
||||
ForestAtmos.configFor = configFor
|
||||
|
||||
-- ------- caches
|
||||
--
|
||||
-- Particle layouts and meshes go stale with the map (map.reloaded, and
|
||||
-- the pipeline's invalidate); called with no map id this also resets the
|
||||
-- shader and texture sentinels, which is what a lost GL context needs.
|
||||
|
||||
local layoutCache = {}
|
||||
local meshCache = {}
|
||||
local shaders = {} -- keyed by variant; nil untried, false refused
|
||||
local leafTex = nil -- the tiling leaf-dapple field
|
||||
local rayMesh = nil -- the fullscreen ray-fan quad, re-aimed per draw
|
||||
|
||||
-- Every bail here is deliberate and silent on screen -- a missing piece
|
||||
-- subtracts itself, never the frame -- but "the beams are off" and "the
|
||||
-- beams are off BECAUSE ..." are different debugging days. Each reason
|
||||
-- is said once on the console, the way VR reports a missing runtime.
|
||||
local said = {}
|
||||
local function say(key, msg)
|
||||
if said[key] then return end
|
||||
said[key] = true
|
||||
print("[DRAMATIC_SHAPE] atmos: " .. msg)
|
||||
end
|
||||
|
||||
function ForestAtmos.invalidate(mapId)
|
||||
if mapId then
|
||||
layoutCache[mapId] = nil
|
||||
meshCache[mapId] = nil
|
||||
else
|
||||
layoutCache, meshCache = {}, {}
|
||||
shaders = {}
|
||||
leafTex = nil
|
||||
rayMesh = nil
|
||||
end
|
||||
end
|
||||
|
||||
function ForestAtmos.setOverride(mapId, entry)
|
||||
overrides[mapId] = entry
|
||||
ForestAtmos.invalidate(mapId)
|
||||
end
|
||||
|
||||
-- ------- the hour's answer
|
||||
--
|
||||
-- One ramp, authored per phase and blended with DayNight's own weights,
|
||||
-- so the fog and the rays can never disagree about what hour it is. The
|
||||
-- two interact three ways: the fog colour leans toward the ray colour
|
||||
-- (noon warms the haze, midnight silvers it), the rays scale with the
|
||||
-- fog's density (a beam IS lit fog -- less medium, less beam), and the
|
||||
-- march accumulates through the same density the surfaces sink into.
|
||||
|
||||
ForestAtmos.RAMP = {
|
||||
day = { fog = { 0.78, 0.86, 0.70 }, ray = { 1.00, 0.93, 0.70 },
|
||||
alpha = 0.55, density = 1.00, motes = 1.0, flies = 0.0 },
|
||||
golden = { fog = { 0.84, 0.76, 0.58 }, ray = { 1.00, 0.85, 0.55 },
|
||||
alpha = 0.35, density = 1.00, motes = 0.6, flies = 0.0 },
|
||||
dawn = { fog = { 0.80, 0.70, 0.66 }, ray = { 1.00, 0.80, 0.62 },
|
||||
alpha = 0.20, density = 1.05, motes = 0.3, flies = 0.25 },
|
||||
dusk = { fog = { 0.78, 0.66, 0.58 }, ray = { 1.00, 0.76, 0.55 },
|
||||
alpha = 0.20, density = 1.05, motes = 0.2, flies = 0.5 },
|
||||
violet = { fog = { 0.52, 0.50, 0.66 }, ray = { 0.82, 0.80, 1.00 },
|
||||
alpha = 0.25, density = 1.10, motes = 0.0, flies = 1.0 },
|
||||
night = { fog = { 0.34, 0.40, 0.56 }, ray = { 0.72, 0.80, 1.00 },
|
||||
alpha = 0.40, density = 1.15, motes = 0.0, flies = 1.0 },
|
||||
}
|
||||
|
||||
-- The frame's atmosphere for `map` at clock `t` (defaulting to now), or
|
||||
-- nil -- no entry, or the row is OFF -- in which case nothing is drawn
|
||||
-- and Voxel3D.fog should be left nil.
|
||||
function ForestAtmos.frame(map, t)
|
||||
if ForestAtmos.setting:get() == "off" then return nil end
|
||||
local cfg = configFor(map and map.id)
|
||||
if not cfg then return nil end
|
||||
local mix = DayNight.mix(t or DayNight.time())
|
||||
local fr, fg, fb, rr, rg, rb = 0, 0, 0, 0, 0, 0
|
||||
local alpha, dens, motes, flies = 0, 0, 0, 0
|
||||
for name, w in pairs(mix) do
|
||||
local p = ForestAtmos.RAMP[name] or ForestAtmos.RAMP.day
|
||||
fr, fg, fb = fr + p.fog[1] * w, fg + p.fog[2] * w, fb + p.fog[3] * w
|
||||
rr, rg, rb = rr + p.ray[1] * w, rg + p.ray[2] * w, rb + p.ray[3] * w
|
||||
alpha = alpha + p.alpha * w
|
||||
dens = dens + p.density * w
|
||||
motes = motes + p.motes * w
|
||||
flies = flies + p.flies * w
|
||||
end
|
||||
-- the haze takes on a little of the light standing in it
|
||||
local LEAN = 0.15
|
||||
fr = fr + (rr - fr) * LEAN
|
||||
fg = fg + (rg - fg) * LEAN
|
||||
fb = fb + (rb - fb) * LEAN
|
||||
local base = cfg.fog or {}
|
||||
local rays = cfg.rays or {}
|
||||
return {
|
||||
-- in exactly the shape Voxel3D.fog takes, so callers assign it whole
|
||||
fog = { color = { fr, fg, fb },
|
||||
density = (base.density or 0) * dens,
|
||||
start = base.start or 0,
|
||||
heightK = base.heightK or 0 },
|
||||
rayColor = { rr, rg, rb },
|
||||
-- a beam is scattered fog: less medium, less beam
|
||||
rayAlpha = alpha * (0.4 + 0.6 * min(dens, 1)),
|
||||
rayStrength = rays.strength or 12,
|
||||
rayReach = rays.reach or 380,
|
||||
moteLevel = motes,
|
||||
fireflyLevel = flies,
|
||||
cfg = cfg,
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- deterministic noise
|
||||
--
|
||||
-- The same written-out xorshift StadiumFx runs (see the note there on why
|
||||
-- not LuaJIT's `bit`): the particle deal and the leaf field must come out
|
||||
-- identical on every machine and every visit.
|
||||
|
||||
local function bxor32(a, b)
|
||||
local r, p = 0, 1
|
||||
for _ = 1, 32 do
|
||||
local x, y = a % 2, b % 2
|
||||
if x ~= y then r = r + p end
|
||||
a, b, p = floor(a / 2), floor(b / 2), p * 2
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
local Rng = {}
|
||||
Rng.__index = Rng
|
||||
|
||||
local function newRng(seed)
|
||||
local s = seed % 0x100000000
|
||||
if s == 0 then s = 0x9E3779B9 end
|
||||
return setmetatable({ s = s }, Rng)
|
||||
end
|
||||
|
||||
function Rng:next()
|
||||
local x = self.s
|
||||
x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13)
|
||||
x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17
|
||||
x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5)
|
||||
self.s = x % 0x100000000
|
||||
return self.s
|
||||
end
|
||||
|
||||
function Rng:unit()
|
||||
return self:next() / 0x100000000
|
||||
end
|
||||
|
||||
-- bilinear value noise on a torus (StadiumFx's), so the leaf field tiles
|
||||
local function lattice(rng, w, h)
|
||||
local g = {}
|
||||
for y = 1, h do
|
||||
local row = {}
|
||||
for x = 1, w do row[x] = rng:unit() end
|
||||
g[y] = row
|
||||
end
|
||||
return g
|
||||
end
|
||||
|
||||
local function smoothstep01(t)
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
local function torus(grid, w, h, x, y)
|
||||
local x0, y0 = floor(x), floor(y)
|
||||
local fx, fy = smoothstep01(x - x0), smoothstep01(y - y0)
|
||||
local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
|
||||
x0, y0 = x0 % w, y0 % h
|
||||
local a = grid[y0 + 1][x0 + 1]
|
||||
local b = grid[y0 + 1][x1 + 1]
|
||||
local c = grid[y1 + 1][x0 + 1]
|
||||
local d = grid[y1 + 1][x1 + 1]
|
||||
return (a + (b - a) * fx) + ((c + (d - c) * fx) - (a + (b - a) * fx)) * fy
|
||||
end
|
||||
|
||||
-- ------- the leaf field
|
||||
--
|
||||
-- One small tiling texture of three-octave value noise, generated once
|
||||
-- from a fixed seed: the pattern of the unseen foliage. The shader reads
|
||||
-- it at two drifting, differently-scaled offsets and thresholds the sum,
|
||||
-- so the pools of light between the leaves slide, open and close -- the
|
||||
-- movement is the WIND's, all in the sampling; the cloth itself never
|
||||
-- changes, which is what keeps a pinned frame reproducible.
|
||||
|
||||
local function leafTexture()
|
||||
if leafTex ~= nil then return leafTex or nil end
|
||||
if not (love and love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage) then
|
||||
leafTex = false
|
||||
return nil
|
||||
end
|
||||
local ok, tex = pcall(function()
|
||||
local N = 128
|
||||
local rng = newRng(0x1EAF)
|
||||
local g1 = lattice(rng, 8, 8)
|
||||
local g2 = lattice(rng, 16, 16)
|
||||
local g3 = lattice(rng, 32, 32)
|
||||
local img = love.image.newImageData(N, N)
|
||||
for y = 0, N - 1 do
|
||||
local v = y / N
|
||||
for x = 0, N - 1 do
|
||||
local u = x / N
|
||||
local n = torus(g1, 8, 8, u * 8, v * 8) * 0.5
|
||||
+ torus(g2, 16, 16, u * 16, v * 16) * 0.3
|
||||
+ torus(g3, 32, 32, u * 32, v * 32) * 0.2
|
||||
img:setPixel(x, y, n, n, n, 1)
|
||||
end
|
||||
end
|
||||
local t = love.graphics.newImage(img)
|
||||
t:setWrap("repeat", "repeat")
|
||||
t:setFilter("linear", "linear")
|
||||
return t
|
||||
end)
|
||||
leafTex = (ok and tex) or false
|
||||
return leafTex or nil
|
||||
end
|
||||
|
||||
-- ------- placement (the particles; the light places itself)
|
||||
|
||||
local MARGIN = 24 -- keep off the map's edge, world px
|
||||
|
||||
function ForestAtmos.layout(cfg, w, h)
|
||||
local rng = newRng((cfg.seed or 0x51D))
|
||||
local canopy = cfg.canopyY or 56
|
||||
local motes = {}
|
||||
for _ = 1, (cfg.motes and cfg.motes.count) or 0 do
|
||||
motes[#motes + 1] = {
|
||||
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
|
||||
y = 3 + rng:unit() * max(canopy - 11, 8),
|
||||
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
|
||||
phase = rng:unit() * 6.2832,
|
||||
rate = 0.5 + rng:unit(),
|
||||
}
|
||||
end
|
||||
local flies = {}
|
||||
for _ = 1, (cfg.fireflies and cfg.fireflies.count) or 0 do
|
||||
flies[#flies + 1] = {
|
||||
x = MARGIN + rng:unit() * max(w - 2 * MARGIN, 1),
|
||||
y = 3 + rng:unit() * 12,
|
||||
z = MARGIN + rng:unit() * max(h - 2 * MARGIN, 1),
|
||||
phase = rng:unit() * 6.2832,
|
||||
rate = 0.5 + rng:unit(),
|
||||
}
|
||||
end
|
||||
return { motes = motes, flies = flies }
|
||||
end
|
||||
|
||||
-- A map is width x height BLOCKS of 4x4 tiles of 8 pixels -- times 32
|
||||
-- for world pixels (the same arithmetic Structures runs in tiles).
|
||||
local function layoutFor(map)
|
||||
local hit = layoutCache[map.id]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local cfg = configFor(map.id)
|
||||
if not cfg then
|
||||
layoutCache[map.id] = false
|
||||
return nil
|
||||
end
|
||||
local def = map.def or {}
|
||||
local L = ForestAtmos.layout(cfg, (def.width or 16) * 32,
|
||||
(def.height or 16) * 32)
|
||||
layoutCache[map.id] = L
|
||||
return L
|
||||
end
|
||||
|
||||
ForestAtmos.layoutFor = layoutFor
|
||||
|
||||
-- ------- the volumetric march
|
||||
--
|
||||
-- A fullscreen quad whose four corners carry the camera's own frustum
|
||||
-- rays; the varying interpolates them into a world ray per pixel. The
|
||||
-- pixel stage walks that ray to the depth buffer's surface, and every
|
||||
-- step of air on the way is lit or not by the shadow map and the leaf
|
||||
-- field, accumulated through the same haze the surfaces sink into.
|
||||
--
|
||||
-- Conventions copied from Water's march: the ray walks the FLAT world
|
||||
-- (the space it is straight in) and every depth compare bends the sample
|
||||
-- first, by the same displacement the vertex stage applies -- so the
|
||||
-- march reads the depth buffer it actually has. The shadow lookup stays
|
||||
-- flat, exactly like the scene shader's own vSun. STEPS is spliced into
|
||||
-- the source rather than sent (the LOW rung is a second compile), and
|
||||
-- there are no uniform arrays anywhere -- see the note in Sky about the
|
||||
-- Android driver that reads them as zero.
|
||||
|
||||
local RAY_SHADER = [[
|
||||
varying vec3 vRay;
|
||||
#ifdef VERTEX
|
||||
attribute vec3 RayDir;
|
||||
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
vRay = RayDir;
|
||||
return transform_projection * vertex_position;
|
||||
}
|
||||
#endif
|
||||
#ifdef PIXEL
|
||||
uniform Image depthTex; // the frame's own depth, detached to read
|
||||
uniform Image sunMap; // the sun's answer (see ShadowMap)
|
||||
uniform Image leafTex; // the unseen foliage, tiling
|
||||
uniform mat4 vp;
|
||||
uniform mat4 sunVP;
|
||||
uniform float sunBias;
|
||||
uniform vec3 eye;
|
||||
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
|
||||
uniform vec2 screen; // canvas size, for the pixel's own uv
|
||||
uniform vec4 fogW; // density, heightK, canopyY, fadeTo
|
||||
uniform vec3 shear; // the noon shear kx, kz; z = reach
|
||||
uniform vec3 rayColor;
|
||||
uniform float strength;
|
||||
uniform vec3 sunward; // unit, toward the unseen sun
|
||||
uniform vec2 wind; // leaf-field drift, uv per second
|
||||
uniform float time;
|
||||
// the viewport, as the scene shader takes it (see Voxel3D): air outside
|
||||
// the model is not air, so a sample out there contributes nothing and
|
||||
// the beams end with the world they fall through
|
||||
uniform vec3 cullAt;
|
||||
uniform vec3 cullShape;
|
||||
uniform vec2 cullRect; // the box's half-extents in x and z
|
||||
|
||||
float dioramaCull(vec3 p) {
|
||||
if (cullShape.z <= 0.5) return 1.0;
|
||||
vec3 cd = p - cullAt;
|
||||
float inside;
|
||||
if (cullShape.z < 1.5) { // the box
|
||||
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
|
||||
} else if (cullShape.z < 2.5) {
|
||||
inside = cullShape.x - length(cd); // the ball
|
||||
} else {
|
||||
inside = cullShape.x - length(cd.xz); // the fight's pillar
|
||||
}
|
||||
return clamp(inside * cullShape.y, 0.0, 1.0);
|
||||
}
|
||||
|
||||
float sunDepth(vec2 uv) {
|
||||
vec4 c = Texel(sunMap, uv);
|
||||
return c.r + c.g * (1.0 / 255.0);
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
vec2 uv = sc / screen;
|
||||
float sceneD = Texel(depthTex, uv).r;
|
||||
vec3 dir = normalize(vRay);
|
||||
// Spend every sample where a sample can glow. Above the canopy no
|
||||
// beam exists, and below the floor there is no air at all -- so the
|
||||
// march runs from where this ray first dips under the leaves to
|
||||
// where it would pass the ground, however long or short that
|
||||
// stretch is. From the orbit camera that is the last few dozen
|
||||
// pixels of a mostly-vertical ray, and dividing the WHOLE reach by
|
||||
// the step count there starved the beams to nothing.
|
||||
float t0 = 0.0;
|
||||
if (eye.y > fogW.z) {
|
||||
if (dir.y >= -0.01) return vec4(0.0);
|
||||
t0 = (eye.y - fogW.z) / -dir.y;
|
||||
}
|
||||
float tEnd = shear.z;
|
||||
if (dir.y < -0.01) {
|
||||
tEnd = min(tEnd, (eye.y + 8.0) / -dir.y);
|
||||
}
|
||||
if (tEnd <= t0) return vec4(0.0);
|
||||
// interleaved gradient noise staggers neighbouring pixels' steps,
|
||||
// which is what turns 20-odd samples into a smooth volume instead
|
||||
// of an onion of banded slices
|
||||
float jitter = fract(52.9829189
|
||||
* fract(dot(sc, vec2(0.06711056, 0.00583715))));
|
||||
float dt = (tEnd - t0) / float(STEPS);
|
||||
// HALF the fog's own extinction, on the way in and per step: the
|
||||
// full rate is what the surfaces sink by, and beams that obeyed it
|
||||
// too died before the orbit camera ever saw them. Half keeps the
|
||||
// depth cue and leaves the light alive.
|
||||
float trans = exp(-fogW.x * 0.5 * t0);
|
||||
float acc = 0.0;
|
||||
for (int i = 0; i < STEPS; i++) {
|
||||
float t = t0 + (float(i) + jitter) * dt;
|
||||
vec3 p = eye + dir * t;
|
||||
// stop at the surface: bend the sample the way the geometry bent
|
||||
vec2 cd = p.xz - curve.xy;
|
||||
vec4 c = vp * vec4(p.x, p.y - dot(cd, cd) * curve.z, p.z, 1.0);
|
||||
if (c.w <= 1e-6) break;
|
||||
if (c.z / c.w * 0.5 + 0.5 > sceneD) break;
|
||||
if (p.y < fogW.z) {
|
||||
// the sun's question: is this air behind a tree? Outside the
|
||||
// frustum nothing was recorded and the air counts as lit, eased
|
||||
// at the rim exactly like the scene shader's shadows
|
||||
float lit = 1.0;
|
||||
vec3 su = (sunVP * vec4(p, 1.0)).xyz;
|
||||
if (su.x > 0.0 && su.x < 1.0 && su.y > 0.0 && su.y < 1.0
|
||||
&& su.z < 1.0) {
|
||||
vec2 e2 = min(su.xy, 1.0 - su.xy);
|
||||
float edge = smoothstep(0.0, 0.06, min(e2.x, e2.y));
|
||||
lit = mix(1.0, step(su.z - sunBias, sunDepth(su.xy)), edge);
|
||||
}
|
||||
// the canopy's question: where did this thread of light pierce
|
||||
// the leaves? Every step of air on one sun ray shares the
|
||||
// answer -- that shared point is what makes a shaft a shaft --
|
||||
// and the two drifting reads of the field are the wind moving
|
||||
// the foliage overhead, opening and closing the beams
|
||||
float up = fogW.z - p.y;
|
||||
vec2 gap = (p.xz - shear.xy * up) * (1.0 / 96.0);
|
||||
float n = Texel(leafTex, gap + wind * time).r * 0.65
|
||||
+ Texel(leafTex, gap * 2.3 - wind * (time * 0.7)
|
||||
+ vec2(0.37, 0.61)).r * 0.35;
|
||||
float dapple = 0.08 + 0.92 * smoothstep(0.45, 0.85, n);
|
||||
// the beam fades IN below the invisible canopy, thins with
|
||||
// altitude like the haze it is made of, and kisses the floor
|
||||
float y = max(p.y, 0.0);
|
||||
float fadeIn = clamp(up / max(fogW.z - fogW.w, 1.0), 0.0, 1.0);
|
||||
float foot = 0.55 + 0.45 * clamp(y / 16.0, 0.0, 1.0);
|
||||
float dens = fogW.x * exp(-y * fogW.y);
|
||||
acc += trans * lit * dapple * fadeIn * foot * dens * dt
|
||||
* dioramaCull(p);
|
||||
}
|
||||
trans *= exp(-fogW.x * 0.5 * dt);
|
||||
}
|
||||
// forward scattering: beams bloom for a camera looking up into the
|
||||
// light, which is most of what makes them read as light IN air
|
||||
float phase = 0.35 + 0.65 * pow(max(dot(dir, sunward), 0.0), 6.0);
|
||||
return vec4(rayColor * (acc * strength * phase), 1.0) * color;
|
||||
}
|
||||
#endif
|
||||
]]
|
||||
|
||||
local function rayShaderFor(steps)
|
||||
local key = "ray" .. steps
|
||||
local s = shaders[key]
|
||||
if s ~= nil then return s or nil end
|
||||
if not (love and love.graphics and love.graphics.newShader) then
|
||||
shaders[key] = false
|
||||
return nil
|
||||
end
|
||||
local src = "#define STEPS " .. steps .. "\n" .. RAY_SHADER
|
||||
local ok, sh = pcall(love.graphics.newShader, src)
|
||||
if not ok then
|
||||
say(key, "ray shader refused -- beams off, fog stays: "
|
||||
.. tostring(sh))
|
||||
end
|
||||
shaders[key] = (ok and sh) or false
|
||||
return shaders[key] or nil
|
||||
end
|
||||
|
||||
local RAY_FORMAT = {
|
||||
{ "VertexPosition", "float", 2 },
|
||||
{ "RayDir", "float", 3 },
|
||||
}
|
||||
|
||||
-- The camera's frustum corners, from the same fields every pass sets:
|
||||
-- eye, focus, fovY, and the placed camera's up when there is one (VR
|
||||
-- eyes roll; the orbit never does). Interpolating a corner ray across
|
||||
-- the quad IS the standard reconstruction for a perspective camera, so
|
||||
-- this works identically for the orbit, first person and both eyes.
|
||||
local function rayQuad(Voxel3D, w, h)
|
||||
local e, fo, fov = Voxel3D.eye, Voxel3D.focus, Voxel3D.fovY
|
||||
if not (e and fo and fov) then return nil end
|
||||
local fx, fy, fz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
|
||||
local fl = sqrt(fx * fx + fy * fy + fz * fz)
|
||||
if fl < 1e-6 then return nil end
|
||||
fx, fy, fz = fx / fl, fy / fl, fz / fl
|
||||
local cam = Voxel3D.camera
|
||||
local up = (cam and cam.up) or { 0, 1, 0 }
|
||||
-- right = forward x up, then a true up perpendicular to both
|
||||
local rx = fy * up[3] - fz * up[2]
|
||||
local ry = fz * up[1] - fx * up[3]
|
||||
local rz = fx * up[2] - fy * up[1]
|
||||
local rl = sqrt(rx * rx + ry * ry + rz * rz)
|
||||
if rl < 1e-6 then return nil end
|
||||
rx, ry, rz = rx / rl, ry / rl, rz / rl
|
||||
local ux = ry * fz - rz * fy
|
||||
local uy = rz * fx - rx * fz
|
||||
local uz = rx * fy - ry * fx
|
||||
local hh = math.tan(fov * 0.5)
|
||||
local hw = hh * (w / h)
|
||||
-- canvas row 0 is the TOP of the frame, which is the +up corner
|
||||
local function corner(su, sv)
|
||||
return fx + rx * hw * su + ux * hh * sv,
|
||||
fy + ry * hw * su + uy * hh * sv,
|
||||
fz + rz * hw * su + uz * hh * sv
|
||||
end
|
||||
local x0, y0, z0 = corner(-1, 1)
|
||||
local x1, y1, z1 = corner(1, 1)
|
||||
local x2, y2, z2 = corner(1, -1)
|
||||
local x3, y3, z3 = corner(-1, -1)
|
||||
local verts = {
|
||||
{ 0, 0, x0, y0, z0 },
|
||||
{ w, 0, x1, y1, z1 },
|
||||
{ w, h, x2, y2, z2 },
|
||||
{ 0, h, x3, y3, z3 },
|
||||
}
|
||||
if not rayMesh then
|
||||
local ok, mesh = pcall(love.graphics.newMesh, RAY_FORMAT, verts,
|
||||
"fan", "stream")
|
||||
rayMesh = ok and mesh or nil
|
||||
return rayMesh
|
||||
end
|
||||
local ok = pcall(rayMesh.setVertices, rayMesh, verts)
|
||||
return ok and rayMesh or nil
|
||||
end
|
||||
|
||||
-- ------- the particles
|
||||
|
||||
local PART_SHADER = [[
|
||||
varying vec2 vCorner;
|
||||
varying float vGlow;
|
||||
#ifdef VERTEX
|
||||
uniform mat4 vp;
|
||||
uniform vec3 curve;
|
||||
uniform vec3 cullAt; // the viewport (see Voxel3D.cull): a mote
|
||||
uniform vec3 cullShape; // outside the model is not in the air
|
||||
uniform vec2 cullRect; // the box's half-extents in x and z
|
||||
uniform vec3 axisR; // the camera's right, world space
|
||||
uniform vec3 axisU; // and its up: the billboard's own frame
|
||||
uniform float time;
|
||||
uniform float size;
|
||||
uniform vec2 sway; // wander amplitude: horizontal, vertical
|
||||
uniform float blinky; // 0 = steady motes, 1 = blinking fireflies
|
||||
attribute vec4 AtmosData; // corner x, corner y, phase, rate
|
||||
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
||||
float ph = AtmosData.z;
|
||||
float rt = AtmosData.w;
|
||||
float t = time * (0.5 + rt);
|
||||
// bounded wander only -- three incommensurate sines, so nothing ever
|
||||
// walks off the map or needs a CPU tick to bring it home
|
||||
vec3 base = vertex_position.xyz + vec3(
|
||||
sin(t * 0.23 + ph) * sway.x,
|
||||
sin(t * 0.17 + ph * 2.7) * sway.y,
|
||||
cos(t * 0.19 + ph * 1.3) * sway.x);
|
||||
float s = 0.5 + 0.5 * sin(t * 1.6 + ph * 9.0);
|
||||
vGlow = mix(1.0, smoothstep(0.35, 0.75, s), blinky);
|
||||
// a whole mote at once: these are points, so the rim can dim them
|
||||
// rather than having to cut one in half
|
||||
if (cullShape.z > 0.5) {
|
||||
vec3 cd = base - cullAt;
|
||||
float inside;
|
||||
if (cullShape.z < 1.5) {
|
||||
inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z));
|
||||
} else if (cullShape.z < 2.5) {
|
||||
inside = cullShape.x - length(cd);
|
||||
} else {
|
||||
inside = cullShape.x - length(cd.xz);
|
||||
}
|
||||
vGlow *= clamp(inside * cullShape.y, 0.0, 1.0);
|
||||
}
|
||||
vCorner = AtmosData.xy;
|
||||
vec4 w = vec4(base + axisR * (AtmosData.x * size)
|
||||
+ axisU * (AtmosData.y * size), 1.0);
|
||||
if (curve.z > 0.0) {
|
||||
vec2 cd = w.xz - curve.xy;
|
||||
w.y -= dot(cd, cd) * curve.z;
|
||||
}
|
||||
return vp * w;
|
||||
}
|
||||
#endif
|
||||
#ifdef PIXEL
|
||||
uniform vec3 dotColor;
|
||||
uniform float level;
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
float d = dot(vCorner, vCorner);
|
||||
float glow = max(0.0, 1.0 - d);
|
||||
glow *= glow;
|
||||
return vec4(dotColor, glow * level * vGlow) * color;
|
||||
}
|
||||
#endif
|
||||
]]
|
||||
|
||||
local function partShader()
|
||||
local s = shaders.part
|
||||
if s ~= nil then return s or nil end
|
||||
if not (love and love.graphics and love.graphics.newShader) then
|
||||
shaders.part = false
|
||||
return nil
|
||||
end
|
||||
local ok, sh = pcall(love.graphics.newShader, PART_SHADER)
|
||||
shaders.part = (ok and sh) or false
|
||||
return shaders.part or nil
|
||||
end
|
||||
|
||||
local PART_FORMAT = {
|
||||
{ "VertexPosition", "float", 3 },
|
||||
{ "AtmosData", "float", 4 },
|
||||
}
|
||||
|
||||
local CORNERS = { { -1, -1 }, { 1, -1 }, { 1, 1 }, { -1, 1 } }
|
||||
|
||||
local function pushQuad(map, n)
|
||||
local b = n * 4
|
||||
map[#map + 1] = b + 1
|
||||
map[#map + 1] = b + 2
|
||||
map[#map + 1] = b + 3
|
||||
map[#map + 1] = b + 1
|
||||
map[#map + 1] = b + 3
|
||||
map[#map + 1] = b + 4
|
||||
end
|
||||
|
||||
local function buildPartMesh(points)
|
||||
if #points == 0 then return nil end
|
||||
local verts, indices = {}, {}
|
||||
for i = 1, #points do
|
||||
local p = points[i]
|
||||
for c = 1, 4 do
|
||||
verts[#verts + 1] = { p.x, p.y, p.z,
|
||||
CORNERS[c][1], CORNERS[c][2], p.phase, p.rate }
|
||||
end
|
||||
pushQuad(indices, i - 1)
|
||||
end
|
||||
local ok, mesh = pcall(love.graphics.newMesh, PART_FORMAT, verts,
|
||||
"triangles", "static")
|
||||
if not ok then return nil end
|
||||
pcall(mesh.setVertexMap, mesh, indices)
|
||||
return mesh
|
||||
end
|
||||
|
||||
local function meshesFor(map, L)
|
||||
local hit = meshCache[map.id]
|
||||
if hit then return hit end
|
||||
local M = {
|
||||
motes = buildPartMesh(L.motes),
|
||||
flies = buildPartMesh(L.flies),
|
||||
}
|
||||
meshCache[map.id] = M
|
||||
return M
|
||||
end
|
||||
|
||||
local MOTE_COLOR = { 1.0, 0.96, 0.78 }
|
||||
local FLY_COLOR = { 0.72, 1.0, 0.45 }
|
||||
|
||||
-- The billboard frame: the camera's own right and up, from the same
|
||||
-- fields every pass sets (per VR eye too -- drawScene runs per eye and
|
||||
-- reads the eye's camera). Degenerate looks answer nil and the
|
||||
-- particles sit this one out.
|
||||
local function billboardAxes(Voxel3D)
|
||||
local e, fo = Voxel3D.eye, Voxel3D.focus
|
||||
if not (e and fo) then return nil end
|
||||
local lx, ly, lz = fo[1] - e[1], fo[2] - e[2], fo[3] - e[3]
|
||||
local ll = sqrt(lx * lx + ly * ly + lz * lz)
|
||||
if ll < 1e-6 then return nil end
|
||||
lx, ly, lz = lx / ll, ly / ll, lz / ll
|
||||
local rx, rz = -lz, lx
|
||||
local rl = sqrt(rx * rx + rz * rz)
|
||||
if rl < 1e-4 then return nil end
|
||||
rx, rz = rx / rl, rz / rl
|
||||
return { rx, 0, rz }, { -rz * ly, rz * lx - rx * lz, rx * ly }
|
||||
end
|
||||
|
||||
-- ------- the draw
|
||||
--
|
||||
-- Inside the scene pass, in VoxelScene's prop slot. The march borrows
|
||||
-- the frame's depth through Voxel3D.beginWater -- the same detach Water
|
||||
-- runs -- and hand-tests every step against it, so the pass itself needs
|
||||
-- no depth attachment; the particles come after, depth-tested additive
|
||||
-- geometry like the Stadium flames. Anything missing -- no entry, OFF, a
|
||||
-- refused shader, no readable depth, no shadow map -- subtracts only
|
||||
-- itself.
|
||||
function ForestAtmos.draw(map)
|
||||
local rung = ForestAtmos.setting:get()
|
||||
if rung == "off" then return end
|
||||
local f = ForestAtmos.frame(map)
|
||||
if not f then return end
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
|
||||
if f.rayAlpha > 0.01 then
|
||||
if not Voxel3D.depthReadable() then
|
||||
say("depth", "no readable depth this frame -- beams off, fog stays")
|
||||
return
|
||||
end
|
||||
-- no beams without the sun's own pass: uvVP is only the world -> map
|
||||
-- transform while a shadow map is actually standing
|
||||
local sunTex = ShadowMap.active() and ShadowMap.texture()
|
||||
if not sunTex then
|
||||
say("sun", "no shadow map standing -- beams off, fog stays")
|
||||
end
|
||||
local leaf = leafTexture()
|
||||
if not leaf then
|
||||
say("leaf", "leaf field would not build -- beams off, fog stays")
|
||||
end
|
||||
local sh = rayShaderFor(rung == "low" and 12 or 24)
|
||||
local w, h = Voxel3D.size()
|
||||
local quad = (sh and sunTex and leaf and w) and rayQuad(Voxel3D, w, h)
|
||||
if sh and sunTex and leaf and w and not quad then
|
||||
say("quad", "no camera frame for the ray fan -- beams off")
|
||||
end
|
||||
if quad then
|
||||
local _, depth = Voxel3D.beginWater(nil)
|
||||
if depth then
|
||||
say("on", "volumetric beams running")
|
||||
local kx, kz = DayNight.shearAt(DayNight.T.day)
|
||||
local kl = sqrt(kx * kx + kz * kz + 1)
|
||||
love.graphics.setBlendMode("add", "alphamultiply")
|
||||
love.graphics.setShader(sh)
|
||||
pcall(sh.send, sh, "depthTex", depth)
|
||||
pcall(sh.send, sh, "sunMap", sunTex)
|
||||
pcall(sh.send, sh, "leafTex", leaf)
|
||||
pcall(sh.send, sh, "vp", "row", Voxel3D.vp)
|
||||
pcall(sh.send, sh, "sunVP", "row", ShadowMap.uvVP)
|
||||
pcall(sh.send, sh, "sunBias", ShadowMap.bias)
|
||||
pcall(sh.send, sh, "eye", Voxel3D.eye)
|
||||
pcall(sh.send, sh, "curve",
|
||||
{ Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
|
||||
Voxel3D.curveK or 0 })
|
||||
pcall(sh.send, sh, "cullAt", cullAt())
|
||||
pcall(sh.send, sh, "cullShape", cullShape())
|
||||
pcall(sh.send, sh, "cullRect", cullRect())
|
||||
pcall(sh.send, sh, "screen", { w, h })
|
||||
pcall(sh.send, sh, "fogW",
|
||||
{ f.fog.density, f.fog.heightK,
|
||||
f.cfg.canopyY or 56, f.cfg.fadeTo or 28 })
|
||||
pcall(sh.send, sh, "shear", { kx, kz, f.rayReach })
|
||||
pcall(sh.send, sh, "rayColor", f.rayColor)
|
||||
pcall(sh.send, sh, "strength", f.rayStrength * f.rayAlpha)
|
||||
pcall(sh.send, sh, "sunward", { -kx / kl, 1 / kl, -kz / kl })
|
||||
pcall(sh.send, sh, "wind", { 0.016, 0.009 })
|
||||
pcall(sh.send, sh, "time", ForestAtmos.time)
|
||||
pcall(love.graphics.draw, quad)
|
||||
love.graphics.setShader()
|
||||
love.graphics.setBlendMode("alpha")
|
||||
end
|
||||
Voxel3D.endWater()
|
||||
end
|
||||
end
|
||||
|
||||
if rung == "full" then
|
||||
local L = layoutFor(map)
|
||||
local M = L and meshesFor(map, L)
|
||||
local psh = partShader()
|
||||
local axisR, axisU = billboardAxes(Voxel3D)
|
||||
if M and psh and axisR then
|
||||
Voxel3D.blend("add")
|
||||
if Voxel3D.beginEffect(psh) then
|
||||
pcall(psh.send, psh, "vp", "row", Voxel3D.vp)
|
||||
pcall(psh.send, psh, "curve",
|
||||
{ Voxel3D.curveX or 0, Voxel3D.curveZ or 0,
|
||||
Voxel3D.curveK or 0 })
|
||||
pcall(psh.send, psh, "cullAt", cullAt())
|
||||
pcall(psh.send, psh, "cullShape", cullShape())
|
||||
pcall(psh.send, psh, "cullRect", cullRect())
|
||||
pcall(psh.send, psh, "axisR", axisR)
|
||||
pcall(psh.send, psh, "axisU", axisU)
|
||||
pcall(psh.send, psh, "time", ForestAtmos.time)
|
||||
if M.motes and f.moteLevel > 0.02 then
|
||||
pcall(psh.send, psh, "size", 1.4)
|
||||
pcall(psh.send, psh, "sway", { 5, 2.5 })
|
||||
pcall(psh.send, psh, "blinky", 0)
|
||||
pcall(psh.send, psh, "dotColor", MOTE_COLOR)
|
||||
pcall(psh.send, psh, "level", f.moteLevel * 0.5)
|
||||
pcall(love.graphics.draw, M.motes)
|
||||
end
|
||||
if M.flies and f.fireflyLevel > 0.02 then
|
||||
pcall(psh.send, psh, "size", 1.6)
|
||||
pcall(psh.send, psh, "sway", { 10, 4 })
|
||||
pcall(psh.send, psh, "blinky", 1)
|
||||
pcall(psh.send, psh, "dotColor", FLY_COLOR)
|
||||
pcall(psh.send, psh, "level", f.fireflyLevel * 0.85)
|
||||
pcall(love.graphics.draw, M.flies)
|
||||
end
|
||||
Voxel3D.endEffect()
|
||||
end
|
||||
Voxel3D.blend(nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return ForestAtmos
|
||||
@@ -0,0 +1,387 @@
|
||||
-- Voxel world mode: free movement for the free-roam rungs.
|
||||
--
|
||||
-- The engine walks a grid: sixteen frames per cell, four directions,
|
||||
-- input locked mid-step. Inside a camera that stands with the player that
|
||||
-- gait reads as riding a rail, so while 1ST or 3RD 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.
|
||||
--
|
||||
-- Both rungs walk identically: the boom behind the shoulder (3RD) changes
|
||||
-- where the eye stands, not which way it points, and the walk was always
|
||||
-- rotated by the YAW. The one thing it does change is which way the body
|
||||
-- POINTS while it moves -- see bodyFacing in the tick.
|
||||
--
|
||||
-- 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
|
||||
-- and the body with it: while something else is walking the player --
|
||||
-- a scripted move, a ledge hop, the grid walk off the rung -- the
|
||||
-- engine's own four-direction facing is the whole truth about which way
|
||||
-- they point, so the card must stop reading our finer one
|
||||
FirstPerson.releaseBody()
|
||||
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
|
||||
-- and NO bonk. The grid walk's collision sound marks a discrete event:
|
||||
-- you pressed a direction, the step was refused, nothing happened. A
|
||||
-- free walk has no such moment -- the body slides along every wall it
|
||||
-- grazes, continuously, and a corridor taken at a slight angle is a
|
||||
-- steady graze from end to end. Rate-limited or not, that came out as a
|
||||
-- machine-gun of bonks for walking normally down a hallway. The wall
|
||||
-- stopping you is the feedback; the sound only ever said so twice a
|
||||
-- second whether or not anything had changed.
|
||||
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. (A body that is WALKING may turn along its
|
||||
-- travel instead -- see below, once there is a travel to turn along; a
|
||||
-- standing one always faces where the camera looks, which is what makes
|
||||
-- A predictable.) pointBody rather than compassFacing, so the card also
|
||||
-- gets the CONTINUOUS bearing behind that compass point.
|
||||
p.facing = FirstPerson.pointBody(0, 0)
|
||||
|
||||
-- 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
|
||||
|
||||
-- and once there IS a direction of travel, the body may point along it
|
||||
-- rather than along the head: on the boom (3RD) you can see yourself, so
|
||||
-- a strafe has to look like walking sideways. In the head it is the head
|
||||
-- either way -- bodyBearing says so.
|
||||
p.facing = FirstPerson.pointBody(wx, wz)
|
||||
|
||||
-- the engine's own bonk clock, kept draining while the free walk has the
|
||||
-- wheel: nothing here rings it (see pushSpecials), but stepping back onto
|
||||
-- the grid must not inherit a cooldown frozen at whatever it held when
|
||||
-- the rung was picked
|
||||
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
|
||||
|
||||
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
|
||||
or FreeMove.WALK
|
||||
local dx, dz = wx * speed, wz * speed
|
||||
|
||||
local hitX = slideX(state, p, dx)
|
||||
local hitZ = slideZ(state, p, dz)
|
||||
|
||||
-- the walk cycle: the wall-bonk clock animates the legs of a player the
|
||||
-- grid thinks is standing still, refreshed while the free walk covers
|
||||
-- ground (Player:update ticks animClock off it; walkPhase reads it)
|
||||
p.bumpFrames = 2
|
||||
|
||||
p.px, p.py = pos.x - 8, pos.z - 8
|
||||
lastPx, lastPy = p.px, p.py
|
||||
|
||||
-- the cell the body stands in; crossing into a new one IS a step
|
||||
local ncx = math.floor(pos.x / 16)
|
||||
local ncy = math.floor(pos.z / 16)
|
||||
if ncx ~= p.cellX or ncy ~= p.cellY then
|
||||
p.cellX, p.cellY = ncx, ncy
|
||||
state:onStepComplete()
|
||||
-- a warp or a battle may have moved the world out from under the
|
||||
-- walk; the adopt check on the next tick picks the pieces up
|
||||
return
|
||||
end
|
||||
|
||||
-- a firm push into something that refused: the engine's own blocked-step
|
||||
-- verbs, aimed the way the push leans
|
||||
local hit, dir
|
||||
if hitX and (not hitZ or math.abs(dx) >= math.abs(dz)) then
|
||||
hit, dir = hitX, (dx > 0 and "right" or "left")
|
||||
elseif hitZ then
|
||||
hit, dir = hitZ, (dz > 0 and "down" or "up")
|
||||
end
|
||||
if hit and math.max(math.abs(dx), math.abs(dz)) > 0.4 * speed then
|
||||
if pushSpecials(state, dir, hit) then
|
||||
FreeMove.drop()
|
||||
return
|
||||
end
|
||||
-- the push handlers may have turned the facing; the walk still rules
|
||||
p.facing = FirstPerson.pointBody(wx, wz)
|
||||
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
|
||||
+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
|
||||
+460
@@ -0,0 +1,460 @@
|
||||
-- LET'S GO: the row, the modes, and every engine seam the capture game
|
||||
-- stands on.
|
||||
--
|
||||
-- Three rungs:
|
||||
--
|
||||
-- OFF nothing changes. The default, and what an unrecognised
|
||||
-- stored value falls back to.
|
||||
-- FULL the whole Let's Go treatment. A wild encounter opens
|
||||
-- STRAIGHT into capture mode (B backs out to the classic
|
||||
-- menu for anyone who came to fight), Poke/Great/Ultra
|
||||
-- Balls are half price at every mart, and EXPERIENCE works
|
||||
-- the way that game's does: every healthy party member
|
||||
-- gains from every catch AND every trainer knockout, each
|
||||
-- measured against its own level. A catch adds the throw
|
||||
-- stack on top -- grade, first throw, new species, combo.
|
||||
-- CATCH ONLY the fights are untouched and the shops are untouched;
|
||||
-- the one change is that throwing a ball -- from the bag,
|
||||
-- or a SAFARI BALL from the safari menu -- runs the throw
|
||||
-- minigame instead of the automatic toss. The minigame's
|
||||
-- grade still folds into the Gen 1 catch roll (a good
|
||||
-- throw should matter or the ring is a lie), but nothing
|
||||
-- outside the throw changes.
|
||||
--
|
||||
-- The capture game itself lives in lib/CatchThrow.lua and the ball it
|
||||
-- throws in lib/Pokeball.lua; this file is the wiring: the ModSetting,
|
||||
-- the two BattleState wraps that intercept a ball being thrown, the
|
||||
-- auto-entry tick for FULL, the price patch, and the experience hooks.
|
||||
--
|
||||
-- ------- where the minigame declines to run
|
||||
--
|
||||
-- The throw is a 3D scene: it needs the staged battle standing (the
|
||||
-- over-the-shoulder shot the option's own 3D-BTL row provides, ON by
|
||||
-- default), a driver with a depth buffer, and a flat screen (the VR seat
|
||||
-- draws through a different pass entirely). Anywhere that fails -- 3D-BTL
|
||||
-- switched off, a headless driver, a headset -- the ball quietly takes
|
||||
-- the engine's own toss, which is exactly what the mod's "declines
|
||||
-- cleanly" rule demands. Trainers, the ghost, the RESTLESS SOUL and the
|
||||
-- old man's demo keep the vanilla path on purpose: those branches ARE
|
||||
-- their behaviour.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local CatchThrow = V.require("CatchThrow")
|
||||
|
||||
local LetsGo = {}
|
||||
|
||||
LetsGo.KEY = "letsgo"
|
||||
LetsGo.LABEL = "LET'S GO"
|
||||
|
||||
-- `false` first: the default, and the fallback for a stored value from
|
||||
-- some other version of this ladder
|
||||
LetsGo.setting = ModSetting.new(LetsGo.KEY, LetsGo.LABEL,
|
||||
{ false, "full", "catching" },
|
||||
{ "OFF", "FULL", "CATCH ONLY" })
|
||||
|
||||
-- false | "full" | "catching"
|
||||
function LetsGo.mode()
|
||||
return LetsGo.setting:get()
|
||||
end
|
||||
|
||||
local function game() return require("src.core.Game") end
|
||||
|
||||
-- ------- half-price balls (FULL)
|
||||
--
|
||||
-- Prices are live data (game.data.items[id].price) and every reader --
|
||||
-- the buy list, the affordability check, the quantity box -- reads them
|
||||
-- per use, so patching the table IS the feature. Applied and reverted on
|
||||
-- the option's edge, polled from the tick because the row, the manager's
|
||||
-- page and a loaded save can all move it and none of them announces to
|
||||
-- us. The sell price follows automatically (the mart pays half of list),
|
||||
-- which is coherent: cheaper balls are worth less back too.
|
||||
local PRICED = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL" }
|
||||
local fullPrices = nil -- originals while halved, or nil
|
||||
|
||||
local function applyPrices()
|
||||
local g = game()
|
||||
local items = g and g.data and g.data.items
|
||||
if not items then return end
|
||||
local wantHalf = LetsGo.mode() == "full"
|
||||
if wantHalf and not fullPrices then
|
||||
fullPrices = {}
|
||||
for _, id in ipairs(PRICED) do
|
||||
local def = items[id]
|
||||
if def and def.price then
|
||||
fullPrices[id] = def.price
|
||||
def.price = math.floor(def.price / 2)
|
||||
end
|
||||
end
|
||||
elseif not wantHalf and fullPrices then
|
||||
for id, price in pairs(fullPrices) do
|
||||
if items[id] then items[id].price = price end
|
||||
end
|
||||
fullPrices = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- whether a throw can be the minigame
|
||||
|
||||
local function vrOn()
|
||||
local ok, vr = pcall(V.require, "VR")
|
||||
return ok and vr and vr.enabled and vr.enabled() or false
|
||||
end
|
||||
|
||||
-- ------- the battles that are cutscenes wearing a battle's clothes
|
||||
--
|
||||
-- The catch tutorials -- the VIRIDIAN CITY old man, and Yellow's PROF.OAK
|
||||
-- catching the PIKACHU (both BattleState:makeOldManDemo, which is why one
|
||||
-- flag covers both) -- are scripted from the first frame: the cursor moves
|
||||
-- itself, the bag opens itself, the ball is thrown by someone who is not
|
||||
-- the player, and the throw always catches a Pokemon nobody keeps. There
|
||||
-- is no decision in them to hand a minigame, and the story beat is the
|
||||
-- point, so LET'S GO stays out of them entirely at whatever rung: no
|
||||
-- capture screen, no FULL treatment, no experience.
|
||||
function LetsGo.scripted(battle)
|
||||
return battle and (battle.demo or battle.oakDemo) and true or false
|
||||
end
|
||||
|
||||
function LetsGo.wantsMinigame(battle)
|
||||
if not LetsGo.mode() then return false end
|
||||
if not battle or battle.kind ~= "wild" then return false end
|
||||
if LetsGo.scripted(battle) then return false end
|
||||
if battle.ghost or battle.noCatch then return false end
|
||||
if not Voxel3D.available() or vrOn() then return false end
|
||||
-- the staged shot must actually be standing: this is "there is a 3D
|
||||
-- battle on screen right now", which the throw is aimed into
|
||||
local ok, shot = pcall(function()
|
||||
return V.require("OverworldBattle").shot()
|
||||
end)
|
||||
return (ok and shot) and true or false
|
||||
end
|
||||
|
||||
-- A Let's Go wild: the encounters FULL owns outright. In these the foe
|
||||
-- never takes a turn, the player's Pokemon is never sent out or shown,
|
||||
-- B runs (and always escapes), and the encounter lives in throw mode
|
||||
-- from the wipe to the last message.
|
||||
function LetsGo.fullWild(battle)
|
||||
return LetsGo.mode() == "full" and battle and battle.kind == "wild"
|
||||
and not LetsGo.scripted(battle)
|
||||
and not (battle.safari or battle.ghost or battle.noCatch)
|
||||
and true or false
|
||||
end
|
||||
|
||||
-- ------- the experience stack (FULL)
|
||||
--
|
||||
-- Let's Go pays a catch like a knockout, through the Gen VII scaled
|
||||
-- formula -- every party member paid against its OWN level -- times the
|
||||
-- catch bonuses. Three engine hooks carry it:
|
||||
--
|
||||
-- battle.catch_exp "does a catch pay at all" -- yes, under FULL
|
||||
-- battle.exp_award the distribution: every healthy party member its
|
||||
-- own full share, no participant split
|
||||
-- exp.gain the amount: the scaled formula times the bonus
|
||||
-- stack, in place of floor(b*L/7)
|
||||
--
|
||||
-- The stack: throw grade (NICE 1.1 / GREAT 1.5 / EXCELLENT 2.0), first
|
||||
-- ball of the encounter 1.5, species new to the dex 1.1, and the catch
|
||||
-- combo tier. Traded 1.5 still rides through the engine's own flag.
|
||||
local expCtx = nil -- {battle, mult} while a Let's Go catch pays out
|
||||
local granting = nil -- set across the applyShare loop for exp.gain
|
||||
|
||||
local function comboMult(n)
|
||||
if n <= 10 then return 1.1 end
|
||||
if n <= 20 then return 1.5 end
|
||||
if n <= 30 then return 2.0 end
|
||||
if n <= 40 then return 2.5 end
|
||||
return 3.0
|
||||
end
|
||||
|
||||
-- the catch combo, persisted with the save (mod.save rides save.modData):
|
||||
-- catching the same species again extends it, anything else restarts it
|
||||
local function bumpCombo(species)
|
||||
local ms = V.mod and V.mod.save
|
||||
local combo = { species = species, count = 1 }
|
||||
if ms then
|
||||
local ok, held = pcall(ms.get, ms, "letsgoCombo")
|
||||
if ok and type(held) == "table" and held.species == species then
|
||||
combo.count = (tonumber(held.count) or 0) + 1
|
||||
end
|
||||
pcall(ms.set, ms, "letsgoCombo", combo)
|
||||
end
|
||||
return combo.count
|
||||
end
|
||||
|
||||
function LetsGo.combo()
|
||||
local ms = V.mod and V.mod.save
|
||||
if not ms then return nil end
|
||||
local ok, held = pcall(ms.get, ms, "letsgoCombo")
|
||||
return ok and type(held) == "table" and held or nil
|
||||
end
|
||||
|
||||
-- Called by CatchThrow the moment a capture resolves as caught, BEFORE
|
||||
-- storeCaughtMon runs -- the dex is not yet marked, so "new species" is
|
||||
-- still answerable, and the exp hooks fire inside storeCaughtMon.
|
||||
function LetsGo.noteCatch(battle, info)
|
||||
local species = battle.enemy and battle.enemy.mon
|
||||
and battle.enemy.mon.species
|
||||
local chain = species and bumpCombo(species) or 1
|
||||
if LetsGo.mode() ~= "full" then return end
|
||||
local mult = info.mult or 1
|
||||
if info.firstThrow then mult = mult * 1.5 end
|
||||
local dex = game().save and game().save.pokedex
|
||||
if dex and species and not dex.owned[species] then mult = mult * 1.1 end
|
||||
mult = mult * comboMult(chain)
|
||||
expCtx = { battle = battle, mult = mult }
|
||||
end
|
||||
|
||||
-- The Gen VII scaled gain: a * b * L / 5, scaled by the RECEIVER's own
|
||||
-- level, +1, then the traded boost and (for a catch) the bonus stack.
|
||||
-- `s`, the split divisor, is 1 -- the award loop below hands every mon a
|
||||
-- full share rather than a share of one.
|
||||
--
|
||||
-- `a` is the wild/trainer multiplier, 1.5 for a trainer's Pokemon. It is
|
||||
-- absent from the catch-side write-ups of this formula for the simple
|
||||
-- reason that a caught Pokemon is always wild, so it is always 1 there --
|
||||
-- which is also why adding it leaves every catch payout exactly where it
|
||||
-- was, verified against the published table in the suite.
|
||||
local function scaledGain(c, mult)
|
||||
local b = (c.defeatedDef and c.defeatedDef.baseExp) or 50
|
||||
local L = c.level or 1
|
||||
local Lp = (c.mon and c.mon.level) or L
|
||||
local a = c.isTrainer and 1.5 or 1
|
||||
local scale = ((2 * L + 10) / (L + Lp + 10)) ^ 2.5
|
||||
local exp = math.floor(math.floor(a * b * L / 5) * scale + 1)
|
||||
if c.traded then exp = math.floor(exp * 1.5) end
|
||||
return math.max(1, math.floor(exp * (mult or 1)))
|
||||
end
|
||||
|
||||
LetsGo._scaledGain = scaledGain -- named for the suite
|
||||
LetsGo._comboMult = comboMult
|
||||
|
||||
-- ------- FULL's auto-entry
|
||||
--
|
||||
-- The moment a wild battle's menu opens under FULL, capture mode opens
|
||||
-- over it, with the last ball the player threw (or the first ball in the
|
||||
-- bag). B backs out to the classic menu and stays out for that battle --
|
||||
-- the bag's own ball route still re-enters the throw.
|
||||
local function autoEnter()
|
||||
if LetsGo.mode() ~= "full" then return end
|
||||
if CatchThrow.active() then return end
|
||||
local ok, battle = pcall(function()
|
||||
return V.require("OverworldBattle").battle()
|
||||
end)
|
||||
if not (ok and battle) then return end
|
||||
local g = game()
|
||||
if not (g.stack and g.stack:top() == battle) then return end
|
||||
if battle.phase ~= "menu" then return end
|
||||
if battle.safari then return end -- the safari menu is already a
|
||||
-- catch menu; its BALL row enters
|
||||
if battle.dramaticShapeDeclined then return end
|
||||
if not LetsGo.wantsMinigame(battle) then return end
|
||||
local ball = CatchThrow.pickBall()
|
||||
local full = LetsGo.fullWild(battle)
|
||||
-- An empty bag does NOT fall back to the classic menu under FULL. A
|
||||
-- Let's Go wild has no player Pokemon in it and a foe that never takes a
|
||||
-- turn, so the menu it would fall back to offers a FIGHT that cannot
|
||||
-- happen -- the encounter has to keep its own screen and its own exit.
|
||||
-- The capture screen opens empty-handed instead: the foe stands there,
|
||||
-- the readout says there is nothing to throw, and RUN is the way out.
|
||||
-- At CATCH ONLY there is no auto-entry to speak of and the bag is the
|
||||
-- only route in, so no balls simply means no throw, as it always did.
|
||||
if not (ball or full) then return end
|
||||
CatchThrow.begin(battle, ball, { consumed = false, canSwitch = true,
|
||||
fullWild = full })
|
||||
end
|
||||
|
||||
-- ------- per frame, from the voxel pipeline's update hook
|
||||
--
|
||||
-- BEFORE OverworldBattle.update on the same tick, so the ball pose this
|
||||
-- frame computes is the ball the scene render a moment later draws.
|
||||
function LetsGo.update(dt)
|
||||
applyPrices()
|
||||
CatchThrow.update(dt)
|
||||
autoEnter()
|
||||
end
|
||||
|
||||
-- ------- install: the two throw seams, the hooks, the input
|
||||
--
|
||||
-- Installed from main.lua AFTER every other input seam, so the capture's
|
||||
-- pointer wraps sit outside them all while it aims.
|
||||
local installed = false
|
||||
|
||||
function LetsGo.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
|
||||
local mod = V.mod
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
if not BattleState.dramaticShapeLetsGoHook then
|
||||
-- The bag's ball route: BagMenu has already consumed the ball and
|
||||
-- closed itself when this is called, so a session here owns a paid
|
||||
-- ball (cancel refunds it). Vanilla path untouched whenever the
|
||||
-- minigame cannot or should not run.
|
||||
local innerThrow = BattleState.throwBall
|
||||
function BattleState:throwBall(ball)
|
||||
if LetsGo.wantsMinigame(self) then
|
||||
if CatchThrow.begin(self, ball, {
|
||||
consumed = true, fullWild = LetsGo.fullWild(self),
|
||||
}) then return end
|
||||
end
|
||||
return innerThrow(self, ball)
|
||||
end
|
||||
|
||||
-- The safari menu's BALL row: same interception, safari flavour --
|
||||
-- the ball count and the flee check belong to the safari turn, and
|
||||
-- CatchThrow hands back to safariEnemyTurn on a failure.
|
||||
local innerSafari = BattleState.safariAction
|
||||
function BattleState:safariAction(choice)
|
||||
if choice == "ball" and LetsGo.wantsMinigame(self)
|
||||
and self.safari and self.safari.balls > 0 then
|
||||
if CatchThrow.begin(self, "SAFARI_BALL",
|
||||
{ consumed = false, safari = true }) then
|
||||
return
|
||||
end
|
||||
end
|
||||
return innerSafari(self, choice)
|
||||
end
|
||||
|
||||
BattleState.dramaticShapeLetsGoHook = true
|
||||
end
|
||||
|
||||
-- a catch pays experience under FULL, exactly as a knockout would
|
||||
-- Never for a scripted demo: the old man's catch is a cutscene, nobody
|
||||
-- keeps the Pokemon, and the party it would pay may not exist yet
|
||||
-- (Yellow's Pallet intro runs before the lab gift). The engine's own
|
||||
-- flow does not reach either hook for a demo today -- oldManThrow ends
|
||||
-- the battle without storeCaughtMon or awardExp -- so this guards the
|
||||
-- INVARIANT rather than a live bug: a demo pays nothing, whatever route
|
||||
-- some later engine takes to get there.
|
||||
mod.hooks:wrap("battle.catch_exp", function(next_, ctx)
|
||||
if LetsGo.mode() == "full" and ctx and ctx.battle
|
||||
and ctx.battle.kind == "wild"
|
||||
and not LetsGo.scripted(ctx.battle) then
|
||||
return true
|
||||
end
|
||||
return next_(ctx)
|
||||
end)
|
||||
|
||||
-- ------- the Let's Go distribution: every healthy party member, in full
|
||||
--
|
||||
-- The engine's own rule is that only the Pokemon that FOUGHT are paid,
|
||||
-- and they split one award between them; EXP.ALL exists to soften that.
|
||||
-- Let's Go deletes the whole arrangement -- everybody gains from
|
||||
-- everything, which is why that game ships no EXP.ALL at all -- and
|
||||
-- each one is measured against its OWN level, so the low member of a
|
||||
-- party pulls several times what the high one does from the same
|
||||
-- knockout.
|
||||
--
|
||||
-- Two ways in. A CATCH arrives with a bonus stack attached (throw
|
||||
-- grade, first ball, new species, combo) which `expCtx` carries. A
|
||||
-- KNOCKOUT under FULL takes the same distribution with no stack --
|
||||
-- those bonuses are rewards for the throw, and there was no throw.
|
||||
--
|
||||
-- Everything else -- CATCH ONLY, the row switched off, another mod's
|
||||
-- battle -- falls through to the engine's own split untouched.
|
||||
-- ------- and it is announced ONCE, not once per Pokemon
|
||||
--
|
||||
-- Six party members would otherwise mean six "X gained N EXP. Points!"
|
||||
-- boxes per knockout. The per-Pokemon lines are suppressed (the `false`
|
||||
-- to applyShare) and one card is shown instead -- see lib/ExpPanel.lua
|
||||
-- for why that is better than a faster wall of the same text.
|
||||
--
|
||||
-- The card is queued BEFORE the loop that fills it. That is not a race:
|
||||
-- applyShare applies its experience immediately and only QUEUES its
|
||||
-- messages, so the loop runs to completion synchronously here, while
|
||||
-- the queue does not reach the card's factory until later -- by which
|
||||
-- time `rows` is complete. Queueing it first is what puts the tally
|
||||
-- ahead of the "grew to level" chatter it is a summary of.
|
||||
local ExpPanel = V.require("ExpPanel")
|
||||
local function payParty(ctx, mult)
|
||||
local battle = ctx.battle
|
||||
local rows = {}
|
||||
battle:uiNext(function() return ExpPanel.new(battle.game, rows) end)
|
||||
granting = { mult = mult }
|
||||
local okAward, err = pcall(function()
|
||||
for _, mon in ipairs(battle.game.save.party) do
|
||||
if mon.hp > 0 then
|
||||
local exp0, lv0 = mon.exp, mon.level
|
||||
ctx.applyShare(mon, 1, false)
|
||||
rows[#rows + 1] = { mon = mon, gained = mon.exp - exp0,
|
||||
from = lv0, to = mon.level }
|
||||
end
|
||||
end
|
||||
end)
|
||||
granting = nil
|
||||
if not okAward then error(err, 0) end
|
||||
end
|
||||
|
||||
mod.hooks:wrap("battle.exp_award", function(next_, ctx)
|
||||
if ctx and LetsGo.scripted(ctx.battle) then return next_(ctx) end
|
||||
local cc = expCtx
|
||||
if cc and ctx and ctx.battle == cc.battle then
|
||||
expCtx = nil
|
||||
return payParty(ctx, cc.mult)
|
||||
end
|
||||
if LetsGo.mode() == "full" and ctx and ctx.battle then
|
||||
return payParty(ctx, 1)
|
||||
end
|
||||
return next_(ctx)
|
||||
end)
|
||||
|
||||
-- and the amount, per receiving mon, while that loop runs
|
||||
mod.hooks:wrap("exp.gain", function(next_, c)
|
||||
if not granting then return next_(c) end
|
||||
return scaledGain(c, granting.mult)
|
||||
end)
|
||||
|
||||
-- ------- FULL owns a wild encounter from its first frame
|
||||
--
|
||||
-- The engine's intro ends by sending the player's Pokemon out -- the
|
||||
-- back pic slides off, "Go! X!", the poof, the grow-in -- and a Let's
|
||||
-- Go wild has no player Pokemon in it at all. The send-out is exactly
|
||||
-- the LAST SIX rows of the intro queue when this event fires (built in
|
||||
-- BattleState's start, gated `not safari and not demo`), so they are
|
||||
-- stripped by SHAPE -- act, wait, act, say, POOF, act -- and left alone
|
||||
-- if a future engine moves them: the veil still hides the visuals, the
|
||||
-- engine just narrates a send-out that is not shown.
|
||||
--
|
||||
-- Stripping them leaves showPlayerBack TRUE for the whole battle, which
|
||||
-- is the flag the engine's own HUD path reads as "no player HUD" -- the
|
||||
-- player's side vanishes from the readout for free.
|
||||
--
|
||||
-- The veil goes up in the same breath: the capture table, installed
|
||||
-- before any session exists, so the whole encounter -- wipe, "Wild X
|
||||
-- appeared!", every beat between throws -- plays from the held head-on
|
||||
-- seat with the player's side out of the shot.
|
||||
mod.events:on("battle.started", function(payload)
|
||||
local b = payload and payload.battle
|
||||
if not (b and LetsGo.fullWild(b)) then return end
|
||||
if not (Voxel3D.available() and not vrOn()) then return end
|
||||
-- deliberately NOT gated on owning a ball: an empty bag still gets the
|
||||
-- Let's Go encounter (see autoEnter), so the send-out still has to go
|
||||
local q = b.queue
|
||||
local n = q and #q or 0
|
||||
if n >= 6 and type(q[n]) == "table" and q[n].fn
|
||||
and q[n - 1] and q[n - 1].anim == "POOF_ANIM"
|
||||
and q[n - 2] and q[n - 2].text
|
||||
and q[n - 3] and q[n - 3].fn
|
||||
and q[n - 4] and q[n - 4].wait
|
||||
and q[n - 5] and q[n - 5].fn then
|
||||
for _ = 1, 6 do table.remove(q) end
|
||||
end
|
||||
pcall(CatchThrow.veil, b)
|
||||
end)
|
||||
|
||||
-- a battle ending sweeps everything: the capture epilogue, the veil, a
|
||||
-- session a script tore down, and the exp context if the payout never
|
||||
-- fired
|
||||
mod.events:on("battle.ended", function()
|
||||
expCtx = nil
|
||||
pcall(CatchThrow.onBattleEnded)
|
||||
end)
|
||||
|
||||
CatchThrow.installInput()
|
||||
end
|
||||
|
||||
return LetsGo
|
||||
+52
-3
@@ -6,9 +6,11 @@
|
||||
-- here -- translation in the fourth column, m[4]/m[8]/m[12].
|
||||
--
|
||||
-- Only what the renderer actually needs: a perspective projection (the
|
||||
-- camera), an orthographic one (the sun's shadow pass), a look-based view,
|
||||
-- and the translate/rotateY/scale a model matrix is built from. No general
|
||||
-- inverse, no quaternions.
|
||||
-- camera), an orthographic one (the sun's shadow pass), an asymmetric one
|
||||
-- (a headset's per-eye frustum), a look-based view, a quaternion rotation
|
||||
-- (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 = {}
|
||||
|
||||
@@ -62,6 +64,53 @@ function Mat4.rotateX(a)
|
||||
0, 0, 0, 1 }
|
||||
end
|
||||
|
||||
function Mat4.rotateZ(a)
|
||||
local c, s = math.cos(a), math.sin(a)
|
||||
return { c, -s, 0, 0,
|
||||
s, c, 0, 0,
|
||||
0, 0, 1, 0,
|
||||
0, 0, 0, 1 }
|
||||
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]).
|
||||
function Mat4.perspective(fovY, aspect, near, far)
|
||||
local f = 1 / math.tan(fovY / 2)
|
||||
|
||||
+83
-4
@@ -47,6 +47,42 @@ local function indexOf(self, value)
|
||||
return 1
|
||||
end
|
||||
|
||||
-- ------- rungs that are not always there
|
||||
--
|
||||
-- A ladder may carry a rung that cannot be selected right now -- STADIUM
|
||||
-- needs models built out of a ROM the player supplies, and until that has
|
||||
-- happened there is nothing behind the option. `gate` is asked per rung and
|
||||
-- decides whether it exists at all this frame.
|
||||
--
|
||||
-- Skipped rather than shown-and-refused, deliberately. A row that can be
|
||||
-- cycled onto and then does nothing is indistinguishable from a broken mod;
|
||||
-- a row that simply has fewer stops reads as the mod not offering something,
|
||||
-- which is the truth. What the player is missing, and how to get it, is said
|
||||
-- once in the row's help text instead of implied by a dead setting.
|
||||
--
|
||||
-- values[1] is never gated: it is the default and the fallback, so there is
|
||||
-- always at least one rung to land on.
|
||||
function ModSetting:setGate(gate)
|
||||
self.gate = gate
|
||||
return self
|
||||
end
|
||||
|
||||
function ModSetting:allows(i)
|
||||
if i == 1 or not self.gate then return true end
|
||||
local ok, allowed = pcall(self.gate, self.values[i], i)
|
||||
return (not ok) or allowed and true or false
|
||||
end
|
||||
|
||||
-- How many rungs are live, for a caller that wants to know whether a row is
|
||||
-- worth showing at all.
|
||||
function ModSetting:rungs()
|
||||
local n = 0
|
||||
for i = 1, #self.values do
|
||||
if self:allows(i) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
-- What the player left it at last session. Read lazily rather than at load
|
||||
-- time: the loader fills modOptions before a mod runs, but reading through
|
||||
-- the API keeps this honest about where the value lives.
|
||||
@@ -63,7 +99,13 @@ function ModSetting:read()
|
||||
end
|
||||
|
||||
function ModSetting:get()
|
||||
return self.values[self:read()]
|
||||
local i = self:read()
|
||||
-- a rung that was live when it was stored and is not now -- the player
|
||||
-- moved the ROM, or opened the same save on another machine -- reads as
|
||||
-- the default rather than as a mode with nothing behind it. The stored
|
||||
-- value is left alone, so putting the ROM back restores their choice.
|
||||
if not self:allows(i) then return self.values[1] end
|
||||
return self.values[i]
|
||||
end
|
||||
|
||||
function ModSetting:level()
|
||||
@@ -91,8 +133,32 @@ function ModSetting:setIndex(i, game)
|
||||
return value
|
||||
end
|
||||
|
||||
-- Set by the STORED VALUE rather than by its place on the ladder, for a
|
||||
-- caller that knows which setting it wants and not where it sits -- a
|
||||
-- preset, or an assertion. An unrecognised value lands on values[1], the
|
||||
-- same default indexOf answers everywhere else, so this can never leave a
|
||||
-- setting holding something the row cannot display.
|
||||
--
|
||||
-- Worth having as its own entry point because a ladder's ORDER is not a
|
||||
-- promise: 3D-BTL grew a third rung in the middle of itself (see
|
||||
-- OverworldBattle), and every caller that had counted to two would have
|
||||
-- silently meant something else afterwards.
|
||||
function ModSetting:setValue(value, game)
|
||||
return self:setIndex(indexOf(self, value), game)
|
||||
end
|
||||
|
||||
-- Step to the next rung that is actually live, in `dir`. Bounded by the
|
||||
-- ladder's length so a gate that refuses everything still terminates on
|
||||
-- values[1], which allows() never gates.
|
||||
function ModSetting:cycle(game, dir)
|
||||
return self:setIndex(self:read() + (dir or 1), game)
|
||||
dir = dir or 1
|
||||
local n = #self.values
|
||||
local i = self:read()
|
||||
for _ = 1, n do
|
||||
i = ((i + dir - 1) % n + n) % n + 1
|
||||
if self:allows(i) then break end
|
||||
end
|
||||
return self:setIndex(i, game)
|
||||
end
|
||||
|
||||
-- Adopt a value set from somewhere else (the mod manager's settings page,
|
||||
@@ -102,6 +168,15 @@ function ModSetting:sync(value)
|
||||
self.index = indexOf(self, value)
|
||||
end
|
||||
|
||||
-- The label of the rung actually in force, which is not the stored one when
|
||||
-- that rung has been gated away (see get). Its own entry point because a
|
||||
-- caller can want the label without wanting a row: SettingsMenu puts one
|
||||
-- setting's rung on the second line of the CATEGORY that contains it.
|
||||
function ModSetting:valueLabel()
|
||||
local i = self:read()
|
||||
return self.labels[self:allows(i) and i or 1]
|
||||
end
|
||||
|
||||
-- The descriptor src/ui/OptionRows.lua renders, in the shape the
|
||||
-- ui.options.rows hook appends.
|
||||
function ModSetting:row()
|
||||
@@ -109,7 +184,7 @@ function ModSetting:row()
|
||||
return {
|
||||
id = "DRAMATIC_SHAPE:" .. self.key,
|
||||
label = self.label,
|
||||
value = function() return self_.labels[self_:read()] end,
|
||||
value = function() return self_:valueLabel() end,
|
||||
step = function(game, dir)
|
||||
self_:cycle(game, dir)
|
||||
return true
|
||||
@@ -120,7 +195,11 @@ end
|
||||
-- The row the mod manager's own settings page builds for this mod.
|
||||
function ModSetting:schema(help)
|
||||
local choices = {}
|
||||
for i, v in ipairs(self.values) do choices[i] = { self.labels[i], v } end
|
||||
-- gated rungs are left off the manager's page too, so the two rows agree
|
||||
-- about what can be chosen
|
||||
for i, v in ipairs(self.values) do
|
||||
if self:allows(i) then choices[#choices + 1] = { self.labels[i], v } end
|
||||
end
|
||||
if #self.values == 2 and self.values[1] == false then
|
||||
return { key = self.key, type = "toggle", label = self.label,
|
||||
default = self.values[1], help = help }
|
||||
|
||||
+535
-73
@@ -60,18 +60,105 @@ if DEBUG == nil or DEBUG == false then DEBUG = nil end
|
||||
OverworldBattle.KEY = "battles"
|
||||
OverworldBattle.LABEL = "3D-BTL"
|
||||
|
||||
-- On by default: a mod whose headline is "the world in 3D" should not need
|
||||
-- the player to go and find the switch before the world shows up in a
|
||||
-- battle. ON is first, so it is also what an unreadable stored value falls
|
||||
-- back to.
|
||||
OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
|
||||
OverworldBattle.LABEL,
|
||||
{ true, false }, { "ON", "OFF" })
|
||||
-- Five rungs. Two independent choices, laid out as one ladder because they
|
||||
-- are one question to the player -- WHAT is standing there, and WHERE:
|
||||
--
|
||||
-- on the MAP on two DISCS
|
||||
-- pics 2D-3D A 2D-3D B
|
||||
-- models STADIUM A STADIUM B
|
||||
--
|
||||
-- 2D-3D A the mode this file was written for: the fight is staged on
|
||||
-- the map and the two Pokemon are the GB's OWN PICS, stood up
|
||||
-- on their tiles as quads (BattleBillboard).
|
||||
-- 2D-3D B those same pics on a pair of DISCS against the sky, with no
|
||||
-- map at all (see lib/StadiumStage.lua). The Game Boy's own
|
||||
-- framing with the Game Boy's own art, in three dimensions --
|
||||
-- and, like every B rung, it works everywhere, including the
|
||||
-- caves and shop floors that have nowhere to stage a fight.
|
||||
-- STADIUM A the staged fight with the Pokemon Stadium battle models in
|
||||
-- place of those quads -- skinned, animated, and playing the
|
||||
-- animation the move being used actually calls for (see
|
||||
-- lib/Stadium.lua). The world is still the world: the fight
|
||||
-- happens on real ground, in the map's own weather and light.
|
||||
-- STADIUM B the models on the discs: both halves swapped at once.
|
||||
-- OFF the engine's own white battle screen.
|
||||
--
|
||||
-- A and B is the STAGE and it is the same stage either way -- the discs do
|
||||
-- not know what is standing on them and BattleScene draws them off
|
||||
-- `arena.discs` alone, which is why the second column cost a value in this
|
||||
-- table and nothing else. The four combinations are all reachable rather
|
||||
-- than only the diagonal, because a player who cannot use the STADIUM rungs
|
||||
-- -- no ROM, or a ROM they would rather not go and find -- should still be
|
||||
-- able to have the disc framing, and because the discs are the answer to
|
||||
-- "this map has nowhere to fight" whichever art is standing on them.
|
||||
--
|
||||
-- 2D-3D A stays FIRST because ModSetting's values[1] is both the default and
|
||||
-- what an unrecognised stored value falls back to, and the stored value for
|
||||
-- this row has been `true` since the row existed. Keeping `true` at the head
|
||||
-- means every save written before the later rungs existed reads back as the
|
||||
-- 2D-3D it was written for, and a mod whose headline is "the world in 3D"
|
||||
-- still does not need the player to go and find the switch.
|
||||
--
|
||||
-- Every other stored value is likewise the one it has always been --
|
||||
-- "stadium" from before there was a B, "stadiumB" from before there was a
|
||||
-- flat one -- so no save loses the mode it chose.
|
||||
--
|
||||
-- Both STADIUM rungs are GATED on the models existing: the mod ships no
|
||||
-- Pokemon Stadium data, and until the player's own ROM has been found and
|
||||
-- built from (StadiumInstall) the row simply has two fewer stops. See
|
||||
-- ModSetting.setGate for why they are skipped rather than shown and refused.
|
||||
-- 2D-3D B is NOT gated: its stage is generated in Lua and its Pokemon are
|
||||
-- the game's own art, so it needs nothing the base game did not ship.
|
||||
OverworldBattle.FLAT_B = "flatB"
|
||||
|
||||
OverworldBattle.setting =
|
||||
ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL,
|
||||
{ true, "flatB", "stadium", "stadiumB", false },
|
||||
{ "2D-3D A", "2D-3D B", "STADIUM A", "STADIUM B", "OFF" })
|
||||
:setGate(function(value)
|
||||
if value ~= "stadium" and value ~= "stadiumB" then return true end
|
||||
local ok, install = pcall(V.require, "StadiumInstall")
|
||||
return ok and install and install.available()
|
||||
end)
|
||||
|
||||
-- Whether the fight stands on the two carried DISCS rather than on the map
|
||||
-- -- the B column above, whichever row of it. Asked by stageFor (what to
|
||||
-- stage on), wantsFront (whether this map needs an arena at all) and, once
|
||||
-- the arena carries the answer as `arena.discs`, by BattleScene and
|
||||
-- VoxelScene for what to draw.
|
||||
--
|
||||
-- Read straight off the row rather than through Stadium, because it is a
|
||||
-- question about the STAGE and half the rungs that answer yes have no
|
||||
-- Stadium models on them at all.
|
||||
function OverworldBattle.discs()
|
||||
local value = OverworldBattle.setting:get()
|
||||
return (value == OverworldBattle.FLAT_B or value == "stadiumB")
|
||||
end
|
||||
|
||||
-- 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()
|
||||
if vrOn() then return true end
|
||||
return OverworldBattle.setting:get() and true or false
|
||||
end
|
||||
|
||||
-- Whether the STADIUM rung is the one selected -- read through Stadium so
|
||||
-- there is one answer to that question and it lives with the mode it
|
||||
-- describes. Required lazily: Stadium sits above this file and requires it
|
||||
-- back (for the row), which a load-time require would deadlock.
|
||||
function OverworldBattle.stadium()
|
||||
local ok, stadium = pcall(V.require, "Stadium")
|
||||
return (ok and stadium and stadium.enabled()) and true or false
|
||||
end
|
||||
|
||||
-- ------- BACK SPRITES: the player's own mon stays on the menu
|
||||
--
|
||||
-- The staged shot stands BOTH mons on the map, which is the mode's whole
|
||||
@@ -98,12 +185,40 @@ OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
|
||||
|
||||
-- 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.
|
||||
-- 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
|
||||
--
|
||||
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
||||
@@ -134,6 +249,9 @@ function OverworldBattle.wantsFront()
|
||||
local g = require("src.core.Game")
|
||||
local ow = g and g.overworld
|
||||
if not (ow and ow.map and ow.player) then return false end
|
||||
-- a B rung carries its own stage, so the answer is yes on every map and
|
||||
-- there is nothing to search or to cache
|
||||
if OverworldBattle.discs() then return true end
|
||||
if staged.mapId ~= ow.map.id then
|
||||
local ok, arena = pcall(BattleArena.find, ow.map,
|
||||
ow.player.cellX, ow.player.cellY,
|
||||
@@ -210,8 +328,37 @@ OverworldBattle.TEXT_RECT = {
|
||||
mimic = { 0, 56, 128, 40 },
|
||||
}
|
||||
|
||||
-- How far apart the two anchors are: the spacing every move animation was
|
||||
-- authored against, and so the yardstick the live pair is measured with.
|
||||
OverworldBattle.ANCHOR_SPAN = math.sqrt(
|
||||
(OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2
|
||||
+ (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2)
|
||||
|
||||
-- The effects layer's scale for this shot: how far apart the two mons
|
||||
-- actually are on screen, over how far apart the slots they were authored
|
||||
-- for were. Clamped hard at both ends -- an effect is pixel art and a wild
|
||||
-- factor is worse than a slightly wrong one -- and held at exactly 1 when
|
||||
-- the marks coincide, which is a projection about to degenerate rather
|
||||
-- than a pair that has genuinely closed up.
|
||||
OverworldBattle.ANIM_SCALE_MIN = 0.5
|
||||
OverworldBattle.ANIM_SCALE_MAX = 2.0
|
||||
|
||||
function OverworldBattle.animScale(shot, px, py)
|
||||
if not (shot and shot.enemy and px and py) then return 1 end
|
||||
local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py
|
||||
local span = math.sqrt(dx * dx + dy * dy)
|
||||
if not (span > 1) then return 1 end
|
||||
local k = span / OverworldBattle.ANCHOR_SPAN
|
||||
return math.max(OverworldBattle.ANIM_SCALE_MIN,
|
||||
math.min(OverworldBattle.ANIM_SCALE_MAX, k))
|
||||
end
|
||||
|
||||
function OverworldBattle.textRects(battle)
|
||||
if not battle or battle.blankForAskName then return {} end
|
||||
-- a capture session aiming has no text to put in the box and takes it
|
||||
-- off the frame (see the drawTextArea wrap): no box, no glass under it
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.hideTextBox then return {} end
|
||||
local r = OverworldBattle.TEXT_RECT
|
||||
local out = { box = r.box }
|
||||
if battle.phase == "moveSelect" then
|
||||
@@ -287,6 +434,10 @@ end
|
||||
-- not nest.
|
||||
local session = nil
|
||||
|
||||
local function isIOS()
|
||||
return love.system and love.system.getOS and love.system.getOS() == "iOS"
|
||||
end
|
||||
|
||||
local function game()
|
||||
return require("src.core.Game")
|
||||
end
|
||||
@@ -352,6 +503,30 @@ function OverworldBattle.forceOG(g)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Where THIS fight stands, on whichever rung is running: the map's own
|
||||
-- ground, or the pair of discs a B rung carries with it.
|
||||
--
|
||||
-- The one place the two columns actually diverge, and it is worth stating
|
||||
-- plainly. On an A rung the answer can be NO -- a corridor, a shop floor, a
|
||||
-- map whose authored entry is a refusal -- and the battle then plays exactly
|
||||
-- as the vanilla game does. A B rung cannot fail: its stage is not something
|
||||
-- the map has to have room for, so a fight in the tightest cave in Kanto is
|
||||
-- staged as readily as one on Route 1.
|
||||
function OverworldBattle.stageFor(state)
|
||||
if OverworldBattle.discs() and Voxel3D.available() then
|
||||
local okStage, arena = pcall(function()
|
||||
return V.require("StadiumStage").arena(state.map)
|
||||
end)
|
||||
if okStage and arena then return arena end
|
||||
-- the discs could not be built; fall through to the map, which is a
|
||||
-- worse picture but a real one
|
||||
end
|
||||
local okFind, arena = pcall(BattleArena.find, state.map,
|
||||
state.player.cellX, state.player.cellY,
|
||||
state.player.surfing)
|
||||
return (okFind and arena) or nil
|
||||
end
|
||||
|
||||
-- Stage a battle triggered from `state`, if this mode can. Returns true when
|
||||
-- a session started -- which is also the only case where anything visible
|
||||
-- changes, so a map with no room for an arena plays exactly the vanilla
|
||||
@@ -362,10 +537,8 @@ function OverworldBattle.begin(state, battle)
|
||||
if not (state and state.map and state.player) then return false end
|
||||
if not Voxel3D.available() then return false end
|
||||
|
||||
local ok, arena = pcall(BattleArena.find, state.map,
|
||||
state.player.cellX, state.player.cellY,
|
||||
state.player.surfing)
|
||||
if not (ok and arena) then return false end
|
||||
local arena = OverworldBattle.stageFor(state)
|
||||
if not 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)
|
||||
@@ -375,6 +548,9 @@ function OverworldBattle.begin(state, battle)
|
||||
armed = false, token = 0 }
|
||||
cullCast(state)
|
||||
BattleCam.reset()
|
||||
-- and, on the STADIUM rung, the pair of models that will stand on this
|
||||
-- arena's two cells. Declines quietly on any other rung.
|
||||
pcall(function() V.require("Stadium").begin(arena) end)
|
||||
return true
|
||||
end
|
||||
|
||||
@@ -405,6 +581,7 @@ function OverworldBattle.finish()
|
||||
restoreCast()
|
||||
session = nil
|
||||
Voxel3D.camera = nil
|
||||
pcall(function() V.require("Stadium").finish() end)
|
||||
end
|
||||
|
||||
-- ------- per-frame
|
||||
@@ -421,6 +598,11 @@ end
|
||||
function OverworldBattle.update(dt)
|
||||
if not session then return end
|
||||
|
||||
-- the shiny arrival sparkle's clock. Ticked here rather than in the draw
|
||||
-- because a paused or covered frame still draws, and a burst that
|
||||
-- advanced on draws would stall behind a text box mid-twinkle.
|
||||
pcall(function() V.require("ShinyFx").update(dt) end)
|
||||
|
||||
local g = game()
|
||||
local top = g and g.stack and g.stack:top()
|
||||
local ow = g and g.overworld
|
||||
@@ -435,6 +617,25 @@ function OverworldBattle.update(dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- Whether the shot is the player's to steer at all. BACK SPRITES pins
|
||||
-- their own mon to the GB's slot on the menu while the foe stands out on
|
||||
-- the map, and there is no angle that half-framed, half-solid
|
||||
-- composition survives -- so under it the camera holds the shot the rig
|
||||
-- was solved for (the slow drift aside, which was always there). Polled
|
||||
-- per frame rather than latched at battle start: the row is reachable
|
||||
-- from the mod manager's page mid-session.
|
||||
-- A LET'S GO capture session holds it too: the throw is aimed in this
|
||||
-- exact framing, and a camera that moved under a ball in flight would
|
||||
-- bend where the flick was pointed after the fact. (The session also
|
||||
-- sets BattleCam.still, which is what stops the drift -- see
|
||||
-- CatchThrow.begin.)
|
||||
BattleCam.steerable = not OverworldBattle.backPinned()
|
||||
and not BattleScene.capture
|
||||
-- the right stick, read as a rate before the rig is built from it: the
|
||||
-- wheel, the keys, the mouse and a drag all arrive as events and have
|
||||
-- already landed, but a stick is a HELD position and only a tick can
|
||||
-- turn it into travel (CamControl, which owns every one of those inputs)
|
||||
pcall(V.require("CamControl").tick, dt)
|
||||
BattleCam.update(dt)
|
||||
-- the battle only exists once it has been pushed; a session opened at
|
||||
-- pushBattle time has it, one opened from battle.started was handed it
|
||||
@@ -443,11 +644,39 @@ function OverworldBattle.update(dt)
|
||||
-- slice: nothing visible can hitch on them
|
||||
ChunkMesher.pump(true)
|
||||
|
||||
-- The STADIUM models, ahead of the pics, because what they decide is
|
||||
-- WHICH pics are needed: a side a model is standing on gets no billboard
|
||||
-- texture rendered for it at all (see Stadium.covers). Posed and skinned
|
||||
-- here too, once for the frame -- the sun pass, the camera and, in a
|
||||
-- headset, both eyes all draw the same skinned meshes.
|
||||
pcall(function()
|
||||
local host = (session.arena and session.arena.map) or session.state.map
|
||||
V.require("Stadium").update(dt, session.battle,
|
||||
BattleScene.groundY(host, session.arena))
|
||||
end)
|
||||
|
||||
-- The mons' textures are rendered HERE, with no canvas bound, for the same
|
||||
-- reason the scene is: the pics layer binds its own targets, and doing that
|
||||
-- inside somebody else's frame means putting the frame back afterwards.
|
||||
local okTex, textures = pcall(OverworldBattle.textures, session.battle)
|
||||
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
|
||||
local ok, shot = pcall(BattleScene.render, session.state, session.arena,
|
||||
textures, session.token)
|
||||
@@ -506,10 +735,135 @@ function OverworldBattle.shot()
|
||||
return nil
|
||||
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 staged fight's arena and floor height, for the capture mode: the
|
||||
-- foe's world cell is the far end of the throw and the floor is what a
|
||||
-- short ball bounces on. nil whenever there is nothing staged, which is
|
||||
-- one of the gates that sends a throw back to the engine's own toss.
|
||||
function OverworldBattle.arenaInfo()
|
||||
if not (session and session.arena and not session.broken) then return nil end
|
||||
local host = session.arena.map or (session.state and session.state.map)
|
||||
if not host then return nil end
|
||||
return session.arena, BattleScene.groundY(host, session.arena)
|
||||
end
|
||||
|
||||
-- The foe's rendered pic texture, for the capture mode's ring. The mark
|
||||
-- BattleScene pins is the CELL's ground point, but a species' art sits
|
||||
-- wherever the artist drew it in the frame -- a bird hovers half a slot
|
||||
-- above its own feet row -- and a timing ring belongs on the CREATURE,
|
||||
-- not on its patch of grass. The capture session reads this canvas back
|
||||
-- once and centres the ring on the art's opaque box. nil on the STADIUM
|
||||
-- rungs (the foe is a model, no pic is rendered) and before the first
|
||||
-- textures pass, both of which the caller treats as "use the heuristic".
|
||||
function OverworldBattle.enemyTexture()
|
||||
if not (session and session.textures) then return nil end
|
||||
return session.textures.enemy
|
||||
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()
|
||||
BattleDOF.invalidate()
|
||||
BattleHud.invalidate()
|
||||
BattlePics.invalidate()
|
||||
-- the STADIUM models hold meshes and textures of this graphics context
|
||||
-- like everything else here does
|
||||
pcall(function() V.require("Stadium").invalidate() end)
|
||||
end
|
||||
|
||||
-- ------- the battle screen's background
|
||||
@@ -665,9 +1019,21 @@ OverworldBattle.TEX_AX, OverworldBattle.TEX_AY = TEX_AX, TEX_AY
|
||||
-- Which side is being rendered, or nil. The placement wrappers read it.
|
||||
local texturing = nil
|
||||
|
||||
-- Which side is being rendered into its own canvas right now, or nil.
|
||||
--
|
||||
-- Exposed because the shiny tint has two applications -- per side here, and
|
||||
-- both-sides-at-once on the flat path (ShinyUI.installBattlePics) -- and
|
||||
-- exactly one of them must run per draw. Asking this is what keeps them
|
||||
-- from stacking, rather than relying on which module installed first.
|
||||
function OverworldBattle.texturingSide()
|
||||
return texturing
|
||||
end
|
||||
|
||||
local texCanvas = {}
|
||||
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 c = texCanvas[side]
|
||||
@@ -706,6 +1072,14 @@ local OFF = {
|
||||
-- feet ended up, in canvas coordinates.
|
||||
function OverworldBattle.sideTexture(battle, side)
|
||||
if not (innerPics and battle) then return nil end
|
||||
-- On the STADIUM rung a side standing a MODEL needs no pic: rendering one
|
||||
-- anyway would hang a second, flat copy of the same Pokemon on the same
|
||||
-- cell. Asked per side, so a species with no pack -- or a substitute
|
||||
-- doll, or the trainer before the send-out -- still comes through here.
|
||||
local okS, covered = pcall(function()
|
||||
return V.require("Stadium").covers(battle, side)
|
||||
end)
|
||||
if okS and covered then return nil end
|
||||
if not sideVisible(battle, side) then return nil end
|
||||
local canvas = texCanvasFor(side)
|
||||
if not canvas then return nil end
|
||||
@@ -727,12 +1101,43 @@ function OverworldBattle.sideTexture(battle, side)
|
||||
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
|
||||
texturing = side
|
||||
|
||||
-- A SHINY on this side, tinted here rather than in ShinyUI's flat-path
|
||||
-- wrap. This is the one place a pic is rendered for ONE side at a time,
|
||||
-- so it is the only place the two sides can be tinted differently -- a
|
||||
-- shiny facing an ordinary mon gets its own colour and leaves the other
|
||||
-- alone, which the engine's both-sides-at-once pic layer cannot do.
|
||||
local shinyTint = nil
|
||||
do
|
||||
-- NOT when this side is showing a PERSON. Both sides can be holding a
|
||||
-- trainer pic rather than a Pokemon -- the foe's portrait before the
|
||||
-- send-out, and the player's own back until "Go!" -- and a shiny is a
|
||||
-- fact about a Pokemon, not about its owner. Tinting through it turned
|
||||
-- the player's trainer sprite a different colour for the whole intro,
|
||||
-- which is what a shiny Pokemon in the party looks like if you do not
|
||||
-- ask this question. The two tests are the same ones sideTexture already
|
||||
-- uses to label the finished texture, asked here instead of after.
|
||||
local person = (side == "enemy"
|
||||
and battle.showEnemyTrainer and battle.trainerPic)
|
||||
or (side == "player"
|
||||
and battle.showPlayerBack and battle.playerBackPic)
|
||||
if not person then
|
||||
local battler = (side == "player") and battle.player or battle.enemy
|
||||
local g2 = game()
|
||||
shinyTint = battler and V.require("ShinyUI")
|
||||
.tintFor(battler.mon, g2 and g2.data) or nil
|
||||
end
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
g.setCanvas(canvas)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
g.setColor(1, 1, 1, 1)
|
||||
innerPics(battle, 0, 0, 0)
|
||||
if shinyTint then
|
||||
V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0)
|
||||
else
|
||||
innerPics(battle, 0, 0, 0)
|
||||
end
|
||||
end)
|
||||
|
||||
texturing = nil
|
||||
@@ -778,12 +1183,26 @@ function OverworldBattle.textures(battle)
|
||||
local out = {}
|
||||
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
||||
local okP, player = true, nil
|
||||
if not OverworldBattle.backPinned() then
|
||||
-- a LET'S GO capture session empties the player's side the same way
|
||||
-- BACK SPRITES does: that mon is simply not in this shot, so no pic is
|
||||
-- rendered for it and no shadow lands under it
|
||||
local cap = BattleScene.capture
|
||||
if not OverworldBattle.backPinned()
|
||||
and not (cap and cap.hidePlayer) then
|
||||
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
||||
end
|
||||
out.enemy = okE and enemy or nil
|
||||
out.player = okP and player or nil
|
||||
if not (out.enemy or out.player) then return nil end
|
||||
-- On the STADIUM rung both sides can legitimately have no pic -- the pair
|
||||
-- of them are models -- and this table must still come back, because it
|
||||
-- carries the HIT FLASH, and because the VR eye pass uses its presence to
|
||||
-- decide there is a staged fight to draw at all.
|
||||
local okStanding, standing = pcall(function()
|
||||
return V.require("Stadium").standing()
|
||||
end)
|
||||
if not (out.enemy or out.player or (okStanding and standing)) then
|
||||
return nil
|
||||
end
|
||||
out.flash = OverworldBattle.flashing(battle)
|
||||
return out
|
||||
end
|
||||
@@ -806,6 +1225,12 @@ function OverworldBattle.install()
|
||||
OverworldState.dramaticShapeBattleHook = true
|
||||
end
|
||||
|
||||
-- the STADIUM rung's own four wraps, which drive the models' animations
|
||||
-- off the fight (see Stadium.install). Idempotent in the same way, and
|
||||
-- installed whichever rung the row is on: the wraps do nothing at all
|
||||
-- while no stadium session is live.
|
||||
pcall(function() V.require("Stadium").install() end)
|
||||
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
if BattleState.dramaticShapeBattleHook then return end
|
||||
|
||||
@@ -831,11 +1256,17 @@ function OverworldBattle.install()
|
||||
-- behind it. There is a world back there now, so they are filled here
|
||||
-- instead -- see BattlePics, which puts the paper back without touching
|
||||
-- the silhouette.
|
||||
--
|
||||
-- The pinned pic is told that its feet are on the box, which is what lets
|
||||
-- the pale-bodied back sprites be filled at all: their bellies leak out
|
||||
-- through an opening too wide to read as a drain, and only the box under
|
||||
-- them settles that it is not a hole. Passed the pre-bake image, because
|
||||
-- that is the one the battle holds a reference to.
|
||||
local innerPic = BattleState.picImage
|
||||
function BattleState:picImage(img)
|
||||
local out = innerPic(self, img)
|
||||
if not OverworldBattle.shot() then return out end
|
||||
return BattlePics.filled(out)
|
||||
return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))
|
||||
end
|
||||
|
||||
-- While a billboard texture is being rendered both pics are put in the same
|
||||
@@ -889,6 +1320,11 @@ function OverworldBattle.install()
|
||||
self.letterboxWhite = false
|
||||
OverworldBattle.drawHudPanels(self)
|
||||
withoutBackgroundFill(self, innerDraw)
|
||||
-- the LET'S GO capture overlay -- the timing ring, the ball readout,
|
||||
-- the grade splash -- drawn last in the same GB frame the engine's
|
||||
-- own HUD drew in, so it letterboxes and chunks identically
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.drawGB then pcall(cap.drawGB, self) end
|
||||
end
|
||||
|
||||
-- The mons are geometry standing on the map now, drawn in the 3D pass
|
||||
@@ -906,6 +1342,10 @@ function OverworldBattle.install()
|
||||
if not shot then
|
||||
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
|
||||
end
|
||||
-- a capture session shows NO player side at all -- not even the
|
||||
-- pinned back pic BACK SPRITES would keep on the menu
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.hidePlayer then return 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.
|
||||
@@ -922,17 +1362,20 @@ function OverworldBattle.install()
|
||||
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.
|
||||
-- them rather than over their own white paper. The INK is Gen 1's own black
|
||||
-- and stays that way whatever is behind the glass -- the panel's tint is
|
||||
-- what earns it its contrast (see BattleHud).
|
||||
local innerText = BattleState.drawTextArea
|
||||
function BattleState:drawTextArea()
|
||||
if not self.dramaticShapeShot then return innerText(self) end
|
||||
local battle = self
|
||||
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
|
||||
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
|
||||
withoutBoxFill(battle, innerText)
|
||||
end)
|
||||
-- While a capture session is being AIMED the box is empty -- the
|
||||
-- battle's phase is parked, so there is no message in it -- and it
|
||||
-- covers the bottom third of the frame, which is exactly the room a
|
||||
-- throw needs to wind up in. So it comes off entirely for those
|
||||
-- frames and is back the instant a message has something to say.
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.hideTextBox then return end
|
||||
return withoutBoxFill(self, innerText)
|
||||
end
|
||||
|
||||
-- Move animations are authored against the pics' fixed slots, and a single
|
||||
@@ -940,7 +1383,7 @@ function OverworldBattle.install()
|
||||
-- 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
|
||||
-- aimed at instead of drifting off it.
|
||||
local innerAnim = BattleState.drawAnimLayer
|
||||
innerAnim = BattleState.drawAnimLayer
|
||||
function BattleState:drawAnimLayer(colorized)
|
||||
local shot = self.dramaticShapeShot
|
||||
if not shot then return innerAnim(self, colorized) end
|
||||
@@ -949,16 +1392,36 @@ function OverworldBattle.install()
|
||||
-- give them. They ride to where the PAIR went: the midpoint of the two
|
||||
-- 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.
|
||||
--
|
||||
-- And they ride the pair's SEPARATION as well, because the mons
|
||||
-- themselves do. Both are geometry standing on the map, so the camera
|
||||
-- sizes them: zoom in and they grow, swing round to side-on and the two
|
||||
-- marks close up as the axis foreshortens. A layer that only slid would
|
||||
-- have held the authored 106-pixel spacing through all of it -- a beam
|
||||
-- fired between two mons that are no longer that far apart, ending in
|
||||
-- the air beside the one it was aimed at. Scaling about the same
|
||||
-- midpoint keeps every authored offset the same fraction of the gap it
|
||||
-- was authored as.
|
||||
local a = OverworldBattle.ANCHOR
|
||||
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
|
||||
-- contributes no movement at all and the pair's centre has gone half as
|
||||
-- far as the foe's mark did.
|
||||
local px, py = shot.player[1], shot.player[2]
|
||||
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
|
||||
local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2
|
||||
local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2
|
||||
local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2
|
||||
local ax = (a.enemy[1] + a.player[1]) / 2
|
||||
local ay = (a.enemy[2] + a.player[2]) / 2
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
||||
love.graphics.translate(cx - ax, cy - ay)
|
||||
-- Clamped, and skipped outright if the marks ever coincide: a
|
||||
-- degenerate projection must leave the effects the size they were
|
||||
-- rather than collapse them to nothing or blow them across the screen.
|
||||
local k = OverworldBattle.animScale(shot, px, py)
|
||||
if k ~= 1 then
|
||||
love.graphics.translate(ax, ay)
|
||||
love.graphics.scale(k, k)
|
||||
love.graphics.translate(-ax, -ay)
|
||||
end
|
||||
local ok, err = pcall(innerAnim, self, colorized)
|
||||
love.graphics.pop()
|
||||
if not ok then error(err, 0) end
|
||||
@@ -1005,28 +1468,13 @@ function OverworldBattle.install()
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- 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
|
||||
-- rewritten: the HUD sets pure black for its text and nothing else, and in
|
||||
-- the colorized pipeline this lands in the grayscale BG canvas, where
|
||||
-- white IS shade 0 and the zone pass then colours it like every other
|
||||
-- lightest-shade surface. One rule, both pipelines.
|
||||
--
|
||||
-- The HP bar is untouched: it is drawn in its own greens and reds, and
|
||||
-- only an exactly-black set is remapped.
|
||||
innerHUDs = BattleState.drawHUDs
|
||||
function BattleState:drawHUDs(slide)
|
||||
-- Normally the HUDs have already been drawn this frame, snapped out to the
|
||||
-- window's edges and composited into the world image (snapHUDs). Drawing
|
||||
-- them here as well would show each block twice, once in each place.
|
||||
if self.dramaticShapeShot and snapped() then return end
|
||||
if not (self.dramaticShapeShot and self.dramaticShapeDark) then
|
||||
return innerHUDs(self, slide)
|
||||
end
|
||||
local battle = self
|
||||
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
|
||||
innerHUDs(battle, slide)
|
||||
end)
|
||||
return innerHUDs(self, slide)
|
||||
end
|
||||
|
||||
BattleState.dramaticShapeBattleHook = true
|
||||
@@ -1058,18 +1506,17 @@ end
|
||||
-- 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.
|
||||
-- on the flat path.
|
||||
--
|
||||
-- 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)
|
||||
function OverworldBattle.hudTexture(battle, slide)
|
||||
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,
|
||||
BattleScene.GB_W, BattleScene.GB_H,
|
||||
function() innerHUDs(battle, slide) end)
|
||||
battle.colorMode = had
|
||||
return ok and layer or nil
|
||||
@@ -1089,28 +1536,31 @@ 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)
|
||||
if not isIOS() then
|
||||
if enemy then live.enemy = rects.enemy end
|
||||
if player then live.player = rects.player end
|
||||
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)
|
||||
-- The text box's frost panel normally goes into this same world-canvas pass.
|
||||
-- On iOS that panel is mirrored upward by the Canvas-to-Canvas path, creating
|
||||
-- the large ghost rectangle behind the Pokemon. Keep the box border/text but
|
||||
-- skip only this frosted backing on iOS.
|
||||
if not isIOS() then
|
||||
for key, rect in pairs(OverworldBattle.textRects(battle)) do
|
||||
live[key] = toWorld(rect, shot)
|
||||
end
|
||||
end
|
||||
local layer = OverworldBattle.hudTexture(battle, slide)
|
||||
if not layer then return false end
|
||||
|
||||
local g = love.graphics
|
||||
@@ -1119,13 +1569,29 @@ function OverworldBattle.snapHUDs(battle, shot)
|
||||
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
|
||||
for _, rect in pairs(live) do BattleHud.panel(rect, shot, 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)
|
||||
local x = bandX[side] + band[1] * shot.scale
|
||||
local targetY = shot.ly + band[2] * shot.scale
|
||||
|
||||
if isIOS() then
|
||||
-- Keep the player's HUD exactly where it currently appears on the
|
||||
-- right. Only the enemy band needs its mirrored destination corrected.
|
||||
local y = targetY
|
||||
if side == "enemy" then
|
||||
y = shot.ph - targetY - band[4] * shot.scale
|
||||
end
|
||||
|
||||
-- iOS presents this Canvas-to-Canvas HUD texture upside down.
|
||||
g.draw(layer, quad, x, y, 0,
|
||||
shot.scale, -shot.scale, 0, band[4])
|
||||
else
|
||||
g.draw(layer, quad, x, targetY, 0,
|
||||
shot.scale, shot.scale)
|
||||
end
|
||||
end
|
||||
end)
|
||||
if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end
|
||||
@@ -1144,10 +1610,8 @@ end
|
||||
-- drawn here, in the GB frame, whichever path laid the glass under it.
|
||||
function OverworldBattle.drawHudPanels(battle)
|
||||
local shot = battle.dramaticShapeShot
|
||||
battle.dramaticShapeDark = nil
|
||||
if not shot then return end
|
||||
if snapped() then
|
||||
battle.dramaticShapeDark = session and session.dark or nil
|
||||
return
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
@@ -1158,9 +1622,7 @@ function OverworldBattle.drawHudPanels(battle)
|
||||
if player then live.player = rect.player end
|
||||
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
|
||||
if not next(live) then return end
|
||||
local dark = BattleHud.verdict(live, shot)
|
||||
battle.dramaticShapeDark = dark
|
||||
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
|
||||
for _, r in pairs(live) do BattleHud.panel(r, shot) end
|
||||
end
|
||||
|
||||
return OverworldBattle
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,594 @@
|
||||
-- A Poke Ball as real geometry: the prop the LET'S GO capture mode throws.
|
||||
--
|
||||
-- The mod has never drawn a ball in 3D -- the one the engine tosses is a 2D
|
||||
-- sprite inside the battle's move-animation layer. This is a ball that can
|
||||
-- fly through the arena, hang in the air in front of the camera, hinge its
|
||||
-- lid open, drink a Pokemon in, click shut, rock on the ground and burst
|
||||
-- back open -- all of it depth-tested, sun-shadowed and hour-tinted like
|
||||
-- everything else in the diorama, because it is a mesh in Voxel3D's own
|
||||
-- format going through Voxel3D's own shader.
|
||||
--
|
||||
-- ------- how it is built
|
||||
--
|
||||
-- Two lat/long hemisphere shells that meet at the equator -- the WHITE base
|
||||
-- and the coloured LID -- each carrying its half of the black band as a
|
||||
-- slightly bulged latitude belt, so the two halves separate exactly where
|
||||
-- the real ball separates. The button is a little cylinder standing out of
|
||||
-- the base's front; the interior is sealed with two pale discs so an open
|
||||
-- ball shows a shell with a floor rather than a view through to the far
|
||||
-- wall's backface. Colour is a palette texture one texel per material and
|
||||
-- one ROW per ball tier (POKE/GREAT/ULTRA/MASTER/SAFARI), exactly the
|
||||
-- HordeGun/Pokedex scheme -- so GREAT is blue and ULTRA wears its yellow
|
||||
-- band without a second mesh, just a different V coordinate.
|
||||
--
|
||||
-- Shade is baked per vertex from the surface normal with StadiumStage's
|
||||
-- fitted constants, which is this mod's answer for anything curved: the
|
||||
-- ball's sun side and belly read as a sphere under the same southeastern
|
||||
-- sun the roofs are lit by.
|
||||
--
|
||||
-- ------- how it animates
|
||||
--
|
||||
-- The HordeGun way: a handful of scalar timers advanced by update(dt) and
|
||||
-- consumed as matrix terms at draw time. No skeleton, no keyframes --
|
||||
-- lid is a hinge matrix about the back of the equator, the wobble is a
|
||||
-- decaying rotateZ about the ground contact point, the caught click is a
|
||||
-- squash pulse, the stars are one shared quad drawn a few times facing the
|
||||
-- eye. The ball owns its POSE only; where it IS (the throw arc, the drop)
|
||||
-- is the caller's problem, which is what keeps this file a prop and not a
|
||||
-- game mode.
|
||||
--
|
||||
-- Nothing here touches love.* until something has to be drawn, so the
|
||||
-- module loads and the state machine runs headless -- the test suite
|
||||
-- exercises the phases without a GPU.
|
||||
|
||||
-- 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 Pokeball = {}
|
||||
Pokeball.__index = Pokeball
|
||||
|
||||
-- ------- the ball's measurements, in world pixels
|
||||
--
|
||||
-- A map cell is 16 and a full-size mon card is 16 wide, so a 4.4-pixel ball
|
||||
-- sits in the hand and against a Pokemon at about the proportion the games
|
||||
-- draw: unmissable in the foreground, believable at the far cell.
|
||||
Pokeball.R = 2.2
|
||||
|
||||
-- the black belt: half-height as a latitude angle, and how far the belt
|
||||
-- bulges past the shell so it reads as a band and not a painted stripe
|
||||
local BAND_LAT = 0.16
|
||||
local BAND_R = 1.045
|
||||
|
||||
-- lid hinge: at the BACK of the equator (-Z), opening backward. 2.0 rad is
|
||||
-- past upright -- the mouth gapes at the sky, which is the capture pose.
|
||||
local HINGE_Z = -0.86 -- as a fraction of R
|
||||
local LID_OPEN = 2.0
|
||||
|
||||
-- tessellation: enough that the silhouette is round at held-ball size,
|
||||
-- cheap enough that six of these would not show on a phone's frame budget
|
||||
local LON = 14
|
||||
local LAT = 5
|
||||
|
||||
-- pose timing
|
||||
local LID_RATE = 6.5 -- lid open/close, in lid-fractions per second
|
||||
local BURST_RATE = 14 -- the breakout pop is a violent open
|
||||
local WOBBLE_T = 0.85 -- one rock, seconds
|
||||
local WOBBLE_A = 0.38 -- how far it tips, radians
|
||||
local PULSE_T = 0.14 -- the caught click's squash pulse
|
||||
local STAR_T = 0.9 -- the caught stars' life
|
||||
local GLOW_DECAY = 2.2 -- additive glow, per second
|
||||
|
||||
-- ------- palette
|
||||
--
|
||||
-- One texel per material (columns), one row per ball tier. Alpha stays 1
|
||||
-- everywhere: the voxel shader discards below 0.5 (Voxel3D's SHADER), so a
|
||||
-- translucent texel is an invisible one.
|
||||
local SLOTS = { TOP = 1, BOTTOM = 2, BAND = 3, RING = 4, FACE = 5,
|
||||
INNER = 6, GLOW = 7, STAR = 8 }
|
||||
local SLOT_N = 8
|
||||
|
||||
local TIERS = { "POKE_BALL", "GREAT_BALL", "ULTRA_BALL", "MASTER_BALL",
|
||||
"SAFARI_BALL" }
|
||||
|
||||
local COLORS = {
|
||||
POKE_BALL = { top = { 0.86, 0.16, 0.16 }, band = { 0.12, 0.12, 0.13 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
GREAT_BALL = { top = { 0.25, 0.45, 0.88 }, band = { 0.12, 0.12, 0.13 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
ULTRA_BALL = { top = { 0.22, 0.22, 0.26 }, band = { 0.85, 0.70, 0.18 },
|
||||
bottom = { 0.93, 0.93, 0.95 } },
|
||||
MASTER_BALL = { top = { 0.48, 0.22, 0.66 }, band = { 0.16, 0.13, 0.19 },
|
||||
bottom = { 0.93, 0.93, 0.95 },
|
||||
glow = { 1.0, 0.72, 0.92 } },
|
||||
SAFARI_BALL = { top = { 0.47, 0.52, 0.26 }, band = { 0.36, 0.27, 0.16 },
|
||||
bottom = { 0.90, 0.88, 0.80 } },
|
||||
}
|
||||
|
||||
local SHARED = {
|
||||
ring = { 0.28, 0.28, 0.30 },
|
||||
face = { 0.96, 0.96, 0.97 },
|
||||
inner = { 0.72, 0.70, 0.68 },
|
||||
glow = { 1.00, 0.92, 0.65 },
|
||||
star = { 1.00, 0.85, 0.25 },
|
||||
}
|
||||
|
||||
local function tierRow(ball)
|
||||
for i, id in ipairs(TIERS) do
|
||||
if id == ball then return i end
|
||||
end
|
||||
return 1 -- an unknown ball is a plain POKE BALL
|
||||
end
|
||||
|
||||
-- palette texel centres
|
||||
local function uvFor(slot, row)
|
||||
return (slot - 0.5) / SLOT_N, (row - 0.5) / #TIERS
|
||||
end
|
||||
|
||||
local palette = nil
|
||||
local function paletteTexture()
|
||||
if palette ~= nil then return palette or nil end
|
||||
local ok, img = pcall(function()
|
||||
local data = love.image.newImageData(SLOT_N, #TIERS)
|
||||
for row, id in ipairs(TIERS) do
|
||||
local c = COLORS[id]
|
||||
local function put(slot, rgb)
|
||||
data:setPixel(slot - 1, row - 1, rgb[1], rgb[2], rgb[3], 1)
|
||||
end
|
||||
put(SLOTS.TOP, c.top)
|
||||
put(SLOTS.BOTTOM, c.bottom)
|
||||
put(SLOTS.BAND, c.band)
|
||||
put(SLOTS.RING, SHARED.ring)
|
||||
put(SLOTS.FACE, SHARED.face)
|
||||
put(SLOTS.INNER, SHARED.inner)
|
||||
put(SLOTS.GLOW, c.glow or SHARED.glow)
|
||||
put(SLOTS.STAR, SHARED.star)
|
||||
end
|
||||
local tex = love.graphics.newImage(data)
|
||||
tex:setFilter("nearest", "nearest")
|
||||
return tex
|
||||
end)
|
||||
palette = ok and img or false
|
||||
return palette or nil
|
||||
end
|
||||
|
||||
-- ------- shade
|
||||
--
|
||||
-- StadiumStage's fitted form of Voxel3D.FACE_SHADE: the same southeastern
|
||||
-- sun, answered for an arbitrary normal instead of one of six faces.
|
||||
local function shadeFor(nx, ny, nz)
|
||||
local s = 0.7725 + nx * 0.06 + ny * 0.225 + nz * 0.11
|
||||
return math.max(0.30, math.min(1.00, s))
|
||||
end
|
||||
|
||||
-- ------- mesh building
|
||||
--
|
||||
-- Everything below appends {x,y,z, u,v, shade} rows plus triangle indices.
|
||||
-- Quads go through the shared corner order; the discs use a degenerate
|
||||
-- fourth vertex, which the rasteriser drops as the zero-area triangle it is.
|
||||
local function quad(verts, map, a, b, c, d)
|
||||
local n = #verts
|
||||
verts[n + 1], verts[n + 2], verts[n + 3], verts[n + 4] = a, b, c, d
|
||||
Voxel3D.pushQuad(map, n / 4)
|
||||
end
|
||||
|
||||
local R = Pokeball.R
|
||||
local TAU = math.pi * 2
|
||||
|
||||
-- a latitude zone of the sphere between phi0 and phi1 (radians from the
|
||||
-- equator, north positive), at radiusK times the shell radius
|
||||
local function zone(verts, map, phi0, phi1, rows, slot, row, radiusK)
|
||||
local u, v = uvFor(slot, row)
|
||||
local r = R * (radiusK or 1)
|
||||
for i = 0, rows - 1 do
|
||||
local pa = phi0 + (phi1 - phi0) * (i / rows)
|
||||
local pb = phi0 + (phi1 - phi0) * ((i + 1) / rows)
|
||||
for j = 0, LON - 1 do
|
||||
local ta = TAU * (j / LON)
|
||||
local tb = TAU * ((j + 1) / LON)
|
||||
local function corner(phi, th)
|
||||
local nx = math.cos(phi) * math.sin(th)
|
||||
local ny = math.sin(phi)
|
||||
local nz = math.cos(phi) * math.cos(th)
|
||||
return { nx * r, ny * r, nz * r, u, v, shadeFor(nx, ny, nz) }
|
||||
end
|
||||
quad(verts, map, corner(pa, ta), corner(pa, tb),
|
||||
corner(pb, tb), corner(pb, ta))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- a disc in a y-plane, sealed with fan quads about the centre
|
||||
local function disc(verts, map, y, radius, slot, row, up)
|
||||
local u, v = uvFor(slot, row)
|
||||
local sh = shadeFor(0, up and 1 or -1, 0)
|
||||
local centre = { 0, y, 0, u, v, sh }
|
||||
for j = 0, LON - 1 do
|
||||
local ta = TAU * (j / LON)
|
||||
local tb = TAU * ((j + 1) / LON)
|
||||
local a = { radius * math.sin(ta), y, radius * math.cos(ta), u, v, sh }
|
||||
local b = { radius * math.sin(tb), y, radius * math.cos(tb), u, v, sh }
|
||||
quad(verts, map, centre, a, b, centre)
|
||||
end
|
||||
end
|
||||
|
||||
-- the button: a ring wall and its face, standing out of the shell along +Z
|
||||
local function button(verts, map, row)
|
||||
local BLON = 10
|
||||
local function ringWall(rad, z0, z1, slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
for j = 0, BLON - 1 do
|
||||
local ta = TAU * (j / BLON)
|
||||
local tb = TAU * ((j + 1) / BLON)
|
||||
local function at(th, z)
|
||||
local nx, ny = math.cos(th), math.sin(th)
|
||||
return { rad * nx, rad * ny, z, u, v, shadeFor(nx, ny, 0) }
|
||||
end
|
||||
quad(verts, map, at(ta, z0), at(tb, z0), at(tb, z1), at(ta, z1))
|
||||
end
|
||||
end
|
||||
local function faceDisc(rad, z, slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
local sh = shadeFor(0, 0, 1)
|
||||
local centre = { 0, 0, z, u, v, sh }
|
||||
for j = 0, BLON - 1 do
|
||||
local ta = TAU * (j / BLON)
|
||||
local tb = TAU * ((j + 1) / BLON)
|
||||
local a = { rad * math.cos(ta), rad * math.sin(ta), z, u, v, sh }
|
||||
local b = { rad * math.cos(tb), rad * math.sin(tb), z, u, v, sh }
|
||||
quad(verts, map, centre, a, b, centre)
|
||||
end
|
||||
end
|
||||
-- the wall starts inside the shell so the junction never shows a gap
|
||||
ringWall(0.75, R * 0.90, R + 0.30, SLOTS.RING)
|
||||
faceDisc(0.75, R + 0.30, SLOTS.RING)
|
||||
ringWall(0.45, R + 0.30, R + 0.42, SLOTS.RING)
|
||||
faceDisc(0.45, R + 0.42, SLOTS.FACE)
|
||||
end
|
||||
|
||||
-- one tier's meshes, memoised: { base = , lid = , spark = }
|
||||
--
|
||||
-- spark is a shared unit card (x -0.5..0.5, y 0..1, z 0) wearing one texel;
|
||||
-- the glow disc, the beam and every star are that card under a matrix.
|
||||
local meshes = {}
|
||||
local function meshesFor(ball)
|
||||
local row = tierRow(ball)
|
||||
local hit = meshes[row]
|
||||
if hit ~= nil then return hit or nil end
|
||||
|
||||
local ok, built = pcall(function()
|
||||
local bv, bm = {}, {}
|
||||
-- the base: white bowl from the south pole up to the band, its half of
|
||||
-- the band, the interior floor and the button on the front
|
||||
zone(bv, bm, -math.pi / 2, -BAND_LAT, LAT, SLOTS.BOTTOM, row)
|
||||
zone(bv, bm, -BAND_LAT, 0, 1, SLOTS.BAND, row, BAND_R)
|
||||
disc(bv, bm, -0.06, R * 0.97, SLOTS.INNER, row, true)
|
||||
button(bv, bm, row)
|
||||
|
||||
local lv, lm = {}, {}
|
||||
-- the lid: its half of the band up to the coloured dome, and its pale
|
||||
-- underside, which is what shows once the hinge tips it back
|
||||
zone(lv, lm, 0, BAND_LAT, 1, SLOTS.BAND, row, BAND_R)
|
||||
zone(lv, lm, BAND_LAT, math.pi / 2, LAT, SLOTS.TOP, row)
|
||||
disc(lv, lm, 0.06, R * 0.97, SLOTS.INNER, row, false)
|
||||
|
||||
local base = Voxel3D.newMesh(bv, bm)
|
||||
local lid = Voxel3D.newMesh(lv, lm)
|
||||
if not (base and lid) then return nil end
|
||||
|
||||
local function card(slot)
|
||||
local u, v = uvFor(slot, row)
|
||||
local cv, cm = {}, {}
|
||||
quad(cv, cm, { -0.5, 0, 0, u, v, 1 }, { 0.5, 0, 0, u, v, 1 },
|
||||
{ 0.5, 1, 0, u, v, 1 }, { -0.5, 1, 0, u, v, 1 })
|
||||
return Voxel3D.newMesh(cv, cm)
|
||||
end
|
||||
return { base = base, lid = lid,
|
||||
glow = card(SLOTS.GLOW), star = card(SLOTS.STAR) }
|
||||
end)
|
||||
meshes[row] = (ok and built) or false
|
||||
return meshes[row] or nil
|
||||
end
|
||||
|
||||
-- dropped so a lost GL context (Android resume) rebuilds everything
|
||||
function Pokeball.invalidate()
|
||||
meshes = {}
|
||||
palette = nil
|
||||
end
|
||||
|
||||
-- ------- an instance: one ball with a pose
|
||||
--
|
||||
-- Loads and runs without graphics; only draw() and cast() want a GPU.
|
||||
function Pokeball.new(ball)
|
||||
return setmetatable({
|
||||
ball = ball or "POKE_BALL",
|
||||
pos = { 0, 0, 0 }, -- world pixels, the ball's CENTRE
|
||||
yaw = 0, -- which way the button faces
|
||||
scale = 1,
|
||||
spin = 0, -- visual spin about the vertical, rad/s
|
||||
tumble = 0, -- end-over-end in flight, rad/s
|
||||
roll = 0, -- SCREEN-PLANE spin, rad/s: rotation about
|
||||
-- the axis out of the ball's face, which
|
||||
-- with the yaw at the camera reads as the
|
||||
-- ball turning clockwise/counter-clockwise
|
||||
-- to the viewer -- the curveball wind-up
|
||||
spinAngle = 0, tumbleAngle = 0, rollAngle = 0,
|
||||
lid = 0, lidTarget = 0, lidRate = LID_RATE,
|
||||
wobbleT = nil, wobbleDir = 1,
|
||||
pulse = nil, -- the caught click's squash
|
||||
glow = 0,
|
||||
stars = nil, -- caught celebration, or nil
|
||||
visible = true,
|
||||
}, Pokeball)
|
||||
end
|
||||
|
||||
-- ------- the verbs the capture flow speaks
|
||||
|
||||
function Pokeball:open()
|
||||
self.lidTarget, self.lidRate = 1, LID_RATE
|
||||
self.glow = 1
|
||||
end
|
||||
|
||||
function Pokeball:close()
|
||||
self.lidTarget, self.lidRate = 0, LID_RATE
|
||||
end
|
||||
|
||||
-- one rock on the ground; dir alternates shakes. Returns how long it takes,
|
||||
-- so the caller can sequence the pauses between shakes.
|
||||
function Pokeball:rock(dir)
|
||||
self.wobbleT = 0
|
||||
self.wobbleDir = dir or 1
|
||||
return WOBBLE_T
|
||||
end
|
||||
|
||||
-- the caught click: squash pulse, a soft flash, and the stars
|
||||
function Pokeball:catchClick()
|
||||
self.pulse = 0
|
||||
self.glow = 0.6
|
||||
local stars = {}
|
||||
for i = 1, 6 do
|
||||
stars[i] = { t = -0.04 * (i - 1), th = TAU * (i - 1) / 6 + 0.4 }
|
||||
end
|
||||
self.stars = stars
|
||||
end
|
||||
|
||||
-- the breakout: the lid blown open and a hard flash
|
||||
function Pokeball:burst()
|
||||
self.lidTarget, self.lidRate = 1, BURST_RATE
|
||||
self.glow = 1
|
||||
end
|
||||
|
||||
function Pokeball:busy()
|
||||
return self.wobbleT ~= nil or self.pulse ~= nil
|
||||
or math.abs(self.lid - self.lidTarget) > 0.02
|
||||
end
|
||||
|
||||
function Pokeball:update(dt)
|
||||
-- lid toward its target, at whatever violence was asked for
|
||||
local d = self.lidTarget - self.lid
|
||||
if d ~= 0 then
|
||||
local step = self.lidRate * dt
|
||||
if math.abs(d) <= step then
|
||||
-- arriving CLOSED from open is the shut click: the squash pulse
|
||||
if self.lid > self.lidTarget then self.pulse = self.pulse or 0 end
|
||||
self.lid = self.lidTarget
|
||||
else
|
||||
self.lid = self.lid + (d > 0 and step or -step)
|
||||
end
|
||||
end
|
||||
if self.wobbleT then
|
||||
self.wobbleT = self.wobbleT + dt
|
||||
if self.wobbleT >= WOBBLE_T then self.wobbleT = nil end
|
||||
end
|
||||
if self.pulse then
|
||||
self.pulse = self.pulse + dt
|
||||
if self.pulse >= PULSE_T then self.pulse = nil end
|
||||
end
|
||||
if self.stars then
|
||||
local live = false
|
||||
for _, s in ipairs(self.stars) do
|
||||
s.t = s.t + dt
|
||||
if s.t < STAR_T then live = true end
|
||||
end
|
||||
if not live then self.stars = nil end
|
||||
end
|
||||
self.glow = math.max(0, self.glow - GLOW_DECAY * dt)
|
||||
self.spinAngle = self.spinAngle + self.spin * dt
|
||||
self.tumbleAngle = self.tumbleAngle + self.tumble * dt
|
||||
self.rollAngle = self.rollAngle + self.roll * dt
|
||||
end
|
||||
|
||||
-- ------- pose as a matrix
|
||||
|
||||
local function smooth(t)
|
||||
if t <= 0 then return 0 end
|
||||
if t >= 1 then return 1 end
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
function Pokeball:matrix()
|
||||
local m = Mat4.mul(Mat4.translate(self.pos[1], self.pos[2], self.pos[3]),
|
||||
Mat4.rotateY(self.yaw))
|
||||
if self.wobbleT then
|
||||
-- a decaying rock about the ground contact: tip, cross through centre,
|
||||
-- tip the other way, settle
|
||||
local t = self.wobbleT / WOBBLE_T
|
||||
local a = WOBBLE_A * math.sin(TAU * t) * (1 - t) * self.wobbleDir
|
||||
m = Mat4.mul(m, Mat4.mul(Mat4.translate(0, -R * self.scale, 0),
|
||||
Mat4.mul(Mat4.rotateZ(a),
|
||||
Mat4.translate(0, R * self.scale, 0))))
|
||||
end
|
||||
if self.spinAngle ~= 0 then m = Mat4.mul(m, Mat4.rotateY(self.spinAngle)) end
|
||||
if self.tumbleAngle ~= 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateX(self.tumbleAngle))
|
||||
end
|
||||
-- the roll turns about the ball's own face axis, so with the yaw aimed
|
||||
-- at the camera it reads as clockwise/counter-clockwise on screen
|
||||
if self.rollAngle ~= 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateZ(self.rollAngle))
|
||||
end
|
||||
local k = self.scale
|
||||
if self.pulse then
|
||||
-- the click: a quick squash and back, more felt than seen
|
||||
local p = math.sin((self.pulse / PULSE_T) * math.pi) * 0.14
|
||||
m = Mat4.mul(m, Mat4.scale(k * (1 + p), k * (1 - p), k * (1 + p)))
|
||||
elseif k ~= 1 then
|
||||
m = Mat4.mul(m, Mat4.scale(k, k, k))
|
||||
end
|
||||
return m
|
||||
end
|
||||
|
||||
-- the hinge: the lid's own extra transform about the back of the equator
|
||||
local function lidMatrix(open)
|
||||
if open <= 0 then return nil end
|
||||
local a = -LID_OPEN * smooth(open)
|
||||
local hz = HINGE_Z * R
|
||||
return Mat4.mul(Mat4.translate(0, 0, hz),
|
||||
Mat4.mul(Mat4.rotateX(a), Mat4.translate(0, 0, -hz)))
|
||||
end
|
||||
|
||||
-- where the open mouth is, for aiming the capture beam
|
||||
function Pokeball:mouth()
|
||||
return self.pos[1], self.pos[2] + R * 0.4 * self.scale, self.pos[3]
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
--
|
||||
-- Assumes a live Voxel3D scene (between beginScene and endScene), exactly
|
||||
-- like Stadium.draw. Seams and glass are off for the duration: the ball is
|
||||
-- not on the voxel grid and does not wear the tileset atlas.
|
||||
local function eyeYaw(x, z)
|
||||
local eye = Voxel3D.eye
|
||||
if not eye then return 0 end
|
||||
return math.atan2(eye[1] - x, eye[3] - z)
|
||||
end
|
||||
|
||||
function Pokeball:draw(pull)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
local model = self:matrix()
|
||||
Voxel3D.draw(m.base, pal, model, pull)
|
||||
local lidM = lidMatrix(self.lid)
|
||||
Voxel3D.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model, pull)
|
||||
|
||||
-- the additive dressing: the open-mouth glow and the caught stars.
|
||||
-- Depth writes are off under "add" (Voxel3D.blend), so these can never
|
||||
-- punch holes for later draws.
|
||||
local anythingAdd = (self.glow > 0.05 and self.lid > 0.1) or self.stars
|
||||
if anythingAdd then
|
||||
Voxel3D.blend("add")
|
||||
if self.glow > 0.05 and self.lid > 0.1 then
|
||||
-- a pulsing octahedron of light standing in the mouth: two crossed
|
||||
-- cards read from every seat in the house
|
||||
local gx, gy, gz = self:mouth()
|
||||
local s = R * (1.1 + 0.25 * self.glow) * self.scale
|
||||
for i = 0, 1 do
|
||||
local card = Mat4.mul(Mat4.translate(gx, gy, gz),
|
||||
Mat4.mul(Mat4.rotateY(eyeYaw(gx, gz) + i * math.pi / 2),
|
||||
Mat4.scale(s, s, 1)))
|
||||
Voxel3D.draw(m.glow, pal, card, pull)
|
||||
end
|
||||
end
|
||||
if self.stars then
|
||||
for _, s in ipairs(self.stars) do
|
||||
if s.t > 0 and s.t < STAR_T then
|
||||
local t = s.t / STAR_T
|
||||
local rr = (R + 4.5 * t) * self.scale
|
||||
local sx = self.pos[1] + math.sin(s.th) * rr
|
||||
local sz = self.pos[3] + math.cos(s.th) * rr
|
||||
local sy = self.pos[2] + (R + 7 * t - 5 * t * t) * self.scale
|
||||
local sc = 1.1 * (1 - t)
|
||||
local card = Mat4.mul(Mat4.translate(sx, sy, sz),
|
||||
Mat4.mul(Mat4.rotateY(eyeYaw(sx, sz)),
|
||||
Mat4.mul(Mat4.rotateZ(TAU * t * 0.5),
|
||||
Mat4.scale(sc, sc, 1))))
|
||||
Voxel3D.draw(m.star, pal, card, pull)
|
||||
end
|
||||
end
|
||||
end
|
||||
Voxel3D.blend(nil)
|
||||
end
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- the capture beam: a crossed pair of additive cards stretched from the
|
||||
-- ball's mouth to the mon it is drinking in. Separate from draw() because
|
||||
-- the caller owns the far end and the fade.
|
||||
function Pokeball:drawBeam(tx, ty, tz, width, strength, pull)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
local x, y, z = self:mouth()
|
||||
local dx, dy, dz = tx - x, ty - y, tz - z
|
||||
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if len < 0.5 then return end
|
||||
dx, dy, dz = dx / len, dy / len, dz / len
|
||||
-- two perpendiculars to the beam axis
|
||||
local ux, uy, uz
|
||||
if math.abs(dy) < 0.94 then
|
||||
ux, uy, uz = -dz, 0, dx -- cross(d, worldUp), unnormalised
|
||||
local l = math.sqrt(ux * ux + uz * uz)
|
||||
ux, uz = ux / l, uz / l
|
||||
else
|
||||
ux, uy, uz = 1, 0, 0
|
||||
end
|
||||
local vx = dy * uz - dz * uy
|
||||
local vy = dz * ux - dx * uz
|
||||
local vz = dx * uy - dy * ux
|
||||
local w = (width or R) * (strength or 1)
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
Voxel3D.blend("add")
|
||||
-- the unit card is x -0.5..0.5, y 0..1: columns map its x to a
|
||||
-- perpendicular and its y to the full run of the axis
|
||||
local a = { ux * w, dx * len, vx, x,
|
||||
uy * w, dy * len, vy, y,
|
||||
uz * w, dz * len, vz, z,
|
||||
0, 0, 0, 1 }
|
||||
local b = { vx * w, dx * len, ux, x,
|
||||
vy * w, dy * len, uy, y,
|
||||
vz * w, dz * len, uz, z,
|
||||
0, 0, 0, 1 }
|
||||
Voxel3D.draw(m.glow, pal, a, pull)
|
||||
Voxel3D.draw(m.glow, pal, b, pull)
|
||||
Voxel3D.blend(nil)
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- ------- the sun's view
|
||||
--
|
||||
-- The same two shells under the same matrix, so the shadow on the ground is
|
||||
-- the pose the camera sees. The caller folds a term into the shadow
|
||||
-- signature while a ball is live (the sun pass is cached -- see
|
||||
-- BattleScene.shadowSignature) or this freezes on its first frame.
|
||||
function Pokeball:cast(shadowMap)
|
||||
if not self.visible then return end
|
||||
local m = meshesFor(self.ball)
|
||||
local pal = paletteTexture()
|
||||
if not (m and pal) then return end
|
||||
local model = self:matrix()
|
||||
shadowMap.draw(m.base, pal, model)
|
||||
local lidM = lidMatrix(self.lid)
|
||||
shadowMap.draw(m.lid, pal, lidM and Mat4.mul(model, lidM) or model)
|
||||
end
|
||||
|
||||
-- a term for the arena's cached shadow signature: quantised, so the cache
|
||||
-- only re-renders when the ball has visibly moved
|
||||
function Pokeball:signature()
|
||||
if not self.visible then return "" end
|
||||
return table.concat({ math.floor(self.pos[1] * 4), math.floor(self.pos[2] * 4),
|
||||
math.floor(self.pos[3] * 4), math.floor(self.lid * 8),
|
||||
self.wobbleT and math.floor(self.wobbleT * 30) or -1 },
|
||||
",")
|
||||
end
|
||||
|
||||
return Pokeball
|
||||
+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
|
||||
@@ -0,0 +1,178 @@
|
||||
-- SELECT on a row explains what it does.
|
||||
--
|
||||
-- ------- why this exists at all
|
||||
--
|
||||
-- Every setting in this mod has ALWAYS carried a paragraph of help. It goes
|
||||
-- into the schema handed to the mod manager (ModSetting:schema takes it), it
|
||||
-- has been written and kept up to date beside every row in main.lua's
|
||||
-- SETTINGS -- and nothing in the engine has ever drawn it. Not the OPTIONS
|
||||
-- menu, whose row is a label and a value and has no room for a third thing;
|
||||
-- not the mod manager's own page, which renders the same two lines. It was
|
||||
-- authored, structured, accurate prose sitting in a field with no reader.
|
||||
--
|
||||
-- So it gets one. A row on this mod's menus says what it IS on one line and
|
||||
-- what it is SET TO on the next, and SELECT says what that means -- which is
|
||||
-- the question a row like RENDER DIST or 2D-3D B cannot answer in eighteen
|
||||
-- characters however the label is worded.
|
||||
--
|
||||
-- SELECT rather than a button that already does something: A steps a setting,
|
||||
-- B leaves, and the d-pad moves. SELECT is free on a menu -- the mod's own
|
||||
-- SELECT hotkey is installed on OverworldController:handleInput, which only
|
||||
-- runs while the overworld is the top state, so a menu can have the button
|
||||
-- without taking anything from the map.
|
||||
--
|
||||
-- ------- the shape of it
|
||||
--
|
||||
-- The game's own dialogue box: drawn with Font.drawBox, so the border is the
|
||||
-- ROM's own glyphs and a mod-supplied font theme retextures this along with
|
||||
-- everything else (Font.BORDER) -- and anchored to the BOTTOM of the screen
|
||||
-- with the menu still visible above it, which is where this game has put
|
||||
-- every line of text anybody has ever read in it.
|
||||
--
|
||||
-- Sized to what it holds rather than to the screen. Each description is one
|
||||
-- sentence, so most of these are five or six tiles tall and the row being
|
||||
-- asked about is still on screen over the top of the box. A sentence long
|
||||
-- enough to overflow scrolls instead of growing past MAX_LINES, a line at a
|
||||
-- time on the d-pad -- which is a fallback, not the design: the answer to a
|
||||
-- description that needs scrolling is a shorter description.
|
||||
|
||||
-- the mod namespace (see main.lua)
|
||||
local V = ...
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local SettingsHelp = {}
|
||||
SettingsHelp.__index = SettingsHelp
|
||||
|
||||
-- NOT opaque: the menu stays drawn underneath, so the row being asked about
|
||||
-- is still on screen above the box. That is most of why the box is only as
|
||||
-- tall as it needs to be.
|
||||
SettingsHelp.isOpaque = false
|
||||
|
||||
-- The box spans the screen's twenty tiles and its border owns the outer ring,
|
||||
-- so text runs from tile 1. Seventeen columns rather than eighteen: tile 18
|
||||
-- is kept clear for the more-arrow, which would otherwise land on top of the
|
||||
-- last character of any line that filled the width.
|
||||
local COLS = 17
|
||||
local PEN_X = 8
|
||||
local SCREEN_ROWS = 18
|
||||
-- title, plus the body, plus the two border rows
|
||||
local CHROME_ROWS = 3
|
||||
-- A sentence needing more than this scrolls. Eight lines of seventeen is 136
|
||||
-- characters, which is a long sentence and a box two thirds up the screen.
|
||||
local MAX_LINES = 8
|
||||
|
||||
-- Break a string into lines that fit, on word boundaries. Unbounded, unlike
|
||||
-- StadiumScreen's -- that one is capping a save path to what a fixed plate can
|
||||
-- show, and this one is the whole point of the screen.
|
||||
local function wrapped(str, cols)
|
||||
cols = cols or COLS
|
||||
local lines, line = {}, nil
|
||||
for word in tostring(str or ""):gmatch("%S+") do
|
||||
local try = line and (line .. " " .. word) or word
|
||||
if #try <= cols then
|
||||
line = try
|
||||
else
|
||||
if line then lines[#lines + 1] = line end
|
||||
-- a word longer than the line is broken across lines rather than cut;
|
||||
-- nothing in the help text is that long today, but losing the end of a
|
||||
-- sentence silently is not a failure mode worth leaving open
|
||||
while #word > cols do
|
||||
lines[#lines + 1] = word:sub(1, cols)
|
||||
word = word:sub(cols + 1)
|
||||
end
|
||||
line = word
|
||||
end
|
||||
end
|
||||
if line then lines[#lines + 1] = line end
|
||||
return lines
|
||||
end
|
||||
|
||||
SettingsHelp.wrapped = wrapped
|
||||
|
||||
function SettingsHelp.new(game, title, body)
|
||||
return setmetatable({
|
||||
game = game,
|
||||
title = tostring(title or ""):gsub("%.%.$", ""),
|
||||
lines = wrapped(body),
|
||||
top = 0,
|
||||
}, SettingsHelp)
|
||||
end
|
||||
|
||||
-- How many body lines this box shows: all of them, unless there are more than
|
||||
-- a box is allowed to be tall.
|
||||
function SettingsHelp:bodyRows()
|
||||
return math.min(#self.lines, MAX_LINES)
|
||||
end
|
||||
|
||||
function SettingsHelp:maxTop()
|
||||
return math.max(0, #self.lines - self:bodyRows())
|
||||
end
|
||||
|
||||
-- Every button that could mean "done" closes it, including SELECT itself --
|
||||
-- the press that opened the box is the one a player is most likely to reach
|
||||
-- for to get rid of it. A is in there too: it steps a setting everywhere else
|
||||
-- on these menus, and stepping one you cannot see would be worse than an
|
||||
-- extra way out.
|
||||
local DISMISS = { "a", "b", "start", "select" }
|
||||
|
||||
function SettingsHelp:update()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
local maxTop = self:maxTop()
|
||||
-- the d-pad only does anything when there is something below the fold; a
|
||||
-- box showing its whole sentence has nowhere to go and says so by not
|
||||
-- moving
|
||||
if input:wasPressed("down") then
|
||||
self.top = math.min(maxTop, self.top + 1)
|
||||
return
|
||||
elseif input:wasPressed("up") then
|
||||
self.top = math.max(0, self.top - 1)
|
||||
return
|
||||
end
|
||||
for _, btn in ipairs(DISMISS) do
|
||||
if input:wasPressed(btn) then
|
||||
local stack = self.game.stack
|
||||
if self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
if stack and stack:top() == self then stack:pop() end
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SettingsHelp:draw()
|
||||
local body = self:bodyRows()
|
||||
local th = body + CHROME_ROWS
|
||||
local ty = SCREEN_ROWS - th -- anchored to the bottom of the screen
|
||||
Font.drawBox(0, ty, 20, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- the row's own name, so the box says what it is about even where it covers
|
||||
-- the row that was asked
|
||||
Font.draw(self.title, PEN_X, (ty + 1) * 8)
|
||||
for i = 1, body do
|
||||
local line = self.lines[self.top + i]
|
||||
if not line then break end
|
||||
Font.draw(line, PEN_X, (ty + 1 + i) * 8)
|
||||
end
|
||||
-- the same marker the options list uses for "there is more below this", so
|
||||
-- it means here what it means there
|
||||
if self.top < self:maxTop() then
|
||||
Font.drawCode(Theme.moreArrow, 144, (ty + th - 2) * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Game:draw stops at the first state that HAS this method, so without one the
|
||||
-- box would inherit whatever is underneath -- which is a menu of ours, whose
|
||||
-- answer happens to be right. Stated anyway: the reason that answer is right
|
||||
-- is not a property of this screen, and a future menu that paints something
|
||||
-- of its own would silently repaint this box with it.
|
||||
function SettingsHelp:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
return SettingsHelp
|
||||
@@ -0,0 +1,464 @@
|
||||
-- This mod's settings, in categories, on menus of their own.
|
||||
--
|
||||
-- ------- why the flat list had to end
|
||||
--
|
||||
-- Every setting used to be spliced straight into the engine's OPTIONS list,
|
||||
-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
|
||||
-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
|
||||
-- was four screens of scrolling inside a list that already carried twenty
|
||||
-- engine rows -- and a player looking for SHADOWS had to know it was in there
|
||||
-- somewhere, past the wireframe and the horizon bend.
|
||||
--
|
||||
-- The engine has no grouping to borrow: a row descriptor is
|
||||
-- { id, label, value, step, activate } and nothing else. No headers, no
|
||||
-- sections, no pages. What it DOES have is `activate`, and a state stack that
|
||||
-- any state may push onto -- which is how the engine's own MODS and CONTROLS
|
||||
-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
|
||||
--
|
||||
-- ------- how the split was chosen
|
||||
--
|
||||
-- Not invented here: the mod already sorted its own settings, in the `full`
|
||||
-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
|
||||
-- take away, and the reasoning written next to each one is always the same
|
||||
-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
|
||||
-- the diorama FULL is a preset for.
|
||||
--
|
||||
-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
|
||||
-- case to disappear under FULL: every child filters itself out and the
|
||||
-- category goes with them (see rows). PERFORMANCE is the three rows marked
|
||||
-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
|
||||
-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
|
||||
-- are the two features that are not about the look at all.
|
||||
--
|
||||
-- ------- what did NOT change
|
||||
--
|
||||
-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
|
||||
-- row id, so options.lua is byte-identical for a player who upgrades and
|
||||
-- changes nothing -- see lib/ModSetting.lua for why the key is the only
|
||||
-- identity a setting has. The hotkeys are untouched too, which is what makes
|
||||
-- the nesting affordable: a buried row is still one keypress away.
|
||||
|
||||
-- the mod namespace (see main.lua)
|
||||
local V = ...
|
||||
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local SettingsMenu = {}
|
||||
SettingsMenu.__index = SettingsMenu
|
||||
|
||||
-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
|
||||
-- covered, so there is no reason to pay for drawing it.
|
||||
SettingsMenu.isOpaque = true
|
||||
|
||||
SettingsMenu.ROOT = "root"
|
||||
SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."
|
||||
|
||||
-- Row ids live in a namespace of their own -- "menu." rather than a setting
|
||||
-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
|
||||
-- settings rows have carried since the beginning.
|
||||
function SettingsMenu.id(catId)
|
||||
return "DRAMATIC_SHAPE:menu." .. catId
|
||||
end
|
||||
|
||||
-- ------- the categories, in menu order
|
||||
--
|
||||
-- `summary` is the second line of the category's own row, the way MODS reads
|
||||
-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
|
||||
-- 3D-BTL for the battles, VR for the headset -- it says that setting's
|
||||
-- current rung, which is the thing a player actually wants to know without
|
||||
-- opening it. Where no single row speaks for the rest, it counts them, which
|
||||
-- is honest rather than arbitrary.
|
||||
SettingsMenu.CATEGORIES = {
|
||||
{ id = "world", label = "3D WORLD..",
|
||||
help = "The diorama itself: how far the world bends, how much of it is "
|
||||
.. "drawn, what the water does and what hour it is outdoors." },
|
||||
{ id = "battles", label = "BATTLES..",
|
||||
summary = function() return V.require("OverworldBattle").setting:valueLabel() end,
|
||||
help = "What a fight is drawn over, how it is framed, and how a ball is "
|
||||
.. "thrown." },
|
||||
{ id = "perf", label = "PERFORMANCE..",
|
||||
help = "What the look costs -- the three most expensive things in the "
|
||||
.. "frame after the geometry itself." },
|
||||
{ id = "vr", label = "VR..",
|
||||
summary = function() return V.require("VR").setting:valueLabel() end,
|
||||
help = "PCVR through OpenXR, and the one comfort setting that belongs to "
|
||||
.. "the headset alone." },
|
||||
}
|
||||
|
||||
-- ------- help for the rows that are not settings
|
||||
--
|
||||
-- The thirteen settings each carry their own paragraph in main.lua's SETTINGS,
|
||||
-- next to the row it explains. What is left is the two pipeline rows -- whose
|
||||
-- descriptors belong to the ENGINE, so there is nowhere in them to put this --
|
||||
-- and the ROM import, which is an action rather than a setting and has no
|
||||
-- SETTINGS entry to live in.
|
||||
local ROW_HELP = {
|
||||
["pipeline:voxel"] = "The overworld extruded into real geometry and walked "
|
||||
.. "by a 3D camera, with the numbered rungs its angle in degrees.",
|
||||
["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model "
|
||||
.. "look, sharp across the middle and softening above and below it.",
|
||||
["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 "
|
||||
.. "cartridge that 3D-BTL's STADIUM rungs need.",
|
||||
}
|
||||
|
||||
-- ------- what the menus are built from
|
||||
--
|
||||
-- SETTINGS lives in main.lua, next to the help text that goes with each row
|
||||
-- and the comments explaining every `when` and `full`. It is handed here
|
||||
-- rather than moved, so this file stays about PRESENTATION and that one stays
|
||||
-- the single place the mod's settings are declared.
|
||||
local settings = {}
|
||||
local pipelineRows = {}
|
||||
|
||||
function SettingsMenu.define(list)
|
||||
settings = list or {}
|
||||
end
|
||||
|
||||
-- What SELECT shows for a row: the setting's own paragraph out of SETTINGS,
|
||||
-- the category's out of CATEGORIES, or one of the three above for the rows
|
||||
-- that have nowhere else to keep it.
|
||||
--
|
||||
-- Looked up BY ID rather than hung on the row as a field, because two of
|
||||
-- these rows are the engine's own tables reused verbatim -- and annotating
|
||||
-- somebody else's table is how a mod ends up owning a field it never meant
|
||||
-- to. nil for a row with nothing to say, which SELECT reads as "no box".
|
||||
function SettingsMenu.helpFor(id)
|
||||
if ROW_HELP[id] then return ROW_HELP[id] end
|
||||
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
|
||||
if SettingsMenu.id(cat.id) == id then return cat.help end
|
||||
end
|
||||
for _, entry in ipairs(settings) do
|
||||
if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
|
||||
-- .rows), captured by the options hook on its way past and shown here instead
|
||||
-- of at the top level. Reused verbatim, tables and all: they persist in
|
||||
-- save.options.pipelines through their own step functions, and rebuilding
|
||||
-- them here would be a second implementation of a thing the engine already
|
||||
-- got right.
|
||||
function SettingsMenu.setPipelineRows(rows)
|
||||
pipelineRows = rows or {}
|
||||
end
|
||||
|
||||
-- ------- a step here has the same consequences as a step anywhere
|
||||
--
|
||||
-- Two of these settings PIN something else when they change: 3D-BTL holds
|
||||
-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
|
||||
-- DAYTIME at SYNC while it owns that row. Both used to happen because every
|
||||
-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
|
||||
-- pinning on its way past.
|
||||
--
|
||||
-- Nothing reruns that hook from in here, so the pin is asked for directly.
|
||||
-- main.lua supplies it, because WHICH values follow which is a question about
|
||||
-- the mod's settings and not about the menu they are on.
|
||||
local onChanged = nil
|
||||
|
||||
function SettingsMenu.setOnChanged(fn)
|
||||
onChanged = fn
|
||||
end
|
||||
|
||||
local function isFull()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
return V.require("VoxelState").isFull(Pipelines.level("voxel"))
|
||||
end
|
||||
|
||||
-- The one rule that decides whether a setting is on a menu, lifted unchanged
|
||||
-- from the options hook it used to live in.
|
||||
--
|
||||
-- FULL: a preset that owns the look, so the rows that describe the look go
|
||||
-- with it. 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 function offered(entry, full)
|
||||
return (entry.full or not full) and (not entry.when or entry.when())
|
||||
end
|
||||
|
||||
-- The rows of one category, or of the root menu. PURE -- no state, no stack,
|
||||
-- no side effects -- so a caller that only wants to know what is on a menu
|
||||
-- (a test, or the root menu asking whether a category has anything in it)
|
||||
-- does not have to push a screen to find out.
|
||||
function SettingsMenu.rows(catId, game)
|
||||
local full = isFull()
|
||||
local out = {}
|
||||
if catId == SettingsMenu.ROOT then
|
||||
for _, row in ipairs(pipelineRows) do
|
||||
-- FULL owns the blur exactly as it owns the wireframe and the horizon
|
||||
-- bend, so T-SHIFT comes off with them
|
||||
if not (full and row.id == "pipeline:tiltshift") then
|
||||
out[#out + 1] = row
|
||||
end
|
||||
end
|
||||
-- ------- settings that belong to no category
|
||||
--
|
||||
-- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level
|
||||
-- menu next to the pipeline rows. For a setting that is about the GAME
|
||||
-- rather than about one of the four things the categories are for --
|
||||
-- SHINY ODDS is the first -- burying it under a heading it does not
|
||||
-- belong to is worse than the flat list this menu was built to end.
|
||||
--
|
||||
-- Above the categories, because these are rows you CHANGE and those are
|
||||
-- rows you OPEN: everything with a value on it stays together at the top
|
||||
-- of the screen, and the "..." rows read as the way further in.
|
||||
for _, entry in ipairs(settings) do
|
||||
if entry.cat == SettingsMenu.ROOT and offered(entry, full) then
|
||||
out[#out + 1] = entry[1]:row()
|
||||
end
|
||||
end
|
||||
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
|
||||
local kids = SettingsMenu.rows(cat.id, game)
|
||||
-- An EMPTY category is not offered. This is the whole of what makes
|
||||
-- 3D WORLD disappear under FULL and VR disappear off Windows: no
|
||||
-- special case, just nothing left inside to open.
|
||||
if kids[1] then
|
||||
out[#out + 1] = {
|
||||
id = SettingsMenu.id(cat.id),
|
||||
label = cat.label,
|
||||
value = cat.summary
|
||||
or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
|
||||
activate = function(g)
|
||||
g.stack:push(SettingsMenu.new(g, cat.id))
|
||||
end,
|
||||
}
|
||||
end
|
||||
end
|
||||
-- ------- and the ROM import, last, on the top-level menu
|
||||
--
|
||||
-- An ACTION and not a setting: there is no rung to store, nothing for the
|
||||
-- mod manager's page to persist and nothing to restore on the next boot,
|
||||
-- so it is appended rather than living in SETTINGS.
|
||||
--
|
||||
-- On the ROOT menu rather than under the battles whose STADIUM rungs it
|
||||
-- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge
|
||||
-- and wait while it builds -- and a player who has been told to import a
|
||||
-- ROM should find the row where the mod begins, not two levels down a
|
||||
-- category they have no reason to open until it has worked. Last, because
|
||||
-- the categories are what the menu is FOR.
|
||||
local ok, importRow = pcall(function()
|
||||
return V.require("StadiumRomPick").row()
|
||||
end)
|
||||
if ok and importRow then out[#out + 1] = importRow end
|
||||
return out
|
||||
end
|
||||
for _, entry in ipairs(settings) do
|
||||
if entry.cat == catId and offered(entry, full) then
|
||||
out[#out + 1] = entry[1]:row()
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- red ink for the mod's row on the OPTIONS menu
|
||||
--
|
||||
-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
|
||||
-- nobody spends an afternoon on it. Twice over:
|
||||
--
|
||||
-- 1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
|
||||
-- and Font.drawCode is a plain love.graphics.draw, which LOVE tints
|
||||
-- MULTIPLICATIVELY. black x red is black.
|
||||
-- 2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
|
||||
-- RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
|
||||
-- red pixel lands in c0, the LIGHTEST slot: white text on white paper.
|
||||
--
|
||||
-- What actually happens on this screen is that setColor picks a SHADE and the
|
||||
-- zone palette picks the COLOR. Black text is c3 and the white box fill is
|
||||
-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
|
||||
-- The engine does the same thing for the party menu's HP bars
|
||||
-- (src/ui/PartyMenu.lua), which is the pattern this follows.
|
||||
SettingsMenu.INK = { 255, 0, 0 }
|
||||
|
||||
-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
|
||||
-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
|
||||
-- Blue and Yellow ship different MEWMON tables, and this way the paper under
|
||||
-- the row is the same white as the row above it in all three.
|
||||
function SettingsMenu.redPalette(data)
|
||||
local base = PaletteFX.pal(data, "MEWMON")
|
||||
if not base then return nil end
|
||||
local out = { base[1], base[2], base[3], base[4] }
|
||||
-- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
|
||||
-- it the ink is the first slot and the paper the last. Put the red where it
|
||||
-- will land on the INK either way: without this the row draws as a solid
|
||||
-- red block with white letters cut out of it.
|
||||
--
|
||||
-- The other modes need nothing. OG, OG INV and CLASSIC discard the table
|
||||
-- outright and substitute their own, so the row simply draws monochrome --
|
||||
-- which is correct: the player asked for a screen with no colors in it.
|
||||
out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
|
||||
return out
|
||||
end
|
||||
|
||||
-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
|
||||
-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
|
||||
-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
|
||||
-- 19 are the box's own borders and tile 1 is the cursor, and all three are
|
||||
-- black glyphs that would turn red along with the text if the band spanned
|
||||
-- the whole row.
|
||||
function SettingsMenu.rowZone(data, slot)
|
||||
local pal = SettingsMenu.redPalette(data)
|
||||
if not pal then return nil end
|
||||
local top = (slot - 1) * 4 + 1
|
||||
return PaletteFX.zone(pal, 2, top, 18, top + 1)
|
||||
end
|
||||
|
||||
-- ------- the screen
|
||||
--
|
||||
-- Deliberately NOT an OptionsMenu instance, though the update loop below is
|
||||
-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
|
||||
-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
|
||||
-- level or the battle rows change -- which would replace a submenu's rows
|
||||
-- with the whole top-level OPTIONS list under the player's cursor. A state of
|
||||
-- our own cannot be caught by it.
|
||||
--
|
||||
-- It still renders through OptionRows, so it is the same four boxes, the same
|
||||
-- cursor and the same bottom line as every other menu in the game.
|
||||
function SettingsMenu.new(game, catId)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
cat = catId or SettingsMenu.ROOT,
|
||||
index = 1,
|
||||
scroll = 0,
|
||||
}, SettingsMenu)
|
||||
self.rows = SettingsMenu.rows(self.cat, game)
|
||||
self.sig = SettingsMenu.signature(self.rows)
|
||||
return self
|
||||
end
|
||||
|
||||
function SettingsMenu.signature(rows)
|
||||
local ids = {}
|
||||
for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
|
||||
return table.concat(ids, "\1")
|
||||
end
|
||||
|
||||
-- The bottom line is the only place on this screen to say anything that is
|
||||
-- not a row: OptionRows' four boxes fill everything above it and there is no
|
||||
-- header slot. It spends that line on the two buttons that are not obvious.
|
||||
--
|
||||
-- It used to carry the category's NAME instead, for orientation. The hint
|
||||
-- won: a binding nobody knows about is worth nothing, and where the player is
|
||||
-- was just answered by the row they pressed A on. Sixteen characters of the
|
||||
-- eighteen the line has, which is also why the name could not stay -- "BACK:
|
||||
-- PERFORMANCE" is seventeen on its own.
|
||||
SettingsMenu.BACK_LABEL = "B BACK SEL HELP"
|
||||
|
||||
function SettingsMenu:backLabel()
|
||||
return SettingsMenu.BACK_LABEL
|
||||
end
|
||||
|
||||
-- A category's contents can change while the player is looking at them: 3D-BTL
|
||||
-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
|
||||
-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
|
||||
-- actually differs, so the common case -- every other rung of every other row
|
||||
-- -- costs one string compare.
|
||||
function SettingsMenu:refresh()
|
||||
local rows = SettingsMenu.rows(self.cat, self.game)
|
||||
local sig = SettingsMenu.signature(rows)
|
||||
if sig == self.sig then return end
|
||||
-- Follow the row the cursor was ON rather than the slot it was in: a row
|
||||
-- can appear ABOVE the one just used, which would otherwise slide the
|
||||
-- cursor onto its neighbour. The bottom line follows itself.
|
||||
local wasBack = self.index > #self.rows
|
||||
local wasOn = self.rows[self.index] and self.rows[self.index].id
|
||||
self.rows, self.sig = rows, sig
|
||||
self.index, self.scroll = 1, 0
|
||||
if wasBack then
|
||||
self.index = #rows + 1
|
||||
else
|
||||
for i, row in ipairs(rows) do
|
||||
if wasOn and row.id == wasOn then self.index = i break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function pop(self)
|
||||
local stack = self.game and self.game.stack
|
||||
if self.game and self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end
|
||||
if stack and stack:top() == self then stack:pop() end
|
||||
end
|
||||
|
||||
-- The engine's own options loop (src/ui/OptionsMenu.update), including its
|
||||
-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
|
||||
-- alone, and the bottom line is a synthetic index past the end of the list
|
||||
-- rather than a row, so nothing a category contains can orphan the way out.
|
||||
function SettingsMenu:update()
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
local rows = self.rows
|
||||
local back = #rows + 1
|
||||
local changed = false
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index - 1
|
||||
if self.index < 1 then self.index = back end
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index + 1
|
||||
if self.index > back then self.index = 1 end
|
||||
elseif input:wasPressed("left") or input:wasPressed("right")
|
||||
or input:wasPressed("a") then
|
||||
local dir = input:wasPressed("left") and -1 or 1
|
||||
local row = rows[self.index]
|
||||
if row and row.activate then
|
||||
if input:wasPressed("a") then row.activate(self.game) end
|
||||
elseif row and row.step then
|
||||
changed = row.step(self.game, dir) and true or false
|
||||
elseif input:wasPressed("a") then
|
||||
pop(self)
|
||||
return
|
||||
end
|
||||
elseif input:wasPressed("select") then
|
||||
-- SELECT explains the row the cursor is on. Every row on these menus has
|
||||
-- something to say -- the settings have carried a paragraph each since
|
||||
-- they were written, and nothing has ever drawn it (see SettingsHelp) --
|
||||
-- but a row that does not is simply left alone rather than opening an
|
||||
-- empty box.
|
||||
local row = rows[self.index]
|
||||
local help = row and SettingsMenu.helpFor(row.id)
|
||||
if help and self.game.stack then
|
||||
self.game.stack:push(
|
||||
V.require("SettingsHelp").new(self.game, row.label, help))
|
||||
end
|
||||
return
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
-- B and START both, like every other menu -- and one level only: this
|
||||
-- pops US, leaving the OPTIONS menu underneath exactly as the player
|
||||
-- left it, with its own onCancel still to fire when they leave THAT.
|
||||
pop(self)
|
||||
return
|
||||
end
|
||||
if changed then
|
||||
-- before the rebuild, not after: pinning can itself change which rows are
|
||||
-- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
|
||||
-- it), and refresh has to see the settled answer
|
||||
if onChanged then pcall(onChanged, self.game) end
|
||||
if self.game.writeOptions then
|
||||
pcall(self.game.writeOptions, self.game)
|
||||
end
|
||||
end
|
||||
self:refresh()
|
||||
self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
|
||||
#self.rows + 1)
|
||||
end
|
||||
|
||||
function SettingsMenu:draw()
|
||||
OptionRows.draw(self.game, self.rows, self.index, self.scroll,
|
||||
self:backLabel(), #self.rows + 1)
|
||||
end
|
||||
|
||||
-- REQUIRED, even though nothing here is red.
|
||||
--
|
||||
-- Game:draw walks the stack from the top and stops at the first state that
|
||||
-- HAS this method, not the first that answers something. Without one of our
|
||||
-- own the walk would fall through to the OPTIONS menu underneath -- whose
|
||||
-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
|
||||
-- zone is addressed by SLOT, so it would land on whatever this menu happens
|
||||
-- to be showing in the same box.
|
||||
--
|
||||
-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
|
||||
function SettingsMenu:sgbPalettes(game)
|
||||
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
return SettingsMenu
|
||||
+57
-6
@@ -130,17 +130,23 @@ local SHADER = [[
|
||||
}
|
||||
#endif
|
||||
#ifdef PIXEL
|
||||
uniform float sprite; // 1 while the CAST is being drawn; see ShadowMap.sprites
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
// the same alpha discard the main pass uses: a sprite card casts its
|
||||
// silhouette, not its 16x16 bounding box
|
||||
if (Texel(tex, tc).a < 0.5) discard;
|
||||
// pack into two channels: the high byte in red, the low in green
|
||||
// pack into two channels: the high byte in red, the low in green.
|
||||
// Blue says WHAT cast this, which costs a channel that was zero anyway
|
||||
// and lets a surface decline one kind of caster -- water does, for the
|
||||
// people (see Water's sunLit).
|
||||
float d = clamp(vDepth, 0.0, 1.0) * 255.0;
|
||||
return vec4(floor(d) / 255.0, fract(d), 0.0, 1.0);
|
||||
return vec4(floor(d) / 255.0, fract(d), sprite, 1.0);
|
||||
}
|
||||
#endif
|
||||
]]
|
||||
|
||||
ShadowMap._source = function() return SHADER end -- named for the suite
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
local canvas = nil -- nil = untried, false = unavailable
|
||||
local canvasRes = 0 -- the edge `canvas` was made at
|
||||
@@ -180,7 +186,7 @@ end
|
||||
local function getCanvas(res)
|
||||
if canvas == false then return nil 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
|
||||
canvas = false
|
||||
return nil
|
||||
@@ -211,10 +217,30 @@ local function getBlank()
|
||||
return blank or nil
|
||||
end
|
||||
|
||||
-- Whether the player asked for shadows at all (the SHADOWS row, see
|
||||
-- lib/Shadows). Asked through a pcall because this module is loaded by
|
||||
-- probes and by the suite with no mod namespace around it, where the answer
|
||||
-- is simply yes.
|
||||
--
|
||||
-- ONE gate for both halves of the module -- can the pass run, and is there
|
||||
-- a map to read -- because they must never disagree: available() alone
|
||||
-- would leave the LAST map standing (`ready` is still true), and every
|
||||
-- surface would go on wearing shadows frozen in the pose the row was
|
||||
-- switched off in.
|
||||
function ShadowMap.wanted()
|
||||
local ok, on = pcall(function() return V.require("Shadows").enabled() end)
|
||||
return (not ok) or on
|
||||
end
|
||||
|
||||
-- Whether the sun pass can run at all. False headless, without shaders, or
|
||||
-- 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 -- and false with the row off,
|
||||
-- where nothing stands in (see lib/Shadows).
|
||||
function ShadowMap.available()
|
||||
if not ShadowMap.wanted() then return false end
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" then
|
||||
return false
|
||||
end
|
||||
if not (love.graphics and love.graphics.newCanvas
|
||||
and love.graphics.setDepthMode) then
|
||||
return false
|
||||
@@ -232,9 +258,12 @@ function ShadowMap.texture()
|
||||
return getBlank()
|
||||
end
|
||||
|
||||
-- True while the map holds a frame the main pass can read.
|
||||
-- True while the map holds a frame the main pass can read. The row's OFF
|
||||
-- lands here as well as on available(): a map drawn a frame ago is still in
|
||||
-- the canvas, and every reader (the scene shader's sunDark, the water's,
|
||||
-- the forest's beams) hangs off this one answer.
|
||||
function ShadowMap.active()
|
||||
return ready and canvas ~= nil and canvas ~= false
|
||||
return ready and canvas ~= nil and canvas ~= false and ShadowMap.wanted()
|
||||
end
|
||||
|
||||
-- The direction the light TRAVELS, normalized. The shear is the shadow a
|
||||
@@ -440,6 +469,9 @@ function ShadowMap.begin(cx, cy, vw, vh)
|
||||
love.graphics.setShader(sh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP)
|
||||
-- the world until a cast pass says otherwise, reset per pass so one that
|
||||
-- forgot to put it back cannot leak into the next map's terrain
|
||||
pcall(sh.send, sh, "sprite", 0)
|
||||
drawing = true
|
||||
ready = false
|
||||
return true
|
||||
@@ -448,6 +480,25 @@ end
|
||||
-- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward
|
||||
-- pull, which is a trick for the VIEW's depth buffer and would drag a
|
||||
-- shadow off whatever throws it.
|
||||
-- Whether what is drawn next is one of the CAST -- a walker, an authored
|
||||
-- figure, a battle's Pokemon -- rather than part of the world. false for the
|
||||
-- length of such a pass, true to put it back.
|
||||
--
|
||||
-- The map records it per texel (the shader's blue channel) so a surface can
|
||||
-- decline that kind of caster, and exactly one does: water. A character
|
||||
-- standing at a lake's edge threw a hard cut-out of its own sprite across
|
||||
-- the surface, which on something showing the sky and the shoreline reads as
|
||||
-- a sticker rather than as a shadow in the water. Everything else -- ground,
|
||||
-- roofs, ledges, the characters themselves -- still takes them.
|
||||
--
|
||||
-- Sent rather than branched, so a caller that forgets to put it back only
|
||||
-- mislabels casters rather than losing them; begin() resets it per pass.
|
||||
function ShadowMap.sprites(on)
|
||||
if not drawing then return end
|
||||
local sh = getShader()
|
||||
if sh then pcall(sh.send, sh, "sprite", on and 1 or 0) end
|
||||
end
|
||||
|
||||
function ShadowMap.draw(mesh, texture, model)
|
||||
if not (drawing and mesh) then return end
|
||||
local sh = getShader()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Voxel world mode: whether the sun casts at all.
|
||||
--
|
||||
-- lib/ShadowMap renders the whole scene a second time from the light every
|
||||
-- frame the view or a pose changes, at up to 2048 squared, and every
|
||||
-- surface in the main pass then takes four taps at it. That is the single
|
||||
-- most expensive thing this mode does after the geometry itself -- and on a
|
||||
-- phone, or an old laptop, it is the difference between the diorama running
|
||||
-- and the diorama stuttering. So it gets a row.
|
||||
--
|
||||
-- OFF means OFF, not "fall back": VoxelScene keeps flat decal shadows for a
|
||||
-- driver that cannot make the map (see Voxel3D.beginShadows), and those are
|
||||
-- a stand-in for a machine that wanted shadows and could not have them.
|
||||
-- A player who has just switched them off wants no shadow under anybody,
|
||||
-- which is what this row gives -- see ShadowMap.wanted, the one gate both
|
||||
-- halves hang off.
|
||||
--
|
||||
-- This file owns the toggle rather than the drawing: the value, where it
|
||||
-- persists, and the row the player finds it on -- exactly as VoxelGrid does
|
||||
-- for the wireframe.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local Shadows = {}
|
||||
|
||||
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
|
||||
-- OPTIONS and the mod manager's own settings page for this mod
|
||||
Shadows.KEY = "shadows"
|
||||
Shadows.LABEL = "SHADOWS"
|
||||
|
||||
-- ON is values[1] and so the default: cast shadows are what the mode is
|
||||
-- for as much as the geometry is -- a world where a building throws
|
||||
-- nothing reads as flat however many voxels it is made of. The row is for
|
||||
-- the machine that cannot carry them, not a look anybody is choosing.
|
||||
Shadows.setting = ModSetting.new(Shadows.KEY, Shadows.LABEL,
|
||||
{ true, false }, { "ON", "OFF" })
|
||||
|
||||
function Shadows.enabled()
|
||||
return Shadows.setting:get() and true or false
|
||||
end
|
||||
|
||||
function Shadows.set(enabled, game)
|
||||
return Shadows.setting:setIndex(enabled and 1 or 2, game)
|
||||
end
|
||||
|
||||
function Shadows.toggle(game)
|
||||
return Shadows.setting:cycle(game)
|
||||
end
|
||||
|
||||
function Shadows.sync(value)
|
||||
Shadows.setting:sync(value and true or false)
|
||||
end
|
||||
|
||||
function Shadows.row()
|
||||
return Shadows.setting:row()
|
||||
end
|
||||
|
||||
return Shadows
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
-- Shiny Pokemon: the one fact, and everywhere that asks it.
|
||||
--
|
||||
-- WHAT MAKES A MON SHINY HERE IS ITS DVs, and nothing else. Gen 1 has no
|
||||
-- shininess of its own, but it does have the four DVs Gen 2 later read to
|
||||
-- decide it, and the engine already ships that reading:
|
||||
-- src/pokemon/Stats.lua:90 isShiny(dvs) -- Defense, Speed and Special all
|
||||
-- exactly 10, Attack one of 2/3/6/7/10/11/14/15. The engine's own comment
|
||||
-- calls it "the RBY virtual shiny" and says it is there for indicator mods.
|
||||
-- This is that mod.
|
||||
--
|
||||
-- Deriving rather than storing is the whole design, and it buys a great
|
||||
-- deal:
|
||||
--
|
||||
-- * It persists for free. DVs are already in every save, every PC box,
|
||||
-- every trade. No new save field, no migration, and a save made before
|
||||
-- this mod was installed already HAS shiny Pokemon in it -- they were
|
||||
-- always there, nothing was ever drawn differently.
|
||||
-- * It survives evolution. Evolution.apply recalculates stats from the
|
||||
-- same dvs table and never touches it (src/pokemon/Evolution.lua:99),
|
||||
-- so a shiny Bulbasaur is a shiny Venusaur without being told.
|
||||
-- * It cannot desync. A flag stored beside the DVs is a second copy of
|
||||
-- the truth, and two copies drift -- most cruelly across a trade or a
|
||||
-- box deposit, where the mon travels and the sidecar does not.
|
||||
-- * PKHeX and the Gen 2 games agree with us, because it is their rule.
|
||||
--
|
||||
-- The odds, though, are ours to set, and that is the one thing DVs alone
|
||||
-- cannot give: random DVs land on that pattern 1/16 * 1/16 * 1/16 * 8/16 =
|
||||
-- exactly 1/8192, the classic rate, and there is no dial on it. So the roll
|
||||
-- happens at encounter time and its VERDICT IS WRITTEN BACK INTO THE DVs
|
||||
-- (forceShiny/forceCommon below). The mon does not carry a flag saying it
|
||||
-- is shiny; it is made genuinely shiny by the game's own formula, and every
|
||||
-- later reader -- ours, the engine's, a future mod's, PKHeX's -- reaches the
|
||||
-- same answer without knowing we were involved.
|
||||
--
|
||||
-- mon.shiny is maintained too, but it is a CACHE and never the source: see
|
||||
-- Shiny.mark.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
-- allowlisted for mods by name -- src/mods/Loader.lua:71 lists
|
||||
-- src.pokemon.Stats precisely so an indicator mod can call isShiny
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local Shiny = {}
|
||||
|
||||
-- ------- the odds
|
||||
--
|
||||
-- One in ODDS_DENOM. The default is 8192 because that is what random DVs
|
||||
-- already produce, so a player who never changes it gets the canonical rate
|
||||
-- and the canonical feel -- this mod's default is not a buff.
|
||||
--
|
||||
-- The roll is made EXACT rather than additive. A naive implementation rolls
|
||||
-- 1/N and forces shiny on a hit, but leaves the natural 1/8192 in place on a
|
||||
-- miss, so the true rate is N and 8192 in parallel -- indistinguishable at
|
||||
-- the default and quietly wrong at every other setting (at 1/100 you would
|
||||
-- ship 1/99.99, and at 1/20000 you could never go rarer than 1/8192 no
|
||||
-- matter what you set). forceCommon on a miss closes that: the rate is what
|
||||
-- the number says.
|
||||
Shiny.ODDS_DENOM = 8192
|
||||
|
||||
-- ------- the row the player cycles
|
||||
--
|
||||
-- A ladder that HALVES, so every step is exactly "twice as often as the one
|
||||
-- above it" and the label says the whole truth -- 1:8192 down to 1:1. The
|
||||
-- rate is what the number says, not an approximation of it, because the
|
||||
-- miss branch of decide() closes the natural 1/8192 (see above); a rung of
|
||||
-- 1:2 really is every other encounter.
|
||||
--
|
||||
-- values[1] is 8192: ModSetting treats the first rung as both the DEFAULT
|
||||
-- and the fallback for an unreadable or unrecognised stored value, so the
|
||||
-- canonical rate is what a player who never opens the menu gets and what a
|
||||
-- corrupted options.lua comes back to.
|
||||
--
|
||||
-- No rung RARER than 8192. The mod's promise is that its default is not a
|
||||
-- change to the game; making the game harder than it ships is a different
|
||||
-- promise and nobody asked for it.
|
||||
local ODDS = { 8192, 4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1 }
|
||||
|
||||
local ODDS_LABELS = {}
|
||||
for i, n in ipairs(ODDS) do ODDS_LABELS[i] = "1:" .. n end
|
||||
|
||||
Shiny.setting = ModSetting.new("shinyOdds", "SHINY ODDS", ODDS, ODDS_LABELS)
|
||||
|
||||
-- ------- the setting is PULLED, not pushed
|
||||
--
|
||||
-- decide() asks this every roll rather than the menu telling us when it
|
||||
-- changed. Two writers exist -- the OPTIONS row and the mod manager's own
|
||||
-- settings page -- and only the first has a change hook to hang on; the
|
||||
-- manager writes through mod.options and calls ModSetting:sync, which
|
||||
-- notifies nothing. Pulling is the only way both are seen, and the cost is
|
||||
-- a table read on an event that happens once per encounter.
|
||||
--
|
||||
-- ODDS_DENOM stays the live value and is written through on every ask, so
|
||||
-- anything already reading that field keeps reading the truth.
|
||||
local pinned = false
|
||||
|
||||
function Shiny.odds()
|
||||
if not pinned then
|
||||
local ok, value = pcall(Shiny.setting.get, Shiny.setting)
|
||||
local n = ok and tonumber(value)
|
||||
if n and n >= 1 then Shiny.ODDS_DENOM = math.floor(n) end
|
||||
end
|
||||
return Shiny.ODDS_DENOM
|
||||
end
|
||||
|
||||
-- Set the denominator BY HAND, which also PINS it: a driver or a test that
|
||||
-- has asked for 1:1 means it, and must not have the next roll quietly put
|
||||
-- back to whatever the player left on the menu. Nothing in the game calls
|
||||
-- this -- the row is how a player changes the rate.
|
||||
--
|
||||
-- Guards the degenerate values because a 0 or a negative here would
|
||||
-- divide-by-zero or make every encounter shiny by accident rather than by
|
||||
-- choice; 1 (always shiny) stays reachable because it is genuinely useful
|
||||
-- for walking the whole model set.
|
||||
function Shiny.setOdds(denom)
|
||||
denom = tonumber(denom)
|
||||
if not denom or denom < 1 then return Shiny.ODDS_DENOM end
|
||||
Shiny.ODDS_DENOM = math.floor(denom)
|
||||
pinned = true
|
||||
return Shiny.ODDS_DENOM
|
||||
end
|
||||
|
||||
-- Hand the row back control, for a test that pinned the odds and wants the
|
||||
-- setting to mean something again afterwards.
|
||||
function Shiny.unpinOdds()
|
||||
pinned = false
|
||||
return Shiny.odds()
|
||||
end
|
||||
|
||||
-- ------- reading it
|
||||
|
||||
-- The eight Attack DVs that satisfy the Gen 2 pattern, in order. Kept as a
|
||||
-- list as well as the engine's set because forceShiny has to CHOOSE one and
|
||||
-- wants the nearest, not just any.
|
||||
local SHINY_ATK = { 2, 3, 6, 7, 10, 11, 14, 15 }
|
||||
|
||||
-- The HP DV is not free: Gen 1 derives it from the low bit of each of the
|
||||
-- other four (src/pokemon/Stats.lua:19). Any write to the four must
|
||||
-- recompute it, or the mon ends up with an HP stat the real game could
|
||||
-- never produce -- which is exactly what a save inspector flags as illegal.
|
||||
local function syncHpDv(dvs)
|
||||
dvs.hp = (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 +
|
||||
(dvs.speed % 2) * 2 + (dvs.special % 2)
|
||||
return dvs
|
||||
end
|
||||
|
||||
-- The single question. Everything visual in this mod routes here.
|
||||
function Shiny.isShiny(mon)
|
||||
if type(mon) ~= "table" then return false end
|
||||
return Stats.isShiny(mon.dvs) == true
|
||||
end
|
||||
|
||||
-- ------- writing it
|
||||
|
||||
-- Make these DVs satisfy the pattern, moving them as little as it allows.
|
||||
--
|
||||
-- Defense, Speed and Special have exactly one legal value each, so they are
|
||||
-- simply pinned. Attack has eight, and the nearest one to whatever was
|
||||
-- rolled is chosen -- a mon rolled at Attack 15 keeps 15, one rolled at 0
|
||||
-- becomes 2. That is not cosmetic: DVs are stats, and a shiny encounter
|
||||
-- should not also be a stat reroll any larger than the pattern demands.
|
||||
local function forceShiny(dvs)
|
||||
local want, best, bestd = dvs.attack or 0, SHINY_ATK[1], nil
|
||||
for _, v in ipairs(SHINY_ATK) do
|
||||
local d = math.abs(v - want)
|
||||
if not bestd or d < bestd then bestd, best = d, v end
|
||||
end
|
||||
dvs.attack = best
|
||||
dvs.defense, dvs.speed, dvs.special = 10, 10, 10
|
||||
return syncHpDv(dvs)
|
||||
end
|
||||
|
||||
-- Make these DVs NOT satisfy the pattern, moving them as little as
|
||||
-- possible: one step on Special is enough to break it, and Special is the
|
||||
-- choice because in Gen 1 it is a single stat rather than the two Gen 2
|
||||
-- split it into, so the disturbance stays inside one number.
|
||||
--
|
||||
-- Only ever reached by a mon that rolled non-shiny and happened to be shiny
|
||||
-- by luck, which is 1/8192 of the time -- so this touches almost nothing,
|
||||
-- and what it does touch it moves by one point.
|
||||
local function forceCommon(dvs)
|
||||
if (dvs.special or 0) == 10 then
|
||||
dvs.special = 9
|
||||
elseif (dvs.defense or 0) == 10 then
|
||||
dvs.defense = 9
|
||||
end
|
||||
return syncHpDv(dvs)
|
||||
end
|
||||
|
||||
-- mon.shiny: the cache.
|
||||
--
|
||||
-- The requirement is a flag ON the Pokemon, and this is it -- but it is
|
||||
-- written from the DVs every time we touch a mon, never read as the truth.
|
||||
-- Keeping it one-directional is what stops it becoming the second copy the
|
||||
-- header warns about: if it ever disagrees with the DVs, the DVs win and
|
||||
-- this is overwritten. It exists so other code -- and a save inspector, and
|
||||
-- a companion mod -- can ask the cheap question without importing Stats.
|
||||
function Shiny.mark(mon)
|
||||
if type(mon) ~= "table" then return false end
|
||||
local is = Shiny.isShiny(mon)
|
||||
mon.shiny = is or nil -- nil rather than false: absent keeps saves clean
|
||||
return is
|
||||
end
|
||||
|
||||
-- Recalculate the stats a DV write invalidated.
|
||||
--
|
||||
-- Split out because both decide() and set() move DVs, and a mon left
|
||||
-- carrying stats computed from its old DVs is wrong in the only way the
|
||||
-- player can actually see: its HP bar.
|
||||
local function restat(mon)
|
||||
if not (mon.level and mon.species) then return end
|
||||
local ok, data = pcall(require, "src.core.Data")
|
||||
local def = ok and data and data.pokemon and data.pokemon[mon.species]
|
||||
if not def then return end
|
||||
local wasFull = mon.hp and mon.stats and mon.hp >= (mon.stats.hp or 0)
|
||||
mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp)
|
||||
-- A wild mon appears at full health, and a mon that WAS full stays full:
|
||||
-- recomputing max HP without following it here would put a freshly
|
||||
-- encountered mon on the field at less than full from its first frame.
|
||||
-- A wounded mon keeps its damage, clamped to the new maximum.
|
||||
if mon.hp then
|
||||
mon.hp = wasFull and mon.stats.hp or math.min(mon.hp, mon.stats.hp)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- our own randomness
|
||||
--
|
||||
-- A PRIVATE stream, not love.math.random, and that is deliberate.
|
||||
--
|
||||
-- The game's RNG is a shared sequence: damage rolls, crits, encounter
|
||||
-- slots and DV generation all draw from it in a fixed order. Taking a draw
|
||||
-- out of it for a shiny check would shift every later draw, so installing
|
||||
-- this mod would quietly change the outcome of fights that have nothing to
|
||||
-- do with shininess -- and the manifest promises `affects_link = false`,
|
||||
-- which a shifted stream would make untrue the moment two machines
|
||||
-- disagreed about whose turn consumed what.
|
||||
--
|
||||
-- Seeded off the clock rather than the save, because shininess is a fact
|
||||
-- about the encounter and not about the file: re-loading a save to re-roll
|
||||
-- a Pokemon is the hunt, and a stream keyed to the save would hand back the
|
||||
-- same answer every time.
|
||||
local stream = nil
|
||||
|
||||
local function roll(n)
|
||||
if not stream then
|
||||
if love and love.math and love.math.newRandomGenerator then
|
||||
stream = love.math.newRandomGenerator(os.time(), os.clock() * 1e6)
|
||||
else
|
||||
-- headless (tests): math.random is nobody's shared sequence there
|
||||
stream = { random = function(_, a, b) return math.random(a, b) end }
|
||||
end
|
||||
end
|
||||
return stream:random(1, n)
|
||||
end
|
||||
|
||||
-- Decide a freshly-built mon, in place.
|
||||
--
|
||||
-- rng may be passed to pin the verdict -- a test hands us a stub. Left nil,
|
||||
-- the private stream above is used.
|
||||
function Shiny.decide(mon, rng)
|
||||
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
||||
-- same shape as love.math.random(lo, hi), so a caller can pass that or a
|
||||
-- stub and the call below reads identically either way
|
||||
rng = rng or function(_lo, hi) return roll(hi) end
|
||||
local hit = rng(1, Shiny.odds()) == 1
|
||||
|
||||
if hit then
|
||||
forceShiny(mon.dvs)
|
||||
restat(mon)
|
||||
elseif Stats.isShiny(mon.dvs) then
|
||||
forceCommon(mon.dvs)
|
||||
restat(mon)
|
||||
end
|
||||
return Shiny.mark(mon)
|
||||
end
|
||||
|
||||
-- Force a specific verdict: for tests, and for a scripted gift mon that
|
||||
-- wants to be shiny on purpose.
|
||||
function Shiny.set(mon, on)
|
||||
if type(mon) ~= "table" or type(mon.dvs) ~= "table" then return false end
|
||||
if on then forceShiny(mon.dvs) else forceCommon(mon.dvs) end
|
||||
restat(mon)
|
||||
return Shiny.mark(mon)
|
||||
end
|
||||
|
||||
return Shiny
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Where shininess enters the game, and where it is shown.
|
||||
--
|
||||
-- ------- one seam decides it
|
||||
--
|
||||
-- Every Pokemon the player can ever own is built by Pokemon.new
|
||||
-- (src/pokemon/Pokemon.lua:60). There are five callers and they are the
|
||||
-- whole surface:
|
||||
--
|
||||
-- BattleState.lua:569 the wild encounter
|
||||
-- BattleState.lua:659 a trainer's party
|
||||
-- BattleState.lua:785 the level-5 stand-in the Oak battle builds
|
||||
-- Commands.lua:656 a gift or a starter
|
||||
-- Commands.lua:969 an in-game trade
|
||||
--
|
||||
-- So the roll goes THERE rather than on the encounter hooks. Two reasons,
|
||||
-- and the second is decisive:
|
||||
--
|
||||
-- * encounter.roll and encounter.species fire before the mon exists --
|
||||
-- they carry {species, level} and nothing to write a verdict onto.
|
||||
-- * makeBattler bakes mon.sprite INSIDE newWild
|
||||
-- (src/battle/BattleState.lua:455-461), before battle.started is
|
||||
-- emitted. A verdict applied at battle.started is already too late for
|
||||
-- the sprite the fight will draw.
|
||||
--
|
||||
-- Wrapping the constructor puts the decision before every one of those, and
|
||||
-- picks up gift mons, starters and trades for free rather than needing a
|
||||
-- seam each.
|
||||
--
|
||||
-- TRAINER MONS COME OUT NON-SHINY BY THEMSELVES, and correctly so. The
|
||||
-- engine overwrites every trainer slot's DVs with a fixed TRAINER_DVS
|
||||
-- (src/battle/BattleState.lua:350, :661) right after construction, and that
|
||||
-- constant fails the shiny pattern. So the roll is made and then discarded
|
||||
-- for them -- which matches the real games, where a trainer's Pokemon is
|
||||
-- never shiny.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
|
||||
local ShinyBattle = {}
|
||||
|
||||
-- ------- install
|
||||
--
|
||||
-- Idempotent by sentinel, the pattern every wrap in this mod uses
|
||||
-- (OverworldBattle.install, Stadium.install): a hot reload must not stack a
|
||||
-- second copy of the wrapper on top of the first.
|
||||
function ShinyBattle.install()
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
if not Pokemon.dramaticShapeShiny then
|
||||
local inner = Pokemon.new
|
||||
function Pokemon.new(data, species, level, rng)
|
||||
local mon = inner(data, species, level, rng)
|
||||
-- pcall: a mon that fails to be decided is an ordinary mon, which is
|
||||
-- a blemish. A mon that fails to be BUILT is a broken game.
|
||||
pcall(Shiny.decide, mon)
|
||||
return mon
|
||||
end
|
||||
Pokemon.dramaticShapeShiny = true
|
||||
end
|
||||
|
||||
-- Party mons that predate the mod, and any mon built by a path we have
|
||||
-- not wrapped, still answer isShiny correctly -- their DVs were always
|
||||
-- there. This only refreshes the mon.shiny cache so a save opened for the
|
||||
-- first time under this mod has the field populated rather than absent
|
||||
-- until the mon next changes.
|
||||
ShinyBattle.markParty()
|
||||
end
|
||||
|
||||
-- Refresh the cached flag across the player's party.
|
||||
function ShinyBattle.markParty()
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if not ok then return end
|
||||
local party = Game and Game.save and Game.save.party
|
||||
if type(party) ~= "table" then return end
|
||||
for _, mon in ipairs(party) do
|
||||
pcall(Shiny.mark, mon)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- asking about a battler
|
||||
--
|
||||
-- The battler wrapper carries the save-shaped mon on `.mon`
|
||||
-- (src/battle/BattleState.lua:432-463), so the question is always about
|
||||
-- that table and never about the wrapper.
|
||||
function ShinyBattle.battlerIsShiny(battler)
|
||||
return battler ~= nil and Shiny.isShiny(battler.mon)
|
||||
end
|
||||
|
||||
-- Which side of a battle, by the engine's own side names.
|
||||
function ShinyBattle.sideIsShiny(battle, side)
|
||||
if not battle then return false end
|
||||
return ShinyBattle.battlerIsShiny(side == "player" and battle.player
|
||||
or battle.enemy)
|
||||
end
|
||||
|
||||
return ShinyBattle
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
-- The shiny sparkle: the flash a Pokemon makes when it first appears.
|
||||
--
|
||||
-- The games announce a shiny with a burst of stars over the sprite the
|
||||
-- instant it lands, before the first text box. This is that moment, in the
|
||||
-- diorama: a ring of additive stars that springs outward from the mon's
|
||||
-- chest, rises, and fades over about three quarters of a second.
|
||||
--
|
||||
-- ------- where the moment IS
|
||||
--
|
||||
-- Harder than it sounds, because the two battle paths arrive differently:
|
||||
--
|
||||
-- the model rung a Pokemon grows out of its ball -- Stadium.update
|
||||
-- already finds that frame (the POOF_ANIM edge) and
|
||||
-- calls StadiumMon:beginGrow.
|
||||
-- the pic rung a WILD foe is simply THERE on the first frame, with
|
||||
-- no poof and no grow at all. There is no animation to
|
||||
-- hang off.
|
||||
--
|
||||
-- So the arming edge is neither of those: it is the frame a side's OCCUPANT
|
||||
-- changes (Stadium's `session.at[side] ~= battler` test, the same identity
|
||||
-- the mode already uses because a trainer leading with two Rattata changes
|
||||
-- occupant without changing species). That edge fires for a send-out, a
|
||||
-- switch and a wild foe alike, which is exactly the set of moments a shiny
|
||||
-- should announce itself.
|
||||
--
|
||||
-- ------- drawn additively, and why it survives the flash
|
||||
--
|
||||
-- Stars are light, so they add rather than cover: `Voxel3D.blend("add")`,
|
||||
-- the same treatment the Poke Ball's glow gets. They are drawn inside the
|
||||
-- battle's flash window alongside the cards and models, so a sparkle during
|
||||
-- a hit flash is lit by it like everything else rather than floating over
|
||||
-- it as a separate layer.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local Mat4 = V.require("Mat4")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
|
||||
local ShinyFx = {}
|
||||
|
||||
local max, min = math.max, math.min
|
||||
|
||||
-- ------- shape and timing
|
||||
|
||||
-- ------- sized to the Pokemon, not to a constant
|
||||
--
|
||||
-- A Pokemon on the map is between 5 and 18 world pixels tall
|
||||
-- (StadiumMon.MIN_HEIGHT/MAX_HEIGHT, REF_HEIGHT 14) and roughly its own
|
||||
-- radius wide. Every earlier attempt here used flat numbers and every one of
|
||||
-- them was wrong for most of the dex: first a ring 7 units across, which sat
|
||||
-- INSIDE anything bigger than a Rattata and was depth-rejected; then, over-
|
||||
-- correcting, a ring 24 across starting 20 units up -- taller than the
|
||||
-- tallest Pokemon there is, so it hung in the sky above a Ponyta with
|
||||
-- nothing under it.
|
||||
--
|
||||
-- One ring cannot fit a Diglett and a Gyarados. The burst is therefore a
|
||||
-- FRACTION of the mon it belongs to: Stadium hands us each side's
|
||||
-- worldHeight and worldRadius (StadiumMon has them, and worldRadius exists
|
||||
-- precisely so "a caller can size something to its footprint"), and every
|
||||
-- distance below is measured off those.
|
||||
ShinyFx.LIFE = 0.75 -- seconds from spring to gone
|
||||
ShinyFx.STARS = 10 -- around the ring
|
||||
|
||||
ShinyFx.CHEST_FRAC = 0.50 -- up the body: the ring is centred on the
|
||||
-- Pokemon, not perched above or below it
|
||||
ShinyFx.RISE_FRAC = 0.16 -- of its height, drifted up over the burst
|
||||
ShinyFx.SIZE_FRAC = 0.20 -- a star, as a fraction of the mon's height
|
||||
|
||||
-- THE RING IS AN ELLIPSE AROUND THE SILHOUETTE, with its two axes measured
|
||||
-- separately. A single radius cannot do this: flattened enough to look like
|
||||
-- a ring seen from the battle's low seat, its vertical reach ends up a third
|
||||
-- of the body's height, so the top and bottom stars sit ON the Pokemon. The
|
||||
-- horizontal axis clears its width, the vertical axis clears its height.
|
||||
ShinyFx.RING_X_FRAC = 1.50 -- of the mon's RADIUS -- just outside its width
|
||||
ShinyFx.RING_X_MIN = 0.34 -- ...but never narrower than this of its height,
|
||||
-- for the thin ones (Onix, Ekans) whose radius
|
||||
-- alone would put the ring inside them
|
||||
ShinyFx.RING_Y_FRAC = 0.62 -- of its HEIGHT -- so the ring reaches its
|
||||
-- shoulders and its feet, not just its middle
|
||||
|
||||
-- The burst OPENS from here rather than from nothing. Springing out of a
|
||||
-- point means every star spends the first frames stacked at the centre --
|
||||
-- which is the middle of the Pokemon, and reads exactly like the sparkles
|
||||
-- being stuck inside it. Starting already clear of the body and expanding
|
||||
-- the rest of the way keeps them outside for the whole life of the effect.
|
||||
ShinyFx.RING_START = 0.72
|
||||
|
||||
-- What a side with no model gets: the flat-pic rung, where a pic stands
|
||||
-- FULL_W (16) units wide in a card. Close enough to a median Pokemon that
|
||||
-- the same fractions land sensibly.
|
||||
ShinyFx.DEFAULT_HEIGHT = 14
|
||||
ShinyFx.DEFAULT_RADIUS = 6
|
||||
|
||||
-- Additive drawing keeps the depth TEST (Voxel3D.blend sets lequal with
|
||||
-- writes off), so a star level with the model is rejected by it however
|
||||
-- bright it is. The extra pull puts the ring in front of the Pokemon it
|
||||
-- belongs to, the same trick the move-animation card uses.
|
||||
ShinyFx.PULL_BONUS = 6
|
||||
|
||||
-- one per side, nil when nothing is playing
|
||||
local live = { player = nil, enemy = nil }
|
||||
|
||||
-- How big the Pokemon on each side actually is, pushed in by Stadium.update
|
||||
-- every frame it has a model. Kept here rather than reached for, because
|
||||
-- ShinyFx is drawn from BattleScene and asking Stadium from inside it would
|
||||
-- close a require loop between the three.
|
||||
local size = { player = nil, enemy = nil }
|
||||
|
||||
-- world pixels, from StadiumMon:worldHeight/worldRadius. Pass nil height to
|
||||
-- say "no model on this side" -- the flat-pic rung, which falls back to the
|
||||
-- defaults above.
|
||||
function ShinyFx.setMetrics(side, height, radius)
|
||||
if side ~= "player" and side ~= "enemy" then return end
|
||||
if not (height and height > 0) then size[side] = nil return end
|
||||
size[side] = { h = height, r = radius or 0 }
|
||||
end
|
||||
local star = nil -- the generated star image, built once
|
||||
|
||||
-- ------- the star
|
||||
--
|
||||
-- Generated rather than shipped: it is a four-pointed twinkle, which is a
|
||||
-- cheap closed form (a radial falloff times a cross-shaped spike term) and
|
||||
-- costs nothing next to an asset that would have to be authored, packed,
|
||||
-- loaded and kept in step with the rest of the mod's art.
|
||||
local function starImage()
|
||||
if star ~= nil then return star or nil end
|
||||
if not (love and love.image and love.graphics) then
|
||||
star = false
|
||||
return nil
|
||||
end
|
||||
local ok, img = pcall(function()
|
||||
local N = 32
|
||||
local data = love.image.newImageData(N, N)
|
||||
local c = (N - 1) / 2
|
||||
for y = 0, N - 1 do
|
||||
for x = 0, N - 1 do
|
||||
local dx, dy = (x - c) / c, (y - c) / c
|
||||
local r = math.sqrt(dx * dx + dy * dy)
|
||||
-- the body: a soft core that is gone by the edge of the square
|
||||
local core = math.max(0, 1 - r)
|
||||
core = core * core * core
|
||||
-- the spikes: bright along the two axes, narrow, and reaching
|
||||
-- further out than the core does
|
||||
local ax, ay = math.abs(dx), math.abs(dy)
|
||||
local spike = math.max(0, 1 - ax * 6) * math.max(0, 1 - ay)
|
||||
+ math.max(0, 1 - ay * 6) * math.max(0, 1 - ax)
|
||||
local a = math.min(1, core + spike * 0.55)
|
||||
-- white with the faintest warm cast, so a sparkle over a cool
|
||||
-- model still reads as light rather than as a blue smear
|
||||
data:setPixel(x, y, 1, 1, 0.97, a)
|
||||
end
|
||||
end
|
||||
return love.graphics.newImage(data)
|
||||
end)
|
||||
star = (ok and img) or false
|
||||
return star or nil
|
||||
end
|
||||
|
||||
-- ------- arming
|
||||
|
||||
-- Start (or restart) the burst on one side. Restarting rather than ignoring
|
||||
-- a second call is deliberate: a shiny that faints and is sent back out
|
||||
-- should sparkle again.
|
||||
-- ARMED, BUT NOT YET RUNNING. The clock does not start here, and that is the
|
||||
-- whole point: the edge this is armed on -- a side's occupant changing --
|
||||
-- happens while the screen is still mid-WIPE, a second or more before the
|
||||
-- battle draws a single frame. A burst that started its three-quarter-second
|
||||
-- life at that moment was always over before anybody could see it, which is
|
||||
-- exactly what "the sparkle isn't appearing" looked like: armed, drawn,
|
||||
-- counted, and finished behind the transition.
|
||||
--
|
||||
-- So `pending` holds it at frame zero until the scene actually draws this
|
||||
-- side (see draw), and the life begins from there.
|
||||
function ShinyFx.arm(side)
|
||||
if side ~= "player" and side ~= "enemy" then return end
|
||||
live[side] = { t = 0, pending = true }
|
||||
if ShinyFx.debug then ShinyFx.debug.armed = (ShinyFx.debug.armed or 0) + 1 end
|
||||
end
|
||||
|
||||
-- The fight is on screen now: let any burst waiting on this side begin.
|
||||
--
|
||||
-- Split from arm because the two moments are genuinely different and were
|
||||
-- conflated twice. Arming happens when the OCCUPANT changes, which is during
|
||||
-- the transition; the burst may only start once the transition is OVER and
|
||||
-- there is somebody watching. Between them it sits at zero.
|
||||
function ShinyFx.release(side)
|
||||
local s = live[side]
|
||||
if s and s.pending then
|
||||
s.pending = nil
|
||||
if ShinyFx.debug then
|
||||
ShinyFx.debug.released = (ShinyFx.debug.released or 0) + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function ShinyFx.clear(side)
|
||||
if ShinyFx.debug and side and live[side] then
|
||||
ShinyFx.debug.cleared = (ShinyFx.debug.cleared or 0) + 1
|
||||
end
|
||||
if side then live[side] = nil else live.player, live.enemy = nil, nil end
|
||||
end
|
||||
|
||||
function ShinyFx.active(side)
|
||||
if side then return live[side] ~= nil end
|
||||
return live.player ~= nil or live.enemy ~= nil
|
||||
end
|
||||
|
||||
function ShinyFx.update(dt)
|
||||
dt = dt or 0
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local s = live[side]
|
||||
-- a pending burst does not age: it is waiting for the scene to draw it
|
||||
-- for the first time, which is when its life actually begins (see arm)
|
||||
if s and not s.pending then
|
||||
s.t = s.t + dt
|
||||
if s.t >= ShinyFx.LIFE then live[side] = nil end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
|
||||
-- Eased so the ring leaves fast and settles, which is what a spark does;
|
||||
-- linear looks like a diagram of a spark.
|
||||
local function easeOut(u) return 1 - (1 - u) * (1 - u) end
|
||||
|
||||
-- Draw whatever is playing. `arena` and `groundY` come from the scene, the
|
||||
-- same two the mon cards are placed from, so a sparkle lands where its
|
||||
-- Pokemon is standing rather than where the layout thinks it should be.
|
||||
-- Why a burst did not draw, for a driver to read back. Rendering faults are
|
||||
-- invisible to the test suite and this one has four separate ways to be a
|
||||
-- no-op, all of them silent.
|
||||
ShinyFx.debug = { calls = 0, noArena = 0, noImage = 0, noMesh = 0,
|
||||
noLive = 0, quads = 0, armed = 0, cleared = 0 }
|
||||
|
||||
function ShinyFx.draw(arena, groundY, pull)
|
||||
local dbg = ShinyFx.debug
|
||||
dbg.calls = dbg.calls + 1
|
||||
if not arena then dbg.noArena = dbg.noArena + 1 return end
|
||||
local img = starImage()
|
||||
if not img then dbg.noImage = dbg.noImage + 1 return end
|
||||
local mesh = BattleBillboard.mesh()
|
||||
if not mesh then dbg.noMesh = dbg.noMesh + 1 return end
|
||||
if not (live.player or live.enemy) then
|
||||
dbg.noLive = dbg.noLive + 1
|
||||
return
|
||||
end
|
||||
|
||||
local drew = false
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local s = live[side]
|
||||
local cell = (side == "player") and arena.player or arena.enemy
|
||||
-- A pending burst is not drawn at all. It is waiting for the fight to be
|
||||
-- ON SCREEN, which is not the same as the scene being drawn: the battle
|
||||
-- renders underneath the transition wipe for a second or so first, and a
|
||||
-- burst started there spends its whole life behind it. Stadium.release
|
||||
-- is what says the wipe is done.
|
||||
if s and s.pending then s = nil end
|
||||
if s and cell then
|
||||
local u = math.min(1, s.t / ShinyFx.LIFE)
|
||||
local e = easeOut(u)
|
||||
-- bright immediately, then out: the announcement is the first frame
|
||||
local alpha = 1 - u * u
|
||||
local x, z = cell[1], cell[2]
|
||||
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
|
||||
|
||||
-- every distance measured off THIS Pokemon (see the header)
|
||||
local m = size[side]
|
||||
local mh = (m and m.h) or ShinyFx.DEFAULT_HEIGHT
|
||||
local mr = (m and m.r and m.r > 0 and m.r) or ShinyFx.DEFAULT_RADIUS
|
||||
local ringX = max(mr * ShinyFx.RING_X_FRAC, mh * ShinyFx.RING_X_MIN)
|
||||
local ringY = mh * ShinyFx.RING_Y_FRAC
|
||||
local starK = mh * ShinyFx.SIZE_FRAC
|
||||
-- open from clear of the body, not from a point (see RING_START)
|
||||
local grow = ShinyFx.RING_START + (1 - ShinyFx.RING_START) * e
|
||||
local baseY = groundY + mh * ShinyFx.CHEST_FRAC
|
||||
+ mh * ShinyFx.RISE_FRAC * e
|
||||
|
||||
if not drew then
|
||||
Voxel3D.blend("add")
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
drew = true
|
||||
end
|
||||
|
||||
for i = 1, ShinyFx.STARS do
|
||||
-- the ring is offset half a step per side so the two sides do not
|
||||
-- twinkle in lockstep when both are shiny
|
||||
local a = (i / ShinyFx.STARS) * math.pi * 2
|
||||
+ (side == "player" and 0.31 or 0)
|
||||
-- stars shrink as they fade, and alternate size so the ring reads
|
||||
-- as scattered rather than as a cog
|
||||
local k = starK * (1 - u * 0.6) * ((i % 2 == 0) and 0.7 or 1)
|
||||
local ox = math.cos(a) * ringX * grow
|
||||
local oy = math.sin(a) * ringY * grow
|
||||
local m = Mat4.mul(
|
||||
Mat4.mul(Mat4.translate(x, baseY, z), Mat4.rotateY(yaw)),
|
||||
Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(k, k, 1)))
|
||||
love.graphics.setColor(1, 1, 1, alpha)
|
||||
Voxel3D.draw(mesh, img, m, (pull or 0) + ShinyFx.PULL_BONUS)
|
||||
dbg.quads = dbg.quads + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if drew then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
Voxel3D.blend("alpha")
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop the generated image (hot reload, or a graphics context that went
|
||||
-- away) -- the same contract StadiumPack.invalidate honours.
|
||||
function ShinyFx.invalidate()
|
||||
if star and star.release then pcall(star.release, star) end
|
||||
star = nil
|
||||
ShinyFx.clear()
|
||||
end
|
||||
|
||||
return ShinyFx
|
||||
@@ -0,0 +1,416 @@
|
||||
-- The shiny recolour: Stadium's own HSL slide, run over decoded texels.
|
||||
--
|
||||
-- THE COLOUR MODEL IS STADIUM'S, not an invention. The Stadium games do not
|
||||
-- ship a second set of textures for a shiny Pokemon; they convert the
|
||||
-- colours the model already has to HSL and slide them -- a hue rotation in
|
||||
-- degrees, plus saturation and lightness on a quantized integer scale of
|
||||
-- -8..+8 where 0 is no change. One step is 12.5%, so +-8 is +-100%: exactly
|
||||
-- the range of GIMP's Hue-Saturation sliders, which is where the 12.5%
|
||||
-- figure was measured. s = -8 is full greyscale, l = +8 is white.
|
||||
--
|
||||
-- That equivalence is why the maths below is GIMP's Hue-Saturation and not
|
||||
-- a plain additive offset:
|
||||
--
|
||||
-- saturation s' = s * (1 + k) multiplicative
|
||||
-- lightness l' = l * (1 + k) k < 0 scale toward black
|
||||
-- l' = l + k * (1 - l) k > 0 blend toward white
|
||||
--
|
||||
-- The multiplicative saturation is the reason this is safe to run over a
|
||||
-- whole texture rather than a masked region: a pixel with no saturation --
|
||||
-- an eye white, a tooth, a grey shadow -- is immune to BOTH the hue
|
||||
-- rotation and the saturation step, for free and by construction. Only the
|
||||
-- lightness step touches achromatic pixels, which is why the species
|
||||
-- carrying big l values (Golbat and Slowpoke at -6, Moltres at +5) are the
|
||||
-- ones worth looking at with human eyes.
|
||||
--
|
||||
-- FIVE SPECIES CANNOT BE SLID. Clefairy, Clefable, Jigglypuff, Wigglytuff
|
||||
-- and Gyarados get a real alternate texture in Stadium, because their shiny
|
||||
-- moves one region a long way and leaves another alone -- Jigglypuff's body
|
||||
-- stays pink while its irises go green -- and a single rotation moves
|
||||
-- everything or nothing.
|
||||
--
|
||||
-- Those five carry `lut` instead: an explicit before/after colour mapping,
|
||||
-- sampled from the verified texture pairs, listing only the colours that
|
||||
-- actually move. A first attempt drove them from a handful of per-region
|
||||
-- anchors and picked the nearest one per pixel, which is wrong in a way
|
||||
-- worth recording: with regions as far apart as Clefairy's pink body and
|
||||
-- its green ear tips, a dark red shadow pixel is "nearest" to the green and
|
||||
-- gets rotated 150 degrees the wrong way. The fixture caught it at a
|
||||
-- 124/255 channel error. An exact table is a few tens of kilobytes and has
|
||||
-- no such failure mode, so these five are data rather than algorithm.
|
||||
--
|
||||
-- WHY THIS RUNS AT EXTRACTION. The textures are already decoded to RGBA in
|
||||
-- memory at that moment (StadiumFragment.decodeTexture), and -- the part
|
||||
-- that matters -- generated effect frames are still distinguishable there.
|
||||
-- StadiumFx marks them `generated = true`, and the packer drops that field,
|
||||
-- so at runtime an additive flame can only be inferred back from the prim
|
||||
-- table. Recolouring a flame is wrong: a shiny Charizard has a shiny hide
|
||||
-- and an ordinary fire. Doing the work while the marker still exists means
|
||||
-- the discrimination is exact rather than reconstructed.
|
||||
--
|
||||
-- THE MEMO IS WHAT MAKES IT AFFORDABLE. These are N64 textures: a few
|
||||
-- hundred distinct colours across tens of thousands of texels. Converting
|
||||
-- per DISTINCT COLOUR instead of per pixel turns the inner loop into a
|
||||
-- table lookup, which is the difference between a pass that is felt during
|
||||
-- the install and one that is not.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ShinyPalette = {}
|
||||
|
||||
local floor, min, max, abs = math.floor, math.min, math.max, math.abs
|
||||
local byte, char, concat = string.byte, string.char, table.concat
|
||||
|
||||
-- ------- HSL
|
||||
|
||||
local function rgbToHsl(r, g, b)
|
||||
r, g, b = r / 255, g / 255, b / 255
|
||||
local mx, mn = max(r, g, b), min(r, g, b)
|
||||
local l = (mx + mn) / 2
|
||||
if mx == mn then return 0, 0, l end -- achromatic: hue is undefined
|
||||
local d = mx - mn
|
||||
local s = l > 0.5 and d / (2 - mx - mn) or d / (mx + mn)
|
||||
local h
|
||||
if mx == r then
|
||||
h = (g - b) / d + (g < b and 6 or 0)
|
||||
elseif mx == g then
|
||||
h = (b - r) / d + 2
|
||||
else
|
||||
h = (r - g) / d + 4
|
||||
end
|
||||
return h * 60, s, l
|
||||
end
|
||||
|
||||
local function hue2rgb(p, q, t)
|
||||
if t < 0 then t = t + 1 end
|
||||
if t > 1 then t = t - 1 end
|
||||
if t < 1 / 6 then return p + (q - p) * 6 * t end
|
||||
if t < 1 / 2 then return q end
|
||||
if t < 2 / 3 then return p + (q - p) * (2 / 3 - t) * 6 end
|
||||
return p
|
||||
end
|
||||
|
||||
local function hslToRgb(h, s, l)
|
||||
if s <= 0 then
|
||||
local v = floor(l * 255 + 0.5)
|
||||
return v, v, v
|
||||
end
|
||||
h = (h % 360) / 360
|
||||
local q = l < 0.5 and l * (1 + s) or l + s - l * s
|
||||
local p = 2 * l - q
|
||||
return floor(hue2rgb(p, q, h + 1 / 3) * 255 + 0.5),
|
||||
floor(hue2rgb(p, q, h) * 255 + 0.5),
|
||||
floor(hue2rgb(p, q, h - 1 / 3) * 255 + 0.5)
|
||||
end
|
||||
|
||||
-- GIMP's two curves, shared by the slide and the anchor paths so both
|
||||
-- reach the same colour from the same k.
|
||||
local function shiftSat(s, k)
|
||||
if k == 0 then return s end
|
||||
return max(0, min(1, s * (1 + k)))
|
||||
end
|
||||
|
||||
local function shiftLight(l, k)
|
||||
if k == 0 then return l end
|
||||
if k < 0 then return max(0, l * (1 + k)) end
|
||||
return min(1, l + k * (1 - l))
|
||||
end
|
||||
|
||||
-- ------- the two kinds of transform
|
||||
|
||||
-- A whole-model slide: the 146 species Stadium recolours this way.
|
||||
local function slideFn(slide)
|
||||
local dh = slide.h or 0
|
||||
local ks = (slide.s or 0) * 0.125
|
||||
local kl = (slide.l or 0) * 0.125
|
||||
return function(r, g, b)
|
||||
local h, s, l = rgbToHsl(r, g, b)
|
||||
-- An achromatic pixel has no hue to rotate and no saturation to scale;
|
||||
-- only a lightness step can reach it. Returning early is not just
|
||||
-- speed, it is exactness: round-tripping grey through HSL and back can
|
||||
-- move it by a unit, and a tooth that drifts is a visible defect.
|
||||
if s <= 0 then
|
||||
if kl == 0 then return r, g, b end
|
||||
local v = floor(shiftLight(l, kl) * 255 + 0.5)
|
||||
return v, v, v
|
||||
end
|
||||
return hslToRgb(h + dh, shiftSat(s, ks), shiftLight(l, kl))
|
||||
end
|
||||
end
|
||||
|
||||
-- An exact colour mapping: the five species Stadium gives a real second
|
||||
-- texture. A colour absent from the table is one the alternate texture left
|
||||
-- alone, so passing it straight through is the correct answer, not a
|
||||
-- fallback -- that is how Wigglytuff keeps its white belly and its black
|
||||
-- inner ears while its body moves to lilac.
|
||||
local function lutFn(lut)
|
||||
return function(r, g, b)
|
||||
local hit = lut[r * 65536 + g * 256 + b]
|
||||
if not hit then return r, g, b end
|
||||
return floor(hit / 65536) % 256, floor(hit / 256) % 256, hit % 256
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the colour table
|
||||
--
|
||||
-- Loaded lazily and cached. Two paths on purpose: through the mod namespace
|
||||
-- when the mod is running, and straight off disk when it is not. The
|
||||
-- extraction byte-diff (tests/stadium_extract_test.lua) stubs V with only
|
||||
-- `require` and `mod.log`, and the recolour has to be exercisable under
|
||||
-- exactly that harness -- a colour transform that can only run inside a
|
||||
-- live LOVE process is a colour transform nobody will test.
|
||||
local colors = nil
|
||||
|
||||
local function loadColors()
|
||||
if colors ~= nil then return colors or nil end
|
||||
if V and V.data then
|
||||
local ok, t = pcall(V.data, "shiny_colors")
|
||||
if ok and type(t) == "table" then colors = t; return colors end
|
||||
end
|
||||
-- Off disk, RELATIVE TO THE MOD rather than to the working directory.
|
||||
-- V.path is the mod's own directory (main.lua sets it; the headless
|
||||
-- harnesses set it to whatever --mod they were given). Guessing from the
|
||||
-- cwd instead is what made this silently find nothing when the extraction
|
||||
-- test was run from the project root rather than from the mod: every
|
||||
-- species built, none recoloured, and a PASS at the end of it.
|
||||
local tries = {}
|
||||
if V and V.path then tries[#tries + 1] = V.path .. "/data/shiny_colors.lua" end
|
||||
tries[#tries + 1] = "data/shiny_colors.lua"
|
||||
tries[#tries + 1] = "mods/DramaticShapeVoxelMod/data/shiny_colors.lua"
|
||||
for _, p in ipairs(tries) do
|
||||
local chunk = loadfile(p)
|
||||
if chunk then
|
||||
local ok, t = pcall(chunk)
|
||||
if ok and type(t) == "table" then colors = t; return colors end
|
||||
end
|
||||
end
|
||||
colors = false -- cache the miss; do not retry the disk per species
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether the colour table was found at all. The extraction asks so it can
|
||||
-- say "no colours" once and loudly, rather than reporting 151 successful
|
||||
-- builds with no shiny variant among them.
|
||||
function ShinyPalette.haveColors()
|
||||
return loadColors() ~= nil
|
||||
end
|
||||
|
||||
-- The spec for one dex number, or nil if we have nothing for it.
|
||||
function ShinyPalette.forDex(dex)
|
||||
local all = loadColors()
|
||||
return all and all[dex] or nil
|
||||
end
|
||||
|
||||
-- Build the pixel transform for one species' spec, or nil when there is
|
||||
-- nothing to do.
|
||||
function ShinyPalette.transform(spec)
|
||||
if type(spec) ~= "table" then return nil end
|
||||
if spec.lut then
|
||||
if next(spec.lut) == nil then return nil end
|
||||
return lutFn(spec.lut)
|
||||
end
|
||||
local s = spec.slide
|
||||
if not s then return nil end
|
||||
if (s.h or 0) == 0 and (s.l or 0) == 0 and (s.s or 0) == 0 then return nil end
|
||||
return slideFn(s)
|
||||
end
|
||||
|
||||
-- ------- the pass over one texture
|
||||
--
|
||||
-- Memoised per distinct colour (see the header). The key packs RGB into one
|
||||
-- integer because a table with 24-bit integer keys is a flat array probe,
|
||||
-- where a "r,g,b" string key would allocate on every pixel -- and allocation
|
||||
-- inside a multi-million-iteration loop is the whole cost.
|
||||
--
|
||||
-- Alpha is copied through untouched, never premultiplied and never
|
||||
-- recomputed: the transform is defined on colour alone, and a shiny
|
||||
-- Gastly's soft edge must stay exactly as soft as it was.
|
||||
function ShinyPalette.recolorTexels(rgba, fn)
|
||||
local n = #rgba
|
||||
if n == 0 or not fn then return rgba end
|
||||
local memo = {}
|
||||
local out, blocks = {}, {}
|
||||
local bi = 0
|
||||
for i = 1, n, 4 do
|
||||
local r, g, b, a = byte(rgba, i, i + 3)
|
||||
local key = r * 65536 + g * 256 + b
|
||||
local hit = memo[key]
|
||||
if not hit then
|
||||
local nr, ng, nb = fn(r, g, b)
|
||||
hit = { nr, ng, nb }
|
||||
memo[key] = hit
|
||||
end
|
||||
bi = bi + 1
|
||||
blocks[bi] = char(hit[1], hit[2], hit[3], a)
|
||||
-- flushed in blocks so the concat never walks a table with millions of
|
||||
-- one-texel strings in it
|
||||
if bi >= 4096 then
|
||||
out[#out + 1] = concat(blocks)
|
||||
blocks, bi = {}, 0
|
||||
end
|
||||
end
|
||||
if bi > 0 then out[#out + 1] = concat(blocks, "", 1, bi) end
|
||||
return concat(out)
|
||||
end
|
||||
|
||||
-- ------- a tint, for the flat sprites
|
||||
--
|
||||
-- The 3D models get real recoloured texels. The 2D battle pics cannot: the
|
||||
-- engine bakes a species palette into a cached image keyed by path and
|
||||
-- palette name, and that cache has no idea which INDIVIDUAL is being drawn.
|
||||
-- What is available per-draw is the draw colour, which multiplies.
|
||||
--
|
||||
-- So the pic is tinted, and the tint is derived from the species' OWN shiny
|
||||
-- slide rather than being a generic gold: run a spread of reference colours
|
||||
-- through the real transform, take the mean ratio out to in, and that is
|
||||
-- the multiply that best stands in for it. A shiny Golbat leans green, a
|
||||
-- shiny Charizard goes dusky, and neither is a costume.
|
||||
--
|
||||
-- ITS ONE LIMIT, stated plainly: a multiply can only darken. Where a species'
|
||||
-- shiny is LIGHTER than its normal, the honest ratio is above 1 and gets
|
||||
-- clamped, so those come out under-shifted -- present, but quieter than the
|
||||
-- model. The floor keeps the darkest cases readable rather than muddy.
|
||||
local TINT_FLOOR = 0.45
|
||||
local tintCache = {}
|
||||
|
||||
-- Mid-tone references across the wheel. Deliberately not greys: the slide
|
||||
-- is multiplicative in saturation, so a grey reference would report no
|
||||
-- change for every species and hand back a tint of 1,1,1.
|
||||
local REFS = {
|
||||
{ 200, 90, 70 }, { 200, 150, 70 }, { 190, 190, 80 }, { 90, 180, 90 },
|
||||
{ 80, 170, 170 }, { 80, 120, 200 }, { 140, 90, 190 }, { 190, 90, 150 },
|
||||
}
|
||||
|
||||
function ShinyPalette.tintFor(dex)
|
||||
local hit = tintCache[dex]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
local fn = ShinyPalette.transform(spec)
|
||||
if not fn then tintCache[dex] = false; return nil end
|
||||
local sr, sg, sb, n = 0, 0, 0, 0
|
||||
|
||||
if spec.lut then
|
||||
-- A lookup table answers only the colours that are IN it, so running
|
||||
-- synthetic references through one returns them untouched and reports a
|
||||
-- tint of exactly 1 -- i.e. no tint, for the five species whose shiny is
|
||||
-- the most dramatic in the game. (A shiny Gyarados came out with an
|
||||
-- ordinary blue pic for precisely this reason.) The table's own entries
|
||||
-- are the right sample: they are what this Pokemon is actually made of.
|
||||
for from, to in pairs(spec.lut) do
|
||||
local fr, fg, fb = floor(from / 65536) % 256, floor(from / 256) % 256,
|
||||
from % 256
|
||||
local tr, tg, tb = floor(to / 65536) % 256, floor(to / 256) % 256,
|
||||
to % 256
|
||||
-- guard the near-black entries: a ratio against 2 is noise, and a
|
||||
-- handful of them would swamp the mean
|
||||
if fr > 24 and fg > 24 and fb > 24 then
|
||||
sr = sr + tr / fr
|
||||
sg = sg + tg / fg
|
||||
sb = sb + tb / fb
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- A slide: measure it against the colour this Pokemon is mostly MADE of.
|
||||
--
|
||||
-- Averaging over a balanced set of references does not work, and the
|
||||
-- reason is worth keeping: a hue rotation moves red toward cyan and cyan
|
||||
-- toward red, so over a symmetric wheel the ratios cancel and every
|
||||
-- species reports a tint of 1. Charizard and Ponyta both came back with no
|
||||
-- tint at all that way. One real body colour, rotated, is the whole
|
||||
-- answer.
|
||||
if n == 0 and spec.dom then
|
||||
local dr = floor(spec.dom / 65536) % 256
|
||||
local dg = floor(spec.dom / 256) % 256
|
||||
local db = spec.dom % 256
|
||||
if dr > 12 and dg > 12 and db > 12 then
|
||||
local r, g, b = fn(dr, dg, db)
|
||||
sr, sg, sb, n = r / dr, g / dg, b / db, 1
|
||||
end
|
||||
end
|
||||
|
||||
if n == 0 then
|
||||
for _, c in ipairs(REFS) do
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
sr = sr + r / c[1]
|
||||
sg = sg + g / c[2]
|
||||
sb = sb + b / c[3]
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
local t = {
|
||||
max(TINT_FLOOR, min(1, sr / n)),
|
||||
max(TINT_FLOOR, min(1, sg / n)),
|
||||
max(TINT_FLOOR, min(1, sb / n)),
|
||||
}
|
||||
-- a tint that came out as no tint at all is worse than none: it costs a
|
||||
-- colour-hook wrap per draw and changes nothing
|
||||
if t[1] > 0.995 and t[2] > 0.995 and t[3] > 0.995 then
|
||||
tintCache[dex] = false
|
||||
return nil
|
||||
end
|
||||
tintCache[dex] = t
|
||||
return t
|
||||
end
|
||||
|
||||
-- A transform for PALETTE colours rather than texture texels.
|
||||
--
|
||||
-- The two are not the same job, and using the texel transform on a palette
|
||||
-- quietly does nothing for five species. A lookup table answers only the
|
||||
-- colours that are in it -- the ones its model is painted with -- and the
|
||||
-- engine's ADVANCED palettes are a different set of colours entirely
|
||||
-- (BLUEMON's blue is not any blue on the Gyarados model). Asked to shift a
|
||||
-- palette, the table therefore returns it unchanged, and the most dramatic
|
||||
-- shiny in the game comes out identical.
|
||||
--
|
||||
-- So: slide species use the slide, which is defined on all colours. Table
|
||||
-- species fall back to their tint multiplier, which IS derived from the
|
||||
-- table and does carry its direction.
|
||||
function ShinyPalette.paletteTransform(dex)
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
if not spec then return nil end
|
||||
if not spec.lut then return ShinyPalette.transform(spec) end
|
||||
local t = ShinyPalette.tintFor(dex)
|
||||
if not t then return nil end
|
||||
return function(r, g, b)
|
||||
return floor(min(255, r * t[1]) + 0.5),
|
||||
floor(min(255, g * t[2]) + 0.5),
|
||||
floor(min(255, b * t[3]) + 0.5)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the pass over one species' whole texture array
|
||||
|
||||
-- Recolour `textures` in place, skipping the ones that must not move.
|
||||
--
|
||||
-- Two exclusions, both load-bearing:
|
||||
--
|
||||
-- generated / index == -1 StadiumFx's flipbook frames -- flames, beams,
|
||||
-- sparks. A shiny Pokemon has a shiny hide and
|
||||
-- an ordinary fire; tinting the attack effects
|
||||
-- would read as a bug. This marker exists ONLY
|
||||
-- here, which is why the recolour lives at
|
||||
-- extraction (see the header).
|
||||
-- w or h of zero a degenerate slot with nothing to transform.
|
||||
--
|
||||
-- Returns the number of textures actually touched, so the caller can tell a
|
||||
-- species that recoloured from one that silently did not.
|
||||
function ShinyPalette.recolorTextures(textures, spec)
|
||||
local fn = ShinyPalette.transform(spec)
|
||||
if not fn then return 0 end
|
||||
local touched = 0
|
||||
for i = 1, #textures do
|
||||
local t = textures[i]
|
||||
local skip = t.generated == true or t.index == -1
|
||||
or not t.w or not t.h or t.w == 0 or t.h == 0
|
||||
if not skip and t.rgba and #t.rgba > 0 then
|
||||
t.rgba = ShinyPalette.recolorTexels(t.rgba, fn)
|
||||
touched = touched + 1
|
||||
end
|
||||
end
|
||||
return touched
|
||||
end
|
||||
|
||||
return ShinyPalette
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
-- Where a shiny SHOWS on the flat art: the battle pics and the status page.
|
||||
--
|
||||
-- The Stadium models carry genuinely recoloured texels (see ShinyPalette and
|
||||
-- the extraction). Everything drawn as a Game Boy pic cannot, because the
|
||||
-- engine bakes a species palette into an image cache keyed by path and
|
||||
-- palette name -- a cache with no notion of WHICH Rattata is being drawn. So
|
||||
-- the flat side is answered two ways:
|
||||
--
|
||||
-- the battle pic tinted at draw time, per side, with the multiply
|
||||
-- ShinyPalette.tintFor derives from that species' own
|
||||
-- shiny slide. Under ADVANCED (`redpp`, the pokered-gbc
|
||||
-- colour pack) the pic is at its most colourful and the
|
||||
-- tint reads clearly; under the DMG modes there is
|
||||
-- barely any colour to shift, which is why the status
|
||||
-- page also carries a plain, mode-proof MARK.
|
||||
-- the status page a star beside the level, drawn in the engine's own
|
||||
-- GB pixel grid so it is palette-processed like every
|
||||
-- other pixel on the screen rather than floating over
|
||||
-- the finished frame.
|
||||
--
|
||||
-- Both are monkeypatches, idempotent by sentinel, the pattern the rest of
|
||||
-- this mod uses (OverworldBattle.install, Stadium.install). The summary
|
||||
-- screen has no hook at all -- there is no `ui.summary.*` anywhere in the
|
||||
-- engine -- so a wrap is the only route to it, and it is deliberately a thin
|
||||
-- one: draw the engine's screen, then add one glyph.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local ShinyUI = {}
|
||||
|
||||
-- ------- the tint, applied to one draw
|
||||
--
|
||||
-- Lifted in shape from OverworldBattle.withTint, and for the same reason it
|
||||
-- exists there: the pics layer sets its own colour many times over as it
|
||||
-- draws (the faint slide's fade, the damage blink), so the way to tint the
|
||||
-- result without clobbering any of that is to multiply every colour it sets
|
||||
-- on its way past. Restored unconditionally, including on error, because a
|
||||
-- leaked setColor would tint the entire rest of the frame.
|
||||
function ShinyUI.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
|
||||
setColor(1, 1, 1, 1)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- The tint for a mon, or nil when it is not shiny or we have no colours.
|
||||
function ShinyUI.tintFor(mon, data)
|
||||
if not (mon and Shiny.isShiny(mon)) then return nil end
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
local dex = def and def.dex
|
||||
if not dex then return nil end
|
||||
return ShinyPalette.tintFor(dex)
|
||||
end
|
||||
|
||||
-- ------- the star
|
||||
--
|
||||
-- Drawn as rectangles rather than as a font character because the Game Boy
|
||||
-- charmap has no star, and a letter would read as a typo. Four spokes and a
|
||||
-- centre, in the same near-black the screen's text uses, so the palette pass
|
||||
-- treats it exactly like a glyph -- under ADVANCED and under every DMG mode
|
||||
-- alike.
|
||||
--
|
||||
-- Seven pixels square, which is the largest that fits the gap beside the
|
||||
-- level without touching the DrawLineBox bracket at column 19.
|
||||
function ShinyUI.drawStar(px, py)
|
||||
local g = love.graphics
|
||||
local r, gg, b, a = g.getColor()
|
||||
g.setColor(0, 0, 0, 1)
|
||||
-- vertical, horizontal, then the four diagonal nubs
|
||||
g.rectangle("fill", px + 3, py, 1, 7)
|
||||
g.rectangle("fill", px, py + 3, 7, 1)
|
||||
g.rectangle("fill", px + 1, py + 1, 1, 1)
|
||||
g.rectangle("fill", px + 5, py + 1, 1, 1)
|
||||
g.rectangle("fill", px + 1, py + 5, 1, 1)
|
||||
g.rectangle("fill", px + 5, py + 5, 1, 1)
|
||||
g.setColor(r, gg, b, a)
|
||||
end
|
||||
|
||||
-- ------- install
|
||||
|
||||
function ShinyUI.install()
|
||||
ShinyUI.installSummary()
|
||||
ShinyUI.installBattlePics()
|
||||
end
|
||||
|
||||
-- The status page. Wraps the draw and adds the star afterwards, so the
|
||||
-- engine's own layout is untouched and a layout change upstream costs us
|
||||
-- the glyph's position and nothing else.
|
||||
function ShinyUI.installSummary()
|
||||
local ok, SummaryMenu = pcall(require, "src.ui.SummaryMenu")
|
||||
if not ok or type(SummaryMenu) ~= "table" then return end
|
||||
if SummaryMenu.dramaticShapeShiny then return end
|
||||
local inner = SummaryMenu.draw
|
||||
if type(inner) ~= "function" then return end
|
||||
|
||||
function SummaryMenu:draw(...)
|
||||
local out = { inner(self, ...) }
|
||||
-- page 1 only: page 2 wipes the block the level sits in
|
||||
-- (status_screen.asm ClearScreenArea over (9,2)), so a mark left there
|
||||
-- would be half-erased by the engine's own clear.
|
||||
if self.page == 1 and Shiny.isShiny(self.mon) then
|
||||
-- beside PrintLevel at (14,2): column 13, row 2, in the gap the
|
||||
-- level's own leading space leaves
|
||||
pcall(ShinyUI.drawStar, 104, 17)
|
||||
end
|
||||
return unpack(out)
|
||||
end
|
||||
|
||||
-- The summary PIC, through its PALETTE.
|
||||
--
|
||||
-- Recolouring the sprite's pixels here does NOT work, and it is worth
|
||||
-- writing down why rather than leaving it to be re-attempted: the summary
|
||||
-- art is four-shade DMG grey, and the screen's colour is applied
|
||||
-- afterwards by the palette pass over the finished frame. Whatever RGB is
|
||||
-- put in the ImageData is remapped away by it. The colour of that pic
|
||||
-- lives in the palette and nowhere else, so the palette is what has to
|
||||
-- move. (Tried it, shot it, reverted it.)
|
||||
--
|
||||
-- This is the ADVANCED-palette answer, and it is better than a multiply:
|
||||
-- SetPal_StatusScreen hands the pic zone the species palette
|
||||
-- (PaletteFX.monPal), and sgbPalettes is a method on the MENU, so unlike
|
||||
-- the battle pic's image cache it knows which individual is on screen.
|
||||
-- Running those four colours through the species' own shiny transform
|
||||
-- gives the summary a genuinely recoloured Pokemon -- brightening
|
||||
-- included, which a draw-colour multiply cannot do.
|
||||
--
|
||||
-- Only ZONE entries are touched. The first palette the engine returns is
|
||||
-- the whole-screen HP-bar one, and rotating that would recolour the text.
|
||||
local innerPal = SummaryMenu.sgbPalettes
|
||||
if type(innerPal) == "function" then
|
||||
function SummaryMenu:sgbPalettes(game, ...)
|
||||
local out = innerPal(self, game, ...)
|
||||
if type(out) ~= "table" or not Shiny.isShiny(self.mon) then return out end
|
||||
local def = game and game.data and game.data.pokemon
|
||||
and game.data.pokemon[self.mon.species]
|
||||
local fn = def and def.dex
|
||||
and ShinyPalette.transform(ShinyPalette.forDex(def.dex))
|
||||
if not fn then return out end
|
||||
for _, z in ipairs(out) do
|
||||
if type(z) == "table" and z.w and z.h and type(z.colors) == "table" then
|
||||
-- copied, never mutated in place: monPal hands back the dataset's
|
||||
-- own palette table, and writing through it would recolour every
|
||||
-- Pokemon of the species everywhere for the rest of the process
|
||||
local cols = {}
|
||||
for i, c in ipairs(z.colors) do
|
||||
if type(c) == "table" and c[1] then
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
cols[i] = { r, g, b }
|
||||
else
|
||||
cols[i] = c
|
||||
end
|
||||
end
|
||||
z.colors = cols
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
end
|
||||
|
||||
SummaryMenu.dramaticShapeShiny = true
|
||||
end
|
||||
|
||||
-- The battle pics. The engine's pic layer is reached through
|
||||
-- BattleState:drawPicsLayer, which draws BOTH sides in one call -- so a
|
||||
-- per-side tint has to bracket each side separately, which is exactly what
|
||||
-- OverworldBattle.sideTexture already does when it renders one side at a
|
||||
-- time into its own canvas. That is where the tint belongs on the 3D path;
|
||||
-- this wrap covers the FLAT path, where the engine draws the battle itself.
|
||||
function ShinyUI.installBattlePics()
|
||||
local ok, BattleState = pcall(require, "src.battle.BattleState")
|
||||
if not ok or type(BattleState) ~= "table" then return end
|
||||
if BattleState.dramaticShapeShinyPics then return end
|
||||
local inner = BattleState.drawPicsLayer
|
||||
if type(inner) ~= "function" then return end
|
||||
|
||||
function BattleState:drawPicsLayer(...)
|
||||
-- THE 3D PATH HAS ALREADY DONE THIS, per side and better: when the mod
|
||||
-- is rendering one side into its own canvas it brackets that draw with
|
||||
-- that side's own tint (OverworldBattle.sideTexture). Tinting again here
|
||||
-- would square it. Asked as a question rather than left to install
|
||||
-- order, because both wraps are installed from main.lua and whichever
|
||||
-- ran first would otherwise silently decide the outcome.
|
||||
local okOw, Ow = pcall(V.require, "OverworldBattle")
|
||||
if okOw and Ow and Ow.texturingSide and Ow.texturingSide() then
|
||||
return inner(self, ...)
|
||||
end
|
||||
|
||||
-- Both sides at once, so when they disagree the tint cannot be applied
|
||||
-- per-side here without splitting the engine's own draw. When only ONE
|
||||
-- side is shiny we tint the whole layer by it: the other side's pic is
|
||||
-- dimmed slightly, which is far less wrong than a shiny drawn in its
|
||||
-- ordinary colours -- and when both are shiny each gets the mean.
|
||||
local data = self.game and self.game.data
|
||||
local a = self.player and ShinyUI.tintFor(self.player.mon, data)
|
||||
local b = self.enemy and ShinyUI.tintFor(self.enemy.mon, data)
|
||||
local tint = a or b
|
||||
if a and b then
|
||||
tint = { (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, (a[3] + b[3]) / 2 }
|
||||
end
|
||||
if not tint then return inner(self, ...) end
|
||||
local args = { ... }
|
||||
local out
|
||||
ShinyUI.withTint(tint, function() out = { inner(self, unpack(args)) } end)
|
||||
return unpack(out or {})
|
||||
end
|
||||
|
||||
BattleState.dramaticShapeShinyPics = true
|
||||
end
|
||||
|
||||
return ShinyUI
|
||||
+323
-42
@@ -78,6 +78,19 @@ Sky.DITHER_START = 0.6
|
||||
-- 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.
|
||||
@@ -168,12 +181,34 @@ 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
|
||||
uniform float glowInvR; // 1 / the glow's reach
|
||||
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
|
||||
@@ -186,22 +221,62 @@ vec3 bandAt(float i) {
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
float row = floor(sc.y / cell) * cell; // top of this cell row
|
||||
float pos = min(row / max(edge, 1.0), 1.0) * count;
|
||||
float 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);
|
||||
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
||||
if (base < count - 1.0 && (pos - base) > start) {
|
||||
if (parity < 0.5) { c = bandAt(base + 1.0); }
|
||||
}
|
||||
// The sunset's warmth, radiating from the disc: posterised to a few rungs
|
||||
// and checker-dithered between them -- the same 8-bit move as the bands,
|
||||
// so the glow reads as painted light rather than as a smooth airbrush --
|
||||
// and measured cell-to-cell, so its rings ride the diorama's own grid.
|
||||
// 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) {
|
||||
vec2 cc = (floor(sc / cell) + 0.5) * cell;
|
||||
float d = length(cc - glowPos) * glowInvR;
|
||||
float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
|
||||
float 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);
|
||||
@@ -263,6 +338,27 @@ end
|
||||
|
||||
Sky._rampFor = rampFor -- named for the suite
|
||||
|
||||
-- The band ramp for the CURRENT bands, plus how many texels wide it is --
|
||||
-- for a pass that wants to read the same sky this one paints. The water's
|
||||
-- reflection is the one caller: it looks the reflected direction up on this
|
||||
-- very ramp, so the sky on the lake and the sky over it are one palette,
|
||||
-- through one display-mode transform, off one clock.
|
||||
--
|
||||
-- nil where the ramp could not be built, which is exactly when Sky.paint
|
||||
-- falls back to flat bands -- so a driver that loses the gradient loses the
|
||||
-- reflected gradient with it rather than showing two different skies.
|
||||
function Sky.ramp()
|
||||
local bands = Sky.bands()
|
||||
if not (bands and bands[1]) then return nil end
|
||||
local img = rampFor(bands)
|
||||
if not img then return nil end
|
||||
return img, #bands, bands
|
||||
end
|
||||
|
||||
-- How far the twilight glow reaches around the disc, in canvas pixels, for
|
||||
-- a `w`-wide frame. The same number Sky.paint sends as `glowInvR`.
|
||||
Sky.GLOW_REACH = 0.55
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
|
||||
local function getShader()
|
||||
@@ -289,13 +385,14 @@ Sky._getShader = getShader -- named for the suite
|
||||
-- The flat fallback: the same bands as solid rectangles, no checker, on the same
|
||||
-- quantised edges. For a driver that could not compile the shader -- which is
|
||||
-- also every headless run.
|
||||
local function paintFlat(w, h, bands, edge, alpha, cell)
|
||||
local 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(i / n * edge / cell + 0.5) * cell
|
||||
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]
|
||||
@@ -323,28 +420,52 @@ end
|
||||
Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
|
||||
Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius
|
||||
|
||||
-- crater centres as fractions of the radius, so they ride any disc size
|
||||
local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
|
||||
{ -0.15, 0.7 }, { 0.05, 0.05 } }
|
||||
-- crater centres as fractions of the radius, so they ride any disc size.
|
||||
-- Public because the water's reflection draws the same moon (see Water):
|
||||
-- one list, so the disc on the lake cannot drift from the one in the sky.
|
||||
Sky.MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
|
||||
{ -0.15, 0.7 }, { 0.05, 0.05 } }
|
||||
|
||||
local function paintDisc(body, edge, cell, w, h)
|
||||
local g = love.graphics
|
||||
if not (body and body.y and g.setScissor) then return end
|
||||
local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
|
||||
local shades = PaletteFX.effectiveColors(src) or src
|
||||
local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
|
||||
-- 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))
|
||||
-- the low sun looms: the classic sunset exaggeration, and it reads
|
||||
if twilight then r = r + math.max(1, math.floor(r * 0.4)) end
|
||||
-- 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
|
||||
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 sx, sy, sw, sh = g.getScissor()
|
||||
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
|
||||
local craterR = math.max(1, math.floor(r / 5))
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
@@ -353,7 +474,7 @@ local function paintDisc(body, edge, cell, w, h)
|
||||
local c = d <= r * 0.5 and core or main
|
||||
-- dithered rim: the outer ring keeps only one parity of its cells
|
||||
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
|
||||
if body.moon then
|
||||
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)
|
||||
@@ -362,18 +483,86 @@ local function paintDisc(body, edge, cell, w, h)
|
||||
end
|
||||
end
|
||||
end
|
||||
if keep then
|
||||
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
g.rectangle("fill", bx + dx * cell - cell / 2,
|
||||
by + dy * cell - cell / 2, cell, cell)
|
||||
end
|
||||
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).
|
||||
--
|
||||
@@ -384,16 +573,47 @@ end
|
||||
-- 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)
|
||||
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
|
||||
local edge = Sky.region(h, horizonY)
|
||||
-- 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))
|
||||
@@ -412,6 +632,26 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
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
|
||||
@@ -422,30 +662,67 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
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 }
|
||||
sh:send("glowPos", { body.x, body.y })
|
||||
sh:send("glowInvR", 1 / math.max(1, w * 0.55))
|
||||
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)
|
||||
g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge)))
|
||||
-- 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, edge, alpha, cell) 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
|
||||
paintDisc(body, math.min(h, edge), cell, w, h)
|
||||
-- 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
|
||||
@@ -461,6 +738,10 @@ 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
|
||||
|
||||
+818
@@ -0,0 +1,818 @@
|
||||
-- STADIUM battles: the two Pokemon as real 3D models.
|
||||
--
|
||||
-- The 3D-BTL row's two STADIUM rungs. OFF is the engine's own white battle
|
||||
-- field; the 2D-3D rungs stand the GB's own pics up as quads
|
||||
-- (BattleBillboard); STADIUM replaces those quads with the Pokemon Stadium
|
||||
-- battle models -- skinned, animated, and playing the animation the move
|
||||
-- being used actually calls for. A or B decides whether that happens on the
|
||||
-- map or on two discs, and is the same choice on either pair of rungs.
|
||||
--
|
||||
-- The models come out of the Stadium ROM through model_extract, and are
|
||||
-- packed into assets/stadium/NNN.dsm by tools/stadium_pack.py. Nothing here
|
||||
-- knows about the ROM; the pack is the interface.
|
||||
--
|
||||
-- ------- what this file is, and is not
|
||||
--
|
||||
-- It is the MODE: which species is out on each side, which animation the
|
||||
-- fight is asking each of them for, whether the model or the flat pic is
|
||||
-- standing in this frame, and the two draw calls. The arithmetic is
|
||||
-- StadiumRig's, the file format is StadiumPack's, and one side's own state
|
||||
-- is StadiumMon's.
|
||||
--
|
||||
-- It is not a rewrite of the staged battle. The arena is picked the same
|
||||
-- way, the camera is solved the same way, the HUDs and the text box and the
|
||||
-- move animations and the depth of field are all exactly what 2D-3D draws
|
||||
-- -- because all of those are hung off the arena's CELLS, not off the
|
||||
-- pics. Swapping what stands on a cell changes nothing about where the cell
|
||||
-- projects to. That is why this is an option on the mode rather than a
|
||||
-- second mode.
|
||||
--
|
||||
-- ------- declining, per Pokemon
|
||||
--
|
||||
-- Every gate here is per SIDE and per FRAME, not per battle:
|
||||
--
|
||||
-- no pack for that species, or its meshes would not build -> that side
|
||||
-- falls back to its flat pic, and the other side keeps its model
|
||||
--
|
||||
-- the side is showing a TRAINER (the foe's class before the send-out,
|
||||
-- the player's own back before "Go!") -> that is not a Pokemon and there
|
||||
-- is no model for it; the pic stands, exactly as in 2D-3D
|
||||
--
|
||||
-- a SUBSTITUTE is up -> the engine replaces the pic with the mini doll,
|
||||
-- which is the thing the player is being told is there. A model of the
|
||||
-- Pokemon behind the doll would be a lie about the battle state.
|
||||
--
|
||||
-- So `covers` is asked per side per frame, and OverworldBattle renders a
|
||||
-- billboard texture for exactly the sides it answers false for.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local StadiumPack = V.require("StadiumPack")
|
||||
local StadiumMon = V.require("StadiumMon")
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
local ShinyFx = V.require("ShinyFx")
|
||||
|
||||
local Stadium = {}
|
||||
|
||||
-- The stored values of the two 3D-BTL rungs that select this mode. Strings
|
||||
-- rather than further booleans so an older save's `true` still means the
|
||||
-- 2D-3D it was written for (see OverworldBattle.setting).
|
||||
--
|
||||
-- A the models on the MAP -- real ground, the map's own light and sky
|
||||
-- B the models on two DISCS against the sky, with no map at all
|
||||
--
|
||||
-- Everything below is shared: which species is out, which animation the
|
||||
-- fight is asking for, the skinning, the draw. The difference is entirely
|
||||
-- in what the camera is pointed at, which is BattleScene's business and
|
||||
-- StadiumStage's.
|
||||
Stadium.VALUE = "stadium"
|
||||
Stadium.VALUE_B = "stadiumB"
|
||||
|
||||
-- ------- the live pair
|
||||
|
||||
local session = nil -- nil when no staged fight is running
|
||||
|
||||
local function game()
|
||||
return require("src.core.Game")
|
||||
end
|
||||
|
||||
-- Whether the row is on this rung. Deliberately NOT gated on whether the
|
||||
-- packs are installed: a mod folder without assets/stadium still cycles the
|
||||
-- row, and each Pokemon declines on its own when its pack does not load --
|
||||
-- which is one message on the console rather than a row that silently
|
||||
-- refuses to move.
|
||||
function Stadium.selected()
|
||||
return Stadium.mode() ~= nil
|
||||
end
|
||||
|
||||
-- "A", "B", or nil when the row is on neither stadium rung.
|
||||
function Stadium.mode()
|
||||
local OverworldBattle = V.require("OverworldBattle")
|
||||
local value = OverworldBattle.setting:get()
|
||||
if value == Stadium.VALUE then return "A" end
|
||||
if value == Stadium.VALUE_B then return "B" end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether the fight is staged on the DISCS rather than on the map.
|
||||
--
|
||||
-- Not this file's question any more: the flat 2D-3D B rung stands the game's
|
||||
-- own pics on the same two discs with no model anywhere in the frame, so the
|
||||
-- stage and the actors are chosen separately (see OverworldBattle's ladder).
|
||||
-- Kept as a forwarder because "are we on discs" is a fair thing to ask the
|
||||
-- module named after the mode, and because the shot drivers ask it here.
|
||||
function Stadium.discs()
|
||||
return V.require("OverworldBattle").discs()
|
||||
end
|
||||
|
||||
function Stadium.enabled()
|
||||
if not Stadium.selected() then return false end
|
||||
return Voxel3D.available()
|
||||
end
|
||||
|
||||
-- A staged fight has begun on `arena`. Called from OverworldBattle.begin,
|
||||
-- which is the one place that knows a fight is being staged at all.
|
||||
function Stadium.begin(arena)
|
||||
Stadium.finish()
|
||||
if not Stadium.enabled() then return false end
|
||||
-- a new fight gets its own first complaint: `reported` is a one-shot so the
|
||||
-- console is not filled sixty times a second, but latched for the whole
|
||||
-- process it would swallow every failure after the first one ever
|
||||
Stadium.reported = false
|
||||
session = {
|
||||
arena = arena,
|
||||
groundY = 0,
|
||||
player = StadiumMon.new("player"),
|
||||
enemy = StadiumMon.new("enemy"),
|
||||
-- what each side has been TRANSFORMED into, if anything (see install)
|
||||
transform = {},
|
||||
-- sides that are going to collapse, but whose HP bar has not finished
|
||||
-- emptying yet (see faintReady)
|
||||
faintPending = {},
|
||||
-- who was standing in each slot last frame, so a replacement is noticed
|
||||
-- even when it is the same species (see update)
|
||||
at = {},
|
||||
}
|
||||
return true
|
||||
end
|
||||
|
||||
function Stadium.finish()
|
||||
if not session then return end
|
||||
session.player:release()
|
||||
session.enemy:release()
|
||||
session = nil
|
||||
end
|
||||
|
||||
function Stadium.active()
|
||||
return session ~= nil
|
||||
end
|
||||
|
||||
-- ------- which species each side is showing
|
||||
|
||||
-- The National Dex number for a battler, which is the number the Stadium
|
||||
-- packs are keyed by. The engine's species are string keys ("PIKACHU") and
|
||||
-- carry their dex number on the definition, so this is one lookup rather
|
||||
-- than a table of its own.
|
||||
local function dexOf(species)
|
||||
if not species then return nil end
|
||||
local data = game() and game().data
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
return def and def.dex or nil
|
||||
end
|
||||
|
||||
-- Whether this side is showing a TRAINER rather than a Pokemon.
|
||||
local function showingTrainer(battle, side)
|
||||
if side == "enemy" then
|
||||
return (battle.showEnemyTrainer and battle.trainerPic) and true or false
|
||||
end
|
||||
return (battle.showPlayerBack and battle.playerBackPic) and true or false
|
||||
end
|
||||
|
||||
-- Whether this side has anything on the field at all this frame.
|
||||
--
|
||||
-- Mirrors BattleState's own guards, the same way OverworldBattle.sideVisible
|
||||
-- mirrors them for the flat cards: there is no seam that reports "the foe is
|
||||
-- off screen right now", and a model left standing through a send-out or a
|
||||
-- damage blink would be the one thing in the frame that ignored the battle.
|
||||
-- ------- and the collapse gets to finish
|
||||
--
|
||||
-- A fainted Pokemon leaves the field when its pic does, which is the end of
|
||||
-- the engine's slide -- SlideDownFaintedMonPic, seven rows two frames apart,
|
||||
-- FOURTEEN frames of a 60 Hz clock. Under a quarter of a second.
|
||||
--
|
||||
-- The Stadium faint animations are nothing like that short. The briefest in
|
||||
-- the set is 49 frames of a 30 Hz clock -- a second and two thirds -- the
|
||||
-- median is 110 and the longest 230, which is nearly eight seconds. Held to
|
||||
-- the pic's window every one of them was cut off inside its first fifth: the
|
||||
-- Pokemon began to fall and vanished mid-fall, which is worse than not
|
||||
-- animating at all, because the eye has been told something is happening and
|
||||
-- then had it taken away.
|
||||
--
|
||||
-- So a model that is COLLAPSING stays until it has finished collapsing, and
|
||||
-- the two timings stop being tied to each other. That is the whole of the
|
||||
-- divergence: the slide is how long a flat pic takes to slide off the bottom
|
||||
-- of a 160x144 frame, and it has nothing to say about how long it takes a
|
||||
-- Gyarados to fall over.
|
||||
--
|
||||
-- Bounded at both ends rather than open-ended. It ends when the animation
|
||||
-- does (StadiumMon.finished), not when the battle moves on -- so nothing is
|
||||
-- left lying on the field for the rest of the fight -- and the side is reset
|
||||
-- outright the moment a different battler stands in that slot (see update),
|
||||
-- which is what stops the next Pokemon out of the ball arriving face down.
|
||||
local function onField(battle, side, mon)
|
||||
local battler = side == "player" and battle.player or battle.enemy
|
||||
if not (battler and battler.sprite) then return false end
|
||||
-- A model that is GROWING out of its ball is on the field by definition --
|
||||
-- that is what the grow is -- even though the engine still calls the side
|
||||
-- "sending out", because the flat pic it wrote that flag for does not
|
||||
-- appear until the ball has finished opening and this one comes out with
|
||||
-- it (see StadiumMon.GROW_TIME).
|
||||
local growing = (mon and mon.grow) and true or false
|
||||
if side == "enemy" then
|
||||
if battle.enemyHidden then return false end
|
||||
if battle.enemySendingOut and not growing then return false end
|
||||
else
|
||||
if battle.safari or battle.demo then return false end
|
||||
if battle.sendingOut and not growing then return false end
|
||||
-- ------- and not before the battle has even opened
|
||||
--
|
||||
-- The player's Pokemon is not out during the INTRO. Every other guard
|
||||
-- here is a field the engine sets once the battle is running, and during
|
||||
-- the opening none of them is set yet: `showPlayerBack` is still nil
|
||||
-- (BattleState assigns it further in, when the back pic is built),
|
||||
-- `playerBackPic` is nil with it, and `sendingOut` does not go true until
|
||||
-- the ball is actually thrown. So the whole opening read as "this
|
||||
-- Pokemon is standing on the field" and the model was drawn through it --
|
||||
-- two and a half seconds of it, on its tile, playing its standby loop,
|
||||
-- before the trainer sprite it is supposed to be hiding behind had even
|
||||
-- appeared. It then vanished when that sprite arrived and came back with
|
||||
-- its entrance when the ball opened, so the first Pokemon of a battle
|
||||
-- appeared, left and arrived again.
|
||||
--
|
||||
-- A SWITCH has no intro, which is why a switch always looked right and
|
||||
-- was the thing worth comparing against.
|
||||
--
|
||||
-- Gated on the PHASE rather than on a flag latched at the send-out: a
|
||||
-- latch that never fires (a link battle, a script pushing a battle
|
||||
-- straight to the menu) would hide the Pokemon for good, and being wrong
|
||||
-- in that direction is far worse than the two seconds this fixes.
|
||||
if battle.phase == "intro" then return false end
|
||||
end
|
||||
local ok, hidden = pcall(battle.fxHidden, battle, battler)
|
||||
if ok and hidden then return false end
|
||||
-- ------- FLY and DIG: the Pokemon that is not there
|
||||
--
|
||||
-- `fxHidden` above is the damage BLINK and nothing else. The other way a
|
||||
-- Pokemon leaves the screen -- the important one -- is the engine's
|
||||
-- per-battler pic program, `picFx`, and that is where the two-turn moves
|
||||
-- live: FLY runs SE_SLIDE_MON_OFF and DIG SE_SLIDE_MON_DOWN on the charge
|
||||
-- turn, each a 19-24 frame slide that ENDS by setting `hidden`, and the
|
||||
-- release turn puts the pic back through SE_SLIDE_MON_UP /
|
||||
-- SE_SHOW_MON_PIC. Every other vanishing act is the same field: the user
|
||||
-- of Explosion, a Pokemon that has been Teleported away.
|
||||
--
|
||||
-- Without this the model simply stood on its tile while the game said it
|
||||
-- was underground -- and said it in the strongest way it has, by making
|
||||
-- every attack aimed at it miss. That is the one thing in the frame
|
||||
-- contradicting the battle it is part of.
|
||||
--
|
||||
-- Read as the engine's own answer rather than as a list of moves: this
|
||||
-- mode's whole method is to let the battle decide and follow it, and a
|
||||
-- table of move ids here would be a second place for the same facts to
|
||||
-- live and would go stale against a mod that adds a third one.
|
||||
--
|
||||
-- The engine's slide is 19-24 frames, so the model plays the opening of
|
||||
-- its own FLY or DIG animation while the pic slides and is gone when the
|
||||
-- pic is. It is NOT held to the end of that animation the way a collapse
|
||||
-- is (see below), and the difference is not an oversight: the Stadium
|
||||
-- animations are authored as the WHOLE move -- Charizard's DIG is 3.83
|
||||
-- seconds of burrow, emerge and hit -- because Stadium plays it in one
|
||||
-- turn. Gen 1 splits it across two, so cutting at the engine's own hide
|
||||
-- shows the burrowing and holds the strike back for the turn it lands on,
|
||||
-- which is the right half of the animation for the turn being played.
|
||||
local pf = battle.picFx and battle.picFx[battler]
|
||||
if pf and pf.hidden then return false end
|
||||
if battler.fainted then
|
||||
local okF, sliding = pcall(battle.fxFaintActive, battle, battler)
|
||||
if okF and sliding then return true end
|
||||
-- the pic has finished sliding away; the model has not finished falling
|
||||
return (mon and mon.state == "faint" and not mon:finished()) and true
|
||||
or false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
Stadium._onField = onField
|
||||
|
||||
-- Whether the 3D model is standing in for this side's pic this frame. The
|
||||
-- one question OverworldBattle asks, and the answer that decides whether a
|
||||
-- billboard texture gets rendered for that side at all.
|
||||
function Stadium.covers(battle, side)
|
||||
if not (session and battle) then return false end
|
||||
local mon = session[side]
|
||||
if not (mon and mon.rig) then return false end
|
||||
if showingTrainer(battle, side) then return false end
|
||||
local battler = side == "player" and battle.player or battle.enemy
|
||||
-- the substitute doll is what the player is being shown is out there
|
||||
if battler and battler.substituteHP then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the collapse waits for the bar
|
||||
--
|
||||
-- `onFaint` runs the instant HP reaches zero, which is NOT when a Pokemon
|
||||
-- falls over. The engine queues the collapse -- the slide, the cry, the
|
||||
-- "fainted!" line -- to run after the move animation and the HP-bar drain
|
||||
-- (BattleState.onFaint's own comment), and the drain takes real frames: a
|
||||
-- 150 HP mon's bar walks down over some four seconds.
|
||||
--
|
||||
-- So asking for the faint animation at `onFaint` played it against a bar
|
||||
-- that was still emptying: the Pokemon lay down, and then its health went on
|
||||
-- draining above the corpse. What the player reads as the moment of death is
|
||||
-- the bar hitting zero, and that is what this waits for.
|
||||
--
|
||||
-- `shownHP` is the engine's own bar position (BattleState.stepHPDrain walks
|
||||
-- it toward mon.hp a point at a time), so this is not a guess at the timing
|
||||
-- -- it is the same number the bar is drawn from.
|
||||
local function faintReady(battler)
|
||||
if not battler then return false end
|
||||
-- nothing is animating the bar for this battler: there is nothing to wait
|
||||
-- for, and waiting forever would mean never collapsing at all
|
||||
if battler.shownHP == nil then return true end
|
||||
return battler.shownHP <= 0
|
||||
end
|
||||
|
||||
-- Whether a pending collapse is still owed. A switch, a revive or a battler
|
||||
-- that was replaced under us drops it rather than firing late at whoever is
|
||||
-- standing there now.
|
||||
local function faintStillDue(battler)
|
||||
return (battler and battler.faintQueued
|
||||
and battler.mon and (battler.mon.hp or 0) <= 0) and true or false
|
||||
end
|
||||
|
||||
-- named for the suite: these timing rules are the whole of what decides when
|
||||
-- a Pokemon falls and when it goes, and they are testable without a graphics
|
||||
-- context where the mode itself is not
|
||||
Stadium._faintReady = faintReady
|
||||
Stadium._faintStillDue = faintStillDue
|
||||
|
||||
-- ------- per frame
|
||||
--
|
||||
-- Runs from OverworldBattle.update, before the pics are rendered and before
|
||||
-- the scene is drawn: what this decides is exactly which sides need a pic.
|
||||
function Stadium.update(dt, battle, groundY)
|
||||
if not session then return end
|
||||
session.groundY = groundY or session.groundY or 0
|
||||
if not battle then return end
|
||||
|
||||
local arena = session.arena
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local mon = session[side]
|
||||
local battler = side == "player" and battle.player or battle.enemy
|
||||
local dex = nil
|
||||
if battler and not showingTrainer(battle, side) then
|
||||
dex = session.transform[side] or dexOf(battler.mon and battler.mon.species)
|
||||
end
|
||||
|
||||
-- A DIFFERENT POKEMON IS IN THIS SLOT. Normally that shows up as a
|
||||
-- change of species and setSpecies rebuilds everything -- but a trainer
|
||||
-- who leads with two Rattata sends the second one out onto the first
|
||||
-- one's dex number, so nothing downstream would notice. What it would
|
||||
-- inherit is the state, and the state after a faint is `faint`, which
|
||||
-- refuses every request there is (see StadiumMon.request -- a faint is
|
||||
-- meant to be final). The new Pokemon would arrive lying on the ground.
|
||||
--
|
||||
-- The battler TABLE is the identity here rather than the species or the
|
||||
-- mon: it is the slot's occupant, and the engine replaces it on a switch,
|
||||
-- a send-out and a new battle alike.
|
||||
if session.at[side] ~= battler then
|
||||
session.at[side] = battler
|
||||
-- a fresh arrival: this Pokemon has not grown out of its ball yet
|
||||
if mon then mon.grow, mon.grewOwn = nil, nil end
|
||||
if mon and mon.rig and mon.state == "faint" then mon:play("idle") end
|
||||
-- and if it is shiny, announce it. This edge rather than the grow,
|
||||
-- because a WILD foe never grows -- it is on the field from the
|
||||
-- first frame -- and that is the encounter a shiny most wants to be
|
||||
-- announced on. See the header of ShinyFx.
|
||||
if ShinyBattle.battlerIsShiny(battler) then
|
||||
ShinyFx.arm(side)
|
||||
else
|
||||
ShinyFx.clear(side)
|
||||
end
|
||||
end
|
||||
-- the collapse this side is owed, once its bar has finished emptying
|
||||
if session.faintPending and session.faintPending[side] then
|
||||
if not faintStillDue(battler) then
|
||||
session.faintPending[side] = nil
|
||||
elseif faintReady(battler) then
|
||||
session.faintPending[side] = nil
|
||||
if mon and mon.rig then mon:request("faint") end
|
||||
end
|
||||
end
|
||||
|
||||
-- Shininess is a property of the OCCUPANT, not of the species, so it is
|
||||
-- resolved here beside the dex number and passed with it. A shiny
|
||||
-- Rattata and an ordinary one are the same dex and different models.
|
||||
--
|
||||
-- Read off the battler rather than remembered, because Transform makes
|
||||
-- the two disagree: a Ditto that copied a shiny Rattata wears the
|
||||
-- Rattata's dex (session.transform above) and keeps its OWN shininess,
|
||||
-- which is exactly what the games do.
|
||||
local shiny = battler ~= nil and not session.transform[side]
|
||||
and ShinyBattle.battlerIsShiny(battler)
|
||||
|
||||
mon:setSpecies(dex, shiny)
|
||||
-- and tell the pack cache this one is standing there, every frame. Its
|
||||
-- eviction order is keyed on LOADS, and a side only loads when its
|
||||
-- species changes -- so without this a Pokemon that has been out for a
|
||||
-- few turns is the least recently loaded thing in the cache and gets its
|
||||
-- textures released out from under it the moment a fifth species enters
|
||||
-- the battle (see StadiumPack.keep). The shiny flag rides along: the
|
||||
-- shiny and normal models are separate cache entries.
|
||||
if mon.species then StadiumPack.keep(mon.species, mon.shiny) end
|
||||
|
||||
-- how big this Pokemon actually is, so a shiny's sparkle can be sized to
|
||||
-- it rather than to a constant that is wrong for most of the dex (see
|
||||
-- the header of ShinyFx). Pushed every frame: the model can arrive a
|
||||
-- frame or two after the burst is armed, and a send-out is still growing
|
||||
-- while it plays.
|
||||
if mon.rig and mon.model then
|
||||
ShinyFx.setMetrics(side, mon:worldHeight(), mon:worldRadius())
|
||||
else
|
||||
ShinyFx.setMetrics(side, nil)
|
||||
end
|
||||
|
||||
-- and let a waiting sparkle GO, once the fight is actually the thing on
|
||||
-- screen. The battle draws underneath the transition wipe for about a
|
||||
-- second before that, and a burst released then plays out its whole life
|
||||
-- behind it -- armed, drawn, counted, and never seen, which is exactly
|
||||
-- how this looked when it was keyed on the scene drawing instead.
|
||||
local g = game()
|
||||
local top = g and g.stack and g.stack:top()
|
||||
if top == battle then ShinyFx.release(side) end
|
||||
mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
|
||||
and not (battler and battler.substituteHP)
|
||||
-- LET'S GO capture mode: the player's model is out of the shot the
|
||||
-- same way its card and back pic are (the shrink half of the story is
|
||||
-- below, AFTER the grow block, which reassigns mon.scale every frame)
|
||||
local cap = V.require("BattleScene").capture
|
||||
if side == "player" and cap and cap.hidePlayer then
|
||||
mon.visible = false
|
||||
end
|
||||
-- cleared up front, so a side that has just lost its rig cannot leave
|
||||
-- last frame's matrix behind it
|
||||
mon.model_matrix = nil
|
||||
|
||||
if mon.rig then
|
||||
-- ------- the ball is opening: start growing out of it
|
||||
--
|
||||
-- The POOF is the ball coming apart, and it is where a Pokemon should
|
||||
-- begin to exist -- not 27 frames later when the engine starts scaling
|
||||
-- up the flat pic it was written for. Only for a side the battle says
|
||||
-- is actually sending out, so the same animation played at a thrown
|
||||
-- Poke Ball (a capture attempt, which aims it at the FOE) cannot start
|
||||
-- the wrong Pokemon growing.
|
||||
local poof = (battle.animPlaying
|
||||
and battle.animName == "POOF_ANIM") and true or false
|
||||
local sending = (side == "player") and battle.sendingOut
|
||||
or battle.enemySendingOut
|
||||
if poof and sending and mon:beginGrow() then
|
||||
-- and the arrival animation with it, so the whole thing is one
|
||||
-- performance rather than a grow followed by a flourish
|
||||
mon:request("entrance")
|
||||
end
|
||||
|
||||
-- how big it is drawn. Its own ramp while it is growing (see
|
||||
-- StadiumMon.growScale); the engine's three-step one otherwise, which
|
||||
-- still covers a send-out that never showed a poof.
|
||||
if mon.grow then
|
||||
mon.scale = mon:growScale()
|
||||
elseif mon.grewOwn then
|
||||
mon.scale = 1
|
||||
else
|
||||
local okG, grow = pcall(battle.growInScale, battle, battler)
|
||||
mon.scale = (okG and grow) or 1
|
||||
end
|
||||
-- LET'S GO capture: the foe drinking into the ball. AFTER the grow
|
||||
-- block on purpose -- that block reassigns mon.scale every frame,
|
||||
-- and the first cut of this hook sat above it and was silently
|
||||
-- clobbered: the model stood at full size over a ball that had
|
||||
-- supposedly swallowed it. The session's fraction owns the scale
|
||||
-- for as long as it exists; the frame it clears, the grow block
|
||||
-- above is already putting the engine's own answer back.
|
||||
if side == "enemy" and cap and cap.shrink then
|
||||
mon.scale = cap.shrink
|
||||
end
|
||||
mon:update(dt or 0)
|
||||
if mon.visible and arena then
|
||||
local cell = arena[side]
|
||||
local other = arena[side == "player" and "enemy" or "player"]
|
||||
if cell and other then
|
||||
-- posed and skinned inside the same guard the draws use: this is
|
||||
-- where a bad track or a released texture is first touched, and a
|
||||
-- throw here would take the OTHER side's update with it (the
|
||||
-- caller wraps this whole function in one pcall)
|
||||
Stadium.guard(side, mon, "build", function()
|
||||
mon.model_matrix = mon:matrix(cell[1], session.groundY, cell[2],
|
||||
other[1] - cell[1],
|
||||
other[2] - cell[2])
|
||||
mon:build()
|
||||
end)
|
||||
else
|
||||
mon.model_matrix = nil
|
||||
end
|
||||
else
|
||||
mon.model_matrix = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Stadium.debug(dt)
|
||||
end
|
||||
|
||||
-- ------- the draws
|
||||
--
|
||||
-- Both take the pass as they find it: this is called from inside
|
||||
-- BattleScene's own beginScene/endScene window (and, in a headset, from
|
||||
-- VoxelScene's), so the camera, the shadow map, the hour's tint and the hit
|
||||
-- flash are all already set. StadiumRig turns the wireframe and the glass
|
||||
-- mask off around its own draws and puts them back.
|
||||
|
||||
-- ------- one model going wrong is not both
|
||||
--
|
||||
-- These two draws used to be a bare loop inside the caller's single pcall,
|
||||
-- which had two consequences and both were bad. A throw on the FIRST side
|
||||
-- skipped the second, so one broken Pokemon took its opponent off the screen
|
||||
-- with it. And nothing recorded that it had happened, so the same throw came
|
||||
-- back every frame for the rest of the fight -- the mode's own fallback (that
|
||||
-- side draws its flat pic instead) was sitting right there and never reached,
|
||||
-- because falling back needs somebody to decide the model is not working.
|
||||
--
|
||||
-- So each side is drawn inside its own pcall, and a side that throws is
|
||||
-- RETIRED: its rig is released, which is exactly the state a species with no
|
||||
-- pack is in, and OverworldBattle renders a billboard for it from the next
|
||||
-- frame on. The fight carries on with a flat Pokemon instead of a missing
|
||||
-- one, which is the difference the player actually sees.
|
||||
-- On the TABLE rather than a local, because Stadium.update calls it and sits
|
||||
-- above this line: a local would still be nil there.
|
||||
function Stadium.guard(side, mon, what, fn)
|
||||
local ok, err = pcall(fn)
|
||||
if ok then return true end
|
||||
Stadium.report(err)
|
||||
-- release rather than merely hide: the rig holds meshes and texture
|
||||
-- references, and whatever went wrong with them is not going to be better
|
||||
-- next frame. setSpecies rebuilds from scratch if this Pokemon is sent out
|
||||
-- again later.
|
||||
if mon.rig then pcall(mon.release, mon) end
|
||||
mon.rig, mon.visible, mon.model_matrix = nil, false, nil
|
||||
if session then session.broken = session.broken or {} end
|
||||
if session then session.broken[side] = what end
|
||||
return false
|
||||
end
|
||||
|
||||
-- The foe's body for the capture mode's collision and ring, when a MODEL
|
||||
-- is standing there instead of a pic: its own measured height and
|
||||
-- footprint, in world pixels. A model stands on the ground, so the body's
|
||||
-- centre is half its height up. nil whenever no model covers the foe,
|
||||
-- which sends CatchThrow to its pic measurement instead.
|
||||
function Stadium.captureBody()
|
||||
if not session then return nil end
|
||||
local mon = session.enemy
|
||||
if not (mon and mon.rig and mon.visible) then return nil end
|
||||
local okH, h = pcall(mon.worldHeight, mon)
|
||||
if not (okH and h and h > 0) then return nil end
|
||||
-- The POSED body, when there is one: a flying Pokemon is nowhere near
|
||||
-- the mark its cell projects to, and only the pose knows where it went
|
||||
-- (StadiumMon:bodySpan). The bind-pose figures stand in until the
|
||||
-- first skin, which is right for everything that keeps its feet down.
|
||||
local okS, centre, half, girth = pcall(mon.bodySpan, mon)
|
||||
if okS and centre then
|
||||
return { r = math.max(5, math.min(16, math.max(girth or 0, half * 0.8))),
|
||||
yOff = centre,
|
||||
hh = math.max(4, half) }
|
||||
end
|
||||
local okR, r = pcall(mon.worldRadius, mon)
|
||||
local rr = (okR and r and r > 0) and r or h * 0.4
|
||||
return { r = math.max(5, math.min(16, math.max(rr, h * 0.5))),
|
||||
yOff = h * 0.5,
|
||||
hh = math.max(4, h * 0.55) }
|
||||
end
|
||||
|
||||
function Stadium.draw(pull)
|
||||
if not session then return end
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local mon = session[side]
|
||||
if mon.rig and mon.visible and mon.model_matrix then
|
||||
Stadium.guard(side, mon, "draw", function()
|
||||
mon.rig:draw(mon.model_matrix, pull)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The same models as the SUN sees them, so a Pokemon throws the shadow of
|
||||
-- the pose it is actually in -- an outstretched wing puts an outstretched
|
||||
-- wing on the ground.
|
||||
function Stadium.cast(shadowMap)
|
||||
if not session then return end
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local mon = session[side]
|
||||
if mon.rig and mon.visible and mon.model_matrix then
|
||||
Stadium.guard(side, mon, "cast", function()
|
||||
mon.rig:caster(shadowMap, mon.model_matrix)
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Which state a side's model is playing, or nil. Named for the shot drivers:
|
||||
-- checking that an animation starts on the right FRAME is an ordering
|
||||
-- question, and a screenshot cannot answer one.
|
||||
function Stadium.animOf(side)
|
||||
if not session then return nil end
|
||||
local mon = session[side]
|
||||
return mon and mon.state or nil
|
||||
end
|
||||
|
||||
-- Whether this side's model is actually being drawn this frame. Named for
|
||||
-- the shot drivers alongside animOf: "how long does it stay" is a span, and
|
||||
-- a screenshot taken at one moment has no span in it.
|
||||
function Stadium.showing(side)
|
||||
if not session then return false end
|
||||
local mon = session[side]
|
||||
return (mon and mon.visible) and true or false
|
||||
end
|
||||
|
||||
-- How big this side's model is being drawn this frame, 0..1 -- the send-out
|
||||
-- grow. Named for the shot drivers: a ramp is a curve over time and a
|
||||
-- screenshot has one point of it.
|
||||
function Stadium.scaleOf(side)
|
||||
if not session then return nil end
|
||||
local mon = session[side]
|
||||
return mon and mon.scale or nil
|
||||
end
|
||||
|
||||
-- How wide the Pokemon on `side` stands, in world pixels, or nil when there
|
||||
-- is not one. What STADIUM B sizes that side's platform to (StadiumStage).
|
||||
function Stadium.footprint(side)
|
||||
if not session then return nil end
|
||||
local mon = session[side]
|
||||
if not (mon and mon.model) then return nil end
|
||||
local r = mon:worldRadius()
|
||||
return (r > 0) and r or nil
|
||||
end
|
||||
|
||||
-- Whether anything at all is standing this frame -- what the shadow
|
||||
-- signature keys on alongside the pics' own token.
|
||||
function Stadium.standing()
|
||||
if not session then return false end
|
||||
return (session.player.visible or session.enemy.visible) and true or false
|
||||
end
|
||||
|
||||
-- ------- what the fight asks for
|
||||
--
|
||||
-- The animation state machine is driven from four points in the engine's
|
||||
-- own battle, and each is a wrap rather than a rewrite: the inner function
|
||||
-- runs exactly as it always did and this reads what went past.
|
||||
|
||||
local function sideOf(battle, battler)
|
||||
if not (session and battler) then return nil end
|
||||
if battler == battle.player then return "player" end
|
||||
if battler == battle.enemy then return "enemy" end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function ask(battle, battler, state, animIndex, auxIndex)
|
||||
local side = sideOf(battle, battler)
|
||||
if not side then return end
|
||||
local mon = session[side]
|
||||
if mon and mon.rig then mon:request(state, animIndex, auxIndex) end
|
||||
end
|
||||
|
||||
function Stadium.install()
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
if BattleState.dramaticShapeStadiumHook then return end
|
||||
BattleState.dramaticShapeStadiumHook = true
|
||||
|
||||
-- THE ATTACK. performMove is the one place a move is actually used, and
|
||||
-- the move's own `index` is the Gen 1 move id the Stadium tables are
|
||||
-- keyed by -- so the species' own animation for that move comes straight
|
||||
-- out of the pack, with no name mapping and no per-move code.
|
||||
local innerMove = BattleState.performMove
|
||||
function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
if session then
|
||||
local side = sideOf(self, user)
|
||||
local mon = side and session[side]
|
||||
if mon and mon.rig then
|
||||
local okDef, def = pcall(self.moveDef, self, moveInst)
|
||||
local index = okDef and def and def.index or nil
|
||||
if not (index and mon:attack(index)) then
|
||||
-- a move the table has nothing for still swings: the generic
|
||||
-- attack is what the species' own reaction slot resolves to
|
||||
mon:request("attack")
|
||||
end
|
||||
end
|
||||
end
|
||||
return innerMove(self, user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
-- THE HIT is deliberately NOT hooked. There is no damage reaction in this
|
||||
-- set to play -- what looked like one is the species' default attack (see
|
||||
-- StadiumMon's STATES), which is why being hit used to look like swinging.
|
||||
-- The engine's own flash, pic blink and bar drain are what say "that hurt",
|
||||
-- and they are already in the frame.
|
||||
|
||||
-- THE FAINT. Held on its last frame rather than looped (see StadiumMon's
|
||||
-- STATES), because a Pokemon that collapses and then stands back up
|
||||
-- while the message is still on screen is worse than no animation.
|
||||
--
|
||||
-- RECORDED HERE, PLAYED LATER. This runs the moment HP reaches zero, which
|
||||
-- is several seconds before the Pokemon is supposed to fall over -- the
|
||||
-- engine queues the collapse behind the move animation and the HP-bar
|
||||
-- drain. Marking the side and letting Stadium.update fire it when the bar
|
||||
-- empties is what keeps the two together (see faintReady).
|
||||
local innerFaint = BattleState.onFaint
|
||||
function BattleState:onFaint(battler)
|
||||
if session and not (battler and battler.faintQueued) then
|
||||
local side = sideOf(self, battler)
|
||||
if side and session.faintPending then
|
||||
session.faintPending[side] = true
|
||||
end
|
||||
end
|
||||
return innerFaint(self, battler)
|
||||
end
|
||||
|
||||
-- THE ENTRANCE. startGrowIn is the send-out: the ball opens, the pic
|
||||
-- scales up over twelve frames, and the model plays the animation the
|
||||
-- battle system's own entrance slot names.
|
||||
local innerGrow = BattleState.startGrowIn
|
||||
function BattleState:startGrowIn(battler)
|
||||
if session then
|
||||
-- unless the model is already on its way out of the ball, in which
|
||||
-- case the entrance started with the POOF (see update) and asking
|
||||
-- again here would restart it a third of a second in
|
||||
local side = sideOf(self, battler)
|
||||
local mon = side and session[side]
|
||||
if not (mon and mon.grow) then ask(self, battler, "entrance") end
|
||||
end
|
||||
return innerGrow(self, battler)
|
||||
end
|
||||
|
||||
-- TRANSFORM. The engine records a transform by swapping the battler's
|
||||
-- sprite and nothing else, so this is the only seam that reports one --
|
||||
-- and it reports the side, which is all that is needed to point that
|
||||
-- side's model at the copied species. Cleared when a side's own species
|
||||
-- changes under it (a switch, or the next battle).
|
||||
local innerSpecies = BattleState.speciesSprite
|
||||
function BattleState:speciesSprite(species, isPlayerSide)
|
||||
if session then
|
||||
session.transform[isPlayerSide and "player" or "enemy"] = dexOf(species)
|
||||
end
|
||||
return innerSpecies(self, species, isPlayerSide)
|
||||
end
|
||||
|
||||
-- and a switch or a send-out ends any transform on that side
|
||||
local innerSwitch = BattleState.resolveSwitch
|
||||
function BattleState:resolveSwitch(newMon)
|
||||
if session then session.transform.player = nil end
|
||||
return innerSwitch(self, newMon)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- when a draw goes wrong
|
||||
--
|
||||
-- The draw and the shadow cast are both called through a pcall, because a
|
||||
-- throw inside the scene pass would hand the whole voxel mode to Pipelines'
|
||||
-- guard and retire it for the session. Swallowed silently, though, a broken
|
||||
-- model is indistinguishable from an invisible one -- so the first failure
|
||||
-- of a battle says so, once, and the rest of the fight carries on without
|
||||
-- it.
|
||||
Stadium.reported = false
|
||||
|
||||
function Stadium.report(err)
|
||||
if Stadium.reported then return end
|
||||
Stadium.reported = true
|
||||
V.mod.log:warn("stadium: a model failed and was retired for this battle: "
|
||||
.. "%s -- that Pokemon falls back to its flat battle pic, "
|
||||
.. "and its opponent is unaffected", tostring(err))
|
||||
end
|
||||
|
||||
-- DS_STADIUM_DEBUG=1 prints what each side resolved to once a second, which
|
||||
-- is how "nothing is on screen" gets told apart from "nothing was asked
|
||||
-- for". Read through pcall: the loader's sandbox does not hand a mod `os`,
|
||||
-- and a diagnostic must never be why the mod fails to load.
|
||||
local DEBUG = select(2, pcall(function() return os.getenv("DS_STADIUM_DEBUG") end))
|
||||
if DEBUG == nil or DEBUG == false then DEBUG = nil end
|
||||
|
||||
local debugAt = 0
|
||||
|
||||
function Stadium.debug(dt)
|
||||
if not (DEBUG and session) then return end
|
||||
debugAt = debugAt + (dt or 0)
|
||||
if debugAt < 1 then return end
|
||||
debugAt = 0
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local mon = session[side]
|
||||
local m = mon.model_matrix
|
||||
V.mod.log:info("stadium %s: dex=%s rig=%s visible=%s anim=%s t=%.2f "
|
||||
.. "height=%.1f at=%s",
|
||||
side, tostring(mon.species), tostring(mon.rig ~= nil),
|
||||
tostring(mon.visible), tostring(mon.anim), mon.time or 0,
|
||||
mon.model and mon:worldHeight() or 0,
|
||||
m and ("%.0f,%.0f,%.0f"):format(m[4], m[8], m[12]) or "-")
|
||||
end
|
||||
end
|
||||
|
||||
function Stadium.invalidate()
|
||||
if session then
|
||||
session.player:release()
|
||||
session.enemy:release()
|
||||
end
|
||||
StadiumPack.invalidate()
|
||||
-- the discs are a mesh and a texture like anything else, and a graphics
|
||||
-- context that went away took them with it
|
||||
pcall(function() V.require("StadiumStage").invalidate() end)
|
||||
end
|
||||
|
||||
return Stadium
|
||||
@@ -0,0 +1,750 @@
|
||||
-- STADIUM battles: turning the ROM into assets/stadium/NNN.dsm.
|
||||
--
|
||||
-- The Lua half of tools/stadium_pack.py: measure the bind pose, decide
|
||||
-- whether a species' standby loop can be trusted, and write the packed file.
|
||||
-- Together with StadiumRom, StadiumFragment and StadiumFx this is everything
|
||||
-- between `baserom.z64` and a Pokemon standing on a battle tile.
|
||||
--
|
||||
-- The Python remains the ORACLE. tests/stadium_extract_test.lua runs this
|
||||
-- over the same ROM and requires all 151 files to come out byte for byte
|
||||
-- identical to what the packer writes. That is a strong test in a way a unit
|
||||
-- test of any one function here would not be: every rounding mode, every
|
||||
-- iteration order, every off-by-one in an index shows up as a differing byte,
|
||||
-- and there are thirty-four megabytes of them.
|
||||
--
|
||||
-- ------- stepped, not blocking
|
||||
--
|
||||
-- `StadiumBuild.job()` returns a coroutine-backed job that does one species
|
||||
-- per `step()`, so the caller can draw a progress bar between them
|
||||
-- (StadiumInstall). A species is a few tens of milliseconds; the whole set is
|
||||
-- around half a minute, which is far too long to spend inside one frame and
|
||||
-- perfectly fine spread across a loading screen.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumFragment = V.require("StadiumFragment")
|
||||
local StadiumFx = V.require("StadiumFx")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local StadiumBuild = {}
|
||||
|
||||
local floor = math.floor
|
||||
local sin, cos = math.sin, math.cos
|
||||
local pi = math.pi
|
||||
local char = string.char
|
||||
local concat = table.concat
|
||||
local frexp = math.frexp
|
||||
local roundHalfEven = StadiumFragment.roundHalfEven
|
||||
|
||||
-- The battle system's fixed context slots, in slot order from 165. The mod
|
||||
-- indexes this list by POSITION, so the ORDER is the format's contract and
|
||||
-- has to stay identical to StadiumPack.CONTEXT and to the packer's CONTEXTS.
|
||||
StadiumBuild.CONTEXTS = {
|
||||
"idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170",
|
||||
"reaction_171", "reaction_172", "reaction_173", "reaction_174",
|
||||
"struggle", "idle_alt", "faint_alt", "flinch", "reaction_179",
|
||||
"reaction_180", "reaction_181", "reaction_182", "entrance_alt",
|
||||
"idle_return",
|
||||
}
|
||||
|
||||
-- Which context name wins when several claim the same animation.
|
||||
local NAME_PREF = { "idle", "attack_default", "faint", "entrance",
|
||||
"struggle", "flinch" }
|
||||
|
||||
local N_MOVES = StadiumRom.N_MOVES
|
||||
local CTX_BASE = 165
|
||||
local NONE16 = 0xFFFF
|
||||
|
||||
-- ------- the bind pose
|
||||
|
||||
-- The game's rotation as a 3x3, rows first (src/F420.c func_8000F730):
|
||||
-- Rx*Ry*Rz in row-vector form.
|
||||
local function quatBasis(r)
|
||||
local sx, cx = sin(r[1] / 32768 * pi), cos(r[1] / 32768 * pi)
|
||||
local sy, cy = sin(r[2] / 32768 * pi), cos(r[2] / 32768 * pi)
|
||||
local sz, cz = sin(r[3] / 32768 * pi), cos(r[3] / 32768 * pi)
|
||||
return { cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz },
|
||||
{ cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz },
|
||||
{ -sy, sx * cy, cx * cy }
|
||||
end
|
||||
|
||||
-- 3x4 (three rotation rows plus a translation column) times the same.
|
||||
local function matMul(a, b)
|
||||
local out = {}
|
||||
for r = 1, 3 do
|
||||
local ar = a[r]
|
||||
out[r] = {
|
||||
ar[1] * b[1][1] + ar[2] * b[2][1] + ar[3] * b[3][1],
|
||||
ar[1] * b[1][2] + ar[2] * b[2][2] + ar[3] * b[3][2],
|
||||
ar[1] * b[1][3] + ar[2] * b[2][3] + ar[3] * b[3][3],
|
||||
ar[1] * b[1][4] + ar[2] * b[2][4] + ar[3] * b[3][4] + ar[4],
|
||||
}
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- One component of one bone's t/r/s at a frame. The extractor's own shape: a
|
||||
-- bare number when the component holds still for the whole animation, one
|
||||
-- number a frame when it does not.
|
||||
local function component(comps, i, frame, fallback)
|
||||
if comps == nil then return fallback end
|
||||
local c = comps[i]
|
||||
if type(c) == "table" then
|
||||
local n = #c
|
||||
if n == 0 then return fallback end
|
||||
return c[frame % n + 1]
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
-- The bone TRS an animation holds at `frame`, rest where it is silent.
|
||||
local function animSample(bones, anim, frame)
|
||||
local tracks = anim.tracks
|
||||
return function(i)
|
||||
local b = bones[i]
|
||||
local tr = tracks[i]
|
||||
if not tr then return b.t, b.r, b.s end
|
||||
return { component(tr.t, 1, frame, b.t[1]),
|
||||
component(tr.t, 2, frame, b.t[2]),
|
||||
component(tr.t, 3, frame, b.t[3]) },
|
||||
{ component(tr.r, 1, frame, b.r[1]),
|
||||
component(tr.r, 2, frame, b.r[2]),
|
||||
component(tr.r, 3, frame, b.r[3]) },
|
||||
{ component(tr.s, 1, frame, b.s[1]),
|
||||
component(tr.s, 2, frame, b.s[2]),
|
||||
component(tr.s, 3, frame, b.s[3]) }
|
||||
end
|
||||
end
|
||||
|
||||
local function restSample(bones)
|
||||
return function(i)
|
||||
local b = bones[i]
|
||||
return b.t, b.r, b.s
|
||||
end
|
||||
end
|
||||
|
||||
-- Every bone's draw matrix at one instant, as 3x4 rows.
|
||||
--
|
||||
-- The game keeps bone scale OUT of the matrix chain: it accumulates in its own
|
||||
-- stack, a bone's local translation is pre-multiplied by the PARENT's
|
||||
-- accumulated scale, and the bone's own accumulated scale is applied to the
|
||||
-- finished matrix at draw time.
|
||||
--
|
||||
-- Two chains, and the distinction is the whole point: `pivot` is the
|
||||
-- rotation/translation a CHILD inherits, and the draw matrix is that with the
|
||||
-- bone's own accumulated scale applied on the right. Folding the scale into
|
||||
-- the chain instead applies every ancestor's scale twice -- which is exactly
|
||||
-- the multiplicative propagation glTF has and the game does not.
|
||||
local function bindMatrices(bones, sample)
|
||||
sample = sample or restSample(bones)
|
||||
local pivot, draw, acc = {}, {}, {}
|
||||
local IDENT = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 } }
|
||||
for i = 1, #bones do
|
||||
local bt, br, bs = sample(i)
|
||||
local p = bones[i].parent
|
||||
local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 }
|
||||
local pm = (p >= 0) and pivot[p + 1] or IDENT
|
||||
local r1, r2, r3 = quatBasis(br)
|
||||
local m = matMul(pm, {
|
||||
{ r1[1], r1[2], r1[3], bt[1] * pa[1] },
|
||||
{ r2[1], r2[2], r2[3], bt[2] * pa[2] },
|
||||
{ r3[1], r3[2], r3[3], bt[3] * pa[3] },
|
||||
})
|
||||
local a = { pa[1] * bs[1], pa[2] * bs[2], pa[3] * bs[3] }
|
||||
acc[i] = a
|
||||
pivot[i] = m
|
||||
-- scale on the right: the bone's own space, so it cannot reach children
|
||||
draw[i] = {
|
||||
{ m[1][1] * a[1], m[1][2] * a[2], m[1][3] * a[3], m[1][4] },
|
||||
{ m[2][1] * a[1], m[2][2] * a[2], m[2][3] * a[3], m[2][4] },
|
||||
{ m[3][1] * a[1], m[3][2] * a[2], m[3][3] * a[3], m[3][4] },
|
||||
}
|
||||
end
|
||||
return draw
|
||||
end
|
||||
|
||||
StadiumBuild.bindMatrices = bindMatrices
|
||||
StadiumBuild.animSample = animSample
|
||||
|
||||
-- The axis-aligned box the whole model occupies under `mats`, in game units
|
||||
-- after the model_root scale.
|
||||
local function poseBox(data, mats)
|
||||
local root = data.rootScale[1]
|
||||
local lo1, lo2, lo3 = 1e30, 1e30, 1e30
|
||||
local hi1, hi2, hi3 = -1e30, -1e30, -1e30
|
||||
for _, prim in ipairs(data.prims) do
|
||||
local pos, skin = prim.pos, prim.skin
|
||||
for i = 1, prim.nverts do
|
||||
local m = mats[skin[i] + 1]
|
||||
if m then
|
||||
local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3]
|
||||
local a = (m[1][1] * x + m[1][2] * y + m[1][3] * z + m[1][4]) * root
|
||||
local b = (m[2][1] * x + m[2][2] * y + m[2][3] * z + m[2][4]) * root
|
||||
local c = (m[3][1] * x + m[3][2] * y + m[3][3] * z + m[3][4]) * root
|
||||
if a < lo1 then lo1 = a end
|
||||
if b < lo2 then lo2 = b end
|
||||
if c < lo3 then lo3 = c end
|
||||
if a > hi1 then hi1 = a end
|
||||
if b > hi2 then hi2 = b end
|
||||
if c > hi3 then hi3 = c end
|
||||
end
|
||||
end
|
||||
end
|
||||
return lo1, lo2, lo3, hi1, hi2, hi3
|
||||
end
|
||||
|
||||
-- (height, floor, radius): how tall the mon is, where its lowest point sits
|
||||
-- relative to the model's own origin, and how wide it is -- all in game units
|
||||
-- after the model_root scale.
|
||||
--
|
||||
-- Measured on the BIND POSE, which is the one pose in the set that can be
|
||||
-- trusted for this. It reproduces the verified glTF export exactly on all 151
|
||||
-- species, and it is immune to the animation quirks a handful of them carry
|
||||
-- (see idleIsBroken) -- quirks that would otherwise decide how big every OTHER
|
||||
-- frame of those species is drawn.
|
||||
--
|
||||
-- The floor is the interesting number, and it reads cleanly: 119 of the 151
|
||||
-- sit within 5% of zero, which says the model origin IS where the game stands
|
||||
-- a Pokemon on its field. Every species that does not is one that hovers.
|
||||
local function stance(data)
|
||||
local lo1, lo2, lo3, hi1, hi2, hi3 = poseBox(data, bindMatrices(data.bones))
|
||||
if lo1 > hi1 then return 0.0, 0.0, 0.0 end
|
||||
local w, d = hi1 - lo1, hi3 - lo3
|
||||
return hi2 - lo2, lo2, (w > d and w or d) / 2
|
||||
end
|
||||
|
||||
StadiumBuild.stance = stance
|
||||
|
||||
-- Whether this species' standby loop is corrupt as extracted.
|
||||
--
|
||||
-- No species trips this today. Exeggutor, Tangela and Magmar used to, when
|
||||
-- the flags byte was misread and their hermite-keyframe animations were
|
||||
-- decoded as packed streams, throwing bones hundreds of units off the body.
|
||||
-- It stays as the guard against the next extraction bug: played, a broken
|
||||
-- idle looks like a Pokemon coming apart, and the mod would rather show
|
||||
-- the sprite fallback (see StadiumMon).
|
||||
--
|
||||
-- The test is deliberately narrow, because "differs from the bind pose" is NOT
|
||||
-- brokenness. It is asked only of the STANDBY loop -- the one animation that
|
||||
-- is supposed to stay where it is, since a faint is meant to end far from the
|
||||
-- standing pose and an attack is meant to lunge -- and it wants both a large
|
||||
-- size blow-up and real drift, or an enormous amount of one. Dewgong is what
|
||||
-- calibrates it: its idle is 2.4x its own bind pose because the BIND is the
|
||||
-- collapsed one, and it drifts barely at all, so it must not be caught.
|
||||
local function idleIsBroken(data, idle)
|
||||
if idle == nil then return false end
|
||||
local bones = data.bones
|
||||
local _, lo2, _, _, hi2 = poseBox(data, bindMatrices(bones))
|
||||
local span = hi2 - lo2
|
||||
if span <= 0 then return false end
|
||||
local worstH, worstDrift = 1.0, 0.0
|
||||
local frame = 0
|
||||
while frame < idle.frames do
|
||||
local _, flo2, _, _, fhi2 = poseBox(data,
|
||||
bindMatrices(bones, animSample(bones, idle, frame)))
|
||||
local h = (fhi2 - flo2) / span
|
||||
if h > worstH then worstH = h end
|
||||
local d1 = (flo2 - lo2) / span
|
||||
local d2 = (fhi2 - hi2) / span
|
||||
if d1 < 0 then d1 = -d1 end
|
||||
if d2 < 0 then d2 = -d2 end
|
||||
if d1 > worstDrift then worstDrift = d1 end
|
||||
if d2 > worstDrift then worstDrift = d2 end
|
||||
frame = frame + 3
|
||||
end
|
||||
return (worstH > 2.5 and worstDrift > 1.5)
|
||||
or worstDrift > 2.0 or worstH > 3.4
|
||||
end
|
||||
|
||||
-- ------- writing
|
||||
|
||||
local function clamp(v, lo, hi)
|
||||
if v < lo then return lo end
|
||||
if v > hi then return hi end
|
||||
return v
|
||||
end
|
||||
|
||||
-- Toward zero, which is what Python's int() does to a float and NOT what
|
||||
-- floor() does to a negative one.
|
||||
--
|
||||
-- It matters in exactly one place, and it is easy to miss: almost everything
|
||||
-- reaching the integer writers below has already been rounded, so truncation
|
||||
-- is a no-op on it. The exception is the generated effects' crossed quads
|
||||
-- (StadiumFx), whose vertices are raw floats and straddle the origin -- so
|
||||
-- the ones at negative x, and only those, come out a unit adrift if this
|
||||
-- floors.
|
||||
local function trunc(v)
|
||||
if v >= 0 then return floor(v) end
|
||||
return -floor(-v)
|
||||
end
|
||||
|
||||
-- 16.16, which holds every bone scale in the set (-31 .. 100) with more
|
||||
-- precision than anything can see.
|
||||
local function fixed(v)
|
||||
return clamp(roundHalfEven(v * 65536), -2147483648, 2147483647)
|
||||
end
|
||||
|
||||
-- IEEE 754 single, little-endian, rounded to nearest with ties to even -- the
|
||||
-- same rounding Python's struct.pack('<f') does, so the four floats in the
|
||||
-- header come out bit for bit the same as the packer's.
|
||||
local function f32(x)
|
||||
local sign = 0
|
||||
if x < 0 or (x == 0 and 1 / x < 0) then
|
||||
sign = 128
|
||||
x = -x
|
||||
end
|
||||
if x ~= x then return char(0, 0, 192, 127 + sign) end -- NaN
|
||||
if x == math.huge then return char(0, 0, 128, 127 + sign) end
|
||||
if x == 0 then return char(0, 0, 0, sign) end
|
||||
local m, e = frexp(x) -- x = m * 2^e, 0.5 <= m < 1
|
||||
local E = e - 1 + 127
|
||||
local mant
|
||||
if E >= 255 then
|
||||
return char(0, 0, 128, 127 + sign) -- overflow
|
||||
elseif E <= 0 then
|
||||
-- subnormal: no exponent left, so the mantissa carries the whole value
|
||||
mant = roundHalfEven(x / 2 ^ -149)
|
||||
if mant >= 8388608 then
|
||||
mant, E = mant - 8388608, 1
|
||||
else
|
||||
E = 0
|
||||
end
|
||||
else
|
||||
mant = roundHalfEven((m * 2 - 1) * 8388608)
|
||||
if mant == 8388608 then -- rounded up into the next
|
||||
mant, E = 0, E + 1
|
||||
if E >= 255 then return char(0, 0, 128, 127 + sign) end
|
||||
end
|
||||
end
|
||||
local b4 = sign + floor(E / 2)
|
||||
local b3 = (E % 2) * 128 + floor(mant / 65536)
|
||||
local b2 = floor(mant / 256) % 256
|
||||
local b1 = mant % 256
|
||||
return char(b1, b2, b3, b4)
|
||||
end
|
||||
|
||||
StadiumBuild.f32 = f32
|
||||
|
||||
local Writer = {}
|
||||
Writer.__index = Writer
|
||||
|
||||
local function newWriter()
|
||||
return setmetatable({ parts = {}, n = 0 }, Writer)
|
||||
end
|
||||
|
||||
function Writer:raw(s)
|
||||
self.n = self.n + 1
|
||||
self.parts[self.n] = s
|
||||
end
|
||||
|
||||
function Writer:u8(v)
|
||||
self:raw(char(v % 256))
|
||||
end
|
||||
|
||||
function Writer:i8(v)
|
||||
v = clamp(trunc(v), -128, 127)
|
||||
self:raw(char(v % 256))
|
||||
end
|
||||
|
||||
function Writer:u16(v)
|
||||
v = v % 65536
|
||||
self:raw(char(v % 256, floor(v / 256)))
|
||||
end
|
||||
|
||||
function Writer:i16(v)
|
||||
v = clamp(trunc(v), -32768, 32767) % 65536
|
||||
self:raw(char(v % 256, floor(v / 256)))
|
||||
end
|
||||
|
||||
function Writer:u32(v)
|
||||
v = v % 4294967296
|
||||
self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256,
|
||||
floor(v / 16777216) % 256))
|
||||
end
|
||||
|
||||
function Writer:i32(v)
|
||||
v = clamp(trunc(v), -2147483648, 2147483647) % 4294967296
|
||||
self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256,
|
||||
floor(v / 16777216) % 256))
|
||||
end
|
||||
|
||||
function Writer:f32(v)
|
||||
self:raw(f32(v))
|
||||
end
|
||||
|
||||
function Writer:bytes()
|
||||
return concat(self.parts)
|
||||
end
|
||||
|
||||
-- One component of one bone's t/r/s in one animation. `values` is the
|
||||
-- extractor's own shape: a bare number when the component holds still for the
|
||||
-- whole animation, or one number a frame when it does not. That fold is where
|
||||
-- most of the size saving is -- a bone that only rotates costs two bytes for
|
||||
-- each of its six other components.
|
||||
local function writeTrackComponent(w, values, kind)
|
||||
local isArray = type(values) == "table"
|
||||
w:u8(isArray and 1 or 0)
|
||||
if kind == "s" then
|
||||
if isArray then
|
||||
for i = 1, #values do w:i32(fixed(values[i])) end
|
||||
else
|
||||
w:i32(fixed(values))
|
||||
end
|
||||
else
|
||||
if isArray then
|
||||
for i = 1, #values do w:i16(roundHalfEven(values[i])) end
|
||||
else
|
||||
w:i16(roundHalfEven(values))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Which animation each fixed battle context slot resolves to: entries 165
|
||||
-- upward of the species' own battle table, in slot order. An entry naming an
|
||||
-- animation the species does not have is written as "none" rather than
|
||||
-- clamped -- the mod would rather fall back than play the wrong clip.
|
||||
function StadiumBuild.contextTable(rows, nAnims)
|
||||
local ctx = {}
|
||||
for i = 1, #StadiumBuild.CONTEXTS do
|
||||
local row = rows[CTX_BASE + i - 1]
|
||||
local ai = row and row[1] or nil
|
||||
ctx[i] = (ai ~= nil and ai < nAnims) and ai or NONE16
|
||||
end
|
||||
return ctx
|
||||
end
|
||||
|
||||
-- ------- naming the animations
|
||||
--
|
||||
-- build.py's label_animations. The names are not read at runtime -- the mod
|
||||
-- addresses animations by index through the move and context tables -- but
|
||||
-- they are in the format, so they have to be produced the same way for the
|
||||
-- oracle diff to mean anything. They also make a packed file readable in a
|
||||
-- hex dump, which is worth the byte apiece.
|
||||
|
||||
local function labelAnimations(data, rows, nAux)
|
||||
local anims = data.anims
|
||||
local n = #anims
|
||||
local uses, moveUses = {}, {}
|
||||
local auxOrder, auxCount = {}, {}
|
||||
for i = 1, n do
|
||||
uses[i], moveUses[i] = {}, 0
|
||||
auxOrder[i], auxCount[i] = {}, {}
|
||||
end
|
||||
for e = 0, rows.n - 1 do
|
||||
local ai = rows[e][1]
|
||||
if ai < n then
|
||||
if e < N_MOVES then
|
||||
moveUses[ai + 1] = moveUses[ai + 1] + 1
|
||||
elseif e >= CTX_BASE and e < CTX_BASE + #StadiumBuild.CONTEXTS then
|
||||
local list = uses[ai + 1]
|
||||
list[#list + 1] = StadiumBuild.CONTEXTS[e - CTX_BASE + 1]
|
||||
end
|
||||
local ax = rows[e][2]
|
||||
if ax >= 0 and ax < nAux then
|
||||
local counts, order = auxCount[ai + 1], auxOrder[ai + 1]
|
||||
if counts[ax] == nil then
|
||||
counts[ax] = 0
|
||||
order[#order + 1] = ax
|
||||
end
|
||||
counts[ax] = counts[ax] + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, n do
|
||||
-- sorted(set(uses)) -- the alphabetically first context is the fallback
|
||||
-- name, so the ordering is part of the answer
|
||||
local seen, ctx = {}, {}
|
||||
for _, name in ipairs(uses[i]) do
|
||||
if not seen[name] then
|
||||
seen[name] = true
|
||||
ctx[#ctx + 1] = name
|
||||
end
|
||||
end
|
||||
table.sort(ctx)
|
||||
local name = nil
|
||||
for _, pref in ipairs(NAME_PREF) do
|
||||
if seen[pref] then
|
||||
name = pref
|
||||
break
|
||||
end
|
||||
end
|
||||
if not name then
|
||||
if moveUses[i] > 0 then
|
||||
name = "attack"
|
||||
elseif ctx[1] then
|
||||
name = ctx[1]
|
||||
else
|
||||
name = "anim" .. (i - 1)
|
||||
end
|
||||
end
|
||||
anims[i].name = name
|
||||
-- Counter.most_common(1): the highest count, and on a tie the one that
|
||||
-- was inserted first
|
||||
local best, bestN = -1, -1
|
||||
local order, counts = auxOrder[i], auxCount[i]
|
||||
for _, ax in ipairs(order) do
|
||||
if counts[ax] > bestN then
|
||||
best, bestN = ax, counts[ax]
|
||||
end
|
||||
end
|
||||
anims[i].aux = best
|
||||
end
|
||||
|
||||
local seenName = {}
|
||||
for i = 1, n do
|
||||
local base = anims[i].name
|
||||
local k = seenName[base] or 0
|
||||
seenName[base] = k + 1
|
||||
if k > 0 then anims[i].name = base .. "_" .. (k + 1) end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the pack
|
||||
|
||||
function StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
local w = newWriter()
|
||||
local bones, prims = data.bones, data.prims
|
||||
local textures, anims, aux = data.textures, data.anims, data.auxAnims
|
||||
|
||||
local height, floorY, radius = stance(data)
|
||||
|
||||
local idleIndex = ctx[1] -- CONTEXTS[1] is "idle"
|
||||
local idle = (idleIndex ~= NONE16) and anims[idleIndex + 1] or nil
|
||||
local static = idleIsBroken(data, idle)
|
||||
|
||||
w:raw("DSM3")
|
||||
w:u16(species)
|
||||
w:u16(#bones)
|
||||
w:u16(#prims)
|
||||
w:u16(#textures)
|
||||
w:u16(#anims)
|
||||
w:u16(#aux)
|
||||
w:f32(data.rootScale[1])
|
||||
-- 1 = hold the bind pose, never play an animation
|
||||
w:u8(static and 1 or 0)
|
||||
w:f32(height)
|
||||
w:f32(floorY)
|
||||
w:f32(radius)
|
||||
|
||||
for m = 1, N_MOVES do
|
||||
local row = moveRows[m]
|
||||
w:u16((row and row[1] < #anims) and row[1] or NONE16)
|
||||
end
|
||||
for m = 1, N_MOVES do
|
||||
local row = moveRows[m]
|
||||
w:i16((row and row[2] >= 0 and row[2] < #aux) and row[2] or -1)
|
||||
end
|
||||
for i = 1, #ctx do w:u16(ctx[i]) end
|
||||
|
||||
for i = 1, #bones do
|
||||
local b = bones[i]
|
||||
w:i16(b.parent)
|
||||
for k = 1, 3 do w:i16(roundHalfEven(b.t[k])) end
|
||||
for k = 1, 3 do w:i16(b.r[k]) end
|
||||
for k = 1, 3 do w:i32(fixed(b.s[k])) end
|
||||
end
|
||||
|
||||
for i = 1, #prims do
|
||||
local p = prims[i]
|
||||
w:u16(p.tex)
|
||||
-- the display list's own cull mode: 1024 is G_CULL_BACK
|
||||
w:u8((p.cull and p.cull ~= 0) and 1 or 0)
|
||||
w:u8((p.blend == "add") and 1 or 0)
|
||||
w:i16(p.texAnim or -1)
|
||||
-- sorted by the stream's own byte, which is what the reader keys on
|
||||
local keys = {}
|
||||
if p.texMap then
|
||||
for k in pairs(p.texMap) do keys[#keys + 1] = k end
|
||||
table.sort(keys)
|
||||
end
|
||||
w:u8(#keys)
|
||||
for _, k in ipairs(keys) do
|
||||
w:u8(k)
|
||||
w:u16(p.texMap[k])
|
||||
end
|
||||
local frames = p.fxFrames
|
||||
w:u16(frames and #frames or 0)
|
||||
if frames then
|
||||
for k = 1, #frames do w:u16(frames[k]) end
|
||||
end
|
||||
local pos, uv, nrm, skin = p.pos, p.uv, p.nrm, p.skin
|
||||
w:u16(p.nverts)
|
||||
w:u16(p.nidx)
|
||||
for k = 1, p.nverts do
|
||||
w:i16(pos[k * 3 - 2])
|
||||
w:i16(pos[k * 3 - 1])
|
||||
w:i16(pos[k * 3])
|
||||
-- 1/512, which puts a texel of the largest texture in the set well
|
||||
-- inside a step and still reaches the +-32 the wrapped coordinates of
|
||||
-- some display lists run to
|
||||
w:i16(roundHalfEven(uv[k * 2 - 1] * 512))
|
||||
w:i16(roundHalfEven(uv[k * 2] * 512))
|
||||
w:i8(roundHalfEven(nrm[k * 3 - 2] * 127))
|
||||
w:i8(roundHalfEven(nrm[k * 3 - 1] * 127))
|
||||
w:i8(roundHalfEven(nrm[k * 3] * 127))
|
||||
w:u8(skin[k])
|
||||
end
|
||||
for k = 1, p.nidx do w:u16(p.idx[k]) end
|
||||
end
|
||||
|
||||
for i = 1, #textures do
|
||||
local t = textures[i]
|
||||
w:u16(t.w)
|
||||
w:u16(t.h)
|
||||
w:u32(#t.rgba)
|
||||
w:raw(t.rgba)
|
||||
end
|
||||
|
||||
local REST = { t = { 0, 0, 0 }, r = { 0, 0, 0 }, s = { 1.0, 1.0, 1.0 } }
|
||||
for i = 1, #anims do
|
||||
local a = anims[i]
|
||||
local name = a.name or ""
|
||||
if #name > 255 then name = name:sub(1, 255) end
|
||||
w:u8(#name)
|
||||
w:raw(name)
|
||||
w:u16(a.frames)
|
||||
w:u16(a.loopStart or 0)
|
||||
w:i16(a.aux or -1)
|
||||
for bi = 1, #bones do
|
||||
local tr = a.tracks[bi]
|
||||
if not tr then
|
||||
w:u8(0)
|
||||
else
|
||||
w:u8(1)
|
||||
for _, key in ipairs({ "t", "r", "s" }) do
|
||||
local comps = tr[key]
|
||||
if comps == nil then
|
||||
-- a bone the animation leaves at its rest value for this path:
|
||||
-- written as three constants so the reader never has to branch on
|
||||
-- a missing path
|
||||
comps = bones[bi][key] or REST[key]
|
||||
end
|
||||
for c = 1, 3 do writeTrackComponent(w, comps[c], key) end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
for i = 1, #aux do
|
||||
local a = aux[i]
|
||||
w:u16(a.frames)
|
||||
w:u16(a.loopStart or 0)
|
||||
w:u16(#a.channels)
|
||||
for _, ch in ipairs(a.channels) do
|
||||
w:u16(ch.n)
|
||||
for k = 1, ch.n do w:u16(ch[k]) end
|
||||
end
|
||||
end
|
||||
|
||||
return w:bytes(), height, floorY, radius
|
||||
end
|
||||
|
||||
-- ------- one species, end to end
|
||||
|
||||
-- The same three steps build.py takes: parse the fragment, label the
|
||||
-- animations off the species' battle table, then hang the generated fire/gas
|
||||
-- stand-ins on the bones the game's own effect callbacks hang off.
|
||||
function StadiumBuild.species(rom, fileno)
|
||||
local blob = rom:model(fileno)
|
||||
if not blob then return nil, ("file %d is not in the archive"):format(fileno) end
|
||||
local data, err = StadiumFragment.extract(blob, ("%d.bin"):format(fileno))
|
||||
if not data then return nil, err end
|
||||
local species = data.species
|
||||
local rows = rom:battleRows(species)
|
||||
labelAnimations(data, rows, #data.auxAnims)
|
||||
StadiumFx.attach(data, species)
|
||||
|
||||
local moveRows = {}
|
||||
for m = 1, N_MOVES do moveRows[m] = rows[m - 1] end
|
||||
local ctx = StadiumBuild.contextTable(rows, #data.anims)
|
||||
local bytes, height, floorY, radius =
|
||||
StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
|
||||
-- ------- and the shiny, from the same extraction
|
||||
--
|
||||
-- ORDER MATTERS AND IS THE WHOLE TRICK. The normal pack is written FIRST,
|
||||
-- off untouched texels, so `bytes` is bit-for-bit what it has always been
|
||||
-- and tests/stadium_extract_test.lua keeps diffing green against the
|
||||
-- Python oracle. Only then are the textures recoloured and the model
|
||||
-- packed a second time. The oracle knows nothing about shiny and does not
|
||||
-- need to: the format did not move, so there is no second implementation
|
||||
-- to keep in step and no DSM4.
|
||||
--
|
||||
-- Recolouring HERE rather than at load is what makes the effect textures
|
||||
-- separable. StadiumFx marks its generated frames `generated = true` and
|
||||
-- the packer drops the field, so this is the last moment a flame is
|
||||
-- distinguishable from a hide without inferring it back from the prim
|
||||
-- table. A shiny Charizard has a shiny hide and an ordinary fire.
|
||||
--
|
||||
-- Failure is not fatal: a species whose colours we lack, or a transform
|
||||
-- that throws, simply ships without a shiny variant and the runtime falls
|
||||
-- back to the normal model. Losing a recolour is a blemish; losing the
|
||||
-- install is a broken mod.
|
||||
local shinyBytes
|
||||
local ok, err = pcall(function()
|
||||
local spec = ShinyPalette.forDex(species)
|
||||
if not spec then return end
|
||||
if ShinyPalette.recolorTextures(data.textures, spec) == 0 then return end
|
||||
shinyBytes = StadiumBuild.pack(data, species, moveRows, ctx)
|
||||
end)
|
||||
if not ok and V and V.mod and V.mod.log then
|
||||
V.mod.log.warn("shiny recolour failed for species %d: %s",
|
||||
species, tostring(err))
|
||||
end
|
||||
|
||||
return { species = species, bytes = bytes, shinyBytes = shinyBytes,
|
||||
height = height,
|
||||
floor = floorY, radius = radius, bones = #data.bones,
|
||||
prims = #data.prims, anims = #data.anims,
|
||||
warnings = data.warnings }
|
||||
end
|
||||
|
||||
-- ------- the stepped job
|
||||
--
|
||||
-- `write(species, bytes)` is called for each finished pack and must answer
|
||||
-- truthy; anything else stops the job with an error. Returning a job rather
|
||||
-- than taking a callback for progress keeps the caller in charge of when work
|
||||
-- happens, which is what lets a loading screen stay responsive.
|
||||
function StadiumBuild.job(rom, write, count)
|
||||
local total = count or StadiumRom.N_POKEMON
|
||||
local n = rom:modelCount()
|
||||
if total > n then total = n end
|
||||
local job = { total = total, done = 0, bytes = 0, failed = {}, species = nil }
|
||||
|
||||
function job:step()
|
||||
if self.done >= self.total then return false end
|
||||
local fileno = self.done
|
||||
local ok, res, err = pcall(StadiumBuild.species, rom, fileno)
|
||||
if ok and res then
|
||||
local wrote, wErr = write(res.species, res.bytes, res.shinyBytes)
|
||||
if not wrote then
|
||||
self.error = wErr or ("could not write species " .. res.species)
|
||||
self.done = self.total
|
||||
return false
|
||||
end
|
||||
self.bytes = self.bytes + #res.bytes
|
||||
if res.shinyBytes then
|
||||
self.bytes = self.bytes + #res.shinyBytes
|
||||
self.shiny = (self.shiny or 0) + 1
|
||||
end
|
||||
self.species = res.species
|
||||
else
|
||||
self.failed[#self.failed + 1] = fileno
|
||||
self.lastError = ok and err or res
|
||||
end
|
||||
self.done = self.done + 1
|
||||
return self.done < self.total
|
||||
end
|
||||
|
||||
function job:progress()
|
||||
if self.total <= 0 then return 1 end
|
||||
return self.done / self.total
|
||||
end
|
||||
|
||||
return job
|
||||
end
|
||||
|
||||
return StadiumBuild
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,436 @@
|
||||
-- STADIUM battles: the generated fire and gas stand-ins.
|
||||
--
|
||||
-- A port of model_extract/pipeline/effects.py, plus the bind-pose measurement
|
||||
-- build.py sizes them against.
|
||||
--
|
||||
-- IMPORTANT: nothing here is extracted game data. The real tail flame, mane
|
||||
-- fire and gas are drawn by procedural callbacks that live in a different
|
||||
-- fragment -- geo command 0x08 records an attachment point and
|
||||
-- func_80014A60 calls node->unk_10, and the model file supplies only two
|
||||
-- empty display lists plus zeroed scratch buffers for it to fill. Those
|
||||
-- callbacks have not been ported, so the models genuinely contain no flame
|
||||
-- mesh and no flame texture: Charmander's texture set is eyes, claws, teeth
|
||||
-- and skin.
|
||||
--
|
||||
-- What follows is an ORIGINAL, procedurally generated replacement -- looping
|
||||
-- flipbook noise on a pair of crossed quads, anchored to the exact bone the
|
||||
-- callback hangs off so it sits where the real effect would and follows the
|
||||
-- animation. Seeds derive from the species number, so a given Pokemon always
|
||||
-- generates the same flame.
|
||||
--
|
||||
-- Which species get one is the game's own grouping: every species sharing a
|
||||
-- callback shares an effect.
|
||||
--
|
||||
-- 0x810000D8 Charmander, Charmeleon, Charizard, Magmar, Moltres tail flame
|
||||
-- 0x81000108 Ponyta, Rapidash, Moltres's wings small flame
|
||||
-- 0x810000E0 Gastly (only) gas cloud
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local StadiumFx = {}
|
||||
|
||||
local floor = math.floor
|
||||
local sqrt = math.sqrt
|
||||
local sin, cos = math.sin, math.cos
|
||||
local char = string.char
|
||||
local concat = table.concat
|
||||
local pi = math.pi
|
||||
|
||||
local FIRE_TAIL = 0x810000D8
|
||||
local FIRE_SMALL = 0x81000108
|
||||
local AURA = 0x810000E0
|
||||
|
||||
-- Desired size as a fraction of the model's world-space height: length, width.
|
||||
StadiumFx.SIZES = {
|
||||
fire_tail = { 0.40, 0.22 },
|
||||
fire_small = { 0.075, 0.042 },
|
||||
gas = { 1.05, 1.05 },
|
||||
}
|
||||
|
||||
-- ------- 32-bit exclusive-or, in arithmetic
|
||||
--
|
||||
-- The generator below is an xorshift, so it needs a real 32-bit xor and a
|
||||
-- real 32-bit wrap. Written out rather than taken from LuaJIT's `bit`, which
|
||||
-- works in SIGNED 32-bit and would need converting back on every step -- see
|
||||
-- the same note in StadiumFragment.
|
||||
|
||||
local function bxor32(a, b)
|
||||
local r, p = 0, 1
|
||||
for _ = 1, 32 do
|
||||
local x, y = a % 2, b % 2
|
||||
if x ~= y then r = r + p end
|
||||
a, b, p = floor(a / 2), floor(b / 2), p * 2
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
-- ------- deterministic noise
|
||||
|
||||
local Rng = {}
|
||||
Rng.__index = Rng
|
||||
|
||||
local function newRng(seed)
|
||||
local s = seed % 0x100000000
|
||||
if s == 0 then s = 0x9E3779B9 end
|
||||
return setmetatable({ s = s }, Rng)
|
||||
end
|
||||
|
||||
function Rng:next()
|
||||
local x = self.s
|
||||
x = bxor32(x, (x % 0x80000) * 0x2000) -- x ^= (x << 13)
|
||||
x = bxor32(x, floor(x / 0x20000)) -- x ^= x >> 17
|
||||
x = bxor32(x, (x % 0x8000000) * 0x20) -- x ^= (x << 5)
|
||||
self.s = x % 0x100000000
|
||||
return self.s
|
||||
end
|
||||
|
||||
function Rng:unit()
|
||||
return self:next() / 0x100000000
|
||||
end
|
||||
|
||||
-- A w-by-h lattice of unit noise, consumed row by row so the sequence -- and
|
||||
-- therefore the texture -- is reproducible.
|
||||
local function lattice(rng, w, h)
|
||||
local g = {}
|
||||
for y = 1, h do
|
||||
local row = {}
|
||||
for x = 1, w do row[x] = rng:unit() end
|
||||
g[y] = row
|
||||
end
|
||||
return g
|
||||
end
|
||||
|
||||
local function smooth(t)
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
-- Bilinear value noise on a torus, so the field tiles in both axes.
|
||||
local function sample(grid, x, y)
|
||||
local h = #grid
|
||||
local w = #grid[1]
|
||||
local fx0, fy0 = floor(x), floor(y)
|
||||
local x0, y0 = fx0 % w, fy0 % h
|
||||
local x1, y1 = (x0 + 1) % w, (y0 + 1) % h
|
||||
local fx, fy = smooth(x - fx0), smooth(y - fy0)
|
||||
local r0, r1 = grid[y0 + 1], grid[y1 + 1]
|
||||
local a = r0[x0 + 1] + (r0[x1 + 1] - r0[x0 + 1]) * fx
|
||||
local b = r1[x0 + 1] + (r1[x1 + 1] - r1[x0 + 1]) * fx
|
||||
return a + (b - a) * fy
|
||||
end
|
||||
|
||||
-- Sum octaves of tileable noise.
|
||||
local function fbm(grids, x, y, scale)
|
||||
local total, amp, norm = 0.0, 1.0, 0.0
|
||||
for i = 1, #grids do
|
||||
local f = scale * 2 ^ (i - 1)
|
||||
total = total + sample(grids[i], x * f, y * f) * amp
|
||||
norm = norm + amp
|
||||
amp = amp * 0.5
|
||||
end
|
||||
return total / norm
|
||||
end
|
||||
|
||||
-- Intensity -> RGBA, through a piecewise ramp.
|
||||
local function ramp(stops, t)
|
||||
if t < 0.0 then t = 0.0 elseif t > 1.0 then t = 1.0 end
|
||||
for i = 1, #stops - 1 do
|
||||
local a, b = stops[i], stops[i + 1]
|
||||
if t <= b[1] then
|
||||
local k = 0.0
|
||||
if b[1] ~= a[1] then k = (t - a[1]) / (b[1] - a[1]) end
|
||||
return floor(a[2] + (b[2] - a[2]) * k), floor(a[3] + (b[3] - a[3]) * k),
|
||||
floor(a[4] + (b[4] - a[4]) * k), floor(a[5] + (b[5] - a[5]) * k)
|
||||
end
|
||||
end
|
||||
local last = stops[#stops]
|
||||
return last[2], last[3], last[4], last[5]
|
||||
end
|
||||
|
||||
local FIRE_RAMP = {
|
||||
{ 0.00, 0, 0, 0, 0 },
|
||||
{ 0.30, 120, 24, 8, 90 },
|
||||
{ 0.52, 226, 78, 16, 205 },
|
||||
{ 0.74, 252, 176, 44, 245 },
|
||||
{ 1.00, 255, 246, 214, 255 },
|
||||
}
|
||||
|
||||
local GAS_RAMP = {
|
||||
{ 0.00, 0, 0, 0, 0 },
|
||||
{ 0.34, 52, 26, 78, 70 },
|
||||
{ 0.60, 96, 52, 140, 140 },
|
||||
{ 0.82, 148, 96, 196, 190 },
|
||||
{ 1.00, 208, 176, 236, 215 },
|
||||
}
|
||||
|
||||
local TRANSPARENT = char(0, 0, 0, 0)
|
||||
|
||||
-- An upward-advected noise plume. Scrolling by an exact multiple of the
|
||||
-- lattice over the frame count is what makes the loop seamless.
|
||||
local function fireFrames(seed, w, h, frames, wisp)
|
||||
wisp = wisp or 1.0
|
||||
local rng = newRng(seed)
|
||||
local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
|
||||
lattice(rng, 32, 32) }
|
||||
local out = {}
|
||||
for f = 0, frames - 1 do
|
||||
local t = f / frames
|
||||
local buf = {}
|
||||
for i = 1, w * h do buf[i] = TRANSPARENT end
|
||||
for y = 0, h - 1 do
|
||||
local v = y / (h - 1) -- 0 at the base, 1 at the tip
|
||||
-- plume envelope: wide and hot at the base, pinched at the tip
|
||||
local taper = 1.0 - v
|
||||
if taper < 0.0 then taper = 0.0 end
|
||||
taper = taper ^ 0.42
|
||||
for x = 0, w - 1 do
|
||||
local u = (x / (w - 1)) * 2 - 1 -- -1 .. 1 across the flame
|
||||
local denom = taper * 0.95
|
||||
if denom < 0.10 then denom = 0.10 end
|
||||
local radial = 1.0 - (u < 0 and -u or u) / denom
|
||||
if radial > 0 then
|
||||
radial = radial ^ 0.7
|
||||
local n = fbm(grids, x / w, (y / h) - t, 3.0)
|
||||
local lick = 0.55 + 0.75 * (n - 0.5) * wisp
|
||||
local inten = radial * (0.55 + 0.8 * taper) * lick
|
||||
inten = inten - 0.16 * v -- cool towards the tip
|
||||
if inten > 0.02 then
|
||||
local r, g, b, a = ramp(FIRE_RAMP, inten)
|
||||
-- +Y in texture space is up
|
||||
buf[(h - 1 - y) * w + x + 1] = char(r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
out[f + 1] = concat(buf)
|
||||
end
|
||||
return w, h, out
|
||||
end
|
||||
|
||||
-- Slow swirling haze that fades out towards the rim.
|
||||
local function gasFrames(seed, w, h, frames)
|
||||
local rng = newRng(seed)
|
||||
local grids = { lattice(rng, 8, 8), lattice(rng, 16, 16),
|
||||
lattice(rng, 32, 32) }
|
||||
local out = {}
|
||||
for f = 0, frames - 1 do
|
||||
local t = f / frames
|
||||
local buf = {}
|
||||
for i = 1, w * h do buf[i] = TRANSPARENT end
|
||||
local ang = t * 2 * pi
|
||||
local ca, sa = cos(ang), sin(ang)
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local dx = (x / (w - 1)) * 2 - 1
|
||||
local dy = (y / (h - 1)) * 2 - 1
|
||||
local d = sqrt(dx * dx + dy * dy)
|
||||
if d < 1.0 then
|
||||
local falloff = (1.0 - d) ^ 0.85
|
||||
-- rotate the sample point so the haze churns without popping
|
||||
local sx = dx * ca - dy * sa
|
||||
local sy = dx * sa + dy * ca
|
||||
local n = fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
|
||||
local inten = falloff * (0.78 + 1.30 * (n - 0.44))
|
||||
if inten > 0.03 then
|
||||
local r, g, b, a = ramp(GAS_RAMP, inten)
|
||||
buf[y * w + x + 1] = char(r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
out[f + 1] = concat(buf)
|
||||
end
|
||||
return w, h, out
|
||||
end
|
||||
|
||||
-- Two quads at right angles, so the effect reads from any angle. `axis` picks
|
||||
-- which bone-local direction the quad grows along: bone-local +X runs down the
|
||||
-- limb, so a flame laid out along X comes out lying sideways, and 'y' is that
|
||||
-- same quad turned a quarter left about Z, which stands it up. `centred`
|
||||
-- straddles the origin instead of growing from it.
|
||||
local function crossedQuads(bone, length, width, axis, centred)
|
||||
local pos, uv, nrm, skin, idx = {}, {}, {}, {}, {}
|
||||
local ST = { { 0, 0 }, { 1, 0 }, { 1, 1 }, { 0, 1 } }
|
||||
local nv, ni = 0, 0
|
||||
for q = 0, 1 do
|
||||
local base = nv
|
||||
for k = 1, 4 do
|
||||
local s, t = ST[k][1], ST[k][2]
|
||||
local a = (s - 0.5) * width
|
||||
local b = centred and (t - 0.5) * length or t * length
|
||||
local px, py, pz
|
||||
if axis == "x" then
|
||||
if q == 0 then px, py, pz = b, a, 0.0 else px, py, pz = b, 0.0, a end
|
||||
else -- (x, y) -> (-y, x)
|
||||
if q == 0 then px, py, pz = -a, b, 0.0 else px, py, pz = 0.0, b, a end
|
||||
end
|
||||
pos[nv * 3 + 1], pos[nv * 3 + 2], pos[nv * 3 + 3] = px, py, pz
|
||||
uv[nv * 2 + 1], uv[nv * 2 + 2] = s, 1.0 - t
|
||||
if q == 0 then
|
||||
nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 0.0, 0.0, 1.0
|
||||
else
|
||||
nrm[nv * 3 + 1], nrm[nv * 3 + 2], nrm[nv * 3 + 3] = 1.0, 0.0, 0.0
|
||||
end
|
||||
skin[nv + 1] = bone
|
||||
nv = nv + 1
|
||||
end
|
||||
idx[ni + 1], idx[ni + 2], idx[ni + 3] = base, base + 1, base + 2
|
||||
idx[ni + 4], idx[ni + 5], idx[ni + 6] = base, base + 2, base + 3
|
||||
ni = ni + 6
|
||||
end
|
||||
return { pos = pos, uv = uv, nrm = nrm, skin = skin, nverts = nv,
|
||||
idx = idx, nidx = ni }
|
||||
end
|
||||
|
||||
-- ------- the bind pose these are sized against
|
||||
--
|
||||
-- build.py's bind_extent, kept in its own 4x4 column-major convention rather
|
||||
-- than folded into StadiumBuild's 3x4 walk. The two agree -- they are the
|
||||
-- same skeleton -- but the effect sizes come out of THIS one's per-bone scale
|
||||
-- measurement, and rewriting it into the other convention is exactly the kind
|
||||
-- of change that moves a byte without anyone noticing.
|
||||
|
||||
local function trs(t, r, s)
|
||||
local function S(v) return sin(v / 32768 * pi) end
|
||||
local function C(v) return cos(v / 32768 * pi) end
|
||||
local sx, cx = S(r[1]), C(r[1])
|
||||
local sy, cy = S(r[2]), C(r[2])
|
||||
local sz, cz = S(r[3]), C(r[3])
|
||||
return { cy * cz * s[1], cy * sz * s[1], -sy * s[1], 0,
|
||||
(sx * sy * cz - cx * sz) * s[2], (sx * sy * sz + cx * cz) * s[2],
|
||||
sx * cy * s[2], 0,
|
||||
(cx * sy * cz + sx * sz) * s[3], (cx * sy * sz - sx * cz) * s[3],
|
||||
cx * cy * s[3], 0,
|
||||
t[1], t[2], t[3], 1 }
|
||||
end
|
||||
|
||||
local function mul(a, b)
|
||||
local r = {}
|
||||
for c = 0, 3 do
|
||||
for i = 1, 4 do
|
||||
r[c * 4 + i] = a[i] * b[c * 4 + 1] + a[4 + i] * b[c * 4 + 2]
|
||||
+ a[8 + i] * b[c * 4 + 3] + a[12 + i] * b[c * 4 + 4]
|
||||
end
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
-- (height of the bind pose, per-bone local scale). Height rather than the
|
||||
-- largest dimension: sizing off the max would scale Moltres's flames to its
|
||||
-- wingspan.
|
||||
function StadiumFx.bindExtent(data)
|
||||
local root = trs({ 0, 0, 0 }, { 0, 0, 0 }, data.rootScale)
|
||||
local acc, uns, mats = {}, {}, {}
|
||||
for i = 1, #data.bones do
|
||||
local b = data.bones[i]
|
||||
local p = b.parent
|
||||
local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 }
|
||||
local pu = (p >= 0) and uns[p + 1] or root
|
||||
local u = mul(pu, trs({ b.t[1] * pa[1], b.t[2] * pa[2], b.t[3] * pa[3] },
|
||||
b.r, { 1, 1, 1 }))
|
||||
local a = { pa[1] * b.s[1], pa[2] * b.s[2], pa[3] * b.s[3] }
|
||||
local m = {}
|
||||
for k = 1, 16 do m[k] = u[k] end
|
||||
for k = 1, 4 do
|
||||
m[k] = m[k] * a[1]
|
||||
m[4 + k] = m[4 + k] * a[2]
|
||||
m[8 + k] = m[8 + k] * a[3]
|
||||
end
|
||||
acc[i], uns[i], mats[i] = a, u, m
|
||||
end
|
||||
|
||||
local lo = { 1e9, 1e9, 1e9 }
|
||||
local hi = { -1e9, -1e9, -1e9 }
|
||||
for _, prim in ipairs(data.prims) do
|
||||
local pos, skin = prim.pos, prim.skin
|
||||
for i = 1, prim.nverts do
|
||||
local m = mats[skin[i] + 1]
|
||||
if m then
|
||||
local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3]
|
||||
local wx = m[1] * x + m[5] * y + m[9] * z + m[13]
|
||||
local wy = m[2] * x + m[6] * y + m[10] * z + m[14]
|
||||
local wz = m[3] * x + m[7] * y + m[11] * z + m[15]
|
||||
if wx < lo[1] then lo[1] = wx end
|
||||
if wy < lo[2] then lo[2] = wy end
|
||||
if wz < lo[3] then lo[3] = wz end
|
||||
if wx > hi[1] then hi[1] = wx end
|
||||
if wy > hi[2] then hi[2] = wy end
|
||||
if wz > hi[3] then hi[3] = wz end
|
||||
end
|
||||
end
|
||||
end
|
||||
local extent = (lo[1] <= hi[1]) and (hi[2] - lo[2]) or 1.0
|
||||
|
||||
-- how much each bone scales its own local space, so an effect can divide it
|
||||
-- back out and come out the size it asked for wherever it hangs
|
||||
local scales = {}
|
||||
for i = 1, #mats do
|
||||
local m = mats[i]
|
||||
scales[i] = sqrt(m[1] * m[1] + m[2] * m[2] + m[3] * m[3])
|
||||
end
|
||||
return extent, scales
|
||||
end
|
||||
|
||||
-- ------- what a species gets
|
||||
|
||||
-- Returns a list of { kind, bone, geo, w, h, frames }, or an empty list.
|
||||
function StadiumFx.buildFor(species, fx, extent, boneScale)
|
||||
local out = {}
|
||||
for _, node in ipairs(fx) do
|
||||
local cb, bone = node.callback, node.bone
|
||||
if bone >= 0 and bone < #boneScale then
|
||||
local k = boneScale[bone + 1]
|
||||
if k == 0 then k = 1.0 end
|
||||
if cb == FIRE_TAIL then
|
||||
local fl, fw = StadiumFx.SIZES.fire_tail[1], StadiumFx.SIZES.fire_tail[2]
|
||||
local w, h, fr = fireFrames(species * 7919 + 1, 32, 64, 8)
|
||||
out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
|
||||
geo = crossedQuads(bone, extent * fl / k,
|
||||
extent * fw / k, "y", false) }
|
||||
elseif cb == FIRE_SMALL then
|
||||
local fl, fw = StadiumFx.SIZES.fire_small[1],
|
||||
StadiumFx.SIZES.fire_small[2]
|
||||
local w, h, fr = fireFrames(species * 6271 + bone, 24, 40, 8, 1.25)
|
||||
out[#out + 1] = { kind = "fire", bone = bone, w = w, h = h, frames = fr,
|
||||
geo = crossedQuads(bone, extent * fl / k,
|
||||
extent * fw / k, "y", false) }
|
||||
elseif cb == AURA and species == 92 then -- Gastly only
|
||||
local fl, fw = StadiumFx.SIZES.gas[1], StadiumFx.SIZES.gas[2]
|
||||
local w, h, fr = gasFrames(species * 5237 + 3, 48, 48, 10)
|
||||
out[#out + 1] = { kind = "gas", bone = bone, w = w, h = h, frames = fr,
|
||||
geo = crossedQuads(bone, extent * fl / k,
|
||||
extent * fw / k, "y", true) }
|
||||
end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Append the generated prims and their flipbook textures to a model, exactly
|
||||
-- as build.py's attach_effects does. Returns how many were made.
|
||||
function StadiumFx.attach(data, species)
|
||||
if not (data.fx and #data.fx > 0) then return 0 end
|
||||
local extent, boneScale = StadiumFx.bindExtent(data)
|
||||
local made = StadiumFx.buildFor(species, data.fx, extent, boneScale)
|
||||
for _, e in ipairs(made) do
|
||||
local first = #data.textures -- 0-based, as the file
|
||||
for i = 1, #e.frames do
|
||||
data.textures[first + i] = { index = -1, w = e.w, h = e.h,
|
||||
generated = true, rgba = e.frames[i] }
|
||||
end
|
||||
local g = e.geo
|
||||
local fxFrames = {}
|
||||
for i = 1, #e.frames do fxFrames[i] = first + i - 1 end
|
||||
data.prims[#data.prims + 1] = {
|
||||
tex = first, cull = 0, texAnim = -1, texMap = nil,
|
||||
generated = true, effect = e.kind,
|
||||
blend = (e.kind == "fire") and "add" or "alpha",
|
||||
fxFrames = fxFrames,
|
||||
pos = g.pos, uv = g.uv, nrm = g.nrm, skin = g.skin, nverts = g.nverts,
|
||||
idx = g.idx, nidx = g.nidx,
|
||||
}
|
||||
end
|
||||
return #made
|
||||
end
|
||||
|
||||
return StadiumFx
|
||||
@@ -0,0 +1,414 @@
|
||||
-- STADIUM battles: finding the ROM, and building the models out of it once.
|
||||
--
|
||||
-- The mod does not ship the Pokemon Stadium models and cannot: they are that
|
||||
-- game's data. What it ships is the READER -- StadiumRom, StadiumFragment,
|
||||
-- StadiumFx and StadiumBuild -- and the player supplies the cartridge, which
|
||||
-- is exactly the arrangement this engine already has for the Game Boy ROM it
|
||||
-- is a recompilation of (src/import/RomImporter.lua).
|
||||
--
|
||||
-- So: supply a Pokemon Stadium (US) 1.0 ROM -- the OPTIONS row opens a file
|
||||
-- picker for one, or drop it in `baseroms/` -- and the first time the
|
||||
-- game runs with the mod on, the models are built. Once, on a loading screen,
|
||||
-- in about ten seconds. After that the packs sit in the save directory and
|
||||
-- the mod reads them like any other asset.
|
||||
--
|
||||
-- ------- where "baseroms/" is
|
||||
--
|
||||
-- One relative path, and it deliberately covers two different places at once,
|
||||
-- because PhysFS searches the save directory AND the game folder under the
|
||||
-- same names:
|
||||
--
|
||||
-- * a folder install, or a checkout -- `baseroms/` next to main.lua
|
||||
-- * a packaged or fused build, where the game folder is inside an archive
|
||||
-- and cannot be written to -- `baseroms/` in the save directory, whose
|
||||
-- absolute path this reports on screen so it can be found
|
||||
--
|
||||
-- The file goes STRAIGHT IN THERE, with no revision subfolder under it. The
|
||||
-- decompilation's own `make init` uses `baseroms/us/`, and the offline
|
||||
-- pipeline under model_extract/ still reads from there because it shares that
|
||||
-- tree -- but the instruction given to a player is "drop the file in this
|
||||
-- folder", and one folder is the whole of it.
|
||||
--
|
||||
-- Any of `.z64`, `.n64` and `.v64` is accepted; StadiumRom normalises the
|
||||
-- byte order on load.
|
||||
--
|
||||
-- ------- what "installed" means
|
||||
--
|
||||
-- A marker file next to the packs, holding the format magic, how many species
|
||||
-- were written and the md5 of the ROM they came from. All three matter. The
|
||||
-- magic catches a format change (the packs are rebuilt rather than read as
|
||||
-- garbage), the count catches a build that was interrupted half way, and the
|
||||
-- md5 catches the player swapping the ROM for a different revision.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local StadiumPack = V.require("StadiumPack")
|
||||
|
||||
local StadiumInstall = {}
|
||||
|
||||
-- Where a ROM is looked for, and where the built packs are kept.
|
||||
StadiumInstall.ROM_DIR = "baseroms"
|
||||
StadiumInstall.DIR = StadiumPack.CACHE_DIR
|
||||
StadiumInstall.MARKER = StadiumInstall.DIR .. "/pack.info"
|
||||
|
||||
-- Bumped whenever the .dsm format changes, so an old cache is rebuilt rather
|
||||
-- than misread. Must track StadiumPack's magic.
|
||||
StadiumInstall.FORMAT = "DSM3"
|
||||
|
||||
-- Bumped when the packs' CONTENT changes without the byte layout moving, so
|
||||
-- a cache built by an older extractor is rebuilt rather than trusted. Rev 2
|
||||
-- is the hermite-animation decode fix: the five keyframe species (Pidgeot,
|
||||
-- Dodrio, Exeggutor, Tangela, Magmar) come out garbled or bind-posed from
|
||||
-- any rev-1 build.
|
||||
--
|
||||
-- Rev 3 adds the shiny variants (NNNs.dsm). The normal packs are unchanged
|
||||
-- byte for byte, so this is exactly the case REV exists for and not a FORMAT
|
||||
-- bump: nothing about DSM3 moved, there is simply a second file per species
|
||||
-- that a rev-2 cache does not have. Without the bump a player who already
|
||||
-- installed would keep a complete-looking cache with no shiny models in it,
|
||||
-- and every shiny they met would silently show its normal colours.
|
||||
StadiumInstall.REV = 3
|
||||
|
||||
StadiumInstall.COUNT = 151
|
||||
|
||||
-- Named ROM files, then any ROM at all sitting in the folder.
|
||||
--
|
||||
-- Flat in `baseroms/`, with no revision subfolder: the offline pipeline under
|
||||
-- model_extract/ keeps the decompilation's own `baseroms/us/` convention
|
||||
-- because it shares that tree, but what is being asked of a PLAYER here is
|
||||
-- "drop the file in this folder", and one folder is the whole of that
|
||||
-- instruction. A path they have to build out of two parts is a path half of
|
||||
-- them will get wrong, and the failure is silent -- the rungs are simply not
|
||||
-- on the row.
|
||||
local NAMED = {
|
||||
StadiumInstall.ROM_DIR .. "/baserom.z64",
|
||||
StadiumInstall.ROM_DIR .. "/baserom.n64",
|
||||
StadiumInstall.ROM_DIR .. "/baserom.v64",
|
||||
}
|
||||
|
||||
local function fs()
|
||||
return love and love.filesystem
|
||||
end
|
||||
|
||||
local function isFile(path)
|
||||
local f = fs()
|
||||
if not (f and f.getInfo) then return false end
|
||||
local ok, info = pcall(f.getInfo, path, "file")
|
||||
return (ok and info) and true or false
|
||||
end
|
||||
|
||||
-- The ROM's path on the PhysFS read path, or nil.
|
||||
function StadiumInstall.romPath()
|
||||
local f = fs()
|
||||
if not f then return nil end
|
||||
for _, path in ipairs(NAMED) do
|
||||
if isFile(path) then return path end
|
||||
end
|
||||
local ok, items = pcall(f.getDirectoryItems, StadiumInstall.ROM_DIR)
|
||||
if ok and items then
|
||||
table.sort(items)
|
||||
for _, name in ipairs(items) do
|
||||
if name:lower():match("%.[nvz]64$") then
|
||||
local path = StadiumInstall.ROM_DIR .. "/" .. name
|
||||
if isFile(path) then return path end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function StadiumInstall.romPresent()
|
||||
return StadiumInstall.romPath() ~= nil
|
||||
end
|
||||
|
||||
-- Where to tell the player to put it. The save directory is the answer that
|
||||
-- is always writable, and it is the one a packaged build needs.
|
||||
function StadiumInstall.romHint()
|
||||
local f = fs()
|
||||
local base = (f and f.getSaveDirectory and select(2, pcall(f.getSaveDirectory)))
|
||||
if type(base) ~= "string" then base = "the game folder" end
|
||||
return base .. "/" .. StadiumInstall.ROM_DIR
|
||||
end
|
||||
|
||||
-- The same thing with a FILENAME on the end, which is what a player actually
|
||||
-- needs: a folder alone leaves them guessing what to call the file, and the
|
||||
-- guess is not obviously "baserom.z64".
|
||||
--
|
||||
-- Taken from the head of NAMED rather than retyped, so the name shown is by
|
||||
-- construction the first name looked for. It is not the ONLY one that works
|
||||
-- -- `.n64` and `.v64` are accepted, and so is any other name carrying one
|
||||
-- of those extensions -- but an instruction that names one file is one a
|
||||
-- player can follow, and an instruction that lists every possibility is one
|
||||
-- they have to interpret.
|
||||
function StadiumInstall.romHintFile()
|
||||
return StadiumInstall.romHint() .. "/" .. (NAMED[1]:match("[^/]+$") or "")
|
||||
end
|
||||
|
||||
-- ------- the marker
|
||||
|
||||
local function readMarker()
|
||||
local f = fs()
|
||||
if not (f and isFile(StadiumInstall.MARKER)) then return nil end
|
||||
local ok, text = pcall(f.read, StadiumInstall.MARKER)
|
||||
if not (ok and type(text) == "string") then return nil end
|
||||
local format, count, md5, rev = text:match("^(%S+)%s+(%d+)%s*(%S*)%s*(%S*)")
|
||||
if not format then return nil end
|
||||
return { format = format, count = tonumber(count), md5 = md5,
|
||||
rev = tonumber(rev) }
|
||||
end
|
||||
|
||||
-- Whether a complete, current set of packs is on disk.
|
||||
local readyCache = nil
|
||||
|
||||
function StadiumInstall.ready()
|
||||
if readyCache ~= nil then return readyCache end
|
||||
local m = readMarker()
|
||||
readyCache = (m ~= nil and m.format == StadiumInstall.FORMAT
|
||||
and m.count == StadiumInstall.COUNT
|
||||
and m.rev == StadiumInstall.REV) and true or false
|
||||
return readyCache
|
||||
end
|
||||
|
||||
-- Whether a complete set came WITH the mod folder -- a developer checkout
|
||||
-- that has run tools/stadium_pack.py. Never true of a released build, which
|
||||
-- carries no models at all.
|
||||
--
|
||||
-- Sampled at both ends of the dex rather than counted. The question being
|
||||
-- asked is "did somebody run the packer here", not "is every one of the 151
|
||||
-- present"; a genuinely half-written folder is a case for the marker file,
|
||||
-- which is what catches an interrupted RUNTIME build.
|
||||
local function shipped()
|
||||
local mod = V.mod
|
||||
if not (mod and mod.read) then return false end
|
||||
for _, dex in ipairs({ 1, 151 }) do
|
||||
local ok, bytes = pcall(mod.read, mod,
|
||||
("%s/%03d.dsm"):format(StadiumPack.DIR, dex))
|
||||
if not (ok and type(bytes) == "string" and #bytes > 4) then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Whether the packs on disk can be READ, even if they are not current.
|
||||
--
|
||||
-- Format and count, but deliberately NOT rev. The distinction matters on an
|
||||
-- upgrade: a rev bump means the packs are out of date, not that they are
|
||||
-- unreadable, and treating the two the same is what would make the STADIUM
|
||||
-- rungs disappear off the options row for anyone whose cache predates it.
|
||||
-- Losing the recolour until a rebuild is a blemish; losing the mode is not.
|
||||
function StadiumInstall.usable()
|
||||
local m = readMarker()
|
||||
return (m ~= nil and m.format == StadiumInstall.FORMAT
|
||||
and m.count == StadiumInstall.COUNT) and true or false
|
||||
end
|
||||
|
||||
-- Whether the STADIUM rungs can be offered at all: the packs have been built
|
||||
-- from the player's ROM (current or merely readable), or the mod folder
|
||||
-- already carries a set.
|
||||
function StadiumInstall.available()
|
||||
if StadiumInstall.ready() then return true end
|
||||
if StadiumInstall.usable() then return true end
|
||||
return shipped()
|
||||
end
|
||||
|
||||
-- Whether there is work to do: a ROM to build from, and no CURRENT set.
|
||||
--
|
||||
-- Keyed on ready() rather than available(), and that is the whole upgrade
|
||||
-- story. It used to short-circuit on available(), which meant a checkout
|
||||
-- carrying assets/stadium was never pending -- so when REV went to 3 for the
|
||||
-- shiny variants, such a machine did not rebuild, was not asked to, and
|
||||
-- quietly kept serving the old set: every shiny Pokemon drawn in its
|
||||
-- ordinary colours, with nothing on screen to say why. That is exactly what
|
||||
-- happened here, and it took a driver run sitting at "idle 0/151" to notice.
|
||||
--
|
||||
-- The cost this trades away is real and was the original reason: a checkout
|
||||
-- with a ROM now spends one loading screen rebuilding a set it already had
|
||||
-- files for. Once. After that ready() is true and it is not pending again --
|
||||
-- and what it buys is that a rev bump actually reaches the people it was
|
||||
-- bumped for.
|
||||
function StadiumInstall.pending()
|
||||
if not StadiumInstall.romPresent() then return false end
|
||||
return not StadiumInstall.ready()
|
||||
end
|
||||
|
||||
function StadiumInstall.forget()
|
||||
readyCache = nil
|
||||
end
|
||||
|
||||
-- ------- building
|
||||
|
||||
local job = nil
|
||||
local status = { state = "idle", done = 0, total = StadiumInstall.COUNT }
|
||||
|
||||
StadiumInstall.status = status
|
||||
|
||||
-- The shiny variant rides beside its species as NNNs.dsm.
|
||||
--
|
||||
-- A separate FILE rather than a second block inside NNN.dsm, and that is a
|
||||
-- deliberate trade. A second block would mean a new magic (DSM4), the same
|
||||
-- change mirrored into tools/stadium_pack.py, a regenerated oracle and a
|
||||
-- re-run of the 34MB byte diff -- the project's central safety net disturbed
|
||||
-- for a feature that does not need the format to move at all. As its own
|
||||
-- file it is the SAME DSM3 a normal pack is, written by the same writer and
|
||||
-- read by the same reader, and the 151 normal packs stay byte-identical.
|
||||
--
|
||||
-- A species with no shiny variant simply has no NNNs.dsm, and StadiumPack
|
||||
-- falls back to the normal model. That is also what a half-finished install
|
||||
-- looks like, which is the behaviour we want from one.
|
||||
local function writePack(species, bytes, shinyBytes)
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
local ok, err = f.write(("%s/%03d.dsm"):format(StadiumInstall.DIR, species),
|
||||
bytes)
|
||||
if not ok then return false, tostring(err) end
|
||||
if shinyBytes then
|
||||
-- A failed shiny write is not a failed install: the species still has
|
||||
-- its model. Left unwritten, the runtime shows the normal one.
|
||||
local sok, serr = f.write(
|
||||
("%s/%03ds.dsm"):format(StadiumInstall.DIR, species), shinyBytes)
|
||||
if not sok and V.mod and V.mod.log then
|
||||
V.mod.log.warn("shiny pack %03d not written: %s", species, tostring(serr))
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Open the ROM found in `baseroms/` and start a stepped build. Returns false
|
||||
-- plus a reason when there is nothing to build from.
|
||||
function StadiumInstall.begin()
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
local path = StadiumInstall.romPath()
|
||||
if not path then return false, "no ROM in " .. StadiumInstall.ROM_DIR end
|
||||
|
||||
local okRead, bytes = pcall(f.read, path)
|
||||
if not (okRead and type(bytes) == "string") then
|
||||
return false, "could not read " .. path
|
||||
end
|
||||
return StadiumInstall.beginFrom(bytes, path)
|
||||
end
|
||||
|
||||
-- The same, from bytes somebody else has already got hold of -- which is the
|
||||
-- IMPORTED path (StadiumRomPick), where the file is at an absolute location
|
||||
-- love.filesystem cannot see and was read with io.open.
|
||||
--
|
||||
-- The two entry points share everything from here down on purpose: an
|
||||
-- imported cartridge and a dropped one produce the same 151 files, the same
|
||||
-- marker and the same md5, so there is exactly one build in this mod and no
|
||||
-- second one to keep in step.
|
||||
--
|
||||
-- `label` is only ever used to say WHICH file a complaint is about.
|
||||
function StadiumInstall.beginFrom(bytes, label)
|
||||
local f = fs()
|
||||
if not f then return false, "no filesystem" end
|
||||
if type(bytes) ~= "string" or #bytes == 0 then return false, "empty file" end
|
||||
|
||||
local StadiumRom = V.require("StadiumRom")
|
||||
local StadiumBuild = V.require("StadiumBuild")
|
||||
local rom, err = StadiumRom.open(bytes)
|
||||
if not rom then return false, tostring(err) end
|
||||
status.wrongVersion = false
|
||||
if not rom:isExpectedUS() then
|
||||
-- Built anyway rather than refused: a dump can differ from the reference
|
||||
-- for reasons that do not move a single model offset (a byte-order
|
||||
-- variant already normalised on load, a trimmed overdump). But every
|
||||
-- offset in this reader was measured against US 1.0 and nothing else is
|
||||
-- promised, so it is said loudly, with the md5 that IS expected so the
|
||||
-- player can check their own file against it.
|
||||
status.wrongVersion = true
|
||||
V.mod.log:warn("stadium: %s is md5 %s -- the model offsets are keyed to "
|
||||
.. "Pokemon Stadium (US) 1.0, which is md5 %s. Building "
|
||||
.. "anyway, but the models may be wrong or fail to build.",
|
||||
tostring(label or "the ROM"), tostring(rom:md5()),
|
||||
tostring(StadiumRom.US_MD5))
|
||||
end
|
||||
|
||||
-- ------- refuse a ROM with no models in it, BEFORE anything is written
|
||||
--
|
||||
-- A file picker invites the wrong file -- most obviously the Game Boy
|
||||
-- cartridge the player already imported once -- and the reader's answer to
|
||||
-- one is a model count of zero. That has to be caught HERE rather than
|
||||
-- allowed to become an empty build, because an empty build is
|
||||
-- indistinguishable from a finished one further down: `job.total` is
|
||||
-- clamped to the count, `step` completes on the first call with nothing
|
||||
-- attempted and therefore nothing FAILED, and the marker gets written
|
||||
-- saying `DSM3 0`.
|
||||
--
|
||||
-- On a fresh machine that is merely a lie on the loading screen -- READY,
|
||||
-- with no models. On one that already HAD them it is worse: the marker is
|
||||
-- the only thing that makes 151 files on disk count as installed, so
|
||||
-- overwriting it with a zero uninstalls a good set and the STADIUM rungs
|
||||
-- vanish off the row. Nothing below this line runs for a file that cannot
|
||||
-- possibly produce a build.
|
||||
local models = rom:modelCount()
|
||||
if not (models and models >= StadiumInstall.COUNT) then
|
||||
return false, "needs Pokemon Stadium US 1.0"
|
||||
end
|
||||
|
||||
pcall(f.createDirectory, StadiumInstall.DIR)
|
||||
job = StadiumBuild.job(rom, writePack, StadiumInstall.COUNT)
|
||||
job.md5 = rom:md5()
|
||||
status.state = "building"
|
||||
status.done = 0
|
||||
status.total = job.total
|
||||
status.error = nil
|
||||
return true
|
||||
end
|
||||
|
||||
-- One species. Returns true while there is more to do.
|
||||
function StadiumInstall.step()
|
||||
if not job then return false end
|
||||
local more = job:step()
|
||||
status.done = job.done
|
||||
status.species = job.species
|
||||
if job.error then
|
||||
status.state = "failed"
|
||||
status.error = job.error
|
||||
job = nil
|
||||
return false
|
||||
end
|
||||
if not more then
|
||||
local f = fs()
|
||||
-- `job.total > 0` as well as "nothing failed", because a job with nothing
|
||||
-- IN it satisfies the second on its own -- and the marker this writes is
|
||||
-- what makes a set count as installed, so it must never be written for a
|
||||
-- build that did not happen. beginFrom refuses such a ROM outright; this
|
||||
-- is the same rule stated where the consequence is.
|
||||
local wrote = #job.failed == 0 and job.total > 0
|
||||
if wrote and f then
|
||||
pcall(f.write, StadiumInstall.MARKER,
|
||||
("%s %d %s %d\n"):format(StadiumInstall.FORMAT, job.total,
|
||||
tostring(job.md5 or ""),
|
||||
StadiumInstall.REV))
|
||||
readyCache = nil
|
||||
StadiumPack.forget()
|
||||
end
|
||||
if not wrote then
|
||||
status.state = "failed"
|
||||
-- EVERY species failing is not a bad build, it is the wrong file: the
|
||||
-- offsets the reader walks are Pokemon Stadium's, so a different game
|
||||
-- -- or the Game Boy cartridge the player already imported once, which
|
||||
-- is the mistake a file picker invites -- misses on all 151 rather than
|
||||
-- on a few. Worth telling apart, because "0 of 151 models were built"
|
||||
-- reads as a broken mod and this reads as a wrong click.
|
||||
if #job.failed >= job.total then
|
||||
status.error = "needs Pokemon Stadium US 1.0"
|
||||
else
|
||||
status.error = ("%d of %d models could not be built")
|
||||
:format(#job.failed, job.total)
|
||||
end
|
||||
else
|
||||
status.state = "done"
|
||||
end
|
||||
job = nil
|
||||
return false
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function StadiumInstall.cancel()
|
||||
job = nil
|
||||
status.state = "idle"
|
||||
end
|
||||
|
||||
return StadiumInstall
|
||||
@@ -0,0 +1,548 @@
|
||||
-- STADIUM battles: one Pokemon, standing on its tile.
|
||||
--
|
||||
-- The side's live state -- which species is out, the rig posing it, which
|
||||
-- animation the fight has asked for and how far through it is, and the
|
||||
-- matrix that puts it on its cell at the right size facing the right way.
|
||||
-- Stadium owns the pair of these; StadiumRig owns the arithmetic.
|
||||
--
|
||||
-- ------- how big a Pokemon is
|
||||
--
|
||||
-- The one genuinely invented number in this mode, and it is worth saying
|
||||
-- why it is invented rather than measured.
|
||||
--
|
||||
-- The flat 2D-3D mode has an exact answer: a full-size 56-pixel pic covers
|
||||
-- one 16-pixel overworld square, so a canvas pixel is a fixed number of
|
||||
-- world pixels and every species comes out at whatever its own artwork's
|
||||
-- size implies (see BattleBillboard.FULL_W). The camera is then SOLVED to
|
||||
-- make one square that big on screen (BattleCam).
|
||||
--
|
||||
-- The Stadium models have no such anchor. Their units are the N64's, they
|
||||
-- run from Caterpie at 9 units to Gyarados at 147 -- a sixteenfold spread,
|
||||
-- where the Gen 1 pics span barely one and a half -- and the game they come
|
||||
-- from framed each one with its own camera, which a fight staged on the
|
||||
-- overworld cannot do because the two mons share a shot.
|
||||
--
|
||||
-- Taken literally, that spread puts Caterpie at a couple of pixels on a
|
||||
-- 144-pixel screen while Gyarados leaves the frame. So the range is
|
||||
-- COMPRESSED rather than either honoured or discarded: a species is drawn
|
||||
-- at REF_HEIGHT world pixels scaled by its own height over the set's
|
||||
-- median, raised to SQUASH. At 1 that would be the raw sixteenfold spread;
|
||||
-- at 0 every Pokemon would be the same size; at 0.55 the order and the
|
||||
-- feel of the differences survive -- Onix and Gyarados tower, Diglett and
|
||||
-- Caterpie are small enough to have to look for -- inside a range a shared
|
||||
-- frame can hold.
|
||||
--
|
||||
-- ------- and where its feet are
|
||||
--
|
||||
-- The pack measures each model's lowest point against its own origin
|
||||
-- (tools/stadium_pack.py's `stance`), and the answer splits the set in
|
||||
-- three. 119 species sit within 5% of zero: the origin IS the floor, and
|
||||
-- the game stood them on its field with it. A handful sit ABOVE it --
|
||||
-- Zubat, Magnemite, Geodude -- which is a hover the model is authored with.
|
||||
-- The rest hang BELOW it -- Tentacruel, Gastly, Haunter, Weezing, Zapdos --
|
||||
-- which is a model centred on its origin rather than standing on it.
|
||||
--
|
||||
-- So a model is stood on its own lowest point, and then given back as much
|
||||
-- of its authored hover as the shot can hold -- HOVER_CAP of its own height,
|
||||
-- no more. The middle group is unaffected either way, which is the check
|
||||
-- that the rule is reading the data rather than correcting it.
|
||||
--
|
||||
-- The cap is not tidiness. Stadium framed one Pokemon per camera and could
|
||||
-- afford to hang Zubat three body-heights off the floor; this shot has the
|
||||
-- foe's feet on GB row 56 of 144, so the same hover puts Zubat off the top
|
||||
-- of the frame entirely -- which is exactly what it did before the cap. The
|
||||
-- flat 2D-3D mode has the same constraint and answers it by bottom-aligning
|
||||
-- every pic, hovering species included; this keeps the hover but spends
|
||||
-- only the room there is.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local StadiumPack = V.require("StadiumPack")
|
||||
local StadiumRig = V.require("StadiumRig")
|
||||
|
||||
local StadiumMon = {}
|
||||
StadiumMon.__index = StadiumMon
|
||||
|
||||
-- How tall a median Pokemon stands, in world pixels.
|
||||
--
|
||||
-- Not picked by eye: it is what the FLAT mode already puts on those cells.
|
||||
-- A full-size Gen 1 pic is 56 pixels for the foe and 64 for the player's
|
||||
-- own, drawn with its feet on GB rows 56 and 96 of a 144-row frame -- so a
|
||||
-- full-size mon covers 39% of the frame at the far cell and 44% at the near
|
||||
-- one. Against the lens BattleCam solves (about 38 world pixels of frame at
|
||||
-- the far cell, 30 at the near one, because the near one is closer) both of
|
||||
-- those work out at roughly fourteen world pixels.
|
||||
--
|
||||
-- So this is the number that makes a median Stadium model exactly as big as
|
||||
-- the artwork it replaces, which is what keeps the composition the camera
|
||||
-- was solved for.
|
||||
StadiumMon.REF_HEIGHT = 14
|
||||
|
||||
-- The set's own median bind height, in game units (tools/stadium_pack.py
|
||||
-- --report prints it). Only ever a reference point for the ratio above, so
|
||||
-- a re-extraction that moved it slightly changes nothing but the middle of
|
||||
-- the ladder.
|
||||
StadiumMon.MEDIAN = 52.25
|
||||
|
||||
-- How much of the raw size spread survives. See the header.
|
||||
StadiumMon.SQUASH = 0.5
|
||||
|
||||
-- And hard stops either end, because a compression is not a guarantee. The
|
||||
-- ceiling is what keeps Onix and Gyarados inside a frame whose top edge is
|
||||
-- only 56 GB rows above the foe's own feet: past about this they stop being
|
||||
-- imposing and start being cropped.
|
||||
StadiumMon.MIN_HEIGHT = 5
|
||||
StadiumMon.MAX_HEIGHT = 18
|
||||
|
||||
-- How much of an authored hover survives, as a fraction of the Pokemon's
|
||||
-- own height. See the header: Stadium could hang a flier three body-heights
|
||||
-- up because it framed one Pokemon at a time.
|
||||
StadiumMon.HOVER_CAP = 0.5
|
||||
|
||||
-- The animation clock. Every animation in the set is authored at 30 fps
|
||||
-- (model_extract/README.md), and the eyes run on their own counter at the
|
||||
-- same rate.
|
||||
StadiumMon.FPS = StadiumPack.FPS
|
||||
|
||||
-- ------- coming out of the ball
|
||||
--
|
||||
-- The engine grows its flat pic in the Game Boy's own three steps -- 0, then
|
||||
-- 3/7, then 5/7, then full -- across the twelve frames after the ball opens
|
||||
-- (BattleState.growInScale). Two things about that do not carry to a model.
|
||||
--
|
||||
-- It is three steps, which on a 56-pixel sprite is a chunky pop and on a
|
||||
-- smooth 3D model is just a pop. And it starts AFTER the ball: measured, the
|
||||
-- poof animation runs for 27 frames and `startGrowIn` fires on the frame
|
||||
-- after it ends, so the Pokemon does not begin to exist until the ball has
|
||||
-- finished opening -- which reads as the ball opening and then a Pokemon
|
||||
-- being switched on beside it.
|
||||
--
|
||||
-- So the model runs its own ramp, started when the POOF begins rather than
|
||||
-- when it ends, and continuous rather than stepped: it grows out of nothing
|
||||
-- while the ball is opening and reaches full size as the engine's own grow
|
||||
-- finishes. GROW_TIME is measured off that -- 27 frames of poof plus the
|
||||
-- engine's 12 of grow is 39, which is this.
|
||||
StadiumMon.GROW_TIME = 0.65
|
||||
|
||||
-- How far an animation may carry the Pokemon off its tile, in the Pokemon's
|
||||
-- own body-heights, before the excess is taken back out (StadiumRig.anchor).
|
||||
--
|
||||
-- Measured against the frame rather than chosen by eye. A mon is drawn
|
||||
-- REF_HEIGHT world pixels tall and the GB frame holds about 38 world pixels
|
||||
-- at the far cell, with the foe's feet on row 56 of 144 -- so there is
|
||||
-- roughly one body-height of room above it and about one and a half either
|
||||
-- side. Three quarters of a height keeps every part of a travelling Pokemon
|
||||
-- inside that with a margin, and leaves the 83 species that never reach it
|
||||
-- untouched.
|
||||
StadiumMon.TRAVEL = 0.75
|
||||
|
||||
-- ------- the animation the fight is asking for
|
||||
--
|
||||
-- Each entry says which context slot to look up, whether it loops, and
|
||||
-- what it falls back to when the species has no animation in that slot.
|
||||
-- ------- there is no hit reaction, and there never was
|
||||
--
|
||||
-- This used to carry `hit` and `flinch` states, played when damage landed,
|
||||
-- resolving through context slots 166 and 178. Both were wrong, and the data
|
||||
-- says so plainly once the move table is read alongside them:
|
||||
--
|
||||
-- Bulbasaur's slot 166 is a 95-frame animation that 66 of its moves play.
|
||||
-- Pidgey's is 138 frames -- four and a half seconds -- and 111 of its moves
|
||||
-- play it. Slot 178, and 173, 179, 180 and 181, all point at the same one.
|
||||
--
|
||||
-- A four-and-a-half-second animation that most of the move table uses is the
|
||||
-- species' DEFAULT ATTACK, not a flinch, which is why being hit looked like
|
||||
-- swinging: it literally was the swing.
|
||||
--
|
||||
-- Nor is the reaction hiding elsewhere. Exactly one animation per species is
|
||||
-- claimed by no slot and no move, and it is the same length as the idle for
|
||||
-- essentially every one of them -- 48/48, 56/56, 60/60, 84/84 -- so it is a
|
||||
-- second standby loop, not a recoil. The set has no damage reaction in it.
|
||||
--
|
||||
-- So damage plays nothing, and the Pokemon carries on with what it was doing.
|
||||
-- That is not a gap: the engine flashes the screen, blinks the pic and drains
|
||||
-- the bar, which is how Gen 1 says "that hurt" and is already in the frame.
|
||||
local STATES = {
|
||||
idle = { slot = "idle", loop = true },
|
||||
entrance = { slot = "entrance", loop = false, next = "idle" },
|
||||
faint = { slot = "faint", loop = false, hold = true },
|
||||
-- A move names its own animation out of the move table. `attack_default`
|
||||
-- is the fallback for one the table has nothing for -- which is what slot
|
||||
-- 166 actually is, so the generic swing is now a real swing rather than
|
||||
-- the standby loop it used to resolve to.
|
||||
attack = { slot = "attack_default", loop = false, next = "idle" },
|
||||
}
|
||||
|
||||
function StadiumMon.new(side)
|
||||
return setmetatable({
|
||||
side = side, -- "player" or "enemy"
|
||||
species = nil, -- the dex number currently modelled
|
||||
shiny = false, -- and whether it is the recoloured variant
|
||||
model = nil,
|
||||
rig = nil,
|
||||
state = "idle",
|
||||
anim = nil, -- index into model.anims
|
||||
time = 0, -- seconds into it
|
||||
loop = true,
|
||||
hold = false,
|
||||
aux = nil, -- the texture animation running alongside
|
||||
visible = false,
|
||||
scale = 1, -- the send-out grow, 1 the rest of the time
|
||||
}, StadiumMon)
|
||||
end
|
||||
|
||||
function StadiumMon:release()
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, nil
|
||||
-- cleared with the species: a stale true here would make the next
|
||||
-- setSpecies believe a shiny model was already loaded and early-return
|
||||
self.shiny = false
|
||||
end
|
||||
|
||||
-- ------- which species this side is showing
|
||||
--
|
||||
-- Returns true when the model is ready to draw. A species with no pack, one
|
||||
-- whose meshes would not build, or one whose animation data is corrupt at
|
||||
-- source answers false -- and Stadium then leaves that side to the flat
|
||||
-- card, which is a per-POKEMON decline rather than a per-battle one: a fight
|
||||
-- can perfectly well have a model on one side and a pic on the other.
|
||||
--
|
||||
-- ------- staticPose: the corrupt-idle escape hatch
|
||||
--
|
||||
-- StadiumBuild.idleIsBroken measures whether a species' standby loop throws
|
||||
-- bones off the body, and the pack carries the verdict as `staticPose`. A
|
||||
-- species so marked DECLINES here -- the Game Boy's own battle sprite
|
||||
-- stands on the tile instead, drawn by the same 2D-3D path every species
|
||||
-- uses when its model is unavailable -- because a bind pose held for a
|
||||
-- whole fight reads as broken, not as "this one does not animate".
|
||||
--
|
||||
-- No species is marked today. Exeggutor, Tangela and Magmar used to be:
|
||||
-- their animations are hermite keyframes (flags & 8), the extractor misread
|
||||
-- the flags byte and decoded them as packed streams, and the exploding
|
||||
-- result tripped the detector (Pidgeot and Dodrio were garbled by the same
|
||||
-- bug, just not hard enough to trip it). The detector stays, keyed on the
|
||||
-- DATA rather than a list of dex numbers, so a future extraction bug that
|
||||
-- corrupts a species' idle falls back to the sprite instead of coming
|
||||
-- apart on the field -- and nothing here has to be edited when it does.
|
||||
--
|
||||
-- `shiny` is part of the IDENTITY, not a flag applied afterwards. The early
|
||||
-- return below is keyed on it for that reason: a shiny Rattata and an
|
||||
-- ordinary one share a dex number but are different models, loaded from
|
||||
-- different packs, and comparing on the dex alone would keep whichever
|
||||
-- loaded first and colour both sides with it. That is precisely the shape
|
||||
-- of bug the two-Rattata note above describes, and it is silent -- the
|
||||
-- model is valid, it is simply the wrong one.
|
||||
function StadiumMon:setSpecies(dex, shiny)
|
||||
shiny = shiny and true or false
|
||||
if dex == self.species and shiny == (self.shiny or false) then
|
||||
return self.rig ~= nil
|
||||
end
|
||||
if self.rig then self.rig:release() end
|
||||
self.rig, self.model, self.species = nil, nil, dex
|
||||
self.shiny = shiny
|
||||
self.grow, self.grewOwn = nil, nil
|
||||
if not dex then return false end
|
||||
local model = StadiumPack.load(dex, shiny)
|
||||
if not model then return false end
|
||||
-- the pack falls back to the normal model when a species has no shiny
|
||||
-- variant, so believe the model rather than the request
|
||||
self.shiny = model.shiny and true or false
|
||||
if model.staticPose then return false end
|
||||
local rig = StadiumRig.new(model)
|
||||
if not rig then return false end
|
||||
self.model, self.rig = model, rig
|
||||
-- a new Pokemon on the field opens on its standby loop; whoever sent it
|
||||
-- out asks for the entrance a moment later
|
||||
self.state, self.anim, self.time = nil, nil, 0
|
||||
self:play("idle")
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the state machine
|
||||
|
||||
-- Which animation a context slot resolves to for this species, or nil.
|
||||
function StadiumMon:slotAnim(name)
|
||||
local model = self.model
|
||||
local slot = model and StadiumPack.SLOT[name]
|
||||
if not slot then return nil end
|
||||
local index = model.ctx[slot]
|
||||
if not index or index == StadiumPack.NONE then return nil end
|
||||
return index + 1
|
||||
end
|
||||
|
||||
-- Start a state. `animIndex` overrides the state's own slot lookup, which
|
||||
-- is what an attack uses.
|
||||
function StadiumMon:play(state, animIndex, auxIndex)
|
||||
local model = self.model
|
||||
if not model then return false end
|
||||
local def = STATES[state] or STATES.idle
|
||||
local index = animIndex
|
||||
if not index and def.slot then index = self:slotAnim(def.slot) end
|
||||
if not index and def.fallback then index = self:slotAnim(def.fallback) end
|
||||
if not index then
|
||||
-- the species has nothing for this; the standby loop is always there
|
||||
if state == "idle" then index = 1 else return self:play("idle") end
|
||||
end
|
||||
local anim = model.anims[index]
|
||||
if not anim then return false end
|
||||
|
||||
self.state, self.anim, self.time = state, index, 0
|
||||
self.done = false
|
||||
-- (a species whose animations are corrupt at source never gets this far:
|
||||
-- setSpecies declines it outright and its flat pic stands instead)
|
||||
self.loop = def.loop and true or false
|
||||
self.hold = def.hold and true or false
|
||||
-- The eyes that go with it. Every skeletal animation carries the texture
|
||||
-- animation the battle table most often set alongside it (the pack's own
|
||||
-- `aux`), and a move may name a different one -- a hit that leaves the
|
||||
-- Pokemon confused swaps the open eye for the dizzy swirl.
|
||||
self.aux = auxIndex or anim.aux
|
||||
return true
|
||||
end
|
||||
|
||||
-- Ask for a state, but never interrupt one that outranks it. A faint is
|
||||
-- final, and an entrance cannot be cut short by the standby loop it hands
|
||||
-- on to.
|
||||
local RANK = { idle = 0, entrance = 1, attack = 2, faint = 3 }
|
||||
|
||||
function StadiumMon:request(state, animIndex, auxIndex)
|
||||
if not self.model then return false end
|
||||
local now = RANK[self.state] or 0
|
||||
local want = RANK[state] or 0
|
||||
if self.state == "faint" then return false end
|
||||
-- an equal-ranked request RESTARTS: the second move of a two-hit turn
|
||||
-- should swing again rather than be swallowed by the first
|
||||
if want < now then return false end
|
||||
return self:play(state, animIndex, auxIndex)
|
||||
end
|
||||
|
||||
-- The animation a move plays for this species, from the battle system's own
|
||||
-- per-species table (model_extract's moves.json, packed into the .dsm).
|
||||
-- `moveIndex` is the Gen 1 move id, which the engine's move defs carry as
|
||||
-- `index` -- the same numbering, so no name mapping is needed.
|
||||
function StadiumMon:attack(moveIndex)
|
||||
local model = self.model
|
||||
if not (model and moveIndex and moveIndex >= 1
|
||||
and moveIndex <= StadiumPack.N_MOVES) then
|
||||
return false
|
||||
end
|
||||
local index = model.moveAnim[moveIndex]
|
||||
if not index or index == StadiumPack.NONE then return false end
|
||||
local aux = model.moveAux[moveIndex]
|
||||
return self:request("attack", index + 1,
|
||||
(aux and aux >= 0) and (aux + 1) or nil)
|
||||
end
|
||||
|
||||
-- ------- per frame
|
||||
|
||||
function StadiumMon:update(dt)
|
||||
-- kept for build(), which runs later in the same frame and needs it to
|
||||
-- advance the anchor's filter (StadiumRig.anchor). Stashed before the
|
||||
-- early-outs below, so a species with nothing to play still has one.
|
||||
self.dt = dt or 0
|
||||
-- the ball-to-full-size ramp, which runs whether or not there is an
|
||||
-- animation to play alongside it
|
||||
if self.grow then
|
||||
self.grow = self.grow + (dt or 0) / StadiumMon.GROW_TIME
|
||||
if self.grow >= 1 then self.grow = nil end
|
||||
end
|
||||
local model = self.model
|
||||
if not (model and self.anim) then return end
|
||||
local anim = model.anims[self.anim]
|
||||
if not anim then return end
|
||||
self.time = self.time + (dt or 0)
|
||||
if self.time >= anim.seconds and not self.loop then
|
||||
if self.hold then
|
||||
-- a faint stays down: hold the last frame rather than snapping back
|
||||
-- to a standing pose the moment the animation runs out
|
||||
self.time = math.max(0, anim.seconds - 1 / StadiumMon.FPS)
|
||||
-- and SAY so, once. The clamp above means the clock can no longer be
|
||||
-- asked whether the animation is over -- it stops a frame short of the
|
||||
-- end and stays there forever -- and something has to know, because a
|
||||
-- collapse that has finished is the moment the Pokemon may leave the
|
||||
-- field (see Stadium's onField).
|
||||
self.done = true
|
||||
else
|
||||
local nextState = (STATES[self.state] or {}).next or "idle"
|
||||
self:play(nextState)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the grow
|
||||
--
|
||||
-- Begin coming out of the ball. Answers whether it actually started, so the
|
||||
-- caller can play the entrance alongside it and the engine's own send-out
|
||||
-- seam a moment later does not restart what is already running.
|
||||
function StadiumMon:beginGrow()
|
||||
if self.grow or not self.model then return false end
|
||||
self.grow = 0
|
||||
-- and remember that THIS arrival was ours to size, so the engine's own
|
||||
-- three-step ramp is not consulted again for it. Ours starts earlier and
|
||||
-- finishes a few frames sooner, and in that gap the engine's ramp still
|
||||
-- reads 5/7 -- so falling back to it shrank the Pokemon from 0.96 back to
|
||||
-- 0.71 and then snapped it to full, a visible hitch at the end of an
|
||||
-- animation that exists to not have one.
|
||||
self.grewOwn = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- How big this Pokemon is drawn this frame, as a fraction of its real size.
|
||||
--
|
||||
-- Smoothstep rather than a straight ramp or an ease-out: the ball is opening
|
||||
-- for the first half of this, so a curve that is already near full size by
|
||||
-- then would have the Pokemon standing there while the ball is still coming
|
||||
-- apart. Slow, then quick through the middle, then settling exactly as the
|
||||
-- engine's own grow ends.
|
||||
function StadiumMon:growScale()
|
||||
local t = self.grow
|
||||
if not t then return 1 end
|
||||
if t <= 0 then return 0 end
|
||||
if t >= 1 then return 1 end
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
-- Whether a HELD animation -- which in practice means a faint -- has played
|
||||
-- all the way through and is now sitting on its last frame. Always false for
|
||||
-- a looping one, which never finishes, and for one that hands on to another
|
||||
-- state, which has already stopped being itself by the time anyone can ask.
|
||||
function StadiumMon:finished()
|
||||
return self.done and true or false
|
||||
end
|
||||
|
||||
-- How tall this species stands on the map, in world pixels.
|
||||
function StadiumMon:worldHeight()
|
||||
local model = self.model
|
||||
local h = model and model.height or 0
|
||||
if not (h > 0) then return StadiumMon.REF_HEIGHT end
|
||||
local k = (h / StadiumMon.MEDIAN) ^ StadiumMon.SQUASH
|
||||
local out = StadiumMon.REF_HEIGHT * k
|
||||
if out < StadiumMon.MIN_HEIGHT then out = StadiumMon.MIN_HEIGHT end
|
||||
if out > StadiumMon.MAX_HEIGHT then out = StadiumMon.MAX_HEIGHT end
|
||||
return out
|
||||
end
|
||||
|
||||
-- How wide this Pokemon stands, in world pixels -- the same scale
|
||||
-- worldHeight is in, so a caller can size something to its footprint.
|
||||
--
|
||||
-- Only STADIUM B asks: it needs to know how big a platform to put under a
|
||||
-- mon, and "as tall as it is" is the wrong answer for a Snorlax, which is
|
||||
-- half as tall as an Onix and three times as wide.
|
||||
--
|
||||
-- The send-out grow is deliberately NOT folded in. A Pokemon scaling up out
|
||||
-- of its ball should arrive on a platform that was already there, not one
|
||||
-- that inflates under its feet.
|
||||
function StadiumMon:worldRadius()
|
||||
local model = self.model
|
||||
if not model then return 0 end
|
||||
local h = model.height or 0
|
||||
if not (h > 0) then return 0 end
|
||||
return (model.radius or 0) * self:worldHeight() / h
|
||||
end
|
||||
|
||||
-- The model matrix: stand this Pokemon on world (x, groundY, z) facing
|
||||
-- (faceX, faceZ), at whatever the send-out grow has done to its size.
|
||||
--
|
||||
-- The vertices the rig writes are in the model's RAW units -- before the
|
||||
-- model_root scale the game applies -- so the scale here carries that too,
|
||||
-- and the floor offset is measured in the same raw units on the way in.
|
||||
function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
|
||||
local model = self.model
|
||||
if not model then return nil end
|
||||
local root = model.rootScale
|
||||
if not (root and root > 0) then root = 1 end
|
||||
local k = root * self:worldHeight() / math.max(model.height, 1e-6)
|
||||
k = k * (self.scale or 1)
|
||||
-- stand it on its own lowest point, then give back as much of the
|
||||
-- authored hover as the shot can hold (see the header)
|
||||
local floor = model.floor or 0
|
||||
local hover = math.min(math.max(floor, 0),
|
||||
StadiumMon.HOVER_CAP * math.max(model.height, 0))
|
||||
local lift = (floor - hover) / root
|
||||
local yaw = 0
|
||||
if faceX and faceZ and (faceX ~= 0 or faceZ ~= 0) then
|
||||
-- the card and the model share this convention: an unrotated model
|
||||
-- faces +Z, which is map SOUTH, which is what "facing down" is in the
|
||||
-- flat game (see Voxel3D's axis note)
|
||||
yaw = math.atan2(faceX, faceZ)
|
||||
end
|
||||
self.yaw = yaw
|
||||
return Mat4.mul(
|
||||
Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
|
||||
Mat4.scale(k, k, k)),
|
||||
Mat4.translate(0, -lift, 0))
|
||||
end
|
||||
|
||||
-- How far this Pokemon's LOWEST rendered point stands above the ground it
|
||||
-- is placed on, in world pixels -- the authored hover the matrix above
|
||||
-- gives back, actually applied.
|
||||
--
|
||||
-- Derived by repeating that matrix's own arithmetic rather than by
|
||||
-- re-deriving it in closed form: root scale, the model's floor and the
|
||||
-- hover cap interact in a way that is easy to get subtly wrong, and a
|
||||
-- caller that guessed would place things at the feet of a Pokemon that is
|
||||
-- flying. Which is exactly what a Pidgey does -- it renders a good third
|
||||
-- of its own height clear of its tile, and anything aimed at its cell
|
||||
-- mark lands under it.
|
||||
function StadiumMon:groundGap()
|
||||
local centre, half = self:bodySpan()
|
||||
if centre then return math.max(0, centre - half) end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Where this Pokemon's body actually SITS above the ground it is placed
|
||||
-- on, and how big it is: the centre height, the half height and the
|
||||
-- girth, all in world pixels.
|
||||
--
|
||||
-- Measured off the POSED vertices (StadiumRig:posedBounds) and put
|
||||
-- through this matrix's own scale and lift, so the answer is the shape
|
||||
-- the camera is about to see. That matters most for the species it is
|
||||
-- hardest to guess about: a Pidgey's standby animation flies it well
|
||||
-- clear of its tile, and anything aimed at its cell mark -- a capture
|
||||
-- ring, a thrown ball's collision -- lands under an empty patch of grass
|
||||
-- while the bird hovers above it. Nothing static says so; only the pose
|
||||
-- does.
|
||||
--
|
||||
-- nil before the first skin(), or with no rig: the caller falls back to
|
||||
-- the bind-pose height, which is right for everything that stands.
|
||||
function StadiumMon:bodySpan()
|
||||
local model, rig = self.model, self.rig
|
||||
if not (model and rig and rig.posedBounds) then return nil end
|
||||
local okB, lo, hi, girth = pcall(rig.posedBounds, rig)
|
||||
if not (okB and lo) then return nil end
|
||||
local root = model.rootScale
|
||||
if not (root and root > 0) then root = 1 end
|
||||
local k = root * self:worldHeight() / math.max(model.height, 1e-6)
|
||||
k = k * (self.scale or 1)
|
||||
local floor = model.floor or 0
|
||||
local hover = math.min(math.max(floor, 0),
|
||||
StadiumMon.HOVER_CAP * math.max(model.height, 0))
|
||||
local lift = (floor - hover) / root
|
||||
-- the same map the model matrix applies: world = k * (posed - lift)
|
||||
return k * ((lo + hi) * 0.5 - lift), k * (hi - lo) * 0.5, k * (girth or 0)
|
||||
end
|
||||
|
||||
-- Pose and skin for this frame. Separate from the draw because both the
|
||||
-- SUN and the camera -- and, in a headset, both eyes -- want the same
|
||||
-- skinned mesh, and skinning it once is the whole reason this is worth
|
||||
-- doing on the CPU.
|
||||
function StadiumMon:build()
|
||||
if not (self.rig and self.model) then return false end
|
||||
-- self.anim is nil while a species has nothing to play, and pose() reads
|
||||
-- that as "the bind pose", which is exactly what is wanted
|
||||
self.rig:pose(self.anim, self.time * StadiumMon.FPS, self.loop)
|
||||
-- and then back onto the tile, because these animations were authored for
|
||||
-- a camera that followed the Pokemon and this one does not move (see
|
||||
-- StadiumRig.anchor)
|
||||
self.rig:anchor(StadiumMon.TRAVEL, self.dt)
|
||||
self.rig:skin(self.yaw or 0)
|
||||
-- no clock of its own: the texture animation rides the frame pose() just
|
||||
-- resolved, which is what keeps a blink inside its standby loop and a
|
||||
-- fainted Pokemon's eyes shut once it has stopped moving
|
||||
self.rig:textures(self.aux)
|
||||
return true
|
||||
end
|
||||
|
||||
return StadiumMon
|
||||
@@ -0,0 +1,661 @@
|
||||
-- STADIUM battles: reading one species' model off disk.
|
||||
--
|
||||
-- `NNN.dsm` holds one Pokemon Stadium battle model. It is written by
|
||||
-- StadiumBuild, out of the player's own copy of that ROM, the first time the
|
||||
-- mod runs (see StadiumInstall) -- and by tools/stadium_pack.py, which is the
|
||||
-- oracle that Lua path is tested against. This file is the other half of that
|
||||
-- format and nothing else: bytes in, tables out. What the tables MEAN is
|
||||
-- StadiumRig's business (posing a skeleton) and StadiumMon's (which animation
|
||||
-- a fight is asking for).
|
||||
--
|
||||
-- Three things shape it.
|
||||
--
|
||||
-- BINARY, NOT LUA. A species is a couple of hundred kilobytes of numbers,
|
||||
-- most of it animation, and a Lua source file of that is a parse the loader
|
||||
-- would pay for on every boot whether a battle happened or not. A byte
|
||||
-- string is read once, on the frame a fight starts, and only for the two
|
||||
-- species actually fighting.
|
||||
--
|
||||
-- LAZY ANIMATIONS. Geometry, bones and textures are decoded on load --
|
||||
-- they are small, and every one of them is needed the moment the mon
|
||||
-- appears. The animations are not: a fight uses idle, an entrance and
|
||||
-- whichever handful of attacks come up, out of the seven to twenty-one a
|
||||
-- species carries. So the load pass SCANS the animation block, recording
|
||||
-- where each one starts and skipping the rest, and a track is decoded the
|
||||
-- first time something plays it. That turns a 200 KB decode into a 20 KB
|
||||
-- one plus a few milliseconds spread over the fight.
|
||||
--
|
||||
-- AN LRU OF FOUR. A model is shared by everything that draws that species
|
||||
-- -- both sides of a mirror match, both VR eyes -- and kept for a few
|
||||
-- battles after, because the next fight on the same route is very often
|
||||
-- the same Pokemon. Four is enough for a wild fight (two) plus the
|
||||
-- trainer's next two, and it bounds what the mode can hold to a few
|
||||
-- megabytes.
|
||||
--
|
||||
-- Everything is pcall-guarded and every failure answers nil: a missing
|
||||
-- pack, a truncated file or a driver that will not make an image all end
|
||||
-- at the same place, which is the flat 2D-3D card this mode falls back to
|
||||
-- (see Stadium).
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local StadiumPack = {}
|
||||
|
||||
local byte = string.byte
|
||||
local floor = math.floor
|
||||
|
||||
-- ------- where a pack comes from
|
||||
--
|
||||
-- Two places, asked in this order.
|
||||
--
|
||||
-- CACHE_DIR is in the save directory and is what actually ships: the mod
|
||||
-- carries no models (they are Pokemon Stadium's data), so StadiumInstall
|
||||
-- builds them out of the player's own ROM on first run and writes them here.
|
||||
--
|
||||
-- DIR is inside the mod, and exists for a developer checkout that has run
|
||||
-- tools/stadium_pack.py -- which is also how the oracle the Lua extractor is
|
||||
-- tested against gets built. It is second because a locally built CURRENT
|
||||
-- cache should win over whatever a checkout happens to have lying around --
|
||||
-- current as judged by StadiumInstall's marker, so a cache an old extractor
|
||||
-- built does not shadow a fresh set (see readPack).
|
||||
StadiumPack.CACHE_DIR = "dramatic_shape/stadium"
|
||||
StadiumPack.DIR = "assets/stadium"
|
||||
|
||||
-- The shiny variant sits beside its species as NNNs.dsm -- the same DSM3,
|
||||
-- written by the same writer, differing only in its texture bytes. See the
|
||||
-- note over StadiumInstall's writePack for why it is a separate file and not
|
||||
-- a second block in the pack.
|
||||
local function packName(dir, species, shiny)
|
||||
return shiny and ("%s/%03ds.dsm"):format(dir, species)
|
||||
or ("%s/%03d.dsm"):format(dir, species)
|
||||
end
|
||||
|
||||
local function readPack(species, shiny)
|
||||
-- The cache only counts when StadiumInstall's marker says it is a
|
||||
-- complete, CURRENT build -- an old cache (a rev the extractor has since
|
||||
-- fixed, a format that moved) must not shadow a fresh shipped set, and a
|
||||
-- half-written folder must not be read at all. Required lazily: Install
|
||||
-- requires this module at load, so the reverse edge cannot be taken then.
|
||||
local rel = packName(StadiumPack.CACHE_DIR, species, shiny)
|
||||
local install = V.require("StadiumInstall")
|
||||
local mod = V.mod
|
||||
local haveShipped = false
|
||||
if mod and mod.read then
|
||||
local okS, b = pcall(mod.read, mod, packName(StadiumPack.DIR, species, shiny))
|
||||
haveShipped = okS and type(b) == "string" and #b > 4
|
||||
end
|
||||
-- A CURRENT cache always wins. A stale one (readable, but built by an older
|
||||
-- extractor) wins only when there is no shipped set to prefer instead --
|
||||
-- that ordering is what stops a cache from an extractor rev we have since
|
||||
-- fixed shadowing good files, while still leaving something on screen for a
|
||||
-- player whose only copy IS that cache. A half-written folder is caught by
|
||||
-- the marker and satisfies neither.
|
||||
if love and love.filesystem and love.filesystem.getInfo
|
||||
and (install.ready() or (install.usable() and not haveShipped)) then
|
||||
local okInfo, info = pcall(love.filesystem.getInfo, rel, "file")
|
||||
if okInfo and info then
|
||||
local ok, bytes = pcall(love.filesystem.read, rel)
|
||||
if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
|
||||
end
|
||||
end
|
||||
if not (mod and mod.read) then return nil end
|
||||
local ok, bytes = pcall(mod.read, mod,
|
||||
packName(StadiumPack.DIR, species, shiny))
|
||||
if ok and type(bytes) == "string" and #bytes > 4 then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The battle system's context slots, in the order tools/stadium_pack.py
|
||||
-- writes them -- slot 165 upward (see model_extract/manifest.json's
|
||||
-- animationSlots). Indexed by POSITION, so this list is the format's
|
||||
-- contract and the packer's CONTEXTS must stay identical to it.
|
||||
-- Position 2 was called "hit" until the move table was read against it: it
|
||||
-- is the animation most of a species' MOVES play, which makes it the default
|
||||
-- attack and not a damage reaction (see StadiumMon's STATES). The slot TABLE
|
||||
-- is indexed by position, but the name also reaches the packed files: the
|
||||
-- packers bake it into the animation NAME strings, so it has to match
|
||||
-- tools/stadium_pack.py's CONTEXTS *and* pipeline/battle.py's CONTEXT_SLOTS,
|
||||
-- or the oracle diff reports every species.
|
||||
StadiumPack.CONTEXT = {
|
||||
"idle", "attack_default", "faint", "entrance", "reaction_169", "reaction_170",
|
||||
"reaction_171", "reaction_172", "reaction_173", "reaction_174",
|
||||
"struggle", "idle_alt", "faint_alt", "flinch", "reaction_179",
|
||||
"reaction_180", "reaction_181", "reaction_182", "entrance_alt",
|
||||
"idle_return",
|
||||
}
|
||||
|
||||
-- name -> slot position, for callers that ask by name
|
||||
StadiumPack.SLOT = {}
|
||||
for i, name in ipairs(StadiumPack.CONTEXT) do StadiumPack.SLOT[name] = i end
|
||||
|
||||
StadiumPack.N_MOVES = 165
|
||||
StadiumPack.NONE = 0xFFFF
|
||||
|
||||
-- The frame rate every animation in the set is authored at
|
||||
-- (model_extract/README.md: keyframe times are frame / 30).
|
||||
StadiumPack.FPS = 30
|
||||
|
||||
-- ------- readers
|
||||
--
|
||||
-- One cursor threaded through by hand rather than an object: this runs over
|
||||
-- a couple of hundred thousand values on the frame a battle starts, and a
|
||||
-- method call per value is the difference between a hitch and no hitch.
|
||||
|
||||
local function u8(s, p) return byte(s, p), p + 1 end
|
||||
|
||||
local function u16(s, p)
|
||||
local a, b = byte(s, p, p + 1)
|
||||
return a + b * 256, p + 2
|
||||
end
|
||||
|
||||
local function i16(s, p)
|
||||
local a, b = byte(s, p, p + 1)
|
||||
local v = a + b * 256
|
||||
if v >= 32768 then v = v - 65536 end
|
||||
return v, p + 2
|
||||
end
|
||||
|
||||
local function u32(s, p)
|
||||
local a, b, c, d = byte(s, p, p + 3)
|
||||
return a + b * 256 + c * 65536 + d * 16777216, p + 4
|
||||
end
|
||||
|
||||
local function i32(s, p)
|
||||
local v
|
||||
v, p = u32(s, p)
|
||||
if v >= 2147483648 then v = v - 4294967296 end
|
||||
return v, p
|
||||
end
|
||||
|
||||
-- IEEE 754 single, by hand. LOVE has love.data.unpack, but this file reads
|
||||
-- exactly four floats per model (the header's extents) and a hand decode
|
||||
-- costs nothing while removing a version floor from the mod's whole
|
||||
-- STADIUM path.
|
||||
local function f32(s, p)
|
||||
local b1, b2, b3, b4 = byte(s, p, p + 3)
|
||||
local sign = 1
|
||||
if b4 >= 128 then sign, b4 = -1, b4 - 128 end
|
||||
local expo = b4 * 2 + floor(b3 / 128)
|
||||
local mant = (b3 % 128) * 65536 + b2 * 256 + b1
|
||||
if expo == 255 then
|
||||
if mant == 0 then return sign * math.huge, p + 4 end
|
||||
return 0, p + 4
|
||||
end
|
||||
if expo == 0 then return sign * mant * 2 ^ -149, p + 4 end
|
||||
return sign * (1 + mant / 8388608) * 2 ^ (expo - 127), p + 4
|
||||
end
|
||||
|
||||
-- 16.16 fixed point, which is how bone scales are stored (they run from
|
||||
-- about -31 to 100 across the set and a float would cost twice the bytes
|
||||
-- for precision nothing can see).
|
||||
local function fixed(s, p)
|
||||
local v
|
||||
v, p = i32(s, p)
|
||||
return v / 65536, p
|
||||
end
|
||||
|
||||
-- ------- the load
|
||||
|
||||
local function readHeader(s, p, model)
|
||||
model.species, p = u16(s, p)
|
||||
model.boneCount, p = u16(s, p)
|
||||
model.primCount, p = u16(s, p)
|
||||
model.texCount, p = u16(s, p)
|
||||
model.animCount, p = u16(s, p)
|
||||
model.auxCount, p = u16(s, p)
|
||||
model.rootScale, p = f32(s, p)
|
||||
-- a species whose standby loop is corrupt in the source extraction, and
|
||||
-- which the mod therefore holds at its bind pose (see the packer's
|
||||
-- idle_is_broken). Three of the 151.
|
||||
local static
|
||||
static, p = u8(s, p)
|
||||
model.staticPose = static ~= 0
|
||||
model.height, p = f32(s, p)
|
||||
model.floor, p = f32(s, p)
|
||||
model.radius, p = f32(s, p)
|
||||
|
||||
local moveAnim, moveAux, ctx = {}, {}, {}
|
||||
for i = 1, StadiumPack.N_MOVES do moveAnim[i], p = u16(s, p) end
|
||||
for i = 1, StadiumPack.N_MOVES do moveAux[i], p = i16(s, p) end
|
||||
for i = 1, #StadiumPack.CONTEXT do ctx[i], p = u16(s, p) end
|
||||
model.moveAnim, model.moveAux, model.ctx = moveAnim, moveAux, ctx
|
||||
return p
|
||||
end
|
||||
|
||||
-- The bone tree, as flat parallel arrays: a rig walk touches every bone
|
||||
-- every frame and an array of little tables would be a cache miss per bone
|
||||
-- and a table per bone to collect.
|
||||
local function readBones(s, p, model)
|
||||
local n = model.boneCount
|
||||
local parent, t, r, sc = {}, {}, {}, {}
|
||||
for i = 1, n do
|
||||
-- 0-based in the file, 1-based here, and 0 for "no parent" so the rig's
|
||||
-- walk can test it without a sentinel comparison
|
||||
local par
|
||||
par, p = i16(s, p)
|
||||
parent[i] = par + 1
|
||||
local b = (i - 1) * 3
|
||||
t[b + 1], p = i16(s, p)
|
||||
t[b + 2], p = i16(s, p)
|
||||
t[b + 3], p = i16(s, p)
|
||||
r[b + 1], p = i16(s, p)
|
||||
r[b + 2], p = i16(s, p)
|
||||
r[b + 3], p = i16(s, p)
|
||||
sc[b + 1], p = fixed(s, p)
|
||||
sc[b + 2], p = fixed(s, p)
|
||||
sc[b + 3], p = fixed(s, p)
|
||||
end
|
||||
model.parent, model.restT, model.restR, model.restS = parent, t, r, sc
|
||||
return p
|
||||
end
|
||||
|
||||
-- One drawable piece: the triangles that share a texture and a cull mode.
|
||||
--
|
||||
-- Positions and normals stay in BONE-LOCAL space, exactly as the display
|
||||
-- list had them, because that is what makes the skinning a single matrix
|
||||
-- multiply per vertex (every vertex in the set is rigidly bound to one bone
|
||||
-- -- see model_extract/README.md) rather than a weighted blend.
|
||||
local function readPrims(s, p, model)
|
||||
local prims = {}
|
||||
for i = 1, model.primCount do
|
||||
local prim = {}
|
||||
prim.tex, p = u16(s, p)
|
||||
prim.tex = prim.tex + 1
|
||||
local cull, blend
|
||||
cull, p = u8(s, p)
|
||||
blend, p = u8(s, p)
|
||||
prim.cull = cull ~= 0
|
||||
prim.additive = blend ~= 0
|
||||
prim.texAnim, p = i16(s, p)
|
||||
|
||||
-- the texture-animation channel's value -> which texture to swap in.
|
||||
-- Keyed by the stream's own byte, so the rig can look one up without
|
||||
-- searching.
|
||||
local mapN
|
||||
mapN, p = u8(s, p)
|
||||
if mapN > 0 then
|
||||
local map = {}
|
||||
for _ = 1, mapN do
|
||||
local key, tex
|
||||
key, p = u8(s, p)
|
||||
tex, p = u16(s, p)
|
||||
map[key] = tex + 1
|
||||
end
|
||||
prim.texMap = map
|
||||
end
|
||||
|
||||
local fxN
|
||||
fxN, p = u16(s, p)
|
||||
if fxN > 0 then
|
||||
local frames = {}
|
||||
for k = 1, fxN do
|
||||
frames[k], p = u16(s, p)
|
||||
frames[k] = frames[k] + 1
|
||||
end
|
||||
prim.fxFrames = frames
|
||||
end
|
||||
|
||||
local nv, ni
|
||||
nv, p = u16(s, p)
|
||||
ni, p = u16(s, p)
|
||||
prim.vertCount, prim.indexCount = nv, ni
|
||||
|
||||
-- five arrays rather than one array of vertices, for the same reason
|
||||
-- the bones are flat: the skinning loop reads them in step and writes
|
||||
-- one LOVE vertex row out
|
||||
local px, py, pz = {}, {}, {}
|
||||
local uv = {}
|
||||
local nx, ny, nz = {}, {}, {}
|
||||
local bone = {}
|
||||
for k = 1, nv do
|
||||
px[k], p = i16(s, p)
|
||||
py[k], p = i16(s, p)
|
||||
pz[k], p = i16(s, p)
|
||||
local u, v
|
||||
u, p = i16(s, p)
|
||||
v, p = i16(s, p)
|
||||
uv[k * 2 - 1], uv[k * 2] = u / 512, v / 512
|
||||
local a, b, c
|
||||
a, p = u8(s, p)
|
||||
b, p = u8(s, p)
|
||||
c, p = u8(s, p)
|
||||
if a >= 128 then a = a - 256 end
|
||||
if b >= 128 then b = b - 256 end
|
||||
if c >= 128 then c = c - 256 end
|
||||
nx[k], ny[k], nz[k] = a / 127, b / 127, c / 127
|
||||
bone[k], p = u8(s, p)
|
||||
bone[k] = bone[k] + 1
|
||||
end
|
||||
prim.px, prim.py, prim.pz = px, py, pz
|
||||
prim.uv = uv
|
||||
prim.nx, prim.ny, prim.nz = nx, ny, nz
|
||||
prim.bone = bone
|
||||
|
||||
local idx = {}
|
||||
for k = 1, ni do
|
||||
idx[k], p = u16(s, p)
|
||||
idx[k] = idx[k] + 1
|
||||
end
|
||||
prim.index = idx
|
||||
prims[i] = prim
|
||||
end
|
||||
model.prims = prims
|
||||
return p
|
||||
end
|
||||
|
||||
-- The textures, kept as the raw RGBA8 they arrived as and turned into
|
||||
-- images on first use. A species carries every frame of every blink and
|
||||
-- every dizzy swirl; a fight that never shows one should not pay to
|
||||
-- upload it.
|
||||
--
|
||||
-- Raw rather than PNG, which is what DSM3 changed: an ImageData over these
|
||||
-- bytes is a memcpy where a PNG is a decode on the frame a battle starts,
|
||||
-- and -- the reason it was actually done -- uncompressed pixels are the same
|
||||
-- pixels whichever side wrote them, so the Lua extractor's output can be
|
||||
-- diffed against the Python packer's byte for byte. Two deflate
|
||||
-- implementations need not agree; two arrays of pixels do.
|
||||
local function readTextures(s, p, model)
|
||||
local tex = {}
|
||||
for i = 1, model.texCount do
|
||||
local w, h, len
|
||||
w, p = u16(s, p)
|
||||
h, p = u16(s, p)
|
||||
len, p = u32(s, p)
|
||||
tex[i] = { w = w, h = h, rgba = s:sub(p, p + len - 1) }
|
||||
p = p + len
|
||||
end
|
||||
model.textures = tex
|
||||
return p
|
||||
end
|
||||
|
||||
-- How many bytes one animation's track block occupies, without decoding
|
||||
-- any of it. This is the scan that makes lazy animations possible: nine
|
||||
-- components a bone, each either one value or one a frame, and the only
|
||||
-- thing that has to be READ is the byte that says which.
|
||||
local COMP_BYTES = { 2, 2, 2, 2, 2, 2, 4, 4, 4 } -- t t t r r r s s s
|
||||
|
||||
local function skipTracks(s, p, boneCount, frames)
|
||||
for _ = 1, boneCount do
|
||||
local present
|
||||
present, p = u8(s, p)
|
||||
if present ~= 0 then
|
||||
for c = 1, 9 do
|
||||
local kind
|
||||
kind, p = u8(s, p)
|
||||
p = p + COMP_BYTES[c] * (kind == 0 and 1 or frames)
|
||||
end
|
||||
end
|
||||
end
|
||||
return p
|
||||
end
|
||||
|
||||
local function readAnims(s, p, model)
|
||||
local anims = {}
|
||||
for i = 1, model.animCount do
|
||||
local len
|
||||
len, p = u8(s, p)
|
||||
local name = s:sub(p, p + len - 1)
|
||||
p = p + len
|
||||
local frames, loopStart, aux
|
||||
frames, p = u16(s, p)
|
||||
loopStart, p = u16(s, p)
|
||||
aux, p = i16(s, p)
|
||||
anims[i] = {
|
||||
name = name, frames = frames, loopStart = loopStart,
|
||||
aux = aux >= 0 and (aux + 1) or nil,
|
||||
seconds = frames / StadiumPack.FPS,
|
||||
offset = p, -- where its tracks start; decoded later
|
||||
}
|
||||
p = skipTracks(s, p, model.boneCount, frames)
|
||||
end
|
||||
model.anims = anims
|
||||
return p
|
||||
end
|
||||
|
||||
local function readAux(s, p, model)
|
||||
local aux = {}
|
||||
for i = 1, model.auxCount do
|
||||
local frames, loopStart, chanN
|
||||
frames, p = u16(s, p)
|
||||
loopStart, p = u16(s, p)
|
||||
chanN, p = u16(s, p)
|
||||
local chans = {}
|
||||
for c = 1, chanN do
|
||||
local n
|
||||
n, p = u16(s, p)
|
||||
local stream = {}
|
||||
for k = 1, n do stream[k], p = u16(s, p) end
|
||||
chans[c] = stream
|
||||
end
|
||||
aux[i] = { frames = frames, loopStart = loopStart, channels = chans }
|
||||
end
|
||||
model.auxAnims = aux
|
||||
return p
|
||||
end
|
||||
|
||||
-- ------- a track block, decoded on demand
|
||||
--
|
||||
-- The shape a pose walk wants: `tracks[bone]` is either nil (this bone
|
||||
-- holds its rest transform for the whole animation) or nine entries, each
|
||||
-- either a number (constant) or an array of one value per frame.
|
||||
--
|
||||
-- That fold is the source data's own, not something imposed here: a bone
|
||||
-- that only rotates costs two bytes for each of its six other components,
|
||||
-- and across the 151 species it is most of the reason the whole set is 24
|
||||
-- megabytes rather than a hundred.
|
||||
function StadiumPack.tracks(model, index)
|
||||
local anim = model.anims and model.anims[index]
|
||||
if not anim then return nil end
|
||||
if anim.tracks then return anim.tracks end
|
||||
local s, p = model.bytes, anim.offset
|
||||
if not (s and p) then return nil end
|
||||
local frames = anim.frames
|
||||
local out = {}
|
||||
for b = 1, model.boneCount do
|
||||
local present
|
||||
present, p = u8(s, p)
|
||||
if present ~= 0 then
|
||||
local comps = {}
|
||||
for c = 1, 9 do
|
||||
local kind
|
||||
kind, p = u8(s, p)
|
||||
local read = (c >= 7) and fixed or i16
|
||||
if kind == 0 then
|
||||
comps[c], p = read(s, p)
|
||||
else
|
||||
local arr = {}
|
||||
for k = 1, frames do arr[k], p = read(s, p) end
|
||||
comps[c] = arr
|
||||
end
|
||||
end
|
||||
out[b] = comps
|
||||
end
|
||||
end
|
||||
anim.tracks = out
|
||||
return out
|
||||
end
|
||||
|
||||
-- One texture as a LOVE image, decoded on first ask.
|
||||
function StadiumPack.image(model, index)
|
||||
local slot = model.textures and model.textures[index]
|
||||
if not slot then return nil end
|
||||
if slot.image ~= nil then return slot.image or nil end
|
||||
local ok, img = pcall(function()
|
||||
local data = love.image.newImageData(slot.w, slot.h, "rgba8", slot.rgba)
|
||||
local image = love.graphics.newImage(data)
|
||||
-- N64 art at N64 resolution: nearest keeps the texels the size the
|
||||
-- artist drew them, exactly as every other texture in this mode
|
||||
image:setFilter("nearest", "nearest")
|
||||
return image
|
||||
end)
|
||||
slot.image = (ok and img) or false
|
||||
return slot.image or nil
|
||||
end
|
||||
|
||||
-- ------- the cache
|
||||
|
||||
local cache = {} -- cache key -> model
|
||||
local order = {} -- cache key, least recently used first
|
||||
|
||||
-- The key is the species for a normal model and species+SHINY for a shiny
|
||||
-- one, so the two are separate entries that cannot overwrite each other.
|
||||
--
|
||||
-- They MUST be separate. The model table carries the decoded textures and
|
||||
-- the lazily-built love Images hanging off them, and it is deliberately
|
||||
-- shared by both sides and both VR eyes -- so a single entry per species
|
||||
-- would mean a shiny Rattata and an ordinary one in the same fight fighting
|
||||
-- over one texture set, and whichever loaded last would colour both.
|
||||
local SHINY = 1000 -- clear of the 1..151 dex range
|
||||
|
||||
local function cacheKey(species, shiny)
|
||||
if not species then return nil end
|
||||
return shiny and (species + SHINY) or species
|
||||
end
|
||||
|
||||
-- Four, because a mirror match between a shiny and a normal of the SAME
|
||||
-- species is now two distinct models rather than one shared table, and both
|
||||
-- sides must survive a fifth species being called out mid-fight. See keep().
|
||||
StadiumPack.KEEP = 4
|
||||
|
||||
local function touch(species)
|
||||
for i = #order, 1, -1 do
|
||||
if order[i] == species then table.remove(order, i) end
|
||||
end
|
||||
order[#order + 1] = species
|
||||
while #order > StadiumPack.KEEP do
|
||||
local drop = table.remove(order, 1)
|
||||
local model = cache[drop]
|
||||
cache[drop] = nil
|
||||
if model and model.textures then
|
||||
for _, slot in ipairs(model.textures) do
|
||||
if slot.image and slot.image.release then
|
||||
pcall(slot.image.release, slot.image)
|
||||
end
|
||||
-- CLEARED, not just released. A released Image is still a truthy
|
||||
-- value, and `image()` below hands back whatever is in this field
|
||||
-- without looking at it -- so leaving the corpse here meant the next
|
||||
-- ask returned a dead object, which reached mesh:setTexture and threw
|
||||
-- "Cannot use object after it has been released" from inside the
|
||||
-- scene pass. Nil means the next ask decodes it again, which is the
|
||||
-- whole point of the slot being lazy.
|
||||
slot.image = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Say that this species is IN USE, so the cache does not evict it.
|
||||
--
|
||||
-- The eviction order above is a least-recently-LOADED list, not a
|
||||
-- least-recently-used one: `touch` runs from `load`, and `load` is only
|
||||
-- reached when a side's species CHANGES (StadiumMon.setSpecies returns early
|
||||
-- otherwise). A Pokemon that stands on the field for several turns therefore
|
||||
-- never refreshes its position, drifts to the front of the queue, and is
|
||||
-- evicted -- its textures released -- while it is still being drawn sixty
|
||||
-- times a second. That is what a fifth species entering a battle did: call
|
||||
-- out a Clefairy and whatever had been standing longest lost its textures
|
||||
-- mid-fight.
|
||||
--
|
||||
-- So the mode says, every frame, which two species are actually standing
|
||||
-- there (see Stadium.update). With KEEP at 4 and two sides, the two in use
|
||||
-- are always the two most recent and cannot reach the front of the queue.
|
||||
function StadiumPack.keep(species, shiny)
|
||||
local key = cacheKey(species, shiny)
|
||||
if key and cache[key] then touch(key) end
|
||||
end
|
||||
|
||||
-- Whether a pack for this species is on disk at all. Cheap enough to ask
|
||||
-- before a battle commits to the mode, and the honest test: a mod
|
||||
-- installed without its assets folder must decline rather than error.
|
||||
--
|
||||
-- Asked WITHOUT the shiny flag on purpose by the callers that gate the mode:
|
||||
-- whether a species can be modelled at all is a question about its normal
|
||||
-- pack. A missing shiny variant does not disqualify the species, it just
|
||||
-- means that one mon is drawn in its ordinary colours.
|
||||
function StadiumPack.available(species, shiny)
|
||||
local key = cacheKey(species, shiny)
|
||||
if key and cache[key] then return true end
|
||||
return readPack(species, shiny) ~= nil
|
||||
end
|
||||
|
||||
-- The model for a National Dex number (1..151), or nil.
|
||||
--
|
||||
-- `shiny` selects the recoloured variant. When a species has no shiny pack
|
||||
-- -- an install from before rev 3, a recolour that failed at extraction, a
|
||||
-- species we have no colours for -- this FALLS BACK to the normal model
|
||||
-- rather than returning nil. The alternative is a shiny Pokemon that drops
|
||||
-- to a flat 2D pic while its ordinary twin stands in 3D, which reads as a
|
||||
-- bug; wrong colours read as a mod that has not finished installing.
|
||||
function StadiumPack.load(species, shiny)
|
||||
if not (species and species >= 1 and species <= 151) then return nil end
|
||||
local key = cacheKey(species, shiny)
|
||||
local hit = cache[key]
|
||||
if hit ~= nil then
|
||||
touch(key)
|
||||
return hit or nil
|
||||
end
|
||||
|
||||
local bytes = readPack(species, shiny)
|
||||
if not bytes and shiny then
|
||||
return StadiumPack.load(species, false)
|
||||
end
|
||||
if not bytes then
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
|
||||
local ok, model = pcall(function()
|
||||
if bytes:sub(1, 4) ~= "DSM3" then
|
||||
error("not a DSM3 pack -- delete it and let the mod rebuild it", 0)
|
||||
end
|
||||
local m = { bytes = bytes }
|
||||
local p = 5
|
||||
p = readHeader(bytes, p, m)
|
||||
p = readBones(bytes, p, m)
|
||||
p = readPrims(bytes, p, m)
|
||||
p = readTextures(bytes, p, m)
|
||||
p = readAnims(bytes, p, m)
|
||||
readAux(bytes, p, m)
|
||||
return m
|
||||
end)
|
||||
if not ok then
|
||||
V.mod.log:warn("stadium: %s did not read: %s -- that Pokemon "
|
||||
.. "falls back to its flat pic",
|
||||
packName("", species, shiny):sub(2), tostring(model))
|
||||
-- A corrupt SHINY pack must not cost the species its model: fall back to
|
||||
-- the normal one, exactly as a missing file does above.
|
||||
if shiny then
|
||||
cache[key] = false
|
||||
return StadiumPack.load(species, false)
|
||||
end
|
||||
cache[key] = false
|
||||
return nil
|
||||
end
|
||||
|
||||
model.shiny = shiny and true or nil
|
||||
cache[key] = model
|
||||
touch(key)
|
||||
return model
|
||||
end
|
||||
|
||||
-- Drop everything (hot reload, or a graphics context that went away).
|
||||
function StadiumPack.invalidate()
|
||||
for _, model in pairs(cache) do
|
||||
if model and model.textures then
|
||||
for _, slot in ipairs(model.textures) do
|
||||
if slot.image and slot.image.release then
|
||||
pcall(slot.image.release, slot.image)
|
||||
end
|
||||
slot.image = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function StadiumPack.forget()
|
||||
StadiumPack.invalidate()
|
||||
cache, order = {}, {}
|
||||
end
|
||||
|
||||
return StadiumPack
|
||||
@@ -0,0 +1,864 @@
|
||||
-- STADIUM battles: posing a skeleton and skinning it, on the CPU.
|
||||
--
|
||||
-- One instance of this is one Pokemon standing on the map -- the meshes it
|
||||
-- draws through and the scratch space its pose is computed in. The MODEL
|
||||
-- (geometry, bones, animations, textures) is shared and read-only; this is
|
||||
-- everything about it that is per-Pokemon and changes every frame.
|
||||
--
|
||||
-- ------- why the CPU
|
||||
--
|
||||
-- Because these models are tiny and the mod's shader already exists. A
|
||||
-- battle model is 674 vertices on average and 1311 at the worst, of which
|
||||
-- exactly two are on screen at a time -- so skinning them by hand costs
|
||||
-- about two thousand vertex transforms a frame, which is less than the
|
||||
-- grass pass does on an empty route. What it buys is that the finished
|
||||
-- vertices go into Voxel3D's OWN vertex format, through Voxel3D's OWN
|
||||
-- shader, and therefore get every single thing the rest of the diorama
|
||||
-- gets for free: the depth buffer decides what is in front of what, the
|
||||
-- sun pass throws a real shadow of the actual pose, the hour's tint lands
|
||||
-- on it, the hit flash flattens it, and the tilt-shift and the
|
||||
-- depth-of-field see it as part of the picture. A GPU skinning path would
|
||||
-- have needed a second shader that then had to re-implement all of that,
|
||||
-- and a second shadow shader beside it.
|
||||
--
|
||||
-- It is also what makes the FORMAT work. Every vertex in the Stadium set is
|
||||
-- rigidly bound to ONE bone with weight 1 (model_extract/README.md), so
|
||||
-- skinning is a single matrix multiply per vertex with no blend -- and the
|
||||
-- per-vertex `shade` Voxel3D wants, which no glTF has, is computed here
|
||||
-- from the bone-local normal.
|
||||
--
|
||||
-- ------- the two matrix chains
|
||||
--
|
||||
-- The game keeps bone scale OUT of the matrix chain (func_800143C0): scale
|
||||
-- accumulates in its own stack, a bone's local translation is
|
||||
-- pre-multiplied by its parent's accumulated scale, and a bone's own
|
||||
-- accumulated scale is applied to the finished matrix only at draw time.
|
||||
-- glTF cannot express that -- its node scale propagates to children -- and
|
||||
-- the reference export works around it by splitting every bone into two
|
||||
-- nodes.
|
||||
--
|
||||
-- Here it falls out naturally, as two arrays:
|
||||
--
|
||||
-- pivot rotation and translation only. This is what a CHILD inherits,
|
||||
-- and it is a pure rotation, which is also why the normals are
|
||||
-- transformed with it rather than with the draw matrix.
|
||||
-- draw the same matrix with the bone's accumulated scale applied on
|
||||
-- the right, which is the one vertices go through.
|
||||
--
|
||||
-- Folding the scale into the chain instead is the obvious mistake and it
|
||||
-- applies every ancestor's scale once per generation. It is caught by the
|
||||
-- suite: tools/stadium_pack.py measures the bind pose with this exact walk
|
||||
-- and its answer matches the verified glTF export on all 151 species.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local StadiumPack = V.require("StadiumPack")
|
||||
|
||||
local StadiumRig = {}
|
||||
StadiumRig.__index = StadiumRig
|
||||
|
||||
local sin, cos, floor = math.sin, math.cos, math.floor
|
||||
|
||||
-- binary angle (32768 = pi) to radians
|
||||
local ANG = math.pi / 32768
|
||||
|
||||
-- ------- how a surface is lit
|
||||
--
|
||||
-- Voxel3D shades a face by its DIRECTION rather than by a light uniform:
|
||||
-- every terrain and character mesh in this mode carries a per-vertex
|
||||
-- `shade` baked from which way its face points, and the shadow map
|
||||
-- multiplies on top of that (see Voxel3D.FACE_SHADE). A skinned model has
|
||||
-- no fixed faces to bake, so the same answer is computed per vertex from
|
||||
-- the posed normal -- and these four numbers are FACE_SHADE's own six
|
||||
-- values, fitted:
|
||||
--
|
||||
-- +Y up 1.00 -Y down 0.55 +X east 0.84 -X west 0.72
|
||||
-- +Z south 0.90 -Z north 0.68
|
||||
--
|
||||
-- so a Pokemon's flank catches the same southeastern sun the roof of the
|
||||
-- house behind it does, and the two read as being in one picture.
|
||||
local SHADE_BASE = 0.7725
|
||||
local SHADE_X = 0.06
|
||||
local SHADE_Y = 0.225
|
||||
local SHADE_Z = 0.11
|
||||
|
||||
-- ------- an instance
|
||||
|
||||
-- `model` is a StadiumPack model. Returns nil where meshes cannot be made,
|
||||
-- which is the same "no 3D" answer every other GPU object in this mod gives.
|
||||
function StadiumRig.new(model)
|
||||
if not (model and model.prims) then return nil end
|
||||
if not (love.graphics and love.graphics.newMesh) then return nil end
|
||||
|
||||
local self = setmetatable({
|
||||
model = model,
|
||||
-- The two chains, flat: twelve numbers a bone, row-major 3x4.
|
||||
--
|
||||
-- Named with the M rather than `pivot` and `draw` because an instance
|
||||
-- field called `draw` shadows the DRAW METHOD through __index, and the
|
||||
-- failure that causes is a nasty one: the shadow pass calls caster()
|
||||
-- and keeps working, so a Pokemon casts a perfect animated shadow onto
|
||||
-- ground it is not standing on.
|
||||
pivotM = {},
|
||||
drawM = {},
|
||||
-- the accumulated scale, which is the third thing the game's own walk
|
||||
-- carries and neither matrix can hold
|
||||
accX = {}, accY = {}, accZ = {},
|
||||
parts = {},
|
||||
-- what the pose walk last answered, so a frame that neither moved the
|
||||
-- animation nor turned the model can skip the whole thing
|
||||
poseKey = nil,
|
||||
-- scratch for the body-centre estimate (see anchor), kept on the rig so
|
||||
-- a per-frame measurement allocates nothing
|
||||
cx = {}, cy = {}, cz = {},
|
||||
}, StadiumRig)
|
||||
|
||||
-- One mesh per primitive: a primitive is already "the triangles sharing
|
||||
-- one texture", which is exactly one draw call's worth.
|
||||
--
|
||||
-- "dynamic" rather than "static": every vertex is rewritten every frame
|
||||
-- the pose changes, which is what the usage hint exists to say.
|
||||
for i, prim in ipairs(model.prims) do
|
||||
local rows = {}
|
||||
local uv = prim.uv
|
||||
for k = 1, prim.vertCount do
|
||||
-- position and shade are filled by skin(); the texture coordinates
|
||||
-- never change, so they are written once here
|
||||
rows[k] = { 0, 0, 0, uv[k * 2 - 1], uv[k * 2], 1 }
|
||||
end
|
||||
local ok, mesh = pcall(love.graphics.newMesh, Voxel3D.FORMAT, rows,
|
||||
"triangles", "dynamic")
|
||||
if not ok then return nil end
|
||||
pcall(mesh.setVertexMap, mesh, prim.index)
|
||||
self.parts[i] = { mesh = mesh, rows = rows, prim = prim }
|
||||
end
|
||||
-- the spot the animations are measured against, taken while there is no
|
||||
-- pose to overwrite (see measureBind)
|
||||
pcall(self.measureBind, self)
|
||||
return self
|
||||
end
|
||||
|
||||
function StadiumRig:release()
|
||||
for _, part in ipairs(self.parts or {}) do
|
||||
if part.mesh and part.mesh.release then
|
||||
pcall(part.mesh.release, part.mesh)
|
||||
end
|
||||
end
|
||||
self.parts = {}
|
||||
end
|
||||
|
||||
-- ------- sampling one track
|
||||
--
|
||||
-- `c` is the pack's own fold: a bare number when the component holds still
|
||||
-- for the whole animation, or one value a frame when it does not. Two frame
|
||||
-- indices and a blend come in because the caller has already resolved what
|
||||
-- "between frame 12 and 13, three tenths of the way" means for THIS
|
||||
-- animation's looping.
|
||||
|
||||
-- One component at one frame.
|
||||
local function sampleAt(c, i)
|
||||
if type(c) == "number" then return c end
|
||||
return c[i]
|
||||
end
|
||||
|
||||
-- ------- interpolation, and the one place it must not happen
|
||||
--
|
||||
-- These streams are not keyframes: they carry ONE VALUE PER FRAME at 30 Hz,
|
||||
-- and the game steps them a frame at a time. So at 60 Hz the honest replay
|
||||
-- is each pose held for two frames -- which is exactly what it looks like,
|
||||
-- a set of models moving at half the frame rate of everything around them.
|
||||
-- Blending between consecutive entries is therefore not reconstructing
|
||||
-- something the source had; it is INVENTING the halfway pose. It is worth
|
||||
-- inventing, because a 30 Hz step against a 60 Hz camera reads as a stutter
|
||||
-- and the halfway pose is right far more often than it is wrong.
|
||||
--
|
||||
-- Where it IS wrong is the reason a naive version of this shipped once and
|
||||
-- had to be taken out: bones snapping to an upside-down pose for a frame,
|
||||
-- arms turning inside out for a few. Rotations here are EULER TRIPLES, and
|
||||
-- a Euler triple is not a direction you can walk along. Two triples can
|
||||
-- describe nearly the same orientation and be nowhere near each other
|
||||
-- component by component -- (0, 20976, 32736) and (0, -19936, -5904) are a
|
||||
-- real pair out of the set -- so walking from one to the other passes
|
||||
-- through orientations that are nothing like either end. That is precisely
|
||||
-- a bone flipping over and back inside one frame.
|
||||
--
|
||||
-- Shortest-arc wrapping (below) fixes the easy half of that, where a
|
||||
-- component crosses the +-pi seam. It cannot fix the hard half, where the
|
||||
-- source simply RE-EXPRESSES a rotation. So the hard half is not fixed, it
|
||||
-- is DETECTED: a bone whose rotation moves more than BREAK_ANGLE in a
|
||||
-- single frame is not being animated, it is being re-expressed or snapped,
|
||||
-- and that bone holds its frame instead of blending. Per bone and all three
|
||||
-- components together, because the three are one rotation and blending two
|
||||
-- of them while holding the third is its own wrong answer.
|
||||
--
|
||||
-- The same guard, in the same spirit, for TRANSLATION: BREAK_MOVE of the
|
||||
-- model's own height inside one frame is a teleport rather than a stride.
|
||||
-- Scale needs none -- a linear blend of two scales lies between them, and
|
||||
-- there is no way for that to be a pose neither end had.
|
||||
|
||||
-- 32768 binary-angle units is pi, so this is a quarter turn in one 30 Hz
|
||||
-- frame -- 2700 degrees a second. Nothing in the set genuinely moves that
|
||||
-- fast; everything that reads as moving that fast is a re-expression.
|
||||
local BREAK_ANGLE = 16384
|
||||
|
||||
-- and half the Pokemon's own height in one frame, which is fifteen body
|
||||
-- heights a second
|
||||
local BREAK_MOVE = 0.5
|
||||
|
||||
-- The signed distance from `c[i0]` to `c[i1]` the SHORT way round, for a
|
||||
-- binary angle. Interpolating 32700 toward -32700 the long way spins the
|
||||
-- bone most of a full turn inside one frame; the short way is 136 units,
|
||||
-- which is what actually happened.
|
||||
local function angleDelta(c, i0, i1)
|
||||
if type(c) == "number" then return 0 end
|
||||
local d = c[i1] - c[i0]
|
||||
if d > 32768 then d = d - 65536 elseif d < -32768 then d = d + 65536 end
|
||||
return d
|
||||
end
|
||||
|
||||
local function linearDelta(c, i0, i1)
|
||||
if type(c) == "number" then return 0 end
|
||||
return c[i1] - c[i0]
|
||||
end
|
||||
|
||||
-- ------- the pose
|
||||
--
|
||||
-- `anim` is an index into model.anims (or nil for the bind pose), `frame` a
|
||||
-- FLOAT frame in that animation's own 30 Hz timeline, and `wrap` whether
|
||||
-- the far end joins back to loopStart (a standby loop) or holds on the last
|
||||
-- frame (a faint).
|
||||
function StadiumRig:pose(anim, frame, wrap)
|
||||
local model = self.model
|
||||
local n = model.boneCount
|
||||
local tracks = anim and StadiumPack.tracks(model, anim) or nil
|
||||
local frames = anim and model.anims[anim] and model.anims[anim].frames or 1
|
||||
|
||||
-- The two frames this instant falls between, and how far. `k` is 0 on
|
||||
-- every whole frame, so a caller that steps in whole frames -- the test
|
||||
-- suite, the blink probe -- sees exactly the frame it asked for.
|
||||
local i0, i1, k = 1, 1, 0
|
||||
if tracks and frames > 1 then
|
||||
local f = frame
|
||||
if f < 0 then f = 0 end
|
||||
local base = floor(f)
|
||||
k = f - base
|
||||
local loop = model.anims[anim].loopStart or 0
|
||||
if not (loop > 0 and loop < frames) then loop = 0 end
|
||||
if base >= frames then
|
||||
if wrap then
|
||||
-- the far end joins back to loopStart, which is where the game's own
|
||||
-- player sends the counter (func_80016FBC)
|
||||
base = loop + (base - loop) % (frames - loop)
|
||||
else
|
||||
base = frames - 1 -- a faint holds where it fell
|
||||
k = 0
|
||||
end
|
||||
end
|
||||
i0 = base + 1
|
||||
if i0 > frames then i0 = frames end
|
||||
if i0 < 1 then i0 = 1 end
|
||||
-- and the frame after it, which past the end of a loop is loopStart --
|
||||
-- the same seam the counter itself crosses. An animation that HOLDS
|
||||
-- (a faint) has nothing after its last frame, so it blends with itself.
|
||||
if i0 < frames then
|
||||
i1 = i0 + 1
|
||||
elseif wrap then
|
||||
i1 = loop + 1
|
||||
else
|
||||
i1, k = i0, 0
|
||||
end
|
||||
end
|
||||
|
||||
-- The frame this animation is actually SHOWING, after the wrap or the
|
||||
-- hold, 0-based -- the WHOLE frame, never the blend. A texture swap has no
|
||||
-- halfway: an eye is open or it is shut, and a pupil interpolated toward a
|
||||
-- swirl is not a thing the hardware could draw. So the skeleton runs at 60
|
||||
-- and the textures step at 30, which is what the game does with both.
|
||||
-- Stashed rather than recomputed because the texture
|
||||
-- animation is sampled at the very same frame (see textures) -- in the
|
||||
-- game one counter drives both, and 73% of the paired animations in the
|
||||
-- set are the same length as each other, which is what that looks like
|
||||
-- from the outside. Two copies of this arithmetic would be two things to
|
||||
-- keep in step; one number cannot drift from itself.
|
||||
self.frameAt = i0 - 1
|
||||
|
||||
local parent = model.parent
|
||||
local restT, restR, restS = model.restT, model.restR, model.restS
|
||||
local pivot, drw = self.pivotM, self.drawM
|
||||
local accX, accY, accZ = self.accX, self.accY, self.accZ
|
||||
|
||||
-- how far a bone may travel in one frame before it is read as a teleport
|
||||
-- rather than a stride. In the vertices' own RAW units, which is what the
|
||||
-- tracks are in: model.height is measured after the model_root scale.
|
||||
local moveBreak = nil
|
||||
if k > 0 then
|
||||
local root = model.rootScale
|
||||
if not (root and root > 0) then root = 1 end
|
||||
local h = (model.height or 0) / root
|
||||
if h > 0 then moveBreak = h * BREAK_MOVE end
|
||||
end
|
||||
|
||||
for b = 1, n do
|
||||
local o3 = (b - 1) * 3
|
||||
local tx, ty, tz, rx, ry, rz, kx, ky, kz
|
||||
local comps = tracks and tracks[b]
|
||||
if comps then
|
||||
tx = sampleAt(comps[1], i0)
|
||||
ty = sampleAt(comps[2], i0)
|
||||
tz = sampleAt(comps[3], i0)
|
||||
rx = sampleAt(comps[4], i0)
|
||||
ry = sampleAt(comps[5], i0)
|
||||
rz = sampleAt(comps[6], i0)
|
||||
kx = sampleAt(comps[7], i0)
|
||||
ky = sampleAt(comps[8], i0)
|
||||
kz = sampleAt(comps[9], i0)
|
||||
if k > 0 then
|
||||
-- ROTATION, all three at once: a bone that snaps holds its frame,
|
||||
-- and a bone that moves holds none of it (see BREAK_ANGLE)
|
||||
local dx = angleDelta(comps[4], i0, i1)
|
||||
local dy = angleDelta(comps[5], i0, i1)
|
||||
local dz = angleDelta(comps[6], i0, i1)
|
||||
if dx < 0 then dx = -dx end
|
||||
if dy < 0 then dy = -dy end
|
||||
if dz < 0 then dz = -dz end
|
||||
if dx <= BREAK_ANGLE and dy <= BREAK_ANGLE and dz <= BREAK_ANGLE then
|
||||
rx = rx + angleDelta(comps[4], i0, i1) * k
|
||||
ry = ry + angleDelta(comps[5], i0, i1) * k
|
||||
rz = rz + angleDelta(comps[6], i0, i1) * k
|
||||
end
|
||||
-- TRANSLATION, likewise together: the three are one offset
|
||||
local mx = linearDelta(comps[1], i0, i1)
|
||||
local my = linearDelta(comps[2], i0, i1)
|
||||
local mz = linearDelta(comps[3], i0, i1)
|
||||
local far = false
|
||||
if moveBreak then
|
||||
far = (mx > moveBreak or mx < -moveBreak)
|
||||
or (my > moveBreak or my < -moveBreak)
|
||||
or (mz > moveBreak or mz < -moveBreak)
|
||||
end
|
||||
if not far then
|
||||
tx, ty, tz = tx + mx * k, ty + my * k, tz + mz * k
|
||||
end
|
||||
-- SCALE, which cannot land anywhere the two ends did not bracket
|
||||
kx = kx + linearDelta(comps[7], i0, i1) * k
|
||||
ky = ky + linearDelta(comps[8], i0, i1) * k
|
||||
kz = kz + linearDelta(comps[9], i0, i1) * k
|
||||
end
|
||||
else
|
||||
-- a bone this animation never touches keeps its rest transform
|
||||
tx, ty, tz = restT[o3 + 1], restT[o3 + 2], restT[o3 + 3]
|
||||
rx, ry, rz = restR[o3 + 1], restR[o3 + 2], restR[o3 + 3]
|
||||
kx, ky, kz = restS[o3 + 1], restS[o3 + 2], restS[o3 + 3]
|
||||
end
|
||||
|
||||
local p = parent[b]
|
||||
local pax, pay, paz = 1, 1, 1
|
||||
if p > 0 then pax, pay, paz = accX[p], accY[p], accZ[p] end
|
||||
-- the parent's accumulated scale, applied to the CHILD's offset. This
|
||||
-- is the whole of what the game does instead of propagating scale.
|
||||
tx, ty, tz = tx * pax, ty * pay, tz * paz
|
||||
|
||||
-- Rx * Ry * Rz in the game's own row-vector form (src/F420.c
|
||||
-- func_8000F730), written out as the rows of a 3x3
|
||||
local ax, ay, az = rx * ANG, ry * ANG, rz * ANG
|
||||
local sx, cx = sin(ax), cos(ax)
|
||||
local sy, cy = sin(ay), cos(ay)
|
||||
local sz, cz = sin(az), cos(az)
|
||||
local m11, m12, m13 = cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz
|
||||
local m21, m22, m23 = cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz
|
||||
local m31, m32, m33 = -sy, sx * cy, cx * cy
|
||||
|
||||
local o = (b - 1) * 12
|
||||
if p > 0 then
|
||||
local q = (p - 1) * 12
|
||||
local a1, a2, a3, a4 = pivot[q + 1], pivot[q + 2], pivot[q + 3], pivot[q + 4]
|
||||
local b1, b2, b3, b4 = pivot[q + 5], pivot[q + 6], pivot[q + 7], pivot[q + 8]
|
||||
local c1, c2, c3, c4 = pivot[q + 9], pivot[q + 10], pivot[q + 11], pivot[q + 12]
|
||||
pivot[o + 1] = a1 * m11 + a2 * m21 + a3 * m31
|
||||
pivot[o + 2] = a1 * m12 + a2 * m22 + a3 * m32
|
||||
pivot[o + 3] = a1 * m13 + a2 * m23 + a3 * m33
|
||||
pivot[o + 4] = a1 * tx + a2 * ty + a3 * tz + a4
|
||||
pivot[o + 5] = b1 * m11 + b2 * m21 + b3 * m31
|
||||
pivot[o + 6] = b1 * m12 + b2 * m22 + b3 * m32
|
||||
pivot[o + 7] = b1 * m13 + b2 * m23 + b3 * m33
|
||||
pivot[o + 8] = b1 * tx + b2 * ty + b3 * tz + b4
|
||||
pivot[o + 9] = c1 * m11 + c2 * m21 + c3 * m31
|
||||
pivot[o + 10] = c1 * m12 + c2 * m22 + c3 * m32
|
||||
pivot[o + 11] = c1 * m13 + c2 * m23 + c3 * m33
|
||||
pivot[o + 12] = c1 * tx + c2 * ty + c3 * tz + c4
|
||||
else
|
||||
pivot[o + 1], pivot[o + 2], pivot[o + 3], pivot[o + 4] = m11, m12, m13, tx
|
||||
pivot[o + 5], pivot[o + 6], pivot[o + 7], pivot[o + 8] = m21, m22, m23, ty
|
||||
pivot[o + 9], pivot[o + 10], pivot[o + 11], pivot[o + 12] = m31, m32, m33, tz
|
||||
end
|
||||
|
||||
local ex, ey, ez = pax * kx, pay * ky, paz * kz
|
||||
accX[b], accY[b], accZ[b] = ex, ey, ez
|
||||
-- the bone's own accumulated scale, on the right: it scales the axes of
|
||||
-- THIS bone's space and cannot reach the children, which is exactly the
|
||||
-- game's draw-time application
|
||||
drw[o + 1], drw[o + 2] = pivot[o + 1] * ex, pivot[o + 2] * ey
|
||||
drw[o + 3], drw[o + 4] = pivot[o + 3] * ez, pivot[o + 4]
|
||||
drw[o + 5], drw[o + 6] = pivot[o + 5] * ex, pivot[o + 6] * ey
|
||||
drw[o + 7], drw[o + 8] = pivot[o + 7] * ez, pivot[o + 8]
|
||||
drw[o + 9], drw[o + 10] = pivot[o + 9] * ex, pivot[o + 10] * ey
|
||||
drw[o + 11], drw[o + 12] = pivot[o + 11] * ez, pivot[o + 12]
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- keeping the Pokemon on its own tile
|
||||
--
|
||||
-- Stadium's animations MOVE the Pokemon, and they move it a long way. Half
|
||||
-- the set's send-out entrances walk the body more than its own height off
|
||||
-- the spot it started on; Dewgong's faint travels nearly ten body-heights,
|
||||
-- and its entrance seven and a half. Every one of them ends exactly where it
|
||||
-- began, because that game framed each Pokemon with a camera of its OWN that
|
||||
-- followed the performance around a stage.
|
||||
--
|
||||
-- This mode has one camera, solved to put two named map cells at two fixed
|
||||
-- points in a 160x144 frame (BattleCam), and a Pokemon that travels seven
|
||||
-- body-heights out of that frame is simply GONE -- which is what sending out
|
||||
-- a Farfetch'd looked like: an empty tile for three and a half seconds,
|
||||
-- while its animation played somewhere off to the left of the shot.
|
||||
--
|
||||
-- So the bulk travel is taken back out. The pose is measured, and whatever
|
||||
-- has carried the body further than `limit` from where the bind pose put it
|
||||
-- is subtracted from every bone.
|
||||
--
|
||||
-- ------- why a LIMIT and not an anchor
|
||||
--
|
||||
-- Pinning the body outright would flatten the animations into mime: a lunge,
|
||||
-- a hop, a recoil and a collapse are all the body moving, and they are the
|
||||
-- part worth having. What breaks the shot is not motion, it is EXCURSION --
|
||||
-- and the two are told apart by how far. Inside the limit nothing is touched
|
||||
-- at all, so the 83 species whose animations stay put are bit-for-bit what
|
||||
-- they were; past it the excess alone is removed, so a big move still reads
|
||||
-- as big and still comes back to the tile it left.
|
||||
--
|
||||
-- ------- where the body IS, and why it is not the median
|
||||
--
|
||||
-- The first version of this took the median bone origin, on the reasoning
|
||||
-- that a handful of bones flung anywhere cannot move a median. True, and it
|
||||
-- had a worse problem: a median is a RANK, and a rank flips. On a bird most
|
||||
-- of the skeleton is wing, so as the wings beat, which bone sits at the
|
||||
-- middle of the sorted list swaps between the up cluster and the down one --
|
||||
-- and the estimate jumps with it. Measured on Pidgey's standby loop the
|
||||
-- median moved a tenth of a body-height between adjacent half-frames, and on
|
||||
-- Pidgeot three whole body-heights. The anchor turns that straight into a
|
||||
-- translation of the ENTIRE Pokemon, so the body counter-shook against its
|
||||
-- own wings and the flapping read as twice its real speed. That is the
|
||||
-- "Pidgey's wings flap super fast" this comment exists because of.
|
||||
--
|
||||
-- The centre is now the bone origins averaged, WEIGHTED BY HOW MANY VERTICES
|
||||
-- EACH BONE MOVES. That fixes both halves at once:
|
||||
--
|
||||
-- * the weights are a property of the MESH, computed once and never
|
||||
-- changing, so there is no rank to flip and no discontinuity available
|
||||
-- to it -- the estimate is as smooth as the bones themselves
|
||||
-- * a bone with little geometry on it barely counts, which is exactly the
|
||||
-- robustness the median was for. Farfetch'd's trail is thirty vertices
|
||||
-- on five bones -- 1.6% of the model -- so streaking three thousand
|
||||
-- units out moves this by nothing worth measuring
|
||||
--
|
||||
-- Against the median it is two to five times smoother on every species
|
||||
-- tested and measures the same travel to within a few percent.
|
||||
|
||||
-- How far the body estimate may move in ONE 30 Hz frame of a species' own
|
||||
-- standby loop before that species is judged unmeasurable and left
|
||||
-- unanchored (see measureBind). The fastest genuine motion in the set is
|
||||
-- about a fifth of a body-height a frame; the one species that fails this
|
||||
-- moves three.
|
||||
StadiumRig.ANCHOR_STEADY = 0.5
|
||||
|
||||
-- Which context slot the standby loop is, without requiring StadiumPack --
|
||||
-- this module is below it and a require would be circular. Position 1 of
|
||||
-- StadiumPack.CONTEXT, which is the format's own contract.
|
||||
local IDLE_SLOT = 1
|
||||
|
||||
-- How much of the model each bone actually carries. Cached on the shared
|
||||
-- model: it is a fact about the mesh, not about this instance.
|
||||
local function boneWeights(model)
|
||||
if model.boneW then return model.boneW, model.boneWTotal end
|
||||
local w, total = {}, 0
|
||||
for b = 1, model.boneCount do w[b] = 0 end
|
||||
for _, prim in ipairs(model.prims) do
|
||||
local bone = prim.bone
|
||||
for k = 1, prim.vertCount do
|
||||
local b = bone[k]
|
||||
if w[b] then w[b] = w[b] + 1; total = total + 1 end
|
||||
end
|
||||
end
|
||||
model.boneW, model.boneWTotal = w, total
|
||||
return w, total
|
||||
end
|
||||
|
||||
-- The body centre of the pose currently in drawM.
|
||||
local function centre(self, n)
|
||||
local model = self.model
|
||||
local w, total = boneWeights(model)
|
||||
if not (total > 0) then return nil end
|
||||
local x, y, z = 0, 0, 0
|
||||
local d = self.drawM
|
||||
for b = 1, n do
|
||||
local q = w[b]
|
||||
if q and q > 0 then
|
||||
local o = (b - 1) * 12
|
||||
x = x + d[o + 4] * q
|
||||
y = y + d[o + 8] * q
|
||||
z = z + d[o + 12] * q
|
||||
end
|
||||
end
|
||||
return x / total, y / total, z / total
|
||||
end
|
||||
|
||||
-- Where the BIND pose puts it -- the spot every animation is measured
|
||||
-- against. Cached on the shared MODEL, because it is a fact about the model
|
||||
-- and not about this instance of it.
|
||||
--
|
||||
-- Called once, from new(), and deliberately not lazily from anchor(): taking
|
||||
-- this measurement means POSING the bind pose, which would overwrite the
|
||||
-- animated pose anchor() was called to correct. Doing it while the rig is
|
||||
-- still being built is the one moment there is no pose to lose.
|
||||
function StadiumRig:measureBind()
|
||||
local model = self.model
|
||||
if model.bindCX then return end
|
||||
self:pose(nil, 0, false)
|
||||
model.bindCX, model.bindCY, model.bindCZ = centre(self, model.boneCount)
|
||||
|
||||
-- ------- and whether this species can be anchored at all
|
||||
--
|
||||
-- Decided ONCE, per model, offline, by walking its standby loop and asking
|
||||
-- how far the body estimate moves between one frame and the next.
|
||||
--
|
||||
-- Everything the anchor does rests on that estimate being a description of
|
||||
-- where the Pokemon is. For 147 species it is: the fastest real motion in
|
||||
-- the set moves the body about a fifth of a body-height per 30 Hz frame.
|
||||
-- Pidgeot's standby loop moves it THREE, because a few of its rotation
|
||||
-- frames are junk (the worst data in the set, and a known issue in its own
|
||||
-- right). There is no filter setting that both tracks a real excursion and
|
||||
-- rejects that -- measured, at four time constants, either the excursions
|
||||
-- came back or the shake did -- because the two are only a factor of
|
||||
-- fifteen apart and a filter is a proportion.
|
||||
--
|
||||
-- So a species whose own idle says its estimate cannot be trusted is not
|
||||
-- anchored, and plays exactly as it did before the anchor existed: it
|
||||
-- travels as far as its animation says, and it does not vibrate. One
|
||||
-- species trading a framing problem for no problem beats 147 trading a
|
||||
-- solved framing problem for a shake.
|
||||
--
|
||||
-- Cheap: forty-odd poses on a model that is about to be posed sixty times
|
||||
-- a second anyway.
|
||||
local idle = model.ctx and model.ctx[IDLE_SLOT]
|
||||
local anim = (idle and idle ~= 0xFFFF) and (idle + 1) or nil
|
||||
local rec = anim and model.anims and model.anims[anim]
|
||||
model.anchorOk = true
|
||||
if rec and rec.frames and rec.frames > 1 then
|
||||
local root = model.rootScale
|
||||
if not (root and root > 0) then root = 1 end
|
||||
local h = (model.height or 0) / root
|
||||
if h > 0 then
|
||||
local px, py, pz, worst = nil, nil, nil, 0
|
||||
for f = 0, rec.frames - 1 do
|
||||
self:pose(anim, f, true)
|
||||
local x, y, z = centre(self, model.boneCount)
|
||||
if x and px then
|
||||
local d = (((x - px) ^ 2 + (y - py) ^ 2 + (z - pz) ^ 2) ^ 0.5) / h
|
||||
if d > worst then worst = d end
|
||||
end
|
||||
px, py, pz = x, y, z
|
||||
end
|
||||
if worst > StadiumRig.ANCHOR_STEADY then
|
||||
model.anchorOk = false
|
||||
V.mod.log:info("stadium: species %s moves its own body %.1f "
|
||||
.. "body-heights in one frame of its standby loop -- "
|
||||
.. "not anchoring it, the measurement cannot be "
|
||||
.. "trusted", tostring(model.species), worst)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- and leave the bind pose behind, not the last frame of the idle
|
||||
self:pose(nil, 0, false)
|
||||
end
|
||||
|
||||
-- ------- and why the offset is SMOOTHED
|
||||
--
|
||||
-- A better centre is not enough on its own. Any estimate that follows the
|
||||
-- pose carries the pose's own frame-to-frame wobble into it, and the anchor
|
||||
-- multiplies that up into a translation of the whole Pokemon -- so a species
|
||||
-- whose source data is erratic (Pidgeot's standby loop has a few frames of
|
||||
-- junk in it, and no estimator can smooth data that is genuinely wrong)
|
||||
-- would shake bodily rather than in the one bone that is wrong.
|
||||
--
|
||||
-- So the offset is low-passed. What the anchor is FOR is a slow excursion --
|
||||
-- a Pokemon swimming seven body-heights away over two seconds -- and that
|
||||
-- survives a filter with this time constant untouched, while anything
|
||||
-- oscillating frame to frame is flattened. The correction ends up describing
|
||||
-- where the Pokemon has drifted TO, never how it is shaking on the way.
|
||||
--
|
||||
-- HALF_LIFE is in seconds: the time the offset takes to close half of any
|
||||
-- gap between where it is and where the pose says it should be. Short enough
|
||||
-- that a real excursion is caught within a few frames of starting, long
|
||||
-- enough that a 30 Hz wobble does not survive it.
|
||||
StadiumRig.ANCHOR_HALF_LIFE = 0.05
|
||||
|
||||
|
||||
-- ------- what this does NOT fix, and why it stops here
|
||||
--
|
||||
-- The filter is a proportion, so it divides the input wobble down rather than
|
||||
-- bounding it -- and one species' data is bad enough to get through anyway.
|
||||
-- Pidgeot's standby loop carries a few frames of junk rotation (the worst in
|
||||
-- the set, and a known issue since before the anchor existed), which moves
|
||||
-- the body estimate three body-heights inside a single frame; filtered, that
|
||||
-- is still about three pixels a frame on a fourteen-pixel model.
|
||||
--
|
||||
-- Two further mechanisms were built and MEASURED against the set, and both
|
||||
-- were taken back out:
|
||||
--
|
||||
-- a rate limit on the correction bounded the shake to a third of a pixel,
|
||||
-- and cost so much tracking that 33 of the 148 entrances went back to
|
||||
-- leaving the frame -- half the problem the anchor exists to solve
|
||||
--
|
||||
-- a rate limit on the MEASUREMENT, to tell a spike from an excursion by
|
||||
-- speed, could not separate them: the fastest real excursion (Dewgong's
|
||||
-- entrance, five and a half body-heights a second) is close enough to
|
||||
-- Pidgeot's sustained junk that any threshold either clipped Dewgong or
|
||||
-- passed Pidgeot, and freezing on distrust made both worse
|
||||
--
|
||||
-- So it stops here, at the setting that is right for the 147 species whose
|
||||
-- data is not broken. Pidgeot is a data problem and belongs with the other
|
||||
-- data problems in the CHANGELOG's Known section, not in this control loop:
|
||||
-- the alternative was distorting every other Pokemon's animation to flatter
|
||||
-- one whose source frames are wrong.
|
||||
|
||||
-- Pull the pose back toward the tile. `limit` is in the Pokemon's own
|
||||
-- body-heights; nil or a non-positive value leaves the pose exactly as posed.
|
||||
-- `dt` is the frame's own delta; without one the offset is applied whole,
|
||||
-- which is what a still (the QA sweep, a probe) wants.
|
||||
function StadiumRig:anchor(limit, dt)
|
||||
if not (limit and limit > 0) then return end
|
||||
local model = self.model
|
||||
local n = model.boneCount
|
||||
-- the vertices are in RAW units, before the model_root scale that
|
||||
-- model.height is measured after
|
||||
local root = model.rootScale
|
||||
if not (root and root > 0) then root = 1 end
|
||||
local h = (model.height or 0) / root
|
||||
if not (h > 0) then return end
|
||||
|
||||
local bx, by, bz = model.bindCX, model.bindCY, model.bindCZ
|
||||
if not bx then return end -- never measured; leave the pose alone
|
||||
if model.anchorOk == false then return end -- and unmeasurable, at that
|
||||
local x, y, z = centre(self, n)
|
||||
if not x then return end
|
||||
|
||||
local dx, dy, dz = x - bx, y - by, z - bz
|
||||
local dist = (dx * dx + dy * dy + dz * dz) ^ 0.5
|
||||
local allow = limit * h
|
||||
|
||||
-- what the pose alone asks for: the EXCESS beyond the limit, so what is
|
||||
-- inside it stays and the motion keeps its shape
|
||||
local ox, oy, oz = 0, 0, 0
|
||||
if dist > allow and dist > 0 then
|
||||
local k = (dist - allow) / dist
|
||||
ox, oy, oz = dx * k, dy * k, dz * k
|
||||
end
|
||||
|
||||
-- and then toward it rather than straight to it (see ANCHOR_HALF_LIFE),
|
||||
-- and never faster than ANCHOR_RATE
|
||||
if dt and dt > 0 then
|
||||
local half = StadiumRig.ANCHOR_HALF_LIFE
|
||||
local a = (half > 0) and (1 - 0.5 ^ (dt / half)) or 1
|
||||
if a > 1 then a = 1 end
|
||||
local px, py, pz = self.anchorX or ox, self.anchorY or oy, self.anchorZ or oz
|
||||
ox = px + (ox - px) * a
|
||||
oy = py + (oy - py) * a
|
||||
oz = pz + (oz - pz) * a
|
||||
end
|
||||
self.anchorX, self.anchorY, self.anchorZ = ox, oy, oz
|
||||
if ox == 0 and oy == 0 and oz == 0 then return end
|
||||
|
||||
local pivot, drw = self.pivotM, self.drawM
|
||||
for b = 1, n do
|
||||
local o = (b - 1) * 12
|
||||
pivot[o + 4] = pivot[o + 4] - ox
|
||||
pivot[o + 8] = pivot[o + 8] - oy
|
||||
pivot[o + 12] = pivot[o + 12] - oz
|
||||
drw[o + 4] = drw[o + 4] - ox
|
||||
drw[o + 8] = drw[o + 8] - oy
|
||||
drw[o + 12] = drw[o + 12] - oz
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the skin
|
||||
--
|
||||
-- Every vertex through its one bone's draw matrix, and its normal through
|
||||
-- the same bone's pivot (a pure rotation, so the normal survives a
|
||||
-- non-uniformly scaled bone -- which several species have).
|
||||
--
|
||||
-- `yaw` is the model matrix's own turn, and it is folded in HERE rather
|
||||
-- than left to the matrix because the shade has to be computed against the
|
||||
-- WORLD normal: a Pokemon turned to face its opponent has a differently lit
|
||||
-- flank than one facing the camera, and the sun does not turn with it.
|
||||
function StadiumRig:skin(yaw)
|
||||
local cy, sy = cos(yaw or 0), sin(yaw or 0)
|
||||
local drw, piv = self.drawM, self.pivotM
|
||||
for _, part in ipairs(self.parts) do
|
||||
local prim, rows = part.prim, part.rows
|
||||
local px, py, pz = prim.px, prim.py, prim.pz
|
||||
local nx, ny, nz = prim.nx, prim.ny, prim.nz
|
||||
local bone = prim.bone
|
||||
for k = 1, prim.vertCount do
|
||||
local o = (bone[k] - 1) * 12
|
||||
local x, y, z = px[k], py[k], pz[k]
|
||||
local row = rows[k]
|
||||
row[1] = drw[o + 1] * x + drw[o + 2] * y + drw[o + 3] * z + drw[o + 4]
|
||||
row[2] = drw[o + 5] * x + drw[o + 6] * y + drw[o + 7] * z + drw[o + 8]
|
||||
row[3] = drw[o + 9] * x + drw[o + 10] * y + drw[o + 11] * z + drw[o + 12]
|
||||
local ax, ay, az = nx[k], ny[k], nz[k]
|
||||
local wx = piv[o + 1] * ax + piv[o + 2] * ay + piv[o + 3] * az
|
||||
local wy = piv[o + 5] * ax + piv[o + 6] * ay + piv[o + 7] * az
|
||||
local wz = piv[o + 9] * ax + piv[o + 10] * ay + piv[o + 11] * az
|
||||
-- the model matrix's yaw, by hand: (x, z) turned, y untouched
|
||||
row[6] = SHADE_BASE + SHADE_X * (cy * wx + sy * wz) + SHADE_Y * wy
|
||||
+ SHADE_Z * (cy * wz - sy * wx)
|
||||
end
|
||||
pcall(part.mesh.setVertices, part.mesh, rows)
|
||||
end
|
||||
end
|
||||
|
||||
-- What this POSE actually occupies, in the rig's own posed space: the
|
||||
-- vertical span of every skinned vertex, and the furthest any of them
|
||||
-- stands from the model's vertical axis.
|
||||
--
|
||||
-- Read off the skinned rows rather than off the pack's bind-pose figures,
|
||||
-- because the two are not the same claim. The bind measurements say how
|
||||
-- big the model is; a caller placing something ON the Pokemon needs to
|
||||
-- know where the Pokemon IS, and for a flying species the standby
|
||||
-- animation carries it a third of its own height off the floor -- a lift
|
||||
-- that exists only in the posed bones and appears in no static field.
|
||||
--
|
||||
-- Answers nil before the first skin(), which is the caller's cue to fall
|
||||
-- back to the bind figures.
|
||||
function StadiumRig:posedBounds()
|
||||
local lo, hi, r2 = nil, nil, 0
|
||||
for _, part in ipairs(self.parts) do
|
||||
local rows, n = part.rows, part.prim.vertCount
|
||||
for k = 1, n do
|
||||
local row = rows[k]
|
||||
local y = row[2]
|
||||
if not lo or y < lo then lo = y end
|
||||
if not hi or y > hi then hi = y end
|
||||
local d = row[1] * row[1] + row[3] * row[3]
|
||||
if d > r2 then r2 = d end
|
||||
end
|
||||
end
|
||||
if not lo then return nil end
|
||||
return lo, hi, math.sqrt(r2)
|
||||
end
|
||||
|
||||
-- ------- which texture each part wears this frame
|
||||
--
|
||||
-- The eyes. A primitive whose display list carried geo command 0x23 with a
|
||||
-- channel index has its texture REPLACED every frame from a stream of
|
||||
-- texture-table indices (src/18140.c func_800176DC) -- which is how every
|
||||
-- Pokemon in the game blinks, and how a confused one gets swirls. glTF has
|
||||
-- no channel for that, so the .glb files carry only the first frame; the
|
||||
-- pack carries the streams.
|
||||
--
|
||||
-- `aux` is an index into model.auxAnims (the stream set) and `frame` its
|
||||
-- own frame counter, which runs independently of the skeletal one.
|
||||
-- The eyes, and everything else a material swaps per frame.
|
||||
--
|
||||
-- Sampled at the SKELETAL animation's own frame -- the one pose() just
|
||||
-- resolved -- and CLAMPED past the end of the stream rather than wrapped.
|
||||
-- Both halves of that matter, and getting either wrong is visible.
|
||||
--
|
||||
-- The frame is the skeleton's because in the game a single counter drives
|
||||
-- both; the data says so plainly, since 507 of the 691 paired animations in
|
||||
-- the set have a texture animation exactly as long as the skeletal one it
|
||||
-- rides with.
|
||||
--
|
||||
-- The clamp is what the game's own sampler does (func_80017540 indexes the
|
||||
-- stream and holds the last entry past its end), and it is the whole
|
||||
-- difference between a blink and a twitch. Rattata's standby loop is forty
|
||||
-- frames and its blink is FIVE -- `6 8 7 8 6`, open through closed and back.
|
||||
-- Wrapped on the blink's own length that plays six times a second, which is
|
||||
-- what it looked like. Clamped, the eye blinks once at the top of the loop
|
||||
-- and stays open for the remaining thirty-five frames, so it blinks about
|
||||
-- once a second and a half.
|
||||
function StadiumRig:textures(aux)
|
||||
local model = self.model
|
||||
local anim = aux and model.auxAnims and model.auxAnims[aux] or nil
|
||||
local frame = self.frameAt or 0
|
||||
for _, part in ipairs(self.parts) do
|
||||
local prim = part.prim
|
||||
local index = prim.tex
|
||||
if anim and prim.texAnim and prim.texAnim >= 0 and prim.texMap then
|
||||
local stream = anim.channels[prim.texAnim + 1]
|
||||
local n = stream and #stream or 0
|
||||
if n > 0 then
|
||||
local at = frame + 1
|
||||
if at > n then at = n end
|
||||
if at < 1 then at = 1 end
|
||||
local mapped = prim.texMap[stream[at]]
|
||||
if mapped then index = mapped end
|
||||
end
|
||||
end
|
||||
part.texture = StadiumPack.image(model, index)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the draw
|
||||
--
|
||||
-- `model` here is the MODEL MATRIX -- where this Pokemon stands, how big
|
||||
-- and which way round -- and `sunModel` the transform the shadow pass drew
|
||||
-- it with, which for these is the same matrix (unlike a character's leaning
|
||||
-- card; see Voxel3D.draw).
|
||||
--
|
||||
-- Seams off for the whole of it: the voxel wireframe draws the integer
|
||||
-- planes of a mesh's own model space, and these vertices are in the N64's
|
||||
-- own units where an integer plane means nothing (see VoxelGrid). Glass off
|
||||
-- for the same reason the sprite passes turn it off -- the mask's
|
||||
-- coordinates belong to the tileset atlas, not to a Pokemon's texture.
|
||||
function StadiumRig:draw(matrix, pull)
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
local additive = nil
|
||||
for _, part in ipairs(self.parts) do
|
||||
if part.prim.additive then
|
||||
-- held back to a second pass so the flames composite over the body
|
||||
-- rather than depth-fighting it
|
||||
additive = additive or {}
|
||||
additive[#additive + 1] = part
|
||||
elseif part.texture then
|
||||
Voxel3D.draw(part.mesh, part.texture, matrix, pull)
|
||||
end
|
||||
end
|
||||
if additive then
|
||||
Voxel3D.blend("add")
|
||||
for _, part in ipairs(additive) do
|
||||
if part.texture then
|
||||
Voxel3D.draw(part.mesh, part.texture, matrix, pull)
|
||||
end
|
||||
end
|
||||
Voxel3D.blend(nil)
|
||||
end
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- The same geometry as the SUN sees it: no camera-ward pull (a trick for
|
||||
-- the view's own depth buffer, which would drag a shadow off its owner) and
|
||||
-- through the shadow pass's own draw call. The generated flame prims are
|
||||
-- skipped -- a fire casts light, not a shadow.
|
||||
function StadiumRig:caster(shadowMap, matrix)
|
||||
for _, part in ipairs(self.parts) do
|
||||
if part.texture and not part.prim.additive then
|
||||
shadowMap.draw(part.mesh, part.texture, matrix)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return StadiumRig
|
||||
@@ -0,0 +1,314 @@
|
||||
-- STADIUM battles: getting at the Pokemon Stadium ROM.
|
||||
--
|
||||
-- Byte order, the archive the battle models are packed into, the Yay0
|
||||
-- decompressor that unwraps each one, and the per-species battle tables. It
|
||||
-- is a port of model_extract/pipeline/rom.py, function for function, and the
|
||||
-- Python remains the reference: tools/stadium_pack.py drives that side and
|
||||
-- tests/stadium_extract_test.lua diffs this side's finished packs against it
|
||||
-- byte for byte.
|
||||
--
|
||||
-- ------- why this exists in Lua at all
|
||||
--
|
||||
-- The mod cannot ship the models. They are ROM data, so what ships is the
|
||||
-- READER, and the player supplies the ROM -- exactly the arrangement the
|
||||
-- engine itself already has for the Game Boy ROM it is a recompilation of
|
||||
-- (src/import/RomImporter.lua). Everything from `baserom.z64` to
|
||||
-- `assets/stadium/NNN.dsm` therefore has to happen here, on the machine, in
|
||||
-- Lua, with no Python and no build step.
|
||||
--
|
||||
-- ------- what makes that tractable
|
||||
--
|
||||
-- Three steps, and none of them needs a decompilation toolchain:
|
||||
--
|
||||
-- 1. BYTE ORDER. The three N64 dump conventions differ by a swap that is
|
||||
-- detected from the magic word and undone once, on load.
|
||||
-- 2. THE ARCHIVE. The segment at 0x920000 is a count and a table of
|
||||
-- (offset, size) records. No compression at that level, no names.
|
||||
-- 3. Yay0. Nintendo's LZ variant: a bitstream where a 1 copies a literal
|
||||
-- byte and a 0 pulls a (distance, length) pair out of a side table.
|
||||
-- Thirty lines, and the same thirty lines the Python has.
|
||||
--
|
||||
-- Verified in the Python by decompressing all 215 entries and diffing against
|
||||
-- what the decompilation's own `make init` produces: 215/215 identical.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local StadiumRom = {}
|
||||
|
||||
local byte = string.byte
|
||||
local char = string.char
|
||||
local concat = table.concat
|
||||
local sub = string.sub
|
||||
local floor = math.floor
|
||||
|
||||
-- ROM offsets, from pokestadium-us.yaml by way of pipeline/rom.py.
|
||||
StadiumRom.POKEMON_MODELS = 0x920000 -- archive of the 215 battle models
|
||||
StadiumRom.BATTLE_DATA = 0x70D3A0 -- per-species battle tables
|
||||
StadiumRom.MAIN_ROM = 0x1000 -- main code segment ...
|
||||
StadiumRom.MAIN_VRAM = 0x80000400 -- ... and where it lands in RAM
|
||||
StadiumRom.PTR_TABLE_VRAM = 0x80075BD0 -- D_80075BD0[species - 1]
|
||||
|
||||
-- The revision every offset above is keyed to. A different ROM still runs --
|
||||
-- it may well be a regional variant with the same layout -- but the caller is
|
||||
-- told, because "the models came out as garbage" and "that is not the ROM
|
||||
-- this was written against" are the same fact and only one of them is useful.
|
||||
StadiumRom.US_MD5 = "ed1378bc12115f71209a77844965ba50"
|
||||
|
||||
-- The battle table's shape: 0xB90 bytes a species, as 0x10-byte entries.
|
||||
-- Entries 0..164 are the moves (entry n drives move n + 1) and 165 up are the
|
||||
-- fixed battle contexts.
|
||||
StadiumRom.STRIDE = 0xB90
|
||||
StadiumRom.ENTRY = 0x10
|
||||
StadiumRom.N_MOVES = 165
|
||||
|
||||
-- How many of the archive's 215 models are the battle Pokemon. The rest are
|
||||
-- props and trophies with no battle table.
|
||||
StadiumRom.N_POKEMON = 151
|
||||
|
||||
-- ------- byte order
|
||||
--
|
||||
-- .z64 is big-endian and native; .v64 has each pair of bytes swapped; .n64
|
||||
-- has each word reversed. `gsub` with a capture-reversing replacement does
|
||||
-- either in one call through C rather than a Lua loop over 33 million bytes.
|
||||
|
||||
local MAGIC_Z64 = "\128\055\018\064"
|
||||
local MAGIC_V64 = "\055\128\064\018"
|
||||
local MAGIC_N64 = "\064\018\055\128"
|
||||
|
||||
-- Normalise a dump to .z64 order, or nil when it is not an N64 ROM at all.
|
||||
function StadiumRom.normalise(bytes)
|
||||
if type(bytes) ~= "string" or #bytes < 0x1000 then return nil end
|
||||
local magic = sub(bytes, 1, 4)
|
||||
if magic == MAGIC_Z64 then return bytes end
|
||||
if magic == MAGIC_V64 then return (bytes:gsub("(.)(.)", "%2%1")) end
|
||||
if magic == MAGIC_N64 then
|
||||
return (bytes:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- Yay0
|
||||
--
|
||||
-- The output has to be RANDOM ACCESS while it is being written -- a back
|
||||
-- reference copies from what has already been produced, and overlapping runs
|
||||
-- are legal and common -- so it is built in a flat table of byte values and
|
||||
-- turned into a string at the end.
|
||||
--
|
||||
-- The string.char conversion is the part that wants care: it is variadic and
|
||||
-- has an argument limit, so the table is walked in blocks and the blocks
|
||||
-- concatenated. Blocks of 4096 keep the call count and the intermediate
|
||||
-- string count both low; the whole 151-model set converts in well under a
|
||||
-- second on LuaJIT, which is what made an FFI buffer unnecessary here and
|
||||
-- kept this module portable to any Lua the engine runs on.
|
||||
|
||||
local CHUNK = 4096
|
||||
|
||||
-- LuaJIT keeps `unpack` global; 5.2+ moved it onto table.
|
||||
local unpack = unpack or table.unpack
|
||||
|
||||
local function bytesToString(out, n)
|
||||
if n == 0 then return "" end
|
||||
local parts, np = {}, 0
|
||||
local i = 1
|
||||
while i <= n do
|
||||
local j = i + CHUNK - 1
|
||||
if j > n then j = n end
|
||||
np = np + 1
|
||||
parts[np] = char(unpack(out, i, j))
|
||||
i = j + 1
|
||||
end
|
||||
return concat(parts)
|
||||
end
|
||||
|
||||
-- Nintendo Yay0. Header: magic, decompressed size, link table offset, chunk
|
||||
-- offset; then a bitstream read a word at a time.
|
||||
function StadiumRom.yay0(src, base)
|
||||
base = base or 0
|
||||
if sub(src, base + 1, base + 4) ~= "Yay0" then return nil, "not Yay0" end
|
||||
local function be32(o)
|
||||
local a, b, c, d = byte(src, base + o + 1, base + o + 4)
|
||||
return ((a * 256 + b) * 256 + c) * 256 + d
|
||||
end
|
||||
local size = be32(4)
|
||||
-- all three cursors are 1-based indices into `src`; the mask stream starts
|
||||
-- immediately after the 16-byte header
|
||||
local maskP = base + 0x10 + 1
|
||||
local linkP = base + be32(8) + 1
|
||||
local chunkP = base + be32(12) + 1
|
||||
|
||||
local out = {}
|
||||
local pos = 0 -- bytes produced so far
|
||||
local mask, bits = 0, 0
|
||||
|
||||
while pos < size do
|
||||
if bits == 0 then
|
||||
local a, b, c, d = byte(src, maskP, maskP + 3)
|
||||
mask = ((a * 256 + b) * 256 + c) * 256 + d
|
||||
maskP = maskP + 4
|
||||
bits = 32
|
||||
end
|
||||
if mask >= 0x80000000 then
|
||||
pos = pos + 1
|
||||
out[pos] = byte(src, chunkP)
|
||||
chunkP = chunkP + 1
|
||||
else
|
||||
local a, b = byte(src, linkP, linkP + 1)
|
||||
linkP = linkP + 2
|
||||
local link = a * 256 + b
|
||||
local dist = link % 0x1000
|
||||
local count = floor(link / 0x1000)
|
||||
if count == 0 then
|
||||
count = byte(src, chunkP) + 0x12
|
||||
chunkP = chunkP + 1
|
||||
else
|
||||
count = count + 2
|
||||
end
|
||||
-- overlapping runs are legal: copying one byte at a time from the
|
||||
-- output as it grows is the behaviour, not a naive version of it
|
||||
local copy = pos - dist
|
||||
for _ = 1, count do
|
||||
pos = pos + 1
|
||||
out[pos] = out[copy]
|
||||
copy = copy + 1
|
||||
end
|
||||
end
|
||||
mask = (mask * 2) % 0x100000000
|
||||
bits = bits - 1
|
||||
end
|
||||
|
||||
return bytesToString(out, size)
|
||||
end
|
||||
|
||||
-- Unwrap whatever container an asset arrived in. The model archive's entries
|
||||
-- are PERS-SZP: an eight-byte magic plus a header size, wrapping a Yay0
|
||||
-- stream.
|
||||
function StadiumRom.decompress(blob)
|
||||
if sub(blob, 1, 8) == "PERS-SZP" then
|
||||
local a, b, c, d = byte(blob, 9, 12)
|
||||
local header = ((a * 256 + b) * 256 + c) * 256 + d
|
||||
return StadiumRom.yay0(blob, header)
|
||||
end
|
||||
if sub(blob, 1, 4) == "Yay0" then return StadiumRom.yay0(blob, 0) end
|
||||
return blob
|
||||
end
|
||||
|
||||
-- ------- the ROM
|
||||
|
||||
local Rom = {}
|
||||
Rom.__index = Rom
|
||||
|
||||
-- `bytes` is the whole file. Returns the ROM, or nil plus why.
|
||||
function StadiumRom.open(bytes)
|
||||
local data = StadiumRom.normalise(bytes)
|
||||
if not data then return nil, "not an N64 ROM (bad magic)" end
|
||||
return setmetatable({ data = data }, Rom)
|
||||
end
|
||||
|
||||
function Rom:u8(o)
|
||||
return byte(self.data, o + 1)
|
||||
end
|
||||
|
||||
function Rom:u32(o)
|
||||
local a, b, c, d = byte(self.data, o + 1, o + 4)
|
||||
if not d then return 0 end
|
||||
return ((a * 256 + b) * 256 + c) * 256 + d
|
||||
end
|
||||
|
||||
function Rom:vramToRom(vram)
|
||||
return StadiumRom.MAIN_ROM + (vram - StadiumRom.MAIN_VRAM)
|
||||
end
|
||||
|
||||
-- The md5 of the normalised image, or nil where LOVE's hash is not there
|
||||
-- (the headless suite). Only ever used to tell the player which ROM they
|
||||
-- gave us, never to refuse one.
|
||||
function Rom:md5()
|
||||
if self.hash ~= nil then return self.hash or nil end
|
||||
local ok, hex = pcall(function()
|
||||
local digest = love.data.hash("md5", self.data)
|
||||
if type(digest) == "userdata" and digest.getString then
|
||||
digest = digest:getString()
|
||||
end
|
||||
return love.data.encode("string", "hex", digest)
|
||||
end)
|
||||
self.hash = (ok and hex) or false
|
||||
return self.hash or nil
|
||||
end
|
||||
|
||||
function Rom:isExpectedUS()
|
||||
local hex = self:md5()
|
||||
return hex == nil or hex == StadiumRom.US_MD5
|
||||
end
|
||||
|
||||
-- ------- the archive
|
||||
--
|
||||
-- Segments that hold many files start with
|
||||
-- u32 tag, u32 0, u32 totalSize, u32 fileCount
|
||||
-- followed by fileCount { u32 offset, u32 size, u32 pad[2] } records, all
|
||||
-- relative to the start of the segment.
|
||||
--
|
||||
-- Only the top three bytes of the first word are reliably zero: the model
|
||||
-- archive puts a nonzero value in the low byte, which is the same quirk the
|
||||
-- decompilation's own tools/unpack_asset.py works around.
|
||||
--
|
||||
-- Returns a list of { start, size } rather than the bytes, so nothing is
|
||||
-- copied until a caller actually wants a file.
|
||||
function Rom:archive(off)
|
||||
local tag = self:u32(off)
|
||||
if (tag - tag % 256) ~= 0 or self:u32(off + 4) ~= 0 then return nil end
|
||||
local count = self:u32(off + 12)
|
||||
if count <= 0 or count >= 4096 then return nil end
|
||||
local out = {}
|
||||
for i = 0, count - 1 do
|
||||
local rec = off + 0x10 + i * 0x10
|
||||
out[i + 1] = { start = off + self:u32(rec), size = self:u32(rec + 4) }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The entries of the battle-model archive, uncopied.
|
||||
function Rom:models()
|
||||
if not self.modelDir then
|
||||
self.modelDir = self:archive(StadiumRom.POKEMON_MODELS) or {}
|
||||
end
|
||||
return self.modelDir
|
||||
end
|
||||
|
||||
function Rom:modelCount()
|
||||
return #self:models()
|
||||
end
|
||||
|
||||
-- One model fragment, decompressed. `fileno` is 0-based, as in the Python and
|
||||
-- in the source-file names: `N.bin` holds species N + 1.
|
||||
function Rom:model(fileno)
|
||||
local rec = self:models()[fileno + 1]
|
||||
if not rec then return nil end
|
||||
return StadiumRom.decompress(sub(self.data, rec.start + 1,
|
||||
rec.start + rec.size))
|
||||
end
|
||||
|
||||
-- ------- the per-species battle tables
|
||||
--
|
||||
-- func_84302658 in src/fragments/62 DMAs 0xB90 bytes a species out of the
|
||||
-- 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. Byte 0 of
|
||||
-- each 0x10-byte entry indexes that Pokemon's animation list and byte 1 its
|
||||
-- auxiliary (texture) animation list.
|
||||
--
|
||||
-- Returns a 0-based array-like table of { anim, aux }, aux 0xFF meaning none
|
||||
-- and coming back as -1 -- the shape the packer writes.
|
||||
function Rom:battleRows(species)
|
||||
local ptrTable = self:vramToRom(StadiumRom.PTR_TABLE_VRAM)
|
||||
local raw = self:u32(ptrTable + (species - 1) * 4)
|
||||
local o = StadiumRom.BATTLE_DATA + raw % 0x1000000
|
||||
local rows = {}
|
||||
local n = StadiumRom.STRIDE / StadiumRom.ENTRY
|
||||
for e = 0, n - 1 do
|
||||
local anim = self:u8(o + e * StadiumRom.ENTRY)
|
||||
local aux = self:u8(o + e * StadiumRom.ENTRY + 1)
|
||||
rows[e] = { anim, aux == 0xFF and -1 or aux }
|
||||
end
|
||||
rows.n = n
|
||||
return rows
|
||||
end
|
||||
|
||||
return StadiumRom
|
||||
@@ -0,0 +1,309 @@
|
||||
-- STADIUM battles: importing the ROM, instead of being told where to put it.
|
||||
--
|
||||
-- The mod ships no Pokemon Stadium models and cannot -- they are that game's
|
||||
-- data -- so the player supplies the cartridge. The original instruction for
|
||||
-- that was "make a folder called baseroms next to the game and drop the file
|
||||
-- in it", which is a fine sentence to write and a poor thing to ask. It needs
|
||||
-- a folder the player has to create, in a place that is different on every
|
||||
-- platform and is inside an unwritable archive on a packaged build, and it
|
||||
-- fails SILENTLY: the two STADIUM rungs are simply not on the row, and
|
||||
-- nothing on screen says why.
|
||||
--
|
||||
-- So this opens a file picker instead, from a row on the OPTIONS menu, and
|
||||
-- the folder keeps working for anyone who prefers it (StadiumInstall).
|
||||
--
|
||||
-- ------- the picker is the host's, not LOVE's
|
||||
--
|
||||
-- LOVE 11.5 has no file dialog. love.window.showFileDialog arrived in 12 and
|
||||
-- love.system.pickFile is a native bridge this project ships for mobile
|
||||
-- rather than part of LOVE at all. What every desktop OS does have is a
|
||||
-- dialog reachable from a shell, so that is what is used here -- osascript on
|
||||
-- macOS, PowerShell's OpenFileDialog on Windows, zenity then kdialog on
|
||||
-- Linux.
|
||||
--
|
||||
-- This is deliberately the SAME four commands the engine's own ROM importer
|
||||
-- uses for the Game Boy cartridge (src/import/RomImporter.lua's chooseRom),
|
||||
-- down to writing the Windows pick as UTF-8 -- the console's OEM codepage
|
||||
-- mangles a non-ASCII path into something that crashes the next text draw.
|
||||
-- Being a second copy of that is worth it: a mod cannot call into the
|
||||
-- importer's private helpers, and the alternative is asking the engine to
|
||||
-- grow a seam for one caller.
|
||||
--
|
||||
-- The dialog BLOCKS. io.popen waits for the player to choose, and the game is
|
||||
-- frozen for as long as it is up. That is what the engine's importer does
|
||||
-- too, it is what a modal dialog means, and the frame it freezes on is an
|
||||
-- options menu.
|
||||
--
|
||||
-- ------- and the ROM is not kept
|
||||
--
|
||||
-- The picked file is read, built from, and forgotten -- nothing is copied
|
||||
-- anywhere. A Stadium cartridge is 32 MB and the models built out of it are
|
||||
-- 34, so keeping both would double the cost of a feature for a file that has
|
||||
-- no further use: the packs are what the game reads afterwards, and the
|
||||
-- marker records the ROM's md5 so a swapped cartridge is still noticed.
|
||||
--
|
||||
-- The one thing that costs is a format bump, which invalidates the packs and
|
||||
-- leaves nothing to rebuild from. That is what the row still being there is
|
||||
-- for -- it reads READY, and pressing it imports again.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local StadiumInstall = V.require("StadiumInstall")
|
||||
|
||||
local StadiumRomPick = {}
|
||||
|
||||
StadiumRomPick.LABEL = "STADIUM ROM"
|
||||
StadiumRomPick.ID = "DRAMATIC_SHAPE:stadiumRom"
|
||||
|
||||
-- Names the REVISION, because that is the thing a player gets wrong: the
|
||||
-- model offsets are keyed to US 1.0 and nothing else is going to work.
|
||||
local PROMPT = "Choose your Pokemon Stadium (US) 1.0 ROM"
|
||||
|
||||
-- ------- the host, at arm's length
|
||||
--
|
||||
-- Everything below is read through pcall and a presence test. The mod loader
|
||||
-- hands a mod the real `io` and `os` today, but a mod that TAKES that for
|
||||
-- granted is one that stops loading the day a sandbox arrives -- and this is
|
||||
-- a convenience on top of a folder scan that works without any of it.
|
||||
|
||||
local function haveShell()
|
||||
local ok, popen = pcall(function() return io and io.popen end)
|
||||
return (ok and popen) and true or false
|
||||
end
|
||||
|
||||
local function haveFiles()
|
||||
local ok, open = pcall(function() return io and io.open end)
|
||||
return (ok and open) and true or false
|
||||
end
|
||||
|
||||
local function osName()
|
||||
local ok, name = pcall(function() return love.system.getOS() end)
|
||||
return ok and name or nil
|
||||
end
|
||||
|
||||
-- Run a command and return its trimmed stdout, or nil for anything that did
|
||||
-- not produce a line -- a cancelled dialog, a missing zenity, a shell that
|
||||
-- is not there.
|
||||
local function commandOutput(cmd)
|
||||
if not haveShell() then return nil end
|
||||
local ok, pipe = pcall(io.popen, cmd)
|
||||
if not (ok and pipe) then return nil end
|
||||
local okRead, out = pcall(pipe.read, pipe, "*a")
|
||||
pcall(pipe.close, pipe)
|
||||
if not (okRead and type(out) == "string") then return nil end
|
||||
out = out:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return (out ~= "") and out or nil
|
||||
end
|
||||
|
||||
-- ------- can this machine open a DIALOG
|
||||
--
|
||||
-- Desktop only, and honestly so.
|
||||
--
|
||||
-- On ANDROID the picker is a native bridge (love.system.pickFile) whose
|
||||
-- kind -> filename mapping is a fixed list of three in the engine's own C++,
|
||||
-- and an unrecognised kind falls through to `picked_rom.gb`. That is not
|
||||
-- merely the wrong name -- it is the file the engine's Game Boy importer is
|
||||
-- watching, and reading that code settles it: the importer's size test only
|
||||
-- SKIPS a 1 MB file it has already imported, so a 32 MB N64 ROM landing
|
||||
-- there falls straight through to `love.filesystem.remove` and
|
||||
-- `startData` -- deleted, and then reported to the player as a broken Game
|
||||
-- Boy ROM. So the bridge is not called until it learns the kind, which is a
|
||||
-- two-line change in System.cpp and an APK rebuild (see README).
|
||||
--
|
||||
-- Android is not stuck without it: conf.lua points the save directory at the
|
||||
-- app's external-files folder, so `baseroms/` there is reachable over USB or
|
||||
-- any file manager with no root and no permission prompt. What Android
|
||||
-- lacked was being TOLD that -- the row vanished, and the folder's absolute
|
||||
-- path was only ever written to a console no phone shows. That is what the
|
||||
-- note below is for.
|
||||
function StadiumRomPick.canDialog()
|
||||
if not (haveShell() and haveFiles()) then return false end
|
||||
local p = osName()
|
||||
return p == "Windows" or p == "OS X" or p == "Linux"
|
||||
end
|
||||
|
||||
-- Kept as the old name for callers that only wanted "is there a dialog".
|
||||
StadiumRomPick.available = StadiumRomPick.canDialog
|
||||
|
||||
-- Where a SAF pick would land if the native bridge grows a Stadium kind.
|
||||
-- Watched unconditionally (see poll): on a build that never writes it this
|
||||
-- costs one getInfo a frame, and on one that does the mod needs no further
|
||||
-- change to use it.
|
||||
StadiumRomPick.PICKED = "picked_stadium.z64"
|
||||
|
||||
-- Open the dialog. Returns the chosen absolute path, or nil when the player
|
||||
-- cancelled or no dialog could be opened.
|
||||
function StadiumRomPick.choose()
|
||||
local p = osName()
|
||||
if p == "OS X" then
|
||||
return commandOutput(
|
||||
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type ]]
|
||||
.. [[{"z64", "n64", "v64"})' 2>/dev/null]]):format(PROMPT))
|
||||
elseif p == "Windows" then
|
||||
local script = table.concat({
|
||||
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||
"$d.Title='" .. PROMPT .. "';",
|
||||
"$d.Filter='Nintendo 64 ROM (*.z64;*.n64;*.v64)|*.z64;*.n64;*.v64"
|
||||
.. "|All files (*.*)|*.*';",
|
||||
-- as UTF-8: the console's OEM codepage would mangle a non-ASCII path
|
||||
-- and crash the next text draw that showed it
|
||||
"if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding="
|
||||
.. "[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}",
|
||||
})
|
||||
return commandOutput(
|
||||
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||
elseif p == "Linux" then
|
||||
local path = commandOutput(
|
||||
([[zenity --file-selection --title="%s" ]]
|
||||
.. [[--file-filter="Nintendo 64 ROM | *.z64 *.n64 *.v64" 2>/dev/null]])
|
||||
:format(PROMPT))
|
||||
if path then return path end
|
||||
-- zenity is absent on plenty of installs (and on most handheld Linux
|
||||
-- distributions); KDE's own dialog is the usual second answer
|
||||
return commandOutput(
|
||||
[[kdialog --getopenfilename "$HOME" "*.z64 *.n64 *.v64|]]
|
||||
.. [[Nintendo 64 ROM" 2>/dev/null]])
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Read an ABSOLUTE path, which love.filesystem cannot: it only sees inside
|
||||
-- the physfs mount, and a picked file is anywhere on the disk. Returns the
|
||||
-- bytes, or nil plus a reason short enough to fit the loading screen.
|
||||
function StadiumRomPick.read(path)
|
||||
if not haveFiles() then return nil, "no file access" end
|
||||
local ok, fp = pcall(io.open, path, "rb")
|
||||
if not (ok and fp) then return nil, "could not open that file" end
|
||||
local okRead, bytes = pcall(fp.read, fp, "*a")
|
||||
pcall(fp.close, fp)
|
||||
if not (okRead and type(bytes) == "string" and #bytes > 0) then
|
||||
return nil, "could not read that file"
|
||||
end
|
||||
return bytes
|
||||
end
|
||||
|
||||
-- ------- the whole flow, from one keypress
|
||||
--
|
||||
-- Pick, read, start the build, and put the loading screen up over whatever
|
||||
-- asked -- which is the OPTIONS menu, so the row is there again underneath
|
||||
-- when the build finishes and now reads READY.
|
||||
--
|
||||
-- A CANCELLED dialog is not a failure and says nothing: the player opened a
|
||||
-- file browser and changed their mind, and a mod that made an announcement
|
||||
-- about that would be the second most annoying thing on the menu.
|
||||
--
|
||||
-- Everything else lands on the loading screen's own failure state, because it
|
||||
-- is the one surface in this mode with room for a sentence -- and because a
|
||||
-- player who has just chosen the wrong file is owed a reason and not a row
|
||||
-- that quietly goes on saying IMPORT.
|
||||
function StadiumRomPick.import(game)
|
||||
if StadiumInstall.status.state == "building" then return false end
|
||||
local StadiumScreen = V.require("StadiumScreen")
|
||||
|
||||
-- No dialog on this platform: say where the file goes, on screen, because
|
||||
-- that is the whole of what the player is missing and the console is not
|
||||
-- somewhere they can read it.
|
||||
if not StadiumRomPick.canDialog() then
|
||||
if game and game.stack then
|
||||
game.stack:push(StadiumScreen.newNote(game, "STADIUM ROM",
|
||||
"PUT STADIUM US 1.0 HERE:",
|
||||
StadiumInstall.romHintFile()))
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local path = StadiumRomPick.choose()
|
||||
if not path then return false end
|
||||
local function fail(why)
|
||||
StadiumInstall.status.state = "failed"
|
||||
StadiumInstall.status.error = why
|
||||
if game and game.stack then
|
||||
game.stack:push(StadiumScreen.new(game, true))
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local bytes, err = StadiumRomPick.read(path)
|
||||
if not bytes then return fail(err or "could not read that file") end
|
||||
|
||||
local ok, beginErr = StadiumInstall.beginFrom(bytes, path)
|
||||
if not ok then return fail(tostring(beginErr)) end
|
||||
if game and game.stack then
|
||||
game.stack:push(StadiumScreen.new(game, true))
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the row
|
||||
--
|
||||
-- An ACTION rather than a value, which is why it is not a ModSetting: there
|
||||
-- is no rung to store, nothing for the mod manager's page to persist, and
|
||||
-- nothing to restore on the next boot. What it shows is a STATE -- the models
|
||||
-- are there or they are not -- and what it does is the only thing it can do.
|
||||
--
|
||||
-- Still offered once they ARE there, reading READY. Pressing it imports
|
||||
-- again, which is how a player swaps to a different revision, and how they
|
||||
-- rebuild after a format bump has invalidated the packs and left nothing on
|
||||
-- disk to rebuild from (see the header: the ROM is not kept).
|
||||
--
|
||||
-- nil where no dialog can be opened, which takes the row off the menu
|
||||
-- entirely rather than offering a button that cannot do anything.
|
||||
function StadiumRomPick.row()
|
||||
return {
|
||||
id = StadiumRomPick.ID,
|
||||
label = StadiumRomPick.LABEL,
|
||||
value = function()
|
||||
if StadiumInstall.status.state == "building" then return "BUILDING" end
|
||||
if StadiumInstall.available() then return "READY" end
|
||||
-- WHERE, not IMPORT, where pressing it can only tell you the folder:
|
||||
-- a row that says IMPORT and then does not import is a worse row than
|
||||
-- one that says what it actually does
|
||||
return StadiumRomPick.canDialog() and "IMPORT" or "WHERE?"
|
||||
end,
|
||||
step = function(game)
|
||||
pcall(StadiumRomPick.import, game)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ------- a pick that landed while we were not looking
|
||||
--
|
||||
-- The desktop dialog BLOCKS, so `import` above can read the answer on the
|
||||
-- next line. A SAF pick cannot work that way: it is a separate activity,
|
||||
-- Android is free to destroy the game while it is up, and the file appears
|
||||
-- some frames later -- so the only way to notice one is to look for it.
|
||||
--
|
||||
-- Nothing writes this filename today (see canDialog). It is watched anyway so
|
||||
-- that teaching the native bridge one more kind is the whole of the Android
|
||||
-- picker work, with no second change needed here.
|
||||
--
|
||||
-- Consumed and DELETED either way: a 32 MB file left in the save directory
|
||||
-- would be imported again on the next boot, and kept forever if the import
|
||||
-- failed.
|
||||
function StadiumRomPick.poll(game)
|
||||
local f = love and love.filesystem
|
||||
if not (f and f.getInfo) then return false end
|
||||
if StadiumInstall.status.state == "building" then return false end
|
||||
local ok, info = pcall(f.getInfo, StadiumRomPick.PICKED, "file")
|
||||
if not (ok and info) then return false end
|
||||
|
||||
local okRead, bytes = pcall(f.read, StadiumRomPick.PICKED)
|
||||
pcall(f.remove, StadiumRomPick.PICKED)
|
||||
if not (okRead and type(bytes) == "string") then return false end
|
||||
|
||||
local StadiumScreen = V.require("StadiumScreen")
|
||||
local started, err = StadiumInstall.beginFrom(bytes, StadiumRomPick.PICKED)
|
||||
if not started then
|
||||
StadiumInstall.status.state = "failed"
|
||||
StadiumInstall.status.error = tostring(err)
|
||||
end
|
||||
if game and game.stack then
|
||||
game.stack:push(StadiumScreen.new(game, true))
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return StadiumRomPick
|
||||
@@ -0,0 +1,391 @@
|
||||
-- STADIUM battles: the one-time build, on screen.
|
||||
--
|
||||
-- A pushed game state, so it draws in the Game Boy's own 160x144 and stops
|
||||
-- everything under it -- which is what it should do, because it is doing real
|
||||
-- work and the player should not be walking around while it happens.
|
||||
--
|
||||
-- ------- why a species a frame
|
||||
--
|
||||
-- A species takes roughly fifty milliseconds to extract, and there are 151 of
|
||||
-- them. That is ten seconds, which has to go somewhere. Doing them one per
|
||||
-- frame puts the whole cost on this screen where it is explained, keeps the
|
||||
-- bar moving at a visible rate, and leaves the frame free to draw between
|
||||
-- them. Batching more per frame would finish no sooner -- the work is the
|
||||
-- same -- and would only make the bar jump.
|
||||
--
|
||||
-- ------- what it says
|
||||
--
|
||||
-- Three things, and each of them is answering a question the player would
|
||||
-- otherwise have to guess at while the game sits there:
|
||||
--
|
||||
-- WHAT is happening -- "STADIUM EXTRACTION", which is what it is.
|
||||
--
|
||||
-- HOW FAR through it is -- a bar, filled by species written rather than by
|
||||
-- elapsed time, so it cannot lie about the remaining work.
|
||||
--
|
||||
-- THAT IT IS ALIVE -- which Pokemon it is on, by name. Not decoration: it
|
||||
-- is the difference between a progress bar the player trusts and one they
|
||||
-- suspect has hung, and it costs one lookup a frame.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local StadiumInstall = V.require("StadiumInstall")
|
||||
|
||||
local StadiumScreen = {}
|
||||
StadiumScreen.__index = StadiumScreen
|
||||
|
||||
-- The Game Boy frame this draws in.
|
||||
local W, H = 160, 144
|
||||
|
||||
-- How long the finished message stays up before the screen retires itself.
|
||||
StadiumScreen.HOLD = 1.1
|
||||
|
||||
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
|
||||
|
||||
-- Black glyphs, because that is the only colour the Game Boy font sheets
|
||||
-- have -- they are black on transparent, so setColor cannot lighten one.
|
||||
-- Everything here is therefore laid out dark-on-light.
|
||||
local function text(str, x, y)
|
||||
local F = font()
|
||||
if not F then return end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
F.draw(str, math.floor(x), math.floor(y))
|
||||
end
|
||||
|
||||
local function centred(str, y)
|
||||
local F = font()
|
||||
if not F then return end
|
||||
text(str, (W - F.width(str)) / 2, y)
|
||||
end
|
||||
|
||||
-- How many glyphs fit across the frame. The font is a fixed eight pixels, so
|
||||
-- twenty is the line -- and a centred string longer than that does not
|
||||
-- overflow tidily off one side, it clips off BOTH and loses its first word as
|
||||
-- well as its last ("that is not a Pokemon Stadium ROM" came out as "at is
|
||||
-- not a Pokemon").
|
||||
--
|
||||
-- Fixed, and it stays fixed: shrinking the text to fit more in was tried and
|
||||
-- the font will not take it. These are 1-bit 8x8 bitmaps, so a fractional
|
||||
-- downscale drops whole pixel rows out of every glyph -- at 0.75 the last
|
||||
-- line of an Android save path came out as mush. Long strings get more LINES
|
||||
-- instead (see the note layout in draw).
|
||||
local COLS = 20
|
||||
|
||||
-- Break a string into lines that fit, on word boundaries, and never more than
|
||||
-- `limit` of them.
|
||||
local function wrapped(str, limit, cols)
|
||||
limit = limit or 2
|
||||
cols = cols or COLS
|
||||
local lines, line = {}, nil
|
||||
local function push(text)
|
||||
if #lines < limit then lines[#lines + 1] = text end
|
||||
end
|
||||
for word in tostring(str):gmatch("%S+") do
|
||||
local try = line and (line .. " " .. word) or word
|
||||
if #try <= cols then
|
||||
line = try
|
||||
else
|
||||
if line then push(line) end
|
||||
-- A word longer than the line is BROKEN ACROSS lines rather than cut.
|
||||
-- It is always a path, and a path is the one thing here that has to be
|
||||
-- readable in full -- an absolute Android save directory runs to
|
||||
-- ninety-odd characters with no spaces in it at all, so truncating at
|
||||
-- twenty told the player almost nothing.
|
||||
while #word > cols do
|
||||
push(word:sub(1, cols))
|
||||
word = word:sub(cols + 1)
|
||||
end
|
||||
line = word
|
||||
end
|
||||
if #lines >= limit then break end
|
||||
end
|
||||
if line then push(line) end
|
||||
return lines
|
||||
end
|
||||
|
||||
-- ------- dex number -> the engine's own species key
|
||||
--
|
||||
-- Built once, from the loaded data rather than from a list of names carried
|
||||
-- here: a list would be a second place for the same 151 facts to live, and
|
||||
-- would go stale against a mod that renames one.
|
||||
local dexNames = nil
|
||||
|
||||
local function speciesName(dex)
|
||||
if not dex then return nil end
|
||||
if not dexNames then
|
||||
dexNames = {}
|
||||
local ok, data = pcall(function()
|
||||
return require("src.core.Game").data
|
||||
end)
|
||||
if ok and data and data.pokemon then
|
||||
for key, def in pairs(data.pokemon) do
|
||||
if type(def) == "table" and def.dex then dexNames[def.dex] = key end
|
||||
end
|
||||
end
|
||||
end
|
||||
return dexNames[dex]
|
||||
end
|
||||
|
||||
-- `adopt` means the caller has ALREADY started the build, or already decided
|
||||
-- it cannot start -- which is the imported path (StadiumRomPick opens the
|
||||
-- picked file itself, because love.filesystem cannot read an absolute path).
|
||||
-- Without it this screen would call begin() on the way in and throw away the
|
||||
-- job it was pushed to display, or overwrite the failure it was pushed to
|
||||
-- explain with a fresh "no ROM in baseroms" -- which would be true, and would
|
||||
-- have nothing to do with what just went wrong.
|
||||
function StadiumScreen.new(game, adopt)
|
||||
return setmetatable({ game = game, hold = 0, started = adopt and true or false,
|
||||
adopted = adopt and true or false }, StadiumScreen)
|
||||
end
|
||||
|
||||
-- ------- the same plate, saying something instead of doing something
|
||||
--
|
||||
-- A NOTE: title, a wrapped body, and a key to dismiss it. It exists because
|
||||
-- the one piece of information a player on a platform with no file dialog
|
||||
-- actually needs -- the absolute path of the folder to put the cartridge in
|
||||
-- -- is long, machine-specific, and was only ever written to the console,
|
||||
-- which nobody on a phone can read.
|
||||
--
|
||||
-- Same state shape and the same plate as the build screen, so there is one
|
||||
-- look and one set of stack manners rather than two.
|
||||
function StadiumScreen.newNote(game, title, lead, body)
|
||||
return setmetatable({ game = game,
|
||||
note = { title = title, lead = lead, body = body } },
|
||||
StadiumScreen)
|
||||
end
|
||||
|
||||
-- Opaque: the loading screen owns the frame, so the map underneath is not
|
||||
-- drawn and not paying for a render it cannot be seen through.
|
||||
StadiumScreen.isOpaque = true
|
||||
|
||||
function StadiumScreen:enter()
|
||||
if self.note or self.adopted then return end
|
||||
local ok, err = StadiumInstall.begin()
|
||||
self.started = ok and true or false
|
||||
if not ok then
|
||||
StadiumInstall.status.state = "failed"
|
||||
StadiumInstall.status.error = err
|
||||
end
|
||||
end
|
||||
|
||||
-- The buttons that dismiss a note. Every face button and START, because the
|
||||
-- prompt says ANY and a player who has to hunt for the right one on a phone
|
||||
-- has been lied to.
|
||||
local DISMISS = { "a", "b", "start", "select" }
|
||||
|
||||
function StadiumScreen:update()
|
||||
-- ------- a note is dismissed by a BUTTON, not by a key
|
||||
--
|
||||
-- `onKeyPressed` is the keyboard, and a phone has none: the touch overlay
|
||||
-- feeds the engine's Input as virtual buttons (Input.overlayPressed), so a
|
||||
-- state that only listens for keys cannot be closed by touch at all. That
|
||||
-- stranded a player on this screen with no way off it -- the one screen in
|
||||
-- the mod whose entire job is to tell somebody something and then get out
|
||||
-- of the way.
|
||||
--
|
||||
-- Polled here rather than handled as an event because `wasPressed` is the
|
||||
-- edge test the engine's own battle screens use, and it is fed by the
|
||||
-- keyboard, the gamepad AND the overlay through one path.
|
||||
if self.note then
|
||||
local input = self.game and self.game.input
|
||||
if input and input.wasPressed then
|
||||
for _, btn in ipairs(DISMISS) do
|
||||
if input:wasPressed(btn) then
|
||||
if self.game.stack and self.game.stack:top() == self then
|
||||
self.game.stack:pop()
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
local status = StadiumInstall.status
|
||||
if status.state == "building" then
|
||||
if not StadiumInstall.step() then
|
||||
-- fell out of building: either finished or failed, both of which hold
|
||||
-- for a moment so the player sees which
|
||||
self.hold = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
self.hold = self.hold + 1 / 60
|
||||
-- a failure stays up longer, because it is the one the player has to read
|
||||
local wait = StadiumScreen.HOLD
|
||||
if status.state == "failed" then
|
||||
wait = StadiumScreen.HOLD * 4
|
||||
elseif status.wrongVersion then
|
||||
-- a warning nobody can read is not a warning
|
||||
wait = StadiumScreen.HOLD * 3
|
||||
end
|
||||
if self.hold >= wait then
|
||||
if self.game and self.game.stack and self.game.stack:top() == self then
|
||||
self.game.stack:pop()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Let the player out of a build that has gone wrong, or that they would
|
||||
-- rather not wait for. Cancelling leaves the packs unbuilt, so the STADIUM
|
||||
-- rungs stay off the row until the next boot offers again -- which is
|
||||
-- honest, and better than a half-built set.
|
||||
local function pop(self)
|
||||
if self.game and self.game.stack and self.game.stack:top() == self then
|
||||
self.game.stack:pop()
|
||||
end
|
||||
end
|
||||
|
||||
function StadiumScreen:onKeyPressed(key)
|
||||
-- A note takes any key too. This is the KEYBOARD path and it is not the
|
||||
-- one that matters on a phone -- see update, which polls the engine's
|
||||
-- Input so the touch overlay's virtual buttons work as well.
|
||||
if self.note then pop(self) return true end
|
||||
if key == "escape" or key == "x" or key == "backspace" then
|
||||
StadiumInstall.cancel()
|
||||
if self.game and self.game.stack and self.game.stack:top() == self then
|
||||
self.game.stack:pop()
|
||||
end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function StadiumScreen:draw()
|
||||
local status = StadiumInstall.status
|
||||
love.graphics.setColor(0.93, 0.94, 0.90, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, W, H)
|
||||
|
||||
if self.note then
|
||||
centred(self.note.title, 12)
|
||||
-- The sentence is kept SHORT so the path can have the rest of the plate
|
||||
-- at full size. Shrinking the path was tried first and does not survive
|
||||
-- the font: these are 1-bit 8x8 bitmaps, so a fractional downscale drops
|
||||
-- whole pixel rows out of every glyph and the last line came out as
|
||||
-- mush. Nine rows of twenty characters is 180, which is longer than any
|
||||
-- real save path, so nothing has to be shrunk to fit.
|
||||
local lead = wrapped(self.note.lead or "", 2)
|
||||
for i, line in ipairs(lead) do centred(line, 30 + (i - 1) * 10) end
|
||||
local y = 30 + #lead * 10 + 6
|
||||
for i, line in ipairs(wrapped(self.note.body or "", 9)) do
|
||||
centred(line, y + (i - 1) * 9)
|
||||
end
|
||||
centred("PRESS ANY KEY", 130)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
-- One line, and it is the whole heading: eighteen glyphs at the font's
|
||||
-- fixed eight pixels is 144 of the frame's 160.
|
||||
centred("STADIUM EXTRACTION", 34)
|
||||
|
||||
if status.state == "failed" then
|
||||
centred("COULD NOT BUILD", 68)
|
||||
local lines = wrapped(status.error or "unknown", 2)
|
||||
for i, line in ipairs(lines) do centred(line, 82 + (i - 1) * 10) end
|
||||
centred("STADIUM IS OFF", 110)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
local done = status.done or 0
|
||||
local total = status.total or StadiumInstall.COUNT
|
||||
local frac = (total > 0) and (done / total) or 1
|
||||
if status.state == "done" then frac = 1 end
|
||||
if frac < 0 then frac = 0 elseif frac > 1 then frac = 1 end
|
||||
|
||||
-- The bar: a dark frame, an EMPTY interior the same colour as the plate,
|
||||
-- and a dark fill growing left to right.
|
||||
--
|
||||
-- The track has to be the plate's own white rather than a light grey. This
|
||||
-- draws inside the Game Boy frame, so the colorization pass quantises
|
||||
-- everything here into the four GB shades and paints them -- and a grey
|
||||
-- track lands one shade down, which comes out as a bar that is GREEN where
|
||||
-- the work is still to do and dark where it is done. The eye reads colour
|
||||
-- as the filled part and gets the progress exactly backwards.
|
||||
local bx, by, bw, bh = 24, 68, W - 48, 9
|
||||
love.graphics.setColor(0.06, 0.05, 0.09, 1)
|
||||
love.graphics.rectangle("fill", bx - 1, by - 1, bw + 2, bh + 2)
|
||||
love.graphics.setColor(0.93, 0.94, 0.90, 1)
|
||||
love.graphics.rectangle("fill", bx, by, bw, bh)
|
||||
love.graphics.setColor(0.06, 0.05, 0.09, 1)
|
||||
love.graphics.rectangle("fill", bx, by, math.floor(bw * frac + 0.5), bh)
|
||||
|
||||
if status.state == "done" then
|
||||
centred("READY", 86)
|
||||
-- and say so if it was built from something other than the revision every
|
||||
-- offset in the reader was measured against: it may look fine, it may be
|
||||
-- subtly wrong, and the player is the only one who can swap the file
|
||||
if status.wrongVersion then
|
||||
centred("NOT US 1.0 --", 104)
|
||||
centred("MODELS MAY BE WRONG", 114)
|
||||
end
|
||||
else
|
||||
local name = speciesName(status.species)
|
||||
centred(("%d/%d"):format(done, total), 86)
|
||||
if name then centred(name, 98) end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- ------- when this comes up
|
||||
--
|
||||
-- The first frame the player is actually IN the world, rather than at boot.
|
||||
-- Two reasons. The engine has its own launcher and ROM importer before the
|
||||
-- game starts, and pushing over those would be fighting them for the screen.
|
||||
-- And `Game.data` has to be loaded for the species names above to resolve.
|
||||
--
|
||||
-- Asked once. If the player cancels, or there is no ROM, this does not come
|
||||
-- back until the next run -- a loading screen that reappears every time you
|
||||
-- step outside would be worse than no stadium models.
|
||||
local asked = false
|
||||
|
||||
function StadiumScreen.maybePush()
|
||||
if asked then return false end
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if not (ok and Game and Game.stack and Game.overworld) then return false end
|
||||
if Game.stack:top() ~= Game.overworld then return false end
|
||||
asked = true
|
||||
if not StadiumInstall.pending() then
|
||||
-- Say where to put a cartridge, ONCE, and only when there is nothing to
|
||||
-- build from and nothing already built. The two STADIUM rungs are simply
|
||||
-- absent in that case (ModSetting.setGate), which is the right thing for
|
||||
-- a row to do and tells the player nothing about why -- and the answer
|
||||
-- they need is an absolute path that depends on how the game was
|
||||
-- installed, so it cannot be written into the options help text.
|
||||
if not StadiumInstall.available() then
|
||||
-- The IMPORT row is the answer wherever a file dialog can be opened,
|
||||
-- and it is the better one: no folder to create, no path to get right,
|
||||
-- no restart. The folder is still said, once, for the platforms with no
|
||||
-- dialog (Android, a handheld Linux with neither zenity nor kdialog)
|
||||
-- and for anyone who would rather drop a file than click through one.
|
||||
-- The STADIUM ROM row is on the OPTIONS menu on every platform now, so
|
||||
-- point at it rather than reciting a path here: where a file dialog can
|
||||
-- be opened it opens one, and where it cannot it shows this same folder
|
||||
-- on screen -- which is the part a phone could not otherwise find out.
|
||||
local okPick, pick = pcall(V.require, "StadiumRomPick")
|
||||
local label = (okPick and pick and pick.LABEL) or "STADIUM ROM"
|
||||
local how = (okPick and pick and pick.canDialog())
|
||||
and "opens a file picker" or "says where to put one"
|
||||
V.mod.log:info("stadium: no Pokemon Stadium (US) 1.0 ROM found, so the "
|
||||
.. "STADIUM battle rungs are off. OPTIONS -> %s %s; the "
|
||||
.. "folder is %s", label, how, StadiumInstall.romHint())
|
||||
end
|
||||
return false
|
||||
end
|
||||
Game.stack:push(StadiumScreen.new(Game))
|
||||
return true
|
||||
end
|
||||
|
||||
-- named for the suite, which drives the screen without a boot
|
||||
function StadiumScreen._reset()
|
||||
asked = false
|
||||
end
|
||||
|
||||
return StadiumScreen
|
||||
@@ -0,0 +1,340 @@
|
||||
-- The B rungs: the two discs the fight is staged on.
|
||||
--
|
||||
-- Where an A rung puts the fight on the MAP -- real ground, whatever the
|
||||
-- route happens to look like -- a B rung puts it on two platforms against
|
||||
-- the sky and draws no map at all.
|
||||
--
|
||||
-- ------- one stage, two rungs
|
||||
--
|
||||
-- The discs do not know what is standing on them. 2D-3D B stands the Game
|
||||
-- Boy's own battle pics there and STADIUM B stands the Pokemon Stadium
|
||||
-- models, and this file is identical for both: it draws two platforms at two
|
||||
-- cells and sizes each to whatever footprint it is given. That is why the
|
||||
-- flat disc rung cost a value in the 3D-BTL ladder and nothing else.
|
||||
--
|
||||
-- ------- why this is a rung and not a fix
|
||||
--
|
||||
-- Staging on the map is the better picture when the map cooperates, and it
|
||||
-- often does not. Half of Kanto's interiors are furniture; a cave floor can
|
||||
-- be nothing but two-cell corridors; some maps have nowhere a fight can be
|
||||
-- SEEN from a low camera and are declined outright (see BattleArena), which
|
||||
-- drops the player back to the flat battle screen with no warning. And even
|
||||
-- where a spot exists, the ground behind the foe is a hedge or a shop counter
|
||||
-- rather than anything a battle wants behind it.
|
||||
--
|
||||
-- Discs have none of those problems, because the stage is CARRIED rather than
|
||||
-- found: it works on every map, in every building, at every step, and the
|
||||
-- framing is the same every time. What it gives up is the thing STADIUM A is
|
||||
-- for -- fighting somewhere real.
|
||||
--
|
||||
-- ------- what stays
|
||||
--
|
||||
-- The sky, and the light. A battle outdoors is under the hour's own sky, with
|
||||
-- its bands and its sun or moon (Voxel3D.beginScene paints it when handed a
|
||||
-- dressed one); a battle in a cave or a room is under that place's own void
|
||||
-- and its own neutral light, exactly as the map itself would be. So the mode
|
||||
-- is abstracted from the GROUND, not from the world -- walk into a cave at
|
||||
-- midnight and the fight looks like a cave at midnight.
|
||||
--
|
||||
-- And the framing. The camera, the pins, the HUDs, the text box, the move
|
||||
-- animations and the depth of field are all identical, because every one of
|
||||
-- them is hung off the arena's CELLS rather than off what is under them. That
|
||||
-- is the same reason STADIUM A could be an option on the mode rather than a
|
||||
-- second mode, and it is why this file is a few hundred lines and not a few
|
||||
-- thousand.
|
||||
|
||||
-- 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 StadiumStage = {}
|
||||
|
||||
local floor = math.floor
|
||||
local sin, cos = math.sin, math.cos
|
||||
local pi = math.pi
|
||||
|
||||
-- ------- the shape of a disc
|
||||
--
|
||||
-- Radius in world pixels, where a map cell is 16 and the two mons stand three
|
||||
-- cells apart.
|
||||
--
|
||||
-- A PLATFORM FOLLOWS WHAT STANDS ON IT rather than being one fixed size,
|
||||
-- because the set's footprints run nearly tenfold: a Caterpie is under four
|
||||
-- world pixels across and Moltres, wings out, is twenty-six. One radius for
|
||||
-- both is either a dinner plate under the caterpillar or a doily under the
|
||||
-- bird.
|
||||
--
|
||||
-- RADIUS is the floor, and it is the number most species land on -- it is a
|
||||
-- little over the map cell a Pokemon is sized to cover, which is the
|
||||
-- proportion the Game Boy's own battle platforms have. PAD is the margin
|
||||
-- around a mon that needs more than that, and MAX_RADIUS stops Moltres from
|
||||
-- being handed something the frame cannot hold.
|
||||
--
|
||||
-- These are the FULL radius, out to where the fade has finished; the solid
|
||||
-- centre a Pokemon actually stands on is SOLID of it. PAD is sized so that a
|
||||
-- mon's own footprint fits inside that centre rather than out over the
|
||||
-- stipple -- 1.8 x 0.70 is a little over 1.25, so a big Pokemon still has
|
||||
-- solid ground under its edges.
|
||||
StadiumStage.RADIUS = 18
|
||||
StadiumStage.MAX_RADIUS = 34
|
||||
StadiumStage.PAD = 1.8
|
||||
|
||||
-- The platform for a mon of this footprint. `r` may be nil -- nothing is
|
||||
-- standing there yet, which is every frame of the send-out before the model
|
||||
-- appears, and it is also the whole of the flat 2D-3D B rung, where a
|
||||
-- Pokemon is a battle pic sized to cover exactly one map cell and RADIUS is
|
||||
-- already a little over that. Either way the platform is the plain one, and
|
||||
-- it has to be there BEFORE the Pokemon lands on it.
|
||||
function StadiumStage.radiusFor(r)
|
||||
local want = (r or 0) * StadiumStage.PAD
|
||||
if want < StadiumStage.RADIUS then return StadiumStage.RADIUS end
|
||||
if want > StadiumStage.MAX_RADIUS then return StadiumStage.MAX_RADIUS end
|
||||
return want
|
||||
end
|
||||
|
||||
-- Per-vertex shading, in the same terms StadiumRig lights the models with, so
|
||||
-- a disc and the Pokemon standing on it agree about where the sun is. Fitted
|
||||
-- to Voxel3D.FACE_SHADE's six values: the constant is the average, and each
|
||||
-- axis term is half the spread between that axis's two faces.
|
||||
local SHADE_BASE = 0.7725
|
||||
local SHADE_X = 0.06
|
||||
local SHADE_Y = 0.225
|
||||
local SHADE_Z = 0.11
|
||||
|
||||
local function shadeFor(nx, ny, nz)
|
||||
local s = SHADE_BASE + nx * SHADE_X + ny * SHADE_Y + nz * SHADE_Z
|
||||
if s < 0.30 then return 0.30 end
|
||||
if s > 1.00 then return 1.00 end
|
||||
return s
|
||||
end
|
||||
|
||||
-- ------- the texture
|
||||
--
|
||||
-- The platform is a FLAT painted disc that fades out at its rim -- no rim
|
||||
-- wall, no thickness, the whole thing carried in one texture on one quad
|
||||
-- lying on the ground plane. That is what the Game Boy's own battle
|
||||
-- platforms are, and it is what keeps the stage from competing with the
|
||||
-- Pokemon standing on it.
|
||||
--
|
||||
-- ------- why the fade is DITHERED
|
||||
--
|
||||
-- The scene shader discards any texel under half alpha outright (it has to:
|
||||
-- that is what keeps a sprite's transparent corners out of the depth buffer).
|
||||
-- So a smooth alpha ramp does not fade -- it comes out as a hard circle cut
|
||||
-- at wherever the ramp crosses 0.5, which is the one thing this must not be.
|
||||
--
|
||||
-- The fade is therefore an ORDERED DITHER baked into the texture's alpha:
|
||||
-- every texel is fully on or fully off, and the proportion that are on falls
|
||||
-- away toward the rim. It is the same trick the sky already uses for its
|
||||
-- bands (Sky.DITHER), it needs no shader change and so risks nothing in any
|
||||
-- other pass, and on a mode built out of visible texels it reads as intended
|
||||
-- rather than as a limitation.
|
||||
--
|
||||
-- The COLOUR is deliberately neutral. Everything the shader does to it after
|
||||
-- this is the environment's: Voxel3D.tint carries the hour outdoors and the
|
||||
-- room's own flat light indoors, and the shadow pass darkens whatever the
|
||||
-- Pokemon standing on it occludes. So one texture is a sunlit platform, a
|
||||
-- dusk platform and a cave platform, without a variant for each.
|
||||
StadiumStage.TEX = 128
|
||||
|
||||
-- Where the solid centre ends, as a fraction of the disc's radius. Inside
|
||||
-- this everything is opaque; from here to the rim the dither thins out.
|
||||
StadiumStage.SOLID = 0.76
|
||||
|
||||
local TOP = { 0.74, 0.71, 0.63 }
|
||||
local TOP_ALT = { 0.67, 0.64, 0.57 }
|
||||
|
||||
local texture = nil
|
||||
|
||||
-- A small deterministic scatter, for the surface itself. Not a random one: an
|
||||
-- authored constant that happens to look unpatterned is worth more here than
|
||||
-- a seed, because it can never change under a different Lua.
|
||||
--
|
||||
-- Quantised into blocks, so the surface reads as TEXELS rather than as noise.
|
||||
-- Per-pixel it came out as a fine mottle that fought the dithered rim for
|
||||
-- attention -- and the rim is the thing worth looking at. At this size the
|
||||
-- grain is roughly the size of the voxels everywhere else in the mode.
|
||||
StadiumStage.GRAIN = 4
|
||||
|
||||
local function grain(x, y)
|
||||
local bx = (x - x % StadiumStage.GRAIN) / StadiumStage.GRAIN
|
||||
local by = (y - y % StadiumStage.GRAIN) / StadiumStage.GRAIN
|
||||
local v = (bx * 37 + by * 71 + ((bx * by) % 13) * 17) % 100
|
||||
return v < 34
|
||||
end
|
||||
|
||||
-- The 8x8 ordered (Bayer) matrix, as thresholds in 0..63. Ordered rather than
|
||||
-- random because a random dither crawls: this pattern is fixed in the
|
||||
-- texture, so the fade holds still while the camera drifts across it.
|
||||
local BAYER = {
|
||||
{ 0, 32, 8, 40, 2, 34, 10, 42 },
|
||||
{ 48, 16, 56, 24, 50, 18, 58, 26 },
|
||||
{ 12, 44, 4, 36, 14, 46, 6, 38 },
|
||||
{ 60, 28, 52, 20, 62, 30, 54, 22 },
|
||||
{ 3, 35, 11, 43, 1, 33, 9, 41 },
|
||||
{ 51, 19, 59, 27, 49, 17, 57, 25 },
|
||||
{ 15, 47, 7, 39, 13, 45, 5, 37 },
|
||||
{ 63, 31, 55, 23, 61, 29, 53, 21 },
|
||||
}
|
||||
|
||||
function StadiumStage.texture()
|
||||
if texture ~= nil then return texture or nil end
|
||||
local ok, img = pcall(function()
|
||||
local n = StadiumStage.TEX
|
||||
local data = love.image.newImageData(n, n)
|
||||
local half = (n - 1) / 2
|
||||
local solid = StadiumStage.SOLID
|
||||
for y = 0, n - 1 do
|
||||
local dy = (y - half) / half
|
||||
for x = 0, n - 1 do
|
||||
local dx = (x - half) / half
|
||||
local d = (dx * dx + dy * dy) ^ 0.5
|
||||
-- how much of this texel's neighbourhood should survive: everything
|
||||
-- inside the solid core, nothing past the rim, and a smooth ramp
|
||||
-- between the two that the dither turns into a stipple
|
||||
local cover
|
||||
if d <= solid then
|
||||
cover = 1.0
|
||||
elseif d >= 1.0 then
|
||||
cover = 0.0
|
||||
else
|
||||
local t = (d - solid) / (1.0 - solid)
|
||||
cover = 1.0 - t * t * (3 - 2 * t) -- smoothstep, falling
|
||||
end
|
||||
local threshold = (BAYER[y % 8 + 1][x % 8 + 1] + 0.5) / 64
|
||||
local a = (cover > threshold) and 1 or 0
|
||||
local c = grain(x, y) and TOP_ALT or TOP
|
||||
data:setPixel(x, y, c[1], c[2], c[3], a)
|
||||
end
|
||||
end
|
||||
local image = love.graphics.newImage(data)
|
||||
-- nearest, like every other texture in this mode: the grain and the
|
||||
-- stipple are both meant to read as texels. Clamped rather than
|
||||
-- repeating now that one texture covers the whole disc.
|
||||
image:setFilter("nearest", "nearest")
|
||||
image:setWrap("clampzero", "clampzero")
|
||||
return image
|
||||
end)
|
||||
texture = (ok and img) or false
|
||||
return texture or nil
|
||||
end
|
||||
|
||||
-- ------- the mesh
|
||||
--
|
||||
-- One quad, lying flat on the ground plane, spanning -1..1 in x and z with
|
||||
-- the whole texture stretched across it. The DISC is the texture's business,
|
||||
-- not the geometry's -- everything outside the painted circle is alpha the
|
||||
-- shader discards -- which is what "a flat texture that fades out at the
|
||||
-- edges" means and what makes this four vertices rather than a hundred and
|
||||
-- fifty.
|
||||
--
|
||||
-- Shaded as a face pointing straight up, because it is one.
|
||||
|
||||
local mesh = nil
|
||||
|
||||
local function build()
|
||||
local s = shadeFor(0, 1, 0)
|
||||
local verts = {
|
||||
{ -1, 0, -1, 0, 0, s },
|
||||
{ 1, 0, -1, 1, 0, s },
|
||||
{ 1, 0, 1, 1, 1, s },
|
||||
{ -1, 0, 1, 0, 1, s },
|
||||
}
|
||||
return Voxel3D.newMesh(verts, { 1, 2, 3, 1, 3, 4 })
|
||||
end
|
||||
|
||||
function StadiumStage.mesh()
|
||||
if mesh == nil then mesh = build() or false end
|
||||
return mesh or nil
|
||||
end
|
||||
|
||||
function StadiumStage.invalidate()
|
||||
if texture and texture.release then pcall(texture.release, texture) end
|
||||
if mesh and mesh.release then pcall(mesh.release, mesh) end
|
||||
texture, mesh = nil, nil
|
||||
end
|
||||
|
||||
-- How far under the ground plane the disc actually sits. A hair, and only so
|
||||
-- that a flat-footed Pokemon's sole -- which is AT the ground plane -- is not
|
||||
-- coplanar with it and left to the depth buffer's mercy.
|
||||
StadiumStage.SINK = 0.06
|
||||
|
||||
-- Where one disc sits: centred on a cell, at the ground plane, so a Pokemon
|
||||
-- placed at that same height stands ON it rather than in it.
|
||||
function StadiumStage.matrix(x, groundY, z, radius)
|
||||
radius = radius or StadiumStage.RADIUS
|
||||
return Mat4.mul(Mat4.translate(x, groundY - StadiumStage.SINK, z),
|
||||
Mat4.scale(radius, 1, radius))
|
||||
end
|
||||
|
||||
-- The two platforms this frame, as (side, matrix) -- shared by the camera's
|
||||
-- pass and the sun's, so the two can never disagree about where they are.
|
||||
local function each(arena, groundY, fn)
|
||||
local ok, Stadium = pcall(V.require, "Stadium")
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local cell = arena[side]
|
||||
if cell then
|
||||
local footprint = ok and Stadium and Stadium.footprint(side) or nil
|
||||
fn(StadiumStage.matrix(cell[1], groundY, cell[2],
|
||||
StadiumStage.radiusFor(footprint)))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the synthetic arena
|
||||
--
|
||||
-- A B rung does not search the map, because it does not stand on it. The
|
||||
-- arena is the same WIDE shape every other staged fight uses -- so the two
|
||||
-- cells are three apart down the middle and BattleCam frames them exactly as
|
||||
-- it always has -- just placed at a fixed spot rather than a found one.
|
||||
--
|
||||
-- Away from the origin on purpose. The coordinates run through the camera
|
||||
-- solve, the sun's frustum fit and the projection to Game Boy pixels, and
|
||||
-- putting a stage at (0, 0) is the kind of thing that hides a sign error for
|
||||
-- months.
|
||||
StadiumStage.ORIGIN = { 16, 16 }
|
||||
|
||||
function StadiumStage.arena(map)
|
||||
local BattleArena = V.require("BattleArena")
|
||||
local arena = BattleArena.at(StadiumStage.ORIGIN[1], StadiumStage.ORIGIN[2],
|
||||
"wide")
|
||||
if not arena then return nil end
|
||||
-- the map is carried for its SKY and its palette only -- what kind of place
|
||||
-- the fight is happening in -- never for its geometry
|
||||
arena.map = map
|
||||
arena.discs = true
|
||||
return arena
|
||||
end
|
||||
|
||||
-- ------- the draws
|
||||
|
||||
-- The discs, in the main pass. No wireframe: everything else in this frame is
|
||||
-- built a unit per voxel and wears the seams that fall out of that, and a
|
||||
-- disc is a turned solid with no grid to draw.
|
||||
function StadiumStage.draw(arena, groundY)
|
||||
if not (arena and arena.discs) then return end
|
||||
local m = StadiumStage.mesh()
|
||||
local tex = StadiumStage.texture()
|
||||
if not (m and tex) then return end
|
||||
Voxel3D.seams(false)
|
||||
Voxel3D.glass(false)
|
||||
each(arena, groundY, function(matrix) Voxel3D.draw(m, tex, matrix) end)
|
||||
Voxel3D.glass(true)
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- And into the sun, so the two Pokemon put real shadows on the platforms they
|
||||
-- are standing on. Without this the shadow map is empty where the discs are
|
||||
-- and a mon casts onto nothing at all -- which, with no ground behind it
|
||||
-- either, reads as the pair floating.
|
||||
function StadiumStage.cast(shadowMap, arena, groundY)
|
||||
if not (arena and arena.discs and shadowMap) then return end
|
||||
local m = StadiumStage.mesh()
|
||||
local tex = StadiumStage.texture()
|
||||
if not (m and tex) then return end
|
||||
each(arena, groundY, function(matrix) shadowMap.draw(m, tex, matrix) end)
|
||||
end
|
||||
|
||||
return StadiumStage
|
||||
+1313
-171
File diff suppressed because it is too large
Load Diff
@@ -270,7 +270,12 @@ local function readback(image)
|
||||
local prev = love.graphics.getCanvas()
|
||||
local ok, data = pcall(function()
|
||||
local w, h = image:getDimensions()
|
||||
local canvas = love.graphics.newCanvas(w, h)
|
||||
-- dpiscale = 1, or this is not a copy. On a highdpi surface (Android,
|
||||
-- iOS -- see conf.lua) newCanvas takes the surface's scale by default,
|
||||
-- so the atlas would be drawn into a texture 2.75x its size and read
|
||||
-- back magnified -- and every tile coordinate below, which counts in
|
||||
-- eights from the top-left, would land somewhere between two tiles.
|
||||
local canvas = love.graphics.newCanvas(w, h, { dpiscale = 1 })
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
-- straight copy: no blending against the cleared target, no tint from
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
-- Voxel world mode: the third-person camera -- the 3RD rung.
|
||||
--
|
||||
-- 3RD is 1ST with the eye pulled off the back of the head. Everything that
|
||||
-- makes the first-person rung work -- the steered attitude, the placed
|
||||
-- camera on Voxel3D's seam, the cards that turn to face the eye, the
|
||||
-- continuous camera-relative walk -- is already general over WHERE the eye
|
||||
-- stands, so this module adds exactly one thing to it: a BOOM.
|
||||
--
|
||||
-- What the boom owns:
|
||||
--
|
||||
-- the LENGTH how far behind the pivot the eye sits, eased in and out
|
||||
-- so stepping between 1ST and 3RD slides rather than cuts,
|
||||
-- and clamped every frame by what the world will allow.
|
||||
--
|
||||
-- the COLLISION a march back along the boom line through the terrain
|
||||
-- height field and the map's own walkability, so backing
|
||||
-- into a wall walks the camera in toward the player's
|
||||
-- shoulders instead of through the wall into the void.
|
||||
-- The recovery is deliberately slower than the intrusion:
|
||||
-- a camera must never be a frame late leaving geometry,
|
||||
-- and must never snap back out the instant a corner clears.
|
||||
--
|
||||
-- the SHOULDER the small lateral rail offset that keeps the character
|
||||
-- off dead centre, faded out with the boom so a camera
|
||||
-- jammed against a wall does not also slide sideways into
|
||||
-- it.
|
||||
--
|
||||
-- Deliberately NOT here: the attitude, the look inputs, the blend, the
|
||||
-- move intent (all lib/FirstPerson.lua, which drives this module and reads
|
||||
-- its answer while building the frame's rig), and movement itself
|
||||
-- (lib/FreeMove.lua, unchanged -- the walk is camera-relative either way,
|
||||
-- and the camera's yaw is the same number on both rungs).
|
||||
--
|
||||
-- Nothing here is required for the rung to draw: with no overworld to ask
|
||||
-- (a headless run, the test suite) every query answers "clear" and the boom
|
||||
-- extends to its full length over an empty world.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
local ThirdPerson = {}
|
||||
|
||||
-- ------- the boom's numbers
|
||||
--
|
||||
-- BOOM is world pixels behind the pivot at full extension. A cell is 16 and
|
||||
-- a character card is 16 tall, so 48 stands the camera three cells back:
|
||||
-- with the first-person lens (65 degrees vertical) that frames the player
|
||||
-- at roughly a quarter of the frame height -- the modern action-game
|
||||
-- middle ground, close enough to read the four-frame sprite and far enough
|
||||
-- to see the cell you are about to walk into.
|
||||
--
|
||||
-- PIVOT_LIFT raises the orbit point above the first-person eye, so the
|
||||
-- boom looks slightly DOWN across the player's shoulder rather than
|
||||
-- straight through the back of their head.
|
||||
--
|
||||
-- SHOULDER is the lateral rail offset, in world pixels, positive to the
|
||||
-- camera's right -- which puts the player left of centre, leaving the
|
||||
-- larger half of the frame in front of them.
|
||||
ThirdPerson.BOOM = 48
|
||||
ThirdPerson.PIVOT_LIFT = 4
|
||||
ThirdPerson.SHOULDER = 4
|
||||
|
||||
-- how long the eye takes to slide out to the boom (and back into the head
|
||||
-- when 1ST is picked), in seconds -- the same order as FirstPerson's own
|
||||
-- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera
|
||||
ThirdPerson.BOOM_TIME = 0.35
|
||||
|
||||
-- ------- the player's own zoom
|
||||
--
|
||||
-- A multiplier on BOOM, stepped by the wheel, Q/E or a pinch (see
|
||||
-- CamControl, which owns every one of those and decides which camera a
|
||||
-- given input is aimed at). The range is deliberately wider IN than OUT:
|
||||
-- close is the shot people reach for, and far enough out the character is
|
||||
-- a few pixels and the rung may as well be an orbit rung.
|
||||
--
|
||||
-- Stepped in fractions rather than world pixels so a notch feels the same
|
||||
-- at both ends -- the near end of a linear step would crawl and the far
|
||||
-- end would leap.
|
||||
ThirdPerson.ZOOM_MIN = 0.45 -- ~22px: over the shoulder, close
|
||||
ThirdPerson.ZOOM_MAX = 2.4 -- ~115px: the character in a landscape
|
||||
ThirdPerson.ZOOM_STEP = 1.18 -- one wheel notch / key press
|
||||
ThirdPerson.ZOOM_TIME = 0.18 -- how fast the eye eases to a new one
|
||||
|
||||
ThirdPerson.zoom = 1 -- eased, what place() actually uses
|
||||
ThirdPerson.zoomGoal = 1 -- what the input asked for
|
||||
|
||||
-- Step the zoom by `notches` (positive pulls the camera OUT). Returns true
|
||||
-- when the goal actually moved, so a caller can tell "zoomed" from "already
|
||||
-- at the stop" and let the input fall through.
|
||||
function ThirdPerson.stepZoom(notches)
|
||||
local was = ThirdPerson.zoomGoal
|
||||
local goal = was * (ThirdPerson.ZOOM_STEP ^ (notches or 0))
|
||||
ThirdPerson.zoomGoal = math.max(ThirdPerson.ZOOM_MIN,
|
||||
math.min(ThirdPerson.ZOOM_MAX, goal))
|
||||
return ThirdPerson.zoomGoal ~= was
|
||||
end
|
||||
|
||||
-- Scale the zoom by a continuous factor -- what a pinch hands over, where
|
||||
-- the gesture's own scale IS the answer and there are no notches.
|
||||
function ThirdPerson.scaleZoom(factor)
|
||||
if not (factor and factor > 0) then return false end
|
||||
return ThirdPerson.stepZoom(math.log(factor) / math.log(ThirdPerson.ZOOM_STEP))
|
||||
end
|
||||
|
||||
-- ------- the collision's numbers
|
||||
--
|
||||
-- STEP is how far apart the samples along the boom line are, in world
|
||||
-- pixels, and REFINE how many bisections narrow the first blocked one --
|
||||
-- four halvings of a 4px step lands the eye within a quarter pixel of the
|
||||
-- face, which is finer than the boom ever needs to be.
|
||||
--
|
||||
-- PAD is the clearance kept between the eye and whatever stopped it. It
|
||||
-- has to beat the placed camera's near plane (|eye - focus| * 0.05, which
|
||||
-- at full extension is about 3.6 world pixels -- see Voxel3D) or the near
|
||||
-- plane clips a hole in the very wall the boom stopped at.
|
||||
--
|
||||
-- CLEAR is how high above a cell's ground the eye must be to pass OVER
|
||||
-- something unwalkable rather than being stopped by it: a fence, a kerb or
|
||||
-- a plant pot should not shove the camera in, a building should. Roughly
|
||||
-- head height, so the eye clears the props and never the walls.
|
||||
ThirdPerson.STEP = 4
|
||||
ThirdPerson.REFINE = 4
|
||||
ThirdPerson.PAD = 5
|
||||
ThirdPerson.CLEAR = 20
|
||||
|
||||
-- How fast the boom is allowed to grow BACK once whatever shortened it is
|
||||
-- out of the way, in world pixels per second. Shortening is instant (a
|
||||
-- camera inside a wall is a hole in the frame); lengthening is rationed,
|
||||
-- so rounding a corner eases the eye back out instead of snapping it.
|
||||
ThirdPerson.RETURN = 150
|
||||
|
||||
-- ------- state
|
||||
--
|
||||
-- `out` is the eased extension, 0 in the head and 1 fully boomed -- the
|
||||
-- number that carries 1ST into 3RD. `len` is the boom's actual length in
|
||||
-- world pixels after the world has had its say, which is what place()
|
||||
-- stands the eye at and update() eases back toward `want`.
|
||||
ThirdPerson.out = 0
|
||||
ThirdPerson.len = 0
|
||||
ThirdPerson.want = 0
|
||||
|
||||
local function ease(t)
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- Whether the 3RD rung is the one selected. Not "is the boom out" -- that
|
||||
-- is extended() below, which stays true through the ease after the rung is
|
||||
-- left, the same way FirstPerson.blend outlives its own rung.
|
||||
--
|
||||
-- A live headset declines the boom outright: VR builds its own eye cameras
|
||||
-- from the tracked pose and never asks place() where to stand, and a
|
||||
-- headset that seats its wearer three cells behind their own body is a
|
||||
-- well-known way to make people ill. Answering false here is what keeps
|
||||
-- everything ELSE the extension decides -- the player's own card, the body
|
||||
-- that turns as it walks -- honest about the head VR actually puts you in.
|
||||
-- Required lazily and guarded: VR reaches this module through FirstPerson,
|
||||
-- and a headless run has no VR module worth loading at all.
|
||||
local function headset()
|
||||
local ok, on = pcall(function() return V.require("VR").active() end)
|
||||
return ok and on or false
|
||||
end
|
||||
|
||||
function ThirdPerson.selected()
|
||||
return Voxel.isThirdPerson(Voxel.level) and not headset()
|
||||
end
|
||||
|
||||
-- The eased extension, 0 at the head and 1 at the full boom.
|
||||
function ThirdPerson.extension()
|
||||
return ease(ThirdPerson.out)
|
||||
end
|
||||
|
||||
-- Whether the boom is out far enough to be a third-person camera at all --
|
||||
-- read off the TARGET extension rather than the live length, so it is
|
||||
-- steady while the world shoves the eye about. What the body reads to
|
||||
-- decide whether it turns along its own travel.
|
||||
function ThirdPerson.extended()
|
||||
return ThirdPerson.extension() > 0.5
|
||||
end
|
||||
|
||||
-- How far back the eye must ACTUALLY be, in world pixels, for the player's
|
||||
-- own card to be worth drawing: a shade under a cell, which is the point
|
||||
-- where a 16-pixel card stops being a character and starts being a wall of
|
||||
-- pixels across the lens.
|
||||
ThirdPerson.SHOW_AT = 14
|
||||
|
||||
-- Whether the player's own card belongs in the frame. Not the same
|
||||
-- question as extended(): back into a fence and the boom collapses into
|
||||
-- the head whatever the rung says, and a card drawn there fills the lens
|
||||
-- from inside exactly as it would in first person -- so it comes out, and
|
||||
-- the rung reads as first person for as long as the world insists on it.
|
||||
function ThirdPerson.showsPlayer()
|
||||
return ThirdPerson.extension() > 0 and ThirdPerson.len >= ThirdPerson.SHOW_AT
|
||||
end
|
||||
|
||||
-- ------- the world the boom has to fit through
|
||||
--
|
||||
-- Everything below asks the live overworld and pcall-guards the asking:
|
||||
-- with no map (headless, the suite, a frame mid-warp) the boom simply
|
||||
-- extends to its full length, which is the right answer for a world with
|
||||
-- nothing in it.
|
||||
|
||||
local function overworld()
|
||||
local ok, ow = pcall(function()
|
||||
return require("src.core.Game").overworld
|
||||
end)
|
||||
if not ok or not ow or not ow.map then return nil end
|
||||
return ow
|
||||
end
|
||||
|
||||
-- Which map, and which of its cells, covers a world point. The player's own
|
||||
-- map first, then the neighbours the scene streams in around it (same ox/oy
|
||||
-- offsets VoxelScene draws them at) -- without that pass the boom would
|
||||
-- shorten against "off the map" every time the player walked within three
|
||||
-- cells of a route connection, which is most of the time.
|
||||
--
|
||||
-- nil means no map covers it: genuinely off the world, where the border
|
||||
-- ring is drawn and the camera has no business going.
|
||||
local function cellAt(ow, wx, wz)
|
||||
local map = ow.map
|
||||
local cx, cy = math.floor(wx / 16), math.floor(wz / 16)
|
||||
if map:inBounds(cx, cy) then return map, cx, cy end
|
||||
for _, nb in ipairs(ow.neighbors or {}) do
|
||||
if nb.map then
|
||||
local nx = math.floor((wx - (nb.ox or 0)) / 16)
|
||||
local ny = math.floor((wz - (nb.oy or 0)) / 16)
|
||||
if nb.map:inBounds(nx, ny) then return nb.map, nx, ny end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether the eye may not stand at this world point. Two refusals, and
|
||||
-- they are different questions:
|
||||
--
|
||||
-- the GROUND is the terrain height field the mesh is actually built from
|
||||
-- (VoxelScene.groundAt -- the same answer a character stands on), so a
|
||||
-- ledge, a raised bank or a cliff stops the boom exactly where it stops
|
||||
-- the geometry, at any pitch.
|
||||
--
|
||||
-- the WALKABILITY is the map's own, and stands in for everything built
|
||||
-- ON the ground that the height field does not describe: house walls,
|
||||
-- trees, signs, counters. Held to CLEAR above that cell's ground so the
|
||||
-- short furniture of the world is passed over rather than bumped into.
|
||||
local function occupied(ow, wx, y, wz)
|
||||
local map, cx, cy = cellAt(ow, wx, wz)
|
||||
if not map then return true end
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local okG, gh = pcall(VoxelScene.groundAt, map, cx, cy)
|
||||
gh = (okG and gh) or 0
|
||||
if y < gh + ThirdPerson.PAD then return true end
|
||||
local okW, walkable = pcall(function() return map:isWalkableCell(cx, cy) end)
|
||||
if okW and not walkable and y < gh + ThirdPerson.CLEAR then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
ThirdPerson._occupied = occupied -- named for the suite
|
||||
|
||||
-- How far back along (bx, by, bz) from `pivot` the eye can stand, up to
|
||||
-- `want`. March at STEP, and when a sample refuses, bisect back into the
|
||||
-- gap between it and the last clear one -- so the answer is the face's own
|
||||
-- position rather than the sampling grid's, and walking toward a wall
|
||||
-- draws the camera in smoothly instead of in four-pixel jerks. PAD comes
|
||||
-- off whatever survives.
|
||||
function ThirdPerson.reach(ow, pivot, bx, by, bz, want)
|
||||
if not ow or want <= 0 then return math.max(0, want) end
|
||||
local function clear(t)
|
||||
return not occupied(ow, pivot[1] + bx * t, pivot[2] + by * t,
|
||||
pivot[3] + bz * t)
|
||||
end
|
||||
local lo = 0
|
||||
local steps = math.ceil(want / ThirdPerson.STEP)
|
||||
local hi = nil
|
||||
for i = 1, steps do
|
||||
local t = math.min(want, i * ThirdPerson.STEP)
|
||||
if clear(t) then
|
||||
lo = t
|
||||
else
|
||||
hi = t
|
||||
break
|
||||
end
|
||||
end
|
||||
if not hi then return want end
|
||||
for _ = 1, ThirdPerson.REFINE do
|
||||
local mid = (lo + hi) / 2
|
||||
if clear(mid) then lo = mid else hi = mid end
|
||||
end
|
||||
return math.max(0, lo - ThirdPerson.PAD)
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
--
|
||||
-- Rides FirstPerson.update, which is itself on the pipeline's own update
|
||||
-- hook, so this runs every frame whatever the rung -- the extension has to
|
||||
-- keep easing back in after 3RD is left. `blend` is FirstPerson's dive into
|
||||
-- the head: while it is fully out (the diorama), the extension SNAPS to its
|
||||
-- target rather than easing, so picking 3RD from an orbit rung is one
|
||||
-- motion (the dive) rather than two (a dive, then a slide backwards).
|
||||
function ThirdPerson.update(dt, blend)
|
||||
-- the player's own zoom FIRST, so everything below measures itself
|
||||
-- against the boom length this frame actually wants. A step is a request
|
||||
-- rather than a jump: three notches of wheel should read as one glide.
|
||||
local zg = ThirdPerson.zoomGoal
|
||||
if ThirdPerson.zoom ~= zg then
|
||||
local k = math.min(1, dt / ThirdPerson.ZOOM_TIME)
|
||||
local z = ThirdPerson.zoom + (zg - ThirdPerson.zoom) * k
|
||||
ThirdPerson.zoom = (math.abs(zg - z) < 1e-4) and zg or z
|
||||
end
|
||||
|
||||
local target = ThirdPerson.selected() and 1 or 0
|
||||
if (blend or 0) <= 0 then
|
||||
ThirdPerson.out = target
|
||||
ThirdPerson.len = ThirdPerson.reachFor() * target
|
||||
-- and the wanted length with it: place() is what normally maintains it
|
||||
-- and it does not run at all while the rig is out of the frame, so a
|
||||
-- stale want left here would have the recovery below creeping the boom
|
||||
-- back out over a camera that is not on screen
|
||||
ThirdPerson.want = ThirdPerson.len
|
||||
else
|
||||
local step = dt / ThirdPerson.BOOM_TIME
|
||||
if ThirdPerson.out < target then
|
||||
ThirdPerson.out = math.min(target, ThirdPerson.out + step)
|
||||
elseif ThirdPerson.out > target then
|
||||
ThirdPerson.out = math.max(target, ThirdPerson.out - step)
|
||||
end
|
||||
end
|
||||
|
||||
-- the rationed recovery: place() already pulled `len` in to whatever the
|
||||
-- world allowed this frame, and this is the only thing that lets it back
|
||||
-- out again
|
||||
if ThirdPerson.len < ThirdPerson.want then
|
||||
ThirdPerson.len = math.min(ThirdPerson.want,
|
||||
ThirdPerson.len + ThirdPerson.RETURN * dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- The boom's full length right now, before the world has its say: BOOM at
|
||||
-- the player's own zoom. Named so the collision march and the shoulder
|
||||
-- fade measure themselves against the same number.
|
||||
function ThirdPerson.reachFor()
|
||||
return ThirdPerson.BOOM * ThirdPerson.zoom
|
||||
end
|
||||
|
||||
-- ------- the eye
|
||||
--
|
||||
-- Where the camera stands, given the pivot the first-person rig would have
|
||||
-- put the eye at and the unit look direction it would have looked along.
|
||||
-- Returns the eye and the focus: both slide by the shoulder offset, so the
|
||||
-- view direction is untouched and only the frame's contents shift.
|
||||
--
|
||||
-- With the boom fully in this is exactly the first-person answer, to the
|
||||
-- pixel -- which is what makes 1ST and 3RD one rig with a number between
|
||||
-- them rather than two cameras to keep in sync.
|
||||
function ThirdPerson.place(pivot, lx, ly, lz, focus)
|
||||
local e = ThirdPerson.extension()
|
||||
if e <= 0 then
|
||||
ThirdPerson.want, ThirdPerson.len = 0, 0
|
||||
return pivot, focus
|
||||
end
|
||||
|
||||
local up = ThirdPerson.PIVOT_LIFT * e
|
||||
local orbit = { pivot[1], pivot[2] + up, pivot[3] }
|
||||
|
||||
local want = ThirdPerson.reachFor() * e
|
||||
ThirdPerson.want = want
|
||||
local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want)
|
||||
-- in instantly, out only as fast as update() allows
|
||||
ThirdPerson.len = math.min(ThirdPerson.len, room)
|
||||
local len = ThirdPerson.len
|
||||
|
||||
-- the rail offset, faded with how much boom actually survived: a camera
|
||||
-- squeezed against a wall gives up its shoulder before it gives up its
|
||||
-- distance. Right of the look, flat: cross(look, worldUp) normalized,
|
||||
-- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same
|
||||
-- right hand FirstPerson.moveWorld strafes along.
|
||||
-- The rail rides the ZOOM as well, so it stays the same fraction of the
|
||||
-- frame at every distance: a fixed four pixels would swamp the close shot
|
||||
-- and vanish from the wide one.
|
||||
local flat = math.sqrt(lx * lx + lz * lz)
|
||||
local sx, sz = 0, 0
|
||||
if flat > 1e-6 then
|
||||
local s = ThirdPerson.SHOULDER * ThirdPerson.zoom * e
|
||||
* (len / math.max(want, 1e-6))
|
||||
sx, sz = -lz / flat * s, lx / flat * s
|
||||
end
|
||||
|
||||
local eye = { orbit[1] - lx * len + sx,
|
||||
orbit[2] - ly * len,
|
||||
orbit[3] - lz * len + sz }
|
||||
local aim = focus and { focus[1] + sx, focus[2] + up, focus[3] + sz }
|
||||
or nil
|
||||
return eye, aim
|
||||
end
|
||||
|
||||
-- What a shadow signature has to include about the boom: the sun's box is
|
||||
-- fitted around this camera, so sliding the eye back (or having a wall
|
||||
-- shove it in) re-fits it even standing still.
|
||||
function ThirdPerson.signature()
|
||||
if ThirdPerson.extension() <= 0 then return "" end
|
||||
return math.floor(ThirdPerson.len) .. "/" ..
|
||||
math.floor(ThirdPerson.extension() * 64)
|
||||
end
|
||||
|
||||
return ThirdPerson
|
||||
+211
-40
@@ -71,6 +71,29 @@ local FALLBACK_HEIGHTS = {
|
||||
-- body builds from the bark rows and the drawn ellipse projects onto
|
||||
-- the hull's round top
|
||||
stump = 16,
|
||||
-- the same hull cut at both ends, hollowed and tapered: an OPEN bin
|
||||
-- standing on a floor (the Vermilion Gym trash cans). The drawn mouth
|
||||
-- ellipse projects onto the round top and down the well, the drawn base
|
||||
-- ellipse is ground contact rather than body, and the plan narrows toward
|
||||
-- the floor. Height is AUTHORED (the profile's can_height, which this
|
||||
-- pin must be kept equal to so anything riding a can lands on its rim) --
|
||||
-- the drawing's own straight run is only a couple of rows, because a GB
|
||||
-- cell spends most of itself on the opening
|
||||
can = 9,
|
||||
-- the same hull SQUASHED front to back (the profile's sapling_squash,
|
||||
-- a percent of the revolved depth): the little trees drawn one cell
|
||||
-- wide -- Celadon Gym's garden trees and the overworld's cuttable
|
||||
-- tree, which are the same drawing on two atlases. A tree is round in
|
||||
-- its canopy but is not a boulder: revolved at full width it fills a
|
||||
-- whole cell of depth, so the plan keeps its circle and shrinks toward
|
||||
-- an ellipse
|
||||
sapling = 16,
|
||||
-- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
|
||||
-- cell of plot: the Pokemon Centers' potted plants. Carved as one
|
||||
-- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
|
||||
-- the object's height, not its depth. BOTH cells take the class; the
|
||||
-- group build anchors on the north one (Structures.buildCylinders)
|
||||
planter = 32,
|
||||
billboard = 16,
|
||||
signpost = 16,
|
||||
post = 16,
|
||||
@@ -83,10 +106,18 @@ local FALLBACK_HEIGHTS = {
|
||||
bed = 7,
|
||||
stool = 8,
|
||||
counter = 8,
|
||||
-- the raised back band of low seating: the Center couch's west strip
|
||||
-- is drawn from above like the rest of the couch, but depicts the
|
||||
-- back and arm rising over the 8px seat
|
||||
backrest = 12,
|
||||
table = 12,
|
||||
desk = 24,
|
||||
prop = 16,
|
||||
cutout = 16,
|
||||
-- a vehicle drawn SIDE-ON: the showroom bicycles. Standee height like
|
||||
-- every other cutout pool -- what differs is the thickness (see
|
||||
-- Structures' PINNED_DEPTH)
|
||||
bike = 16,
|
||||
console = 16,
|
||||
relief = 3,
|
||||
bookcase = 32,
|
||||
@@ -123,6 +154,9 @@ local ART = {
|
||||
cylinder = "cylinder",
|
||||
canopy = "canopy",
|
||||
stump = "cylinder",
|
||||
can = "cylinder",
|
||||
sapling = "cylinder",
|
||||
planter = "planter",
|
||||
billboard = "billboard",
|
||||
-- signposts share the billboard treatment but as their own pool at a
|
||||
-- 2-voxel depth: a sign is a thin plate on a stick, and the standard
|
||||
@@ -145,6 +179,9 @@ local ART = {
|
||||
-- profile archetype Structures builds real steps for -- rising flights
|
||||
-- for stairs leading up, sunken stairwells for stairs leading down
|
||||
bed = "top",
|
||||
-- a backrest's art is the couch seen from above, so like the bed it
|
||||
-- rides the top face of its taller box
|
||||
backrest = "top",
|
||||
stool = "billboard",
|
||||
-- half-cell furniture: a service counter, a low couch. One 8px band,
|
||||
-- so exactly the drawing's bottom row stands up as the front and
|
||||
@@ -158,6 +195,13 @@ local ART = {
|
||||
desk = "upright",
|
||||
prop = "billboard",
|
||||
cutout = "billboard",
|
||||
-- a bicycle is a LINE drawing seen side-on, and its negative space --
|
||||
-- the air inside the frame, between the wheel and the fork -- is what
|
||||
-- makes it read as a bicycle at all. Its own pool at two voxels: any
|
||||
-- thicker and the side faces of neighbouring strokes close those gaps
|
||||
-- from every angle but dead-on, and six of them in a showroom come out
|
||||
-- as one dark lump (which is what the 5px `prop` pool gave)
|
||||
bike = "billboard",
|
||||
-- a machine standing on furniture: the billboard treatment with
|
||||
-- body, plus the one-object contract `cutout` has -- the drawing is
|
||||
-- ringed by the furniture it sits on, and those edges must not be
|
||||
@@ -176,6 +220,7 @@ local ART = {
|
||||
local spec = nil -- the loaded data file, or false when absent
|
||||
local cache = {} -- tileset id -> resolved shape list
|
||||
local figCache = {} -- tileset id -> parsed figure masks, or false
|
||||
local mntCache = {} -- tileset id -> parsed mounted masks, or false
|
||||
local bgCache = {} -- tileset id -> prop background shades, or false
|
||||
|
||||
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
|
||||
@@ -295,6 +340,24 @@ function TileShape.forMap(map)
|
||||
if cache[id] then return cache[id] end
|
||||
|
||||
local heights = TileShape.heights()
|
||||
-- Per-tileset height overrides (a tileset entry's `heights`): the class
|
||||
-- vocabulary is global but the drawings are not -- the DOJO lab tables
|
||||
-- are drawn 6px tall where the default `table` is 12 -- and the height
|
||||
-- a sprite RIDES at (VoxelScene.groundAt) must be the height the art
|
||||
-- actually stands, or the starter balls float over their own table.
|
||||
-- Same gate as the global list: known classes, numbers only.
|
||||
do
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[id]
|
||||
local over = entry and entry.heights
|
||||
if type(over) == "table" then
|
||||
for class, h in pairs(over) do
|
||||
if type(h) == "number" and FALLBACK_HEIGHTS[class] then
|
||||
heights[class] = h
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local authored = authoredGroups(id, heights)
|
||||
local count = math.floor((tileset.imageWidth or 128) / 8)
|
||||
* math.floor((tileset.imageHeight or 48) / 8)
|
||||
@@ -405,68 +468,158 @@ end
|
||||
-- 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: a figure is always a flat sprite card, drawn the way
|
||||
-- SpriteBillboards draws a character (see Structures.buildFigures).
|
||||
-- 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 list = entry and entry.figures
|
||||
local out = {}
|
||||
if type(list) == "table" then
|
||||
for _, f in ipairs(list) do
|
||||
local ok = type(f) == "table" and type(f.w) == "number"
|
||||
and type(f.tiles) == "table" and type(f.under) == "table"
|
||||
and type(f.pixels) == "table"
|
||||
local w = ok and math.floor(f.w) or 0
|
||||
local h = (w >= 1) and (#f.tiles / w) or 0
|
||||
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
|
||||
and #f.under == #f.tiles and #f.pixels == h * 8
|
||||
if ok then
|
||||
for i = 1, h * 8 do
|
||||
local row = f.pixels[i]
|
||||
if type(row) ~= "string" or #row ~= w * 8 then
|
||||
ok = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if ok then
|
||||
local mask, n = {}, 0
|
||||
for ly = 0, h * 8 - 1 do
|
||||
local row = f.pixels[ly + 1]
|
||||
for lx = 0, w * 8 - 1 do
|
||||
if row:sub(lx + 1, lx + 1) ~= "." then
|
||||
mask[ly * (w * 8) + lx] = true
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
if n > 0 then
|
||||
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
|
||||
tiles = f.tiles, under = f.under }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
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.
|
||||
--
|
||||
@@ -539,12 +692,30 @@ function TileShape.bookcaseBackfill(tilesetId)
|
||||
return mode == "above" and mode or nil
|
||||
end
|
||||
|
||||
--- Does this tileset's `bookcase` run carry the measured pane RELIEF on
|
||||
--- its front (a tileset entry's bookcase_relief)? Default yes: the class
|
||||
--- almost always collapses a shelf, a rack or a display case, and every
|
||||
--- one of those seals its contents behind a frame that should stand proud
|
||||
--- of them.
|
||||
---
|
||||
--- A tileset says `bookcase_relief = false` when it borrows the collapse
|
||||
--- for something that is NOT a shelf -- the League's gate walls and
|
||||
--- pilasters, Bill's transporter drums -- where the drawing's light
|
||||
--- regions are the masonry and the barrel, not panes, and sinking them
|
||||
--- carves the surface instead of describing it.
|
||||
function TileShape.bookcaseRelief(tilesetId)
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
return not (entry and entry.bookcase_relief == false)
|
||||
end
|
||||
|
||||
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
|
||||
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
|
||||
function TileShape.invalidate()
|
||||
spec = nil
|
||||
cache = {}
|
||||
figCache = {}
|
||||
mntCache = {}
|
||||
bgCache = {}
|
||||
end
|
||||
|
||||
|
||||
+4
-2
@@ -25,6 +25,7 @@
|
||||
-- failure -- headless, no shader support) apply() hands the canvas back
|
||||
-- untouched, so every other path is byte-for-byte what it always was.
|
||||
|
||||
local V = ...
|
||||
local TiltShift = {}
|
||||
|
||||
TiltShift.level = 0
|
||||
@@ -85,9 +86,10 @@ end
|
||||
|
||||
local function getCanvases(w, h)
|
||||
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
|
||||
local okB, b = pcall(love.graphics.newCanvas, w, h)
|
||||
local okB, b = PixelCanvas.new(w, h)
|
||||
if not okB then return nil end
|
||||
-- the gaussian's fractional tap offsets need linear filtering
|
||||
a:setFilter("linear", "linear")
|
||||
|
||||
+987
@@ -0,0 +1,987 @@
|
||||
-- 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 is the row's own rung first (see VR.setting), and only
|
||||
-- then the VOXEL ladder. STANDARD is the mode described below. The two
|
||||
-- DIORAMA rungs are one presentation instead of a ladder -- the world is
|
||||
-- always a model on the table, cut to a square viewport (a ball with a
|
||||
-- dissolved rim while V-CURVE is on) that the grips pick up, turn and
|
||||
-- open out, with a staged fight arriving as a floating disc of map.
|
||||
-- lib/Diorama owns all of that; what this file owns is pointing the
|
||||
-- mapping at it. DIORAMA-MR is the same with the background keyed green
|
||||
-- for a mixed-reality capture.
|
||||
--
|
||||
-- Within STANDARD, which VR you get mirrors the VOXEL ladder: 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 Diorama = V.require("Diorama")
|
||||
|
||||
local VR = {}
|
||||
|
||||
-- The row: OFF, and then WHICH VR. No hotkey -- the engine's display keys
|
||||
-- are spoken for, and a headset is not something to toggle by accident.
|
||||
--
|
||||
-- STANDARD what this mod shipped: the headset follows the VOXEL
|
||||
-- ladder, orbit rungs becoming a tabletop and 1ST standing
|
||||
-- you inside the world at life size.
|
||||
-- DIORAMA one presentation instead of a ladder -- the world is
|
||||
-- always a model on the table, cut to a viewport you can
|
||||
-- pick up, turn and open out (see lib/Diorama). There is no
|
||||
-- 2D and no first person in it: both are a different promise
|
||||
-- about where the player is standing.
|
||||
-- DIORAMA-MR the same, with the background keyed pure green for a
|
||||
-- mixed-reality capture.
|
||||
--
|
||||
-- `true` is still STANDARD's stored value, deliberately: the row used to be
|
||||
-- a toggle, and a save that stored it as one must come back on the rung it
|
||||
-- was left on rather than falling to OFF.
|
||||
VR.setting = ModSetting.new("vr", "VR",
|
||||
{ false, true, "diorama", "diorama-mr" },
|
||||
{ "OFF", "STANDARD", "DIORAMA", "DIORAMA-MR" })
|
||||
|
||||
-- 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
|
||||
|
||||
-- Which VR the row is asking for: "off", "standard", "diorama" or
|
||||
-- "diorama-mr". The one place the stored value is interpreted -- everything
|
||||
-- else asks this, so a rung added to the ladder is a change here and
|
||||
-- nowhere else.
|
||||
function VR.mode()
|
||||
if not VR.supported() then return "off" end
|
||||
local v = VR.setting:get()
|
||||
if v == true then return "standard" end
|
||||
if v == "diorama" or v == "diorama-mr" then return v end
|
||||
return "off"
|
||||
end
|
||||
|
||||
function VR.enabled()
|
||||
return VR.mode() ~= "off"
|
||||
end
|
||||
|
||||
-- Whether the row is on one of the DIORAMA rungs -- the modes where the
|
||||
-- world is a model with an edge to it rather than a place to stand in.
|
||||
function VR.dioramaMode()
|
||||
local m = VR.mode()
|
||||
return m == "diorama" or m == "diorama-mr"
|
||||
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
|
||||
-- the model goes back on the table where it started: the grab, the turn,
|
||||
-- the viewport's size and the meshes cut for it
|
||||
Diorama.reset()
|
||||
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
|
||||
-- Either free-roam rung puts the headset in the player's head: 3RD's boom
|
||||
-- is a FLAT-SCREEN framing device, and a headset that stands its wearer
|
||||
-- three cells behind their own body is a well-known way to make people
|
||||
-- ill. The rung still changes the walk and the cards the same way; only
|
||||
-- the eye stays where a head belongs.
|
||||
--
|
||||
-- A DIORAMA mode never does either: the world is a model on the table
|
||||
-- whatever the rung says, so first person is refused here rather than
|
||||
-- being made to work at a scale it does not mean.
|
||||
local dio = VR.dioramaMode()
|
||||
local fp = (not dio) and FirstPerson.engaged()
|
||||
local battle, battleFloor
|
||||
if camMode == "battle" then battle, battleFloor = battleStage() end
|
||||
if dio then
|
||||
-- ------- the diorama modes
|
||||
--
|
||||
-- The model presents exactly as the standard view frames it -- the
|
||||
-- pivot VIEW_DIST away along the rung's angle, at the scale that
|
||||
-- reproduces that framing -- and then everything the player has done
|
||||
-- to it goes on top: the carry, the turn, the stick's zoom.
|
||||
--
|
||||
-- A STAGED FIGHT does not move the head here (that is the standard
|
||||
-- mode's over-the-shoulder seat, and it is a first-person answer):
|
||||
-- the MODEL re-centres on the arena and the viewport becomes a
|
||||
-- vertical pillar about it, so the fight arrives as a disc of map
|
||||
-- lifted out of the world and left floating on the table.
|
||||
-- what the model is FRAMED to fill: the view the flat screen would
|
||||
-- have shown ordinarily, and the DISC itself while a fight is staged
|
||||
-- -- a disc left at map scale is a coin on a table across the room.
|
||||
local frame = vh
|
||||
if battle then
|
||||
pivot = VRRig.dioramaPivot(battle.mid[1], battle.mid[2])
|
||||
local cut = Diorama.pillar(battle)
|
||||
if cut then frame = cut.r * 2.6 end
|
||||
else
|
||||
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
|
||||
Diorama.viewport(pivot[1], pivot[3], vh)
|
||||
end
|
||||
anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
|
||||
scale = VRRig.dioramaScale(frame, Voxel.FOCAL) / zoom
|
||||
-- the hand-turn, and -- while a fight is staged -- the arena's own
|
||||
-- quarter turn taken back out, so a turned arena arrives on the table
|
||||
-- facing the head rather than lying across it (Diorama.battleYaw)
|
||||
local dioYaw = battle and Diorama.battleYaw(battle) or Diorama.yaw
|
||||
if dioYaw ~= 0 then mountYaw = dioYaw end
|
||||
elseif 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.
|
||||
-- (`not dio` for the same reason the diorama never had one: a hand-sized
|
||||
-- device hovering over a tabletop town is clutter, and that is as true
|
||||
-- of a tabletop FIGHT -- the panel serves both.)
|
||||
local hand = ctl and ctl.handl or nil
|
||||
if hand and not dio 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
|
||||
|
||||
-- THE WORLD CURVE, for the diorama modes alone. Standing inside a bent
|
||||
-- world is what first person declines on the flat screen too, and the
|
||||
-- battle mount is a placed shot -- but a diorama is a model being looked
|
||||
-- AT, so the bend turns it into a little globe curling over its own
|
||||
-- horizon, which is the whole point of the throw the left stick's click
|
||||
-- makes. Measured against the FLAT view height, so a rung's bend is the
|
||||
-- same bend the flat screen would have drawn.
|
||||
--
|
||||
-- It bends about the scene centre, which for these eyes is the pivot --
|
||||
-- the model's own middle -- so the globe is centred on the model rather
|
||||
-- than on wherever a head happens to be standing.
|
||||
local curveK = dio and V.require("WorldCurve").k(vh) or 0
|
||||
|
||||
local eyes = {}
|
||||
for i = 1, 2 do
|
||||
local v = views[i]
|
||||
eyes[i] = {
|
||||
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw,
|
||||
curveK),
|
||||
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.
|
||||
--
|
||||
-- The DIORAMA modes rebind two of those, because in them there is no
|
||||
-- ladder to step and no table-height to be the only thing worth dragging:
|
||||
--
|
||||
-- left stick click throws V-CURVE to its top rung and back.
|
||||
-- grips one carries the model anywhere in the room; both
|
||||
-- turn it and open the viewport out (Diorama.gesture).
|
||||
--
|
||||
-- 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
|
||||
|
||||
-- Put the VOXEL ladder on a given rung, for the one caller that needs to
|
||||
-- rather than to step: a DIORAMA mode holding the ladder off 2D and off
|
||||
-- both free-roam rungs (see dioramaRung). Handed over by main.lua next to
|
||||
-- cycleVoxel and for the same reason.
|
||||
VR.setVoxelLevel = nil -- setVoxelLevel(game, level), set by main.lua
|
||||
|
||||
-- The rung a diorama mode holds the ladder on when it finds it somewhere
|
||||
-- the mode cannot present: 35 degrees, the standard view's own angle.
|
||||
VR.DIORAMA_RUNG = 3
|
||||
|
||||
-- 2D is not a diorama and neither is standing inside the world, so while a
|
||||
-- diorama mode is live the ladder is held on an orbit rung. Cheap enough to
|
||||
-- ask every frame: it is a table read and, almost always, no write.
|
||||
local function dioramaRung()
|
||||
pcall(function()
|
||||
if not VR.setVoxelLevel then return end
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local level = Pipelines.level("voxel") or 0
|
||||
if level == 0 or Voxel.isFreeCam(level) then
|
||||
VR.setVoxelLevel(require("src.core.Game"), VR.DIORAMA_RUNG)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- The V-CURVE row, thrown to its top rung and back -- what the left stick's
|
||||
-- click does in a diorama, where there is no ladder for it to step.
|
||||
--
|
||||
-- A toggle rather than a cycle, because in a headset the curve is not a
|
||||
-- taste setting with four values: it is the one control that decides
|
||||
-- whether the model is a flat slab of map or a little world curling away
|
||||
-- over its own horizon, and the player wants to see both, now, without
|
||||
-- counting clicks. The rung it was on is remembered so the click gives it
|
||||
-- back rather than dropping the row to OFF.
|
||||
--
|
||||
-- It changes the CUT with it (see lib/Diorama): flat world, square box,
|
||||
-- hard edge; curved world, ball, dissolve. One click swaps the whole
|
||||
-- reading of the model, which is why it is the click worth having here.
|
||||
local curveWas = nil
|
||||
|
||||
function VR.toggleCurve()
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local top = WorldCurve.setting.values[#WorldCurve.setting.values]
|
||||
if WorldCurve.setting:get() == top then
|
||||
WorldCurve.setting:setValue(curveWas or WorldCurve.setting.values[1],
|
||||
Game)
|
||||
curveWas = nil
|
||||
else
|
||||
curveWas = WorldCurve.setting:get()
|
||||
WorldCurve.setting:setValue(top, Game)
|
||||
end
|
||||
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")
|
||||
-- OFF by VALUE, not by stepping the row: the row is a ladder now, and
|
||||
-- one step off STANDARD is DIORAMA rather than the way out
|
||||
VR.setting:setValue(false, 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, dio)
|
||||
if not ctl then
|
||||
releaseInputs()
|
||||
Diorama.releaseGrab()
|
||||
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, the V-CURVE throw in
|
||||
-- a diorama (where the ladder is held on one rung and the click would
|
||||
-- otherwise do nothing), and the way out of horde mode while it runs (the
|
||||
-- rung is locked there too, and a headset has no ESCAPE key)
|
||||
if ctl.toggleChanged and ctl.toggle then
|
||||
if Horde.active then
|
||||
Horde.askExit()
|
||||
elseif dio then
|
||||
VR.toggleCurve()
|
||||
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
|
||||
|
||||
-- THE DIORAMA'S GRIPS take the model itself: one hand carries it through
|
||||
-- the room, both turn it and open the viewport out (see Diorama.gesture).
|
||||
-- The stick's zoom still sizes the model under all of that -- the two
|
||||
-- are different questions, "how big is it" and "how much of it is there".
|
||||
if dio then
|
||||
lastHandY = nil
|
||||
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
|
||||
Diorama.gesture(ctl)
|
||||
return
|
||||
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 mode = VR.mode()
|
||||
local on = mode ~= "off"
|
||||
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
|
||||
|
||||
-- Which VR this frame is, before anything reads it: the diorama's own
|
||||
-- fields (the viewport, the chroma key) are open for the length of the
|
||||
-- frame and shut with the session. The rung guard rides it -- there is
|
||||
-- no 2D diorama and no first-person one.
|
||||
if Diorama.begin(mode) then dioramaRung() 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 dio = VR.dioramaMode()
|
||||
local ctl = VRXR.input(time)
|
||||
driveControls(ctl, dt, (not dio) and FirstPerson.engaged(), dio)
|
||||
|
||||
local worldUp = false
|
||||
if should then
|
||||
local views = VRXR.locateViews(time)
|
||||
if views then
|
||||
worldUp = renderWorld(views, ctl)
|
||||
end
|
||||
end
|
||||
-- the diorama's panel is the tabletop one whatever the rung says: there
|
||||
-- is no first person in the mode to float it closer for
|
||||
local quadPose = updateQuad(worldUp, (not dio) and 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()
|
||||
Diorama.invalidate() -- the base's mesh and its cave-floor texture
|
||||
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
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
-- 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.
|
||||
--
|
||||
-- `off` is the grab-drag adjustment, in metres of LOCAL travel -- where
|
||||
-- the player has carried the model to. A bare number is the height alone,
|
||||
-- which is what the standard mode's one-axis drag has always sent; the
|
||||
-- DIORAMA modes hand over all three (see lib/Diorama). Positive Y drags
|
||||
-- the world up: the anchor is the LOCAL point pinned to the pivot, so
|
||||
-- moving it moves the model with the hand rather than against it.
|
||||
function VRRig.dioramaAnchor(angleRad, off)
|
||||
local d = VRRig.VIEW_DIST
|
||||
local ox, oy, oz = 0, 0, 0
|
||||
if type(off) == "table" then
|
||||
ox, oy, oz = off[1] or 0, off[2] or 0, off[3] or 0
|
||||
elseif type(off) == "number" then
|
||||
oy = off
|
||||
end
|
||||
return { ox,
|
||||
-d * math.cos(angleRad or 0) + oy,
|
||||
-d * math.sin(angleRad or 0) + oz }
|
||||
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).
|
||||
-- curveK the world curve this eye is to be drawn with (see WorldCurve);
|
||||
-- omitted is 0, the curve DECLINED.
|
||||
--
|
||||
-- Off by default because standing inside a bent world is what first person
|
||||
-- already declines on the flat screen, and the battle mount is a placed
|
||||
-- shot. The DIORAMA modes are the case that wants it and asks for it: the
|
||||
-- model is a thing being looked AT, so bending it into a little globe is
|
||||
-- the whole point rather than a broken tabletop -- and it is what the left
|
||||
-- stick's click throws (see lib/VR). Passed in rather than read here
|
||||
-- because a rig has no business deciding what a row means.
|
||||
--
|
||||
-- Beware the shape of the answer: Voxel3D reads `camera.curve` with `or`,
|
||||
-- and 0 is TRUE in Lua, so a 0 here really does pin the bend off -- which
|
||||
-- is exactly why the diorama's curve did nothing until this became a
|
||||
-- parameter.
|
||||
--
|
||||
-- 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 that curve.
|
||||
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw, curveK)
|
||||
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 = curveK or 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
+393
@@ -0,0 +1,393 @@
|
||||
-- RENDER DIST: how much of the map the orbit rungs bother to draw.
|
||||
--
|
||||
-- lib/Diorama cuts a headset's model out of the world with a box the
|
||||
-- player opens and closes with their hands. This is the same cut on the
|
||||
-- flat screen, asked the other way round: not "how much world do I want to
|
||||
-- be holding" but "how much world can this camera actually SEE" -- so that
|
||||
-- nothing off screen is drawn, and nothing on screen is missing.
|
||||
--
|
||||
-- THE FOOTPRINT IS NOT THE WINDOW. That is the whole difficulty, and the
|
||||
-- first cut of this file got it wrong: it took the flat game's own vw-by-vh
|
||||
-- rectangle about the view centre, which is exactly right at 0 degrees and
|
||||
-- wrong at every rung the mode actually has. Tilt the camera and the ground
|
||||
-- it frames stops being that rectangle and becomes a TRAPEZOID -- reaching
|
||||
-- much further north (the far edge of the frame is further away, so it
|
||||
-- covers more ground per pixel), flaring much wider there for the same
|
||||
-- reason, and pulling IN at the south edge, which is nearer the eye than
|
||||
-- the focus is. A window-sized box cuts the north field and both far
|
||||
-- corners off a world that is plainly on screen: gaps at the top and down
|
||||
-- the sides, with sky showing through them.
|
||||
--
|
||||
-- So the footprint is derived from the camera rather than guessed. The
|
||||
-- orbit is one number (see Voxel3D.viewProjection): eye at distance
|
||||
-- FOCAL*vh, pitched `a` off straight down, looking at the view centre,
|
||||
-- with a fov chosen so a straight-down camera frames exactly vh. Cast the
|
||||
-- frame's own corner rays at the ground plane and the trapezoid falls out
|
||||
-- in closed form -- see footprint() for the derivation, which is three
|
||||
-- lines of algebra and no tuning at all.
|
||||
--
|
||||
-- AND THE GROUND IS NOT THE PICTURE. The trapezoid is where the frame's
|
||||
-- rays LAND; what is drawn is what stands on it, and a tree at the bottom
|
||||
-- of the screen has its feet south of the row its top is seen on. Cut to
|
||||
-- the trapezoid alone, the box takes that tree away whole -- the cut is by
|
||||
-- column, so a base one pixel outside loses the whole height -- and the
|
||||
-- bottom of the frame reads as a bite taken out of the scenery. So the
|
||||
-- south edge is walked back down the bottom ray by the tallest thing that
|
||||
-- can stand there; see lift().
|
||||
--
|
||||
-- The CUT is still a rectangle (the shader's box kind), so what is stored
|
||||
-- is the trapezoid's bounding rect: never narrower than the picture, so it
|
||||
-- can never take a bite out of it. It is off-centre in z, because the
|
||||
-- trapezoid is -- the box sits north of the view centre at every rung but
|
||||
-- the top one.
|
||||
--
|
||||
-- THE HORIZON IS WHY THERE IS A ROW AT ALL. Past about 63 degrees (exactly
|
||||
-- atan(2*FOCAL), where the top of the frame lifts off the ground plane) the
|
||||
-- trapezoid stops being finite: the camera can see to the horizon, and "all
|
||||
-- the ground on screen" is an infinite answer. Something has to name a
|
||||
-- distance, and that is what RENDER DIST names -- MAX_REACH view heights,
|
||||
-- times the row's own multiplier. Below that pitch the row does nothing to
|
||||
-- the picture at all, because the honest footprint is already smaller than
|
||||
-- the reach; at 75 it is what decides where the world ends.
|
||||
--
|
||||
-- AND IT PAYS FOR ITSELF. A cut this file can describe in world pixels is
|
||||
-- one VoxelScene can test a whole neighbour map against BEFORE drawing it
|
||||
-- -- see shows() -- so a connected map that lands entirely outside the box
|
||||
-- costs no terrain mesh, no water, no grass, no flowers and no shadow
|
||||
-- pass. Most of the win is at the HIGH rungs, where the camera is nearly
|
||||
-- overhead and the footprint is barely bigger than the window; at 75 it
|
||||
-- sees half the region and skips almost nothing, which is the truth about
|
||||
-- that rung rather than a shortcoming of the test.
|
||||
--
|
||||
-- WHAT IT DOES NOT TOUCH. The free-roam rungs (1ST and 3RD): the player is
|
||||
-- standing IN the world there, and a box around a walking eye is a
|
||||
-- fog-of-war circle rather than a model on a table. The rung tween into
|
||||
-- them opens the box out with the blend rather than dropping it on a
|
||||
-- frame, so diving into a head does not pop the sides away. A headset's
|
||||
-- frame is not touched either -- lib/Diorama owns the cut there, and VR's
|
||||
-- STANDARD rungs are a tabletop already.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local ModSetting = V.require("ModSetting")
|
||||
|
||||
local ViewBox = {}
|
||||
|
||||
ViewBox.KEY = "viewbox"
|
||||
-- RENDER DIST rather than a V- name like the rows either side of it: what
|
||||
-- the player is choosing is HOW MUCH WORLD gets drawn, which is the thing
|
||||
-- every game with this row calls a render distance. The mode read -- the
|
||||
-- slab with sides -- is what that buys, not what the row is asking.
|
||||
ViewBox.LABEL = "RENDER DIST"
|
||||
|
||||
-- The ladder, as a multiplier on the footprint the camera actually frames.
|
||||
-- Rung 0 is FIT and it is the default, and FIT means exactly that: the
|
||||
-- ground on screen and no more. It cannot open a gap -- 1.0 times the
|
||||
-- honest answer is the honest answer -- so the only thing the wider rungs
|
||||
-- buy below the horizon pitch is margin around a cut nobody can see.
|
||||
--
|
||||
-- Where they DO decide the picture is at 75, and at the tween rungs either
|
||||
-- side of it, where the footprint is infinite and MAX_REACH below stands in
|
||||
-- for it: there the ladder is a real render distance and FIT is the closest
|
||||
-- horizon of the four.
|
||||
--
|
||||
-- Geometric rather than even, for the reason WorldCurve's ladder is: what
|
||||
-- the player sees change between two rungs is the AREA inside the box, and
|
||||
-- that goes as the square -- even steps bunch the whole ladder at the
|
||||
-- near end.
|
||||
--
|
||||
-- The last rung is 0, which is no cut at all. It sits at the TOP because
|
||||
-- "everything" is where the ladder is going: FIT, wider, wider, wider,
|
||||
-- all of it.
|
||||
ViewBox.FRACS = { 1.0, 1.5, 2.25, 3.5, 0 }
|
||||
|
||||
ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL,
|
||||
{ 0, 1, 2, 3, 4 },
|
||||
{ "FIT", "WIDE", "WIDER", "WIDEST", "OFF" })
|
||||
|
||||
-- How far the world may reach when the camera can see the HORIZON and the
|
||||
-- honest footprint is infinite, in view heights. Generous on purpose: this
|
||||
-- is a backstop for an unbounded answer, not a curtain to draw across the
|
||||
-- middle distance, and it wants to land well past the edge of the loaded
|
||||
-- neighbourhood so the world runs out before the cut does. Multiplied by
|
||||
-- the row, so a player who can see the seam can push it away.
|
||||
ViewBox.MAX_REACH = 6
|
||||
|
||||
-- ------- the geometry standing on the ground it frames
|
||||
--
|
||||
-- The footprint is where the frame's rays hit the GROUND, and the ground is
|
||||
-- not what the picture is made of. A tree is most of a hundred world pixels
|
||||
-- tall, and a point that high up on the BOTTOM edge's own ray sits south of
|
||||
-- where that ray lands -- nearer the eye, because the ray is coming down. So
|
||||
-- the bottom of the screen is full of things whose feet are outside the
|
||||
-- ground trapezoid, and a box cut to the trapezoid alone takes them away
|
||||
-- whole: the shader cuts a fragment by the column it stands in (Voxel3D's
|
||||
-- dioramaCull is unbounded upward, deliberately, so a cut never takes the
|
||||
-- tops off trees), so a tree one pixel south of the edge loses its whole
|
||||
-- height at once. That is a bite along the bottom of the picture -- a row of
|
||||
-- trees cut through by the frame's own edge, with the ground behind them
|
||||
-- showing.
|
||||
--
|
||||
-- HEIGHT is the tallest thing standing on that ground, and it is the sun
|
||||
-- pass's own figure for the same reason it needs one: how far outside the
|
||||
-- ground it fits can something still reach the picture? Kept here rather
|
||||
-- than read across so this file's cut does not move when the light's
|
||||
-- frustum is retuned; they answer to the same world either way.
|
||||
ViewBox.HEIGHT = 160
|
||||
|
||||
-- And a tile of slack on top, at every pitch. The cut's south edge would
|
||||
-- otherwise land on the frame's own bottom row at the rungs where the term
|
||||
-- below is zero, which is a hard edge (see FADE_FRAC) balanced on the
|
||||
-- pixel it is drawn at -- a supersampled frame (lib/AntiAlias) resolves
|
||||
-- half of it. One tile is cheap and no cut this file makes should be
|
||||
-- decided by a rounding.
|
||||
ViewBox.SOUTH_PAD = 16
|
||||
|
||||
-- How much further south than the ground it lands on the bottom edge of the
|
||||
-- frame can still show, in world pixels.
|
||||
--
|
||||
-- At sy = -1 the ray direction (see footprint) is
|
||||
--
|
||||
-- d = (0, -(cos a + tanY sin a), -(sin a - tanY cos a))
|
||||
--
|
||||
-- in (x, y, z) with y up and -z north, so climbing it costs
|
||||
--
|
||||
-- (sin a - tanY cos a) / (cos a + tanY sin a)
|
||||
--
|
||||
-- of south per world pixel of height. Zero at and below atan(tanY) -- about
|
||||
-- 26 degrees with FOCAL 1, where the ray is shallower than the frame's own
|
||||
-- half-angle and a RAISED point lands north of the ground hit, which no cut
|
||||
-- can lose -- a tile and a half at 35, four at 50, and a good eleven at 75,
|
||||
-- where the eye is nearly level and a tree is nearly all of what is under
|
||||
-- the bottom of the frame.
|
||||
function ViewBox.lift(a)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local tanY = 1 / (2 * (Voxel.FOCAL or 1))
|
||||
local ca = math.max(math.cos(a or 0), 1e-3)
|
||||
local sa = math.max(math.sin(a or 0), 0)
|
||||
local rise = (sa - tanY * ca) / (ca + tanY * sa)
|
||||
if rise <= 0 then return 0 end
|
||||
return ViewBox.HEIGHT * rise
|
||||
end
|
||||
|
||||
-- The rim under V-CURVE, as a fraction of the shorter half-extent, and for
|
||||
-- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides,
|
||||
-- so a hard edge across one is a lie about what is being looked at. Flat,
|
||||
-- the box keeps its hard edge -- that IS the sides.
|
||||
ViewBox.FADE_FRAC = 0.16
|
||||
|
||||
-- How far outside the box a map may still have geometry inside it: the
|
||||
-- border ring ChunkMesher meshes around a body (RING = 3 blocks of 32
|
||||
-- world pixels), which is the one thing a map draws beyond its own
|
||||
-- rectangle. A neighbour kept by this margin that turns out to be entirely
|
||||
-- outside is drawn and then cut per fragment, which is what would have
|
||||
-- happened without the test -- the margin can only cost a draw, never a
|
||||
-- hole.
|
||||
ViewBox.PAD = 96
|
||||
|
||||
function ViewBox.level()
|
||||
return ViewBox.setting:get() or 0
|
||||
end
|
||||
|
||||
-- The multiplier in force, or nil for OFF -- which is also every caller's
|
||||
-- "there is no cut this frame" answer.
|
||||
function ViewBox.frac()
|
||||
local f = ViewBox.FRACS[ViewBox.level() + 1]
|
||||
if not f or f <= 0 then return nil end
|
||||
return f
|
||||
end
|
||||
|
||||
-- Whether this rung is one the box is about: an ORBIT rung, which is every
|
||||
-- level the mode has except OFF (level 0, where there is no 3D pass to cut)
|
||||
-- and the two free-roam rungs (see the header).
|
||||
function ViewBox.appliesTo(level)
|
||||
local ok, applies = pcall(function()
|
||||
local Voxel = V.require("VoxelState")
|
||||
local l = level or Voxel.level or 0
|
||||
return l > 0 and not Voxel.isFreeCam(l)
|
||||
end)
|
||||
return ok and applies or false
|
||||
end
|
||||
|
||||
-- ------- what the live frame is
|
||||
--
|
||||
-- Set by VoxelScene for the length of one flat frame and cleared with it,
|
||||
-- exactly as Diorama's is for a headset's. Nothing else writes it, and
|
||||
-- every reader -- the shader uniforms, the neighbour skip -- hangs off this
|
||||
-- one field being nil or not.
|
||||
ViewBox.cull = nil -- { x, y, z, r, rx, rz, invFade, kind }
|
||||
|
||||
local function curved()
|
||||
local ok, on = pcall(function()
|
||||
return V.require("WorldCurve").active()
|
||||
end)
|
||||
return ok and on or false
|
||||
end
|
||||
|
||||
-- How far out of the orbit and into a walking head the rung tween has got,
|
||||
-- 0..1. The box opens out by one over what is LEFT of the orbit, so it has
|
||||
-- grown past every edge of the frame by the time the eye arrives in the
|
||||
-- head and the cut is dropped -- rather than the sides vanishing on the
|
||||
-- frame the rung number changed, which is a pop in the middle of a move.
|
||||
local function orbitLeft()
|
||||
local ok, blend = pcall(function()
|
||||
return V.require("FirstPerson").blendEased()
|
||||
end)
|
||||
if not ok or type(blend) ~= "number" then return 1 end
|
||||
return 1 - math.max(0, math.min(1, blend))
|
||||
end
|
||||
|
||||
-- ------- the ground this camera frames
|
||||
--
|
||||
-- The orbit (Voxel3D.viewProjection's else branch) is: eye at distance
|
||||
-- k = FOCAL*vh, pitched `a` off straight down and due south of the focus;
|
||||
-- focus on the ground at the view centre; a symmetric frustum whose
|
||||
-- half-tangents are tanY = 1/(2*FOCAL) vertically and tanY*(vw/vh)
|
||||
-- horizontally. Screen coordinates run sx, sy in [-1, 1] with sy = +1 the
|
||||
-- TOP of the frame, which is north.
|
||||
--
|
||||
-- The ray through a screen point is forward + right*sx*tanX + up*sy*tanY,
|
||||
-- and with the orbit's basis (right = +x, forward = (0, -cos a, -sin a),
|
||||
-- up = (0, sin a, -cos a)) that comes out as
|
||||
--
|
||||
-- d = ( sx*tanX, -cos a + sy*tanY*sin a, -sin a - sy*tanY*cos a )
|
||||
--
|
||||
-- Drop it to the ground plane from an eye at height k*cos a and the whole
|
||||
-- trapezoid collapses to ONE denominator,
|
||||
--
|
||||
-- D(sy) = cos a - sy*tanY*sin a
|
||||
--
|
||||
-- with (the sin^2 + cos^2 cancels most of the algebra away):
|
||||
--
|
||||
-- north of centre : (vh/2) * sy / D(sy)
|
||||
-- half-width : (vw/2) * cos a / D(sy)
|
||||
--
|
||||
-- because k*tanY is exactly vh/2 and tanX*k is exactly vw/2, whatever FOCAL
|
||||
-- is. At a = 0 both reduce to vh/2 and vw/2 -- the flat window, which is
|
||||
-- the case the first cut of this file mistook for all of them.
|
||||
--
|
||||
-- D shrinks as sy climbs, so BOTH grow toward the top of the frame, and
|
||||
-- both blow up where D reaches zero: sy* = cot(a)/tanY, the row the horizon
|
||||
-- sits on. Past 63 degrees that row is inside the frame and the answer is
|
||||
-- infinite -- which is what `reach` is for.
|
||||
--
|
||||
-- Returns three DISTANCES from the view centre, all positive: how far the
|
||||
-- picture runs north, how far south, and how far to each side.
|
||||
function ViewBox.footprint(a, vw, vh, reach)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local halfW, halfH = (vw or 320) * 0.5, (vh or 288) * 0.5
|
||||
local tanY = 1 / (2 * (Voxel.FOCAL or 1))
|
||||
-- the orbit never reaches level (75 degrees is the last rung) but a tween
|
||||
-- reads a live angle, and a cos of zero is a horizon through the middle
|
||||
-- of the frame rather than a number
|
||||
local ca = math.max(math.cos(a or 0), 1e-3)
|
||||
local sa = math.max(math.sin(a or 0), 0)
|
||||
-- The screen row the far edge is taken at: the TOP of the frame, or the
|
||||
-- row whose ray lands `reach` out, whichever comes first. Inverting the
|
||||
-- north formula for sy gives
|
||||
--
|
||||
-- sy = reach*cos a / (vh/2 + reach*tanY*sin a)
|
||||
--
|
||||
-- which is always strictly below the horizon row (it approaches it from
|
||||
-- underneath as reach grows), so D below is always positive -- with the
|
||||
-- horizon in frame this never even reaches 1 and the clamp is inert.
|
||||
local sy = reach * ca / (halfH + reach * tanY * sa)
|
||||
if sy > 1 then sy = 1 end
|
||||
local D = ca - sy * tanY * sa
|
||||
if D < 1e-3 then D = 1e-3 end
|
||||
return halfH * sy / D, -- north
|
||||
halfH / (ca + tanY * sa), -- south: the sy = -1 row
|
||||
halfW * ca / D -- and the widest row is the far one
|
||||
end
|
||||
|
||||
-- Open the frame's cut: the bounding rectangle of the ground this camera
|
||||
-- frames, times the row's multiplier. Returns the cut, or nil when this
|
||||
-- frame has none -- which is the row at OFF, a rung the box is not about,
|
||||
-- and a camera that has finished its dive into a head.
|
||||
function ViewBox.frame(cx, cy, vw, vh, level)
|
||||
ViewBox.cull = nil
|
||||
local frac = ViewBox.frac()
|
||||
if not (frac and ViewBox.appliesTo(level)) then return nil end
|
||||
local left = orbitLeft()
|
||||
if left <= 0.001 then return nil end
|
||||
frac = frac / left
|
||||
local Voxel = V.require("VoxelState")
|
||||
local angle = Voxel.angle or 0
|
||||
local north, south, side = ViewBox.footprint(
|
||||
angle, vw, vh, ViewBox.MAX_REACH * (vh or 288))
|
||||
-- the ground the bottom edge lands on is not the southernmost thing under
|
||||
-- it: what STANDS there reaches into the frame from further south (see
|
||||
-- lift). Added before the row's multiplier, so FIT carries it too -- it is
|
||||
-- a correction to the honest answer, not margin around it.
|
||||
south = south + ViewBox.lift(angle) + ViewBox.SOUTH_PAD
|
||||
north, south, side = north * frac, south * frac, side * frac
|
||||
-- The rectangle around it. Off-centre in z, because the trapezoid is:
|
||||
-- the camera looks NORTH from south of its focus, so there is far more
|
||||
-- picture ahead of the view centre than behind it -- at 35 degrees
|
||||
-- roughly twice as much, at 75 the whole frame.
|
||||
--
|
||||
-- The same floor Diorama.radius keeps: a box smaller than a couple of
|
||||
-- tiles is not a viewport, it is a hole the player is standing in.
|
||||
local rx = math.max(24, side)
|
||||
local rz = math.max(24, (north + south) * 0.5)
|
||||
local bent = curved()
|
||||
local fade = bent and math.max(1, math.min(rx, rz) * ViewBox.FADE_FRAC) or 0
|
||||
ViewBox.cull = {
|
||||
x = cx, y = 0, z = cy - (north - south) * 0.5,
|
||||
-- `r` is what the ball and the pillar kinds are sized by and the box
|
||||
-- is not; carried so the cut table has one shape whoever made it
|
||||
r = math.max(rx, rz), rx = rx, rz = rz,
|
||||
-- a zero band is a hard edge: half a pixel of ramp, which is one pixel
|
||||
-- of antialiasing rather than a stair (Diorama says the same)
|
||||
invFade = 1 / math.max(fade, 0.5),
|
||||
kind = V.require("Diorama").BOX,
|
||||
}
|
||||
return ViewBox.cull
|
||||
end
|
||||
|
||||
function ViewBox.stop()
|
||||
ViewBox.cull = nil
|
||||
end
|
||||
|
||||
-- ------- the coarse half of the cut
|
||||
--
|
||||
-- Whether anything inside the world-pixel rectangle (x0, z0)-(x1, z1) can
|
||||
-- be inside this frame's box. True whenever there is no box, so a caller
|
||||
-- may guard every draw with it unconditionally.
|
||||
function ViewBox.shows(x0, z0, x1, z1)
|
||||
local c = ViewBox.cull
|
||||
if not c then return true end
|
||||
local pad = ViewBox.PAD
|
||||
return x0 - pad <= c.x + c.rx and x1 + pad >= c.x - c.rx
|
||||
and z0 - pad <= c.z + c.rz and z1 + pad >= c.z - c.rz
|
||||
end
|
||||
|
||||
-- The same question about a connected neighbour, in the shape VoxelScene
|
||||
-- keeps them: { map, ox, oy } with the offset in world pixels and the map's
|
||||
-- own size in blocks of 32 (the shape prefetch's masks are built from).
|
||||
function ViewBox.showsMap(nb)
|
||||
if not (nb and nb.map and nb.map.def) then return true end
|
||||
return ViewBox.shows(nb.ox or 0, nb.oy or 0,
|
||||
(nb.ox or 0) + (nb.map.def.width or 0) * 32,
|
||||
(nb.oy or 0) + (nb.map.def.height or 0) * 32)
|
||||
end
|
||||
|
||||
-- What the shadow pass has to notice: WHICH neighbours it drew is now a
|
||||
-- function of the row, and the row is the one input to that the sun's own
|
||||
-- signature does not already carry (the centre, the view size and the
|
||||
-- rung are all in it). Widening the box brings a neighbour back into the
|
||||
-- light's frustum, and a map recorded without it must be redrawn.
|
||||
function ViewBox.signature()
|
||||
return ViewBox.frac() or 0
|
||||
end
|
||||
|
||||
function ViewBox.row()
|
||||
return ViewBox.setting:row()
|
||||
end
|
||||
|
||||
function ViewBox.sync(value)
|
||||
ViewBox.setting:sync(value)
|
||||
end
|
||||
|
||||
return ViewBox
|
||||
+799
-43
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45
|
||||
-- 1.0 here is the one-pixel wireframe.
|
||||
VoxelGrid.WIDTH = 1.0
|
||||
|
||||
-- The same width in the CANVAS pixels the shader measures in, which is what
|
||||
-- every sender of it actually wants.
|
||||
--
|
||||
-- The two are the same number until AA renders the pass larger than the
|
||||
-- window (see AntiAlias): there a canvas pixel is a fraction of a display
|
||||
-- one, and a width left at 1.0 would come out a half or a quarter of a line
|
||||
-- after the fold -- the wireframe fading as the smoothing goes up, which
|
||||
-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and
|
||||
-- simply gains the antialiasing everything else in the frame just gained.
|
||||
function VoxelGrid.width()
|
||||
return VoxelGrid.WIDTH * V.require("AntiAlias").factor()
|
||||
end
|
||||
|
||||
-- where it persists and the rows that cycle it (see ModSetting)
|
||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
+634
-69
@@ -15,13 +15,21 @@ local V = ...
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
local Shadows = V.require("Shadows")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local SpriteBillboards = V.require("SpriteBillboards")
|
||||
local TileShape = V.require("TileShape")
|
||||
local TerrainAtlas = V.require("TerrainAtlas")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Sky = V.require("Sky")
|
||||
local Water = V.require("Water")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local Pokedex = V.require("Pokedex")
|
||||
local Diorama = V.require("Diorama")
|
||||
local ViewBox = V.require("ViewBox")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
@@ -212,6 +220,30 @@ local function frameFor(def, facing, phase, flip)
|
||||
return frame, mirror
|
||||
end
|
||||
|
||||
-- The facing a pose SHOWS this camera. The flat frames are "how this pose
|
||||
-- looks from the south", which is where the orbit always stands; a
|
||||
-- first-person eye stands anywhere, so deep enough into the blend the
|
||||
-- facing is remapped to how the pose looks from THERE -- walk behind an
|
||||
-- NPC and their card wears the back sprite. Used by the camera draw and
|
||||
-- the sun pass BOTH: the card the sun stored and the transform a lit card
|
||||
-- reads its own shadowing with must describe the same frame, or the
|
||||
-- mirror-flip half of the pair asks the map about texels the sun filed
|
||||
-- under the other cheek.
|
||||
-- The player's own card asks a different function for the same answer:
|
||||
-- their body's bearing is what the camera is derived FROM, so it is known
|
||||
-- continuously rather than as one of four directions, and measuring
|
||||
-- against the compass point instead flicks the card to a profile for a
|
||||
-- frame or two when the camera is spun fast (see playerFacing).
|
||||
local function viewFacing(p)
|
||||
if FirstPerson.cardBlend() > 0.5 then
|
||||
if p.isPlayer then
|
||||
return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return p.facing
|
||||
end
|
||||
|
||||
-- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as
|
||||
-- a decal: its current sprite frame as a single quad, flattened onto the
|
||||
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
|
||||
@@ -234,17 +266,40 @@ end
|
||||
-- Shared by the solid draw and the silhouette below, so the two can never
|
||||
-- drift apart -- a silhouette standing anywhere but exactly behind the
|
||||
-- figure would read as a second character.
|
||||
--
|
||||
-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed
|
||||
-- about its feet to face the eye (cylindrical billboarding). A south-facing
|
||||
-- card is invisible edge-on to an eye standing east of it, which no orbit
|
||||
-- camera could ever do and a first-person one does constantly. The blend
|
||||
-- carries one pose into the other -- the lean eases out as the yaw eases in
|
||||
-- -- and cardBlend is zero for every camera that is not the first-person
|
||||
-- rig, the battle's placed shot included, so nothing else moves.
|
||||
-- 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 Voxel = V.require("VoxelState")
|
||||
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
|
||||
Mat4.rotateX(Voxel.angle - math.pi / 2))
|
||||
local b = FirstPerson.cardBlend()
|
||||
local m = Mat4.translate(px + 8, y, py + 8)
|
||||
if b > 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
|
||||
end
|
||||
m = Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
|
||||
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
|
||||
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
|
||||
end
|
||||
|
||||
local function billboardPull()
|
||||
local Voxel = V.require("VoxelState")
|
||||
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
|
||||
return VoxelScene.pull(math.max(leanAngle(), 0.05))
|
||||
end
|
||||
|
||||
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
|
||||
@@ -256,10 +311,23 @@ end
|
||||
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
|
||||
-- around him. No cell centring: unlike a character he is not standing on a
|
||||
-- cell, he is standing where he was drawn, which may straddle two.
|
||||
--
|
||||
-- First person turns him at the eye like the walkers (see billboardMatrix)
|
||||
-- -- about his own middle, because unlike a character card his local space
|
||||
-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about
|
||||
-- his edge would swing him off his seat. The width rode in on the record
|
||||
-- for exactly this (ChunkMesher.buildFigureMeshes).
|
||||
local function figureMatrix(f, offX, offZ)
|
||||
local Voxel = V.require("VoxelState")
|
||||
return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
|
||||
Mat4.rotateX(Voxel.angle - math.pi / 2))
|
||||
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
|
||||
@@ -331,7 +399,7 @@ VoxelScene.drawEntity = drawEntity
|
||||
-- mesh for it.
|
||||
local function drawGhost(p)
|
||||
local def = p.sprite.def
|
||||
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
|
||||
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
|
||||
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||
if not mesh then return end
|
||||
local tex = p.sprite:resolveImage()
|
||||
@@ -404,17 +472,25 @@ function VoxelScene.prefetch(state)
|
||||
-- crossing demotes the map just left, and it must not vanish from
|
||||
-- behind the player while its body variant builds; its ring is
|
||||
-- already masked out under this map's body, so the stand-in is safe.
|
||||
local terrain = ChunkMesher.request(state.map, false, masks, true)
|
||||
-- The water surface rides along with whichever variant answers: it was
|
||||
-- cut out of that build's own geometry (ChunkMesher.pair), so the two
|
||||
-- always come from the same slot and a lake is never drawn twice or left
|
||||
-- as a hole.
|
||||
ChunkMesher.request(state.map, false, masks, true)
|
||||
local terrain, water = ChunkMesher.pair(state.map, false)
|
||||
if not terrain then
|
||||
terrain = ChunkMesher.peek(state.map, true)
|
||||
terrain, water = ChunkMesher.pair(state.map, true)
|
||||
end
|
||||
local nbMesh = {}
|
||||
local nbMesh, nbWater = {}, {}
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
nbMesh[i] = ChunkMesher.request(nb.map, true)
|
||||
or ChunkMesher.peek(nb.map, false)
|
||||
ChunkMesher.request(nb.map, true)
|
||||
nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, true)
|
||||
if not nbMesh[i] then
|
||||
nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, false)
|
||||
end
|
||||
end
|
||||
Voxel.ready = terrain ~= nil
|
||||
return terrain, nbMesh
|
||||
return terrain, nbMesh, water, nbWater
|
||||
end
|
||||
|
||||
-- Capture every entity's pose for this frame. pose() advances the hop /
|
||||
@@ -452,7 +528,13 @@ local function posesOf(state, spriteColors)
|
||||
gh = groundAt(state.map, e.cellX, e.cellY),
|
||||
lift = e.py - vy, colors = colors,
|
||||
}
|
||||
if e == state.player then me = posed[#posed] end
|
||||
if e == state.player then
|
||||
me = posed[#posed]
|
||||
-- marked so the camera draw can leave the card out in first
|
||||
-- person, where it would fill the lens from inside; the SUN pass
|
||||
-- reads the same list and deliberately does not check the mark
|
||||
me.isPlayer = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return posed, me
|
||||
@@ -490,6 +572,177 @@ 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
|
||||
if ViewBox.showsMap(nb) then
|
||||
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
|
||||
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
|
||||
ShadowMap.snug(caster))
|
||||
end)
|
||||
end
|
||||
end
|
||||
-- and the seams are back on for the terrain art that follows: grass and
|
||||
-- flowers are the world's own drawing, not people
|
||||
Voxel3D.seams(true)
|
||||
end
|
||||
|
||||
-- ------- the water pass
|
||||
--
|
||||
-- Between the terrain and everything that stands on it, because water is a
|
||||
-- MIRROR and a mirror can only reflect what is already down: the ground, the
|
||||
-- shoreline, the trees and buildings behind it, and the sky the frame opened
|
||||
-- with.
|
||||
--
|
||||
-- THE CAST IS THE AWKWARD ONE, and it is settled by drawing it twice. Gen 1
|
||||
-- draws people over the world and water is world, so a surfing player has to
|
||||
-- composite OVER the water they are sitting on -- which puts them after it,
|
||||
-- and a reflection can only hold what came before it. So `cast` is painted
|
||||
-- into the reflection copy alone (Voxel3D.beginWater), where it is in the
|
||||
-- picture the water reflects and not yet in the picture the water is drawn
|
||||
-- into. Both draws go through drawCast, so they cannot come out different.
|
||||
--
|
||||
-- The ray march finds them the honest way round: a sprite is not in the
|
||||
-- DEPTH buffer at that point, so a ray aimed at one passes through to the
|
||||
-- terrain standing behind it and reads the copy there -- where the sprite is
|
||||
-- already painted. The reflection lands a hair off the sprite's own depth
|
||||
-- and exactly on its colour, which at a lake's worth of ripple is the same
|
||||
-- picture.
|
||||
--
|
||||
-- `draws` is a list of { mesh, texture, model }. Nothing is a special case:
|
||||
-- with the row OFF, no depth texture to read, or a shader that would not
|
||||
-- build, the same meshes go through the ordinary scene shader and come out
|
||||
-- as the flat animated water this mode always drew.
|
||||
-- The overworld's alone: the staged battle draws its water plain, always --
|
||||
-- its placed camera reads this pass wrong, and a stage set wants painted
|
||||
-- water anyway (see BattleScene, where the choice is argued).
|
||||
-- ------- and why the flat draw happens FIRST while the world is curved
|
||||
--
|
||||
-- The reflective pass writes no depth -- it cannot, the depth canvas is
|
||||
-- detached for the length of it so the shader can READ it -- and it does its
|
||||
-- own depth test against that texture instead. That test asks whether
|
||||
-- something opaque is in front, and it answers correctly for every case but
|
||||
-- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so
|
||||
-- no lake can hide another, and the pass simply paints them in mesh order.
|
||||
--
|
||||
-- On a flat world that never matters: every surface lies in the one plane
|
||||
-- at its own recessed height, and a farther sheet always lands farther down
|
||||
-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the
|
||||
-- square of its distance, so the far side of the map swings down and back
|
||||
-- up into the near field of view -- and a sheet of sea a hundred and fifty
|
||||
-- tiles away, drawn later in the same mesh, paints straight over the pond
|
||||
-- at the player's feet. Not a reflection of the far shore: the far shore
|
||||
-- itself, rasterised on top of the water in front of you.
|
||||
--
|
||||
-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the
|
||||
-- ordinary scene shader with depth writes on, and the reflective pass draws
|
||||
-- over the top of what survived: the depth buffer now holds the water
|
||||
-- surface, so the pass's own test throws the far sheet away, and the
|
||||
-- reflection COPY holds it too, so a ray grazing another part of the lake
|
||||
-- reads water rather than the void behind it.
|
||||
--
|
||||
-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY,
|
||||
-- and it stays off -- the reflective pass tests only against terrain, as it
|
||||
-- always did. Painting the surface into the depth texture turns the pass's
|
||||
-- test into a comparison of the surface against ITSELF, which asks the two
|
||||
-- rasterisations to agree to within interpolation error -- and on mobile
|
||||
-- GPUs they don't reliably (that fight is what put the Android port back on
|
||||
-- flat water). Confined to the curve there is no regression to reach: the
|
||||
-- flat world never had the far-shore bug in the first place.
|
||||
function VoxelScene.drawWater(draws, cast)
|
||||
-- prepass only under the bend; see the header
|
||||
local curved = (Voxel3D.curveK or 0) > 0
|
||||
if curved then
|
||||
for _, d in ipairs(draws) do
|
||||
Voxel3D.draw(d[1], d[2], d[3])
|
||||
end
|
||||
end
|
||||
local plain = not curved
|
||||
if Water.enabled() and Voxel3D.depthReadable() then
|
||||
local mirror, depth = Voxel3D.beginWater(cast)
|
||||
local w, h = Voxel3D.size()
|
||||
local ok = mirror and depth and Water.begin({
|
||||
reflect = mirror, depth = depth,
|
||||
vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0,
|
||||
Voxel3D.curveZ or 0,
|
||||
Voxel3D.curveK or 0 },
|
||||
screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY,
|
||||
skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(),
|
||||
lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent,
|
||||
})
|
||||
if ok then
|
||||
for _, d in ipairs(draws) do
|
||||
Water.draw(d[1], d[2], d[3])
|
||||
end
|
||||
Water.finish()
|
||||
plain = false
|
||||
end
|
||||
-- Unconditionally, and OUTSIDE the success branch: beginWater unbinds
|
||||
-- the shader and the depth mode BEFORE it can discover it cannot go on,
|
||||
-- so a frame that bails halfway through has to be put back together
|
||||
-- exactly like one that succeeded -- otherwise every pass after it runs
|
||||
-- with no shader and no depth test.
|
||||
Voxel3D.endWater()
|
||||
end
|
||||
-- the fallback flat draw -- unless the curve's prepass already put the
|
||||
-- same meshes down, in which case a bailed frame is already whole
|
||||
if plain then
|
||||
for _, d in ipairs(draws) do
|
||||
Voxel3D.draw(d[1], d[2], d[3])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- A stamp of everything the sun pass depends on. Nothing in it moving
|
||||
-- means the shadow map it produced last frame is still exactly right, and
|
||||
-- redrawing the whole world from the sun would buy nothing -- which is
|
||||
@@ -517,6 +770,15 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
||||
-- 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())
|
||||
-- and the window box, because WHICH neighbours went into the light is a
|
||||
-- function of it (see ViewBox.signature): opening the row out brings a
|
||||
-- map back inside the cut, and a sun map recorded without it would leave
|
||||
-- that map standing in its own unlit shadow
|
||||
put(ViewBox.signature())
|
||||
put(tostring(terrain))
|
||||
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
|
||||
for _, p in ipairs(posed) do
|
||||
@@ -540,16 +802,37 @@ end
|
||||
-- left out on purpose: thousands of tufts would cast a speckle no bigger
|
||||
-- than the pixels it lands on, at the cost of the mesh being drawn twice.
|
||||
local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
atlasFor)
|
||||
atlasFor, water, nbWater, battleCards, battleToken)
|
||||
if not ShadowMap.available() then return end
|
||||
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.begin(cx, cy, vw, vh) then return end
|
||||
|
||||
ShadowMap.draw(terrain, atlasFor(state.map), nil)
|
||||
-- The window box's coarse cut, here and at every neighbour loop below
|
||||
-- (lib/ViewBox): a connected map lying entirely outside this frame's
|
||||
-- viewport has nothing inside it that could reach the picture, so it is
|
||||
-- not submitted at all. True for every map whenever there is no box,
|
||||
-- which is every frame the row is OFF and every headset frame.
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
if ViewBox.showsMap(nb) then
|
||||
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
|
||||
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
|
||||
if ViewBox.showsMap(nb) then
|
||||
ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
end
|
||||
-- flower billboards live outside the terrain mesh (they draw after the
|
||||
-- characters, pulled -- see render), but the sun still sees them: a
|
||||
@@ -560,22 +843,36 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map),
|
||||
ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
if ViewBox.showsMap(nb) then
|
||||
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
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)
|
||||
if ViewBox.showsMap(nb) then
|
||||
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
|
||||
local def = p.sprite.def
|
||||
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
|
||||
-- viewFacing, exactly as the camera draw picks it (see viewFacing for
|
||||
-- why the two passes must agree): in first person the sun's card
|
||||
-- swaps frame as the eye circles, which costs a redraw the signature
|
||||
-- already charges for (FirstPerson.signature) and keeps a card from
|
||||
-- fringing against a mirror-flipped record of itself
|
||||
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
|
||||
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
||||
if mesh then
|
||||
ShadowMap.draw(mesh, p.sprite:resolveImage(),
|
||||
@@ -584,16 +881,39 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
|
||||
mirror)))
|
||||
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)
|
||||
-- and the STADIUM models, outside the sprite flag and un-snugged, for
|
||||
-- the reasons the flat battle pass gives (BattleScene.castShadows):
|
||||
-- these are geometry, not cut-outs
|
||||
pcall(function()
|
||||
local stageArena, stageY = V.require("OverworldBattle").stage()
|
||||
if stageArena and stageArena.discs then
|
||||
V.require("StadiumStage").cast(ShadowMap, stageArena, stageY or 0)
|
||||
end
|
||||
V.require("Stadium").cast(ShadowMap)
|
||||
end)
|
||||
|
||||
ShadowMap.finish(sig)
|
||||
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),
|
||||
-- return nil: the engine keeps the 2D path for the frame and
|
||||
-- Voxel.ready holds the camera tween at flat, so the switch waits
|
||||
-- invisibly instead of freezing or tilting an empty stage.
|
||||
local terrain, nbMesh = VoxelScene.prefetch(state)
|
||||
local terrain, nbMesh, water, nbWater = VoxelScene.prefetch(state)
|
||||
if not terrain then return nil end
|
||||
|
||||
local cam = state.camera
|
||||
@@ -615,8 +935,25 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
local GlassMask = V.require("GlassMask")
|
||||
Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil
|
||||
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
|
||||
-- The existing day/night ramp is also a darkness factor. Fireflies fade
|
||||
-- in naturally at dusk and reach full contrast only at deepest night.
|
||||
Voxel3D.fireflyNight = outdoor and DayNight.windowLight() or 0
|
||||
local g = VoxelScene.glintStep(glint, cx, cy)
|
||||
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
|
||||
-- and the map's atmosphere, if it has one (see ForestAtmos): the haze
|
||||
-- the scene shader folds every surface into, in the hour's colour.
|
||||
-- nil for every map without an entry -- a clear day, exactly as before.
|
||||
local ForestAtmos = V.require("ForestAtmos")
|
||||
local atmos = ForestAtmos.frame(state.map)
|
||||
Voxel3D.fog = atmos and atmos.fog or nil
|
||||
-- and the DIORAMA modes' viewport and chroma key (lib/Diorama, driven by
|
||||
-- the headset -- lib/VR sets them for the length of one frame). Both are
|
||||
-- put back to nil at the end of this function, so no other pass in the
|
||||
-- frame -- the battle screen's own arena shot above all -- can inherit a
|
||||
-- cut world or a green background.
|
||||
local dioFrame = (eyes and Diorama.on) and true or false
|
||||
Voxel3D.cull = dioFrame and Diorama.cull or nil
|
||||
Voxel3D.keyColor = dioFrame and Diorama.keyColor() or nil
|
||||
|
||||
local function atlasFor(map)
|
||||
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
|
||||
@@ -630,16 +967,81 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
end
|
||||
|
||||
local posed, me = posesOf(state, spriteColors)
|
||||
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor)
|
||||
|
||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||
return nil
|
||||
-- The first-person rig, built (or blended) for this frame and handed to
|
||||
-- Voxel3D BEFORE either pass runs: the sun's box is fitted around this
|
||||
-- camera, and every card matrix asks it which way to turn. With the
|
||||
-- blend fully out the call clears the placed camera and the orbit is
|
||||
-- exactly what it always was. The scene centre it returns walks from
|
||||
-- the orbit's view centre into the head, so the curve's focus and the
|
||||
-- depth reference follow the camera actually in charge.
|
||||
--
|
||||
-- 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
|
||||
|
||||
-- and the ORBIT RUNGS' own viewport (lib/ViewBox): the flat screen's
|
||||
-- answer to the same question the diorama's box asks -- the map cut to
|
||||
-- the window that frames it, so a tilted world reads as a model with
|
||||
-- sides rather than a map running off every edge. Flat frames only: a
|
||||
-- headset's cut is Diorama's above, and the two must never both be live.
|
||||
--
|
||||
-- After the first-person block, so the box is centred on the camera
|
||||
-- actually in charge and opens out with a dive into a head rather than
|
||||
-- vanishing on the frame the rung changed.
|
||||
--
|
||||
-- Ahead of castShadows, deliberately: the sun draws the same neighbours
|
||||
-- the eye does (both ask ViewBox.showsMap), so a map skipped out here is
|
||||
-- skipped out there and nothing is left casting a shadow it cannot own.
|
||||
if not eyes then
|
||||
Voxel3D.cull = ViewBox.frame(cx, cy, vw, vh)
|
||||
else
|
||||
ViewBox.stop()
|
||||
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)
|
||||
-- the window box's coarse cut, exactly as the sun pass took it: the same
|
||||
-- test on the same maps, so the light and the eye can never disagree
|
||||
-- about which neighbours are in this frame (see ViewBox.showsMap)
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
if ViewBox.showsMap(nb) then
|
||||
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy))
|
||||
end
|
||||
end
|
||||
|
||||
-- Without a shadow map (headless, or a driver that could not make the
|
||||
@@ -649,15 +1051,47 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- against the terrain just drawn (a shadow behind a building stays
|
||||
-- hidden) but never depth-writing, so the grass pass at the end of the
|
||||
-- frame still wins its feet-overdraw fights.
|
||||
if not Voxel3D.shadowsActive() then
|
||||
--
|
||||
-- Not with the SHADOWS row off, though: that is a player saying no
|
||||
-- shadows, and standing the fallback in would answer a machine that
|
||||
-- cannot have them (see lib/Shadows).
|
||||
if Shadows.enabled() and not Voxel3D.shadowsActive() then
|
||||
Voxel3D.beginShadows()
|
||||
for _, p in ipairs(posed) do
|
||||
drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
|
||||
drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
|
||||
p.lift)
|
||||
end
|
||||
Voxel3D.endShadows()
|
||||
end
|
||||
|
||||
-- and the water over the top of it, reflecting everything just drawn plus
|
||||
-- the sky the frame opened with (see drawWater).
|
||||
--
|
||||
-- After the fallback decals deliberately: those are the stand-in drop
|
||||
-- shadows for a frame with no shadow map, they write no depth, and a
|
||||
-- lake would otherwise wear one as a black smear. Water covers them,
|
||||
-- which is the same answer the shadow map's own pass gives (see
|
||||
-- ShadowMap.sprites) -- people do not shadow water either way.
|
||||
local waterDraws = {}
|
||||
if water then
|
||||
waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
|
||||
end
|
||||
for i, nb in ipairs(state.neighbors or {}) do
|
||||
if nbWater and nbWater[i] and ViewBox.showsMap(nb) 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
|
||||
@@ -670,7 +1104,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- wrote it, so the silhouette would paint over the player at all times.
|
||||
-- Every character then draws on top as usual, which leaves the silhouette
|
||||
-- showing in exactly one situation: where the world hides them.
|
||||
if me then
|
||||
--
|
||||
-- Not in first person: the card it silhouettes is the one the camera is
|
||||
-- standing inside, and "the world is in front of the player" is every
|
||||
-- wall the player faces.
|
||||
if me and not FirstPerson.hidePlayer() then
|
||||
Voxel3D.beginGhost()
|
||||
drawGhost(me)
|
||||
Voxel3D.endGhost()
|
||||
@@ -689,46 +1127,97 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
|
||||
-- character genuinely behind a building is far deeper and loses the
|
||||
-- test, so buildings and trees really occlude.
|
||||
Voxel3D.seams(false)
|
||||
for _, p in ipairs(posed) do
|
||||
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
|
||||
p.colors, p.lift)
|
||||
end
|
||||
-- back on for everything textured from the atlas again -- figures, grass
|
||||
-- and flowers all sample it, where the mask's coordinates are honest
|
||||
Voxel3D.glass(true)
|
||||
-- Authored figures, alongside the characters and with the same lean and
|
||||
-- the same camera-ward pull -- they ARE characters as far as the artwork
|
||||
-- is concerned, just ones the tileset draws instead of a sprite sheet.
|
||||
-- Drawn after the walkers so a player standing in front of the couch
|
||||
-- wins the overlap, which is the order the flat game draws them in.
|
||||
local figPull = billboardPull()
|
||||
eachFigure(state.map, 0, 0, function(mesh, model, caster)
|
||||
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull,
|
||||
ShadowMap.snug(caster))
|
||||
end)
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
|
||||
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull,
|
||||
ShadowMap.snug(caster))
|
||||
drawCast(state, posed, atlasFor)
|
||||
-- The staged fight's mons, standing on their arena cells in THIS eye's
|
||||
-- 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
|
||||
-- and, on the STADIUM rungs, the models -- the same skinned meshes the
|
||||
-- flat pass and the sun already used this frame, drawn again through
|
||||
-- THIS eye. Unlike the cards there is nothing per-eye about them: a
|
||||
-- model faces its opponent, not the viewer, so both eyes see the same
|
||||
-- pose from their own seats, which is what makes it read as solid.
|
||||
--
|
||||
-- On a disc rung the platforms come with them. In a headset the world is
|
||||
-- still drawn -- the player is standing IN it, which is the whole point
|
||||
-- of the headset, so the rung's "no map" does not apply here -- and the
|
||||
-- discs then read as a stage set down on the ground, which is what they
|
||||
-- are.
|
||||
pcall(function()
|
||||
local stageArena, stageY = V.require("OverworldBattle").stage()
|
||||
if stageArena and stageArena.discs then
|
||||
V.require("StadiumStage").draw(stageArena, stageY or 0)
|
||||
end
|
||||
V.require("Stadium").draw(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
|
||||
-- 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)
|
||||
-- tall grass last, pulled camera-ward exactly as far as the characters
|
||||
-- were (same per-vertex shader bias, so grass never drifts either):
|
||||
-- relative depth between a walker and the tuft row south of their feet
|
||||
-- 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
|
||||
-- buildings it genuinely stands behind (far deeper than the pull).
|
||||
local Voxel = V.require("VoxelState")
|
||||
local pull = VoxelScene.pull(math.max(Voxel.angle, 0.05))
|
||||
-- the same angle the cards leaned by (leanAngle honours VR's override),
|
||||
-- so the tuft rows keep exactly the characters' own depth handicap
|
||||
local lean = math.max(leanAngle(), 0.05)
|
||||
local pull = VoxelScene.pull(lean)
|
||||
-- Character px/py is anchored on the 16 px card. Its world centre/feet
|
||||
-- contact used by the camera code is +8,+8, so use the same point here.
|
||||
-- Keep the prior rendered point so fast steps sweep through every tuft.
|
||||
local gx, gz = -100000, -100000
|
||||
if me then gx, gz = me.px + 8, me.py + 8 end
|
||||
VoxelScene._grassPrevX = VoxelScene._grassPrevX or gx
|
||||
VoxelScene._grassPrevZ = VoxelScene._grassPrevZ or gz
|
||||
-- A warp/map transition is not a walk. Do not sweep one enormous contact
|
||||
-- segment across the new map when the player jumps more than two tiles.
|
||||
local gdx, gdz = gx - VoxelScene._grassPrevX, gz - VoxelScene._grassPrevZ
|
||||
if gdx * gdx + gdz * gdz > 32 * 32 then
|
||||
VoxelScene._grassPrevX, VoxelScene._grassPrevZ = gx, gz
|
||||
end
|
||||
Voxel3D.grassWind(true, gx, gz,
|
||||
VoxelScene._grassPrevX, VoxelScene._grassPrevZ)
|
||||
Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull)
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
if ViewBox.showsMap(nb) then
|
||||
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), pull)
|
||||
end
|
||||
end
|
||||
Voxel3D.grassWind(false)
|
||||
VoxelScene._grassPrevX, VoxelScene._grassPrevZ = gx, gz
|
||||
-- flower billboards: pulled like the characters and the grass, MINUS
|
||||
-- the depth of 8 world pixels along the view (8 sin a -- the camera
|
||||
-- looks along (0, -cos a, -sin a), so that is exactly one tile row of
|
||||
@@ -739,18 +1228,94 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- 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
|
||||
-- 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,
|
||||
fpull, ShadowMap.snug(nil))
|
||||
for _, nb in ipairs(state.neighbors or {}) do
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
if ViewBox.showsMap(nb) then
|
||||
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
||||
Mat4.translate(nb.ox, 0, nb.oy), fpull,
|
||||
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
||||
end
|
||||
end
|
||||
|
||||
return Voxel3D.endScene()
|
||||
-- The map's atmosphere -- god rays down from the invisible canopy, and
|
||||
-- whatever drifts through them (see ForestAtmos). Additive over the
|
||||
-- finished depth buffer, so the trees occlude the light and the light
|
||||
-- writes nothing; here in the prop slot, after everything the beams
|
||||
-- should fall across and inside drawScene so VR gets them per eye. On
|
||||
-- the one map that has any, today.
|
||||
ForestAtmos.draw(state.map)
|
||||
|
||||
-- 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
|
||||
|
||||
-- the viewport fields are this function's for the length of this
|
||||
-- function, whichever way it leaves (see where they are set)
|
||||
local function done(result)
|
||||
Voxel3D.cull, Voxel3D.keyColor = nil, nil
|
||||
ViewBox.stop()
|
||||
return result
|
||||
end
|
||||
|
||||
if not eyes then
|
||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||
return done(nil)
|
||||
end
|
||||
drawScene()
|
||||
return done(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 done(nil)
|
||||
end
|
||||
drawScene()
|
||||
out[i] = Voxel3D.endScene()
|
||||
end
|
||||
return done(out)
|
||||
end
|
||||
|
||||
return VoxelScene
|
||||
|
||||
+43
-3
@@ -32,8 +32,19 @@ local Voxel = {}
|
||||
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
|
||||
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
|
||||
-- like, and two rungs may look the same while meaning different things.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
|
||||
--
|
||||
-- 1ST and 3RD are the other rungs that are more than an angle: the camera
|
||||
-- steps off its orbit entirely and stands with the player -- in their eyes
|
||||
-- (lib/FirstPerson.lua), or on a boom behind their shoulder
|
||||
-- (lib/ThirdPerson.lua) -- with free look and free movement on both. Their
|
||||
-- ANGLE entries are 75 -- the orbit rung they hand over from -- because the
|
||||
-- tween in and out 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 free-roam rig owns the actual camera.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
|
||||
"1ST (EXPERIMENTAL)", "3RD (EXPERIMENTAL)" }
|
||||
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
|
||||
|
||||
-- the rung FULL sits on, so nothing has to hunt for it by label
|
||||
@@ -43,6 +54,30 @@ function Voxel.isFull(level)
|
||||
return (level or Voxel.level) == Voxel.FULL_LEVEL
|
||||
end
|
||||
|
||||
-- the rung the first-person camera sits on, likewise
|
||||
Voxel.FP_LEVEL = 6
|
||||
|
||||
function Voxel.isFirstPerson(level)
|
||||
return (level or Voxel.level) == Voxel.FP_LEVEL
|
||||
end
|
||||
|
||||
-- and the third-person one, which is the same rig with the eye boomed off
|
||||
-- the back of the head (lib/ThirdPerson.lua)
|
||||
Voxel.TP_LEVEL = 7
|
||||
|
||||
function Voxel.isThirdPerson(level)
|
||||
return (level or Voxel.level) == Voxel.TP_LEVEL
|
||||
end
|
||||
|
||||
-- The two of them together: the rungs where the camera stands WITH the
|
||||
-- player rather than orbiting the view centre, which is what decides that
|
||||
-- the look inputs are read, the walk goes free and the cards turn to face
|
||||
-- the eye. Everything that used to ask isFirstPerson for those asks this.
|
||||
function Voxel.isFreeCam(level)
|
||||
level = level or Voxel.level
|
||||
return Voxel.isFirstPerson(level) or Voxel.isThirdPerson(level)
|
||||
end
|
||||
|
||||
-- ------- what the hotkey walks
|
||||
--
|
||||
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
||||
@@ -51,7 +86,12 @@ end
|
||||
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
|
||||
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
|
||||
-- row, which is where a preset that changes other rows belongs.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
|
||||
--
|
||||
-- 1ST and 3RD are on the path: they change the camera and only the camera,
|
||||
-- which is exactly what the key promises -- and the key is also the way back
|
||||
-- OUT of them on a keyboard, where the mouse is captured and the OPTIONS
|
||||
-- menu is a trip.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6, 7 } -- OFF,15,35,50,75,1ST,3RD
|
||||
|
||||
-- The rung a press moves to from `level`.
|
||||
--
|
||||
|
||||
+1440
File diff suppressed because it is too large
Load Diff
+20
-3
@@ -56,11 +56,28 @@ WorldCurve.LABEL = "V-CURVE"
|
||||
-- of the town -- which stops being a look and starts being an occlusion
|
||||
-- bug, since what has rolled away is still there to walk into. (The first
|
||||
-- cut ran 0.18/0.35/0.60 and every rung of it was a marble.)
|
||||
WorldCurve.AMOUNTS = { 0, 0.05, 0.10, 0.18 }
|
||||
--
|
||||
-- 4 AND 5 ARE PAST THAT LINE ON PURPOSE, and they are for the DIORAMA:
|
||||
-- once the world is a model being looked at from outside rather than a
|
||||
-- place being walked around in, "the horizon has closed over the next
|
||||
-- block" stops being a bug and becomes the entire effect -- the town on
|
||||
-- top of a little planet.
|
||||
--
|
||||
-- 5 is the HALF SPHERE, and it is not eyeballed. The drop is a parabola,
|
||||
-- y = k d^2 with k = amount / vh, and the parabola that osculates a sphere
|
||||
-- of radius R at its pole is y = d^2 / 2R -- so k = 1 / 2R, and an amount
|
||||
-- of 1.0 gives R = vh / 2. The diorama's box is cut at exactly half a view
|
||||
-- height (Diorama.BOX_FRAC), so at amount 1.0 the model's own rim is that
|
||||
-- sphere's EQUATOR: the ground turns 45 degrees by the edge of the cut and
|
||||
-- is falling vertically a view-height out. A dome, ending where the model
|
||||
-- ends. 4 is the step between it and 3, geometrically rather than
|
||||
-- arithmetically -- the effect goes as the square of distance, so even
|
||||
-- steps in `amount` would bunch the whole ladder at the bottom.
|
||||
WorldCurve.AMOUNTS = { 0, 0.05, 0.10, 0.18, 0.42, 1.00 }
|
||||
|
||||
WorldCurve.setting = ModSetting.new(WorldCurve.KEY, WorldCurve.LABEL,
|
||||
{ 0, 1, 2, 3 },
|
||||
{ "OFF", "1", "2", "3" })
|
||||
{ 0, 1, 2, 3, 4, 5 },
|
||||
{ "OFF", "1", "2", "3", "4", "5" })
|
||||
|
||||
function WorldCurve.level()
|
||||
return WorldCurve.setting:get() or 0
|
||||
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.3.0",
|
||||
"version": "1.7.2",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
@@ -10,10 +10,11 @@
|
||||
"priority": 100,
|
||||
"dependencies": [],
|
||||
"optional_dependencies": [],
|
||||
"conflicts": [],
|
||||
"conflicts": ["ds_fp_ceiling"],
|
||||
"permissions": [
|
||||
"engine_internals"
|
||||
],
|
||||
"affects_link": false,
|
||||
"description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- 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": "Draws the overworld as a 3D diorama.",
|
||||
"github": "DramaticShape/DramaticShapeVoxelMod"
|
||||
}
|
||||
|
||||
@@ -10,35 +10,76 @@ return {
|
||||
changed = {
|
||||
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
|
||||
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
|
||||
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
|
||||
"with 3D-BTL on 2D-3D A, a battle draws over the map's nearest clear ground instead of over a white field",
|
||||
"with 3D-BTL on a B rung, the fight is staged on two discs against the sky with no map drawn at all, which works on every map including the caves and shop floors that have nowhere to stage a fight",
|
||||
"with 3D-BTL on STADIUM A or STADIUM B, the same fight is staged with the Pokemon Stadium battle models in place of the flat pics -- 148 of the 151 species, skinned and animated, playing the animation the move being used actually calls for; Exeggutor, Tangela and Magmar have corrupt animation data at source and keep their battle sprites",
|
||||
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
|
||||
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
|
||||
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
|
||||
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
|
||||
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
|
||||
"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 and 3RD rungs 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 and 3RD rungs 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 3RD rung the character turns to face where they are walking rather than where the camera looks, so a strafe reads as one; standing still they come back round to the camera's bearing, which is the one A talks along",
|
||||
"on 1ST and 3RD the wall-collision sound is gone: a free walk slides along every wall it grazes rather than refusing a discrete step, so the bonk rang twice a second for walking down a corridor",
|
||||
"the BATTLE BG options row is taken OFF the menu and pinned to WHITE for as long as this mod is installed -- the mode fills the window with the map, so the row's own question (what to put in the voids around the battle) has no voids left to be about, and its WORLD setting drew a second dimmed copy of the overworld under the arena; uninstalling puts the row back",
|
||||
"a staged battle's camera can be steered by the player -- right stick, touch drag or mouse to swing it around the arena and raise it, wheel / Q / E / pinch / stick click for the lens -- between the shot the rig was solved for and a side-on view of the arena, and it opens the lens as it goes so both Pokemon stay framed; the angle and lens carry into the next battle",
|
||||
"with BACK SPRITES on the battle camera is held at the solved shot, because that setting pins your own Pokemon to the menu's slot while the foe stands on the map and no angle holds a half-framed, half-solid composition",
|
||||
},
|
||||
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 / 3RD -- a first-person camera and a third-person one, both with free look and free movement)",
|
||||
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
|
||||
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
|
||||
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
|
||||
"Q and E zoom whichever camera is in front of you -- the third-person boom, a staged battle's lens, or the engine's own survey zoom on an orbit rung -- alongside the mouse wheel, a two-finger pinch, and the pad's left and right stick clicks (out and in). 1ST claims none of them: the eye is in the player's head and there is no distance to change",
|
||||
"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 (2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF, 2D-3D A by default), battles fought in 3D -- 2D-3D stands the game's own pics up as cards and STADIUM replaces them with the Pokemon Stadium battle models, while A stages the fight on the map and B on two carried discs against the sky. Only the STADIUM rungs need a ROM, and they are on the row once those models have been built (see below); 2D-3D B is generated in Lua and needs nothing",
|
||||
"the STADIUM animations are driven from the fight: a move plays the animation that species' own battle table names for it (so DIG really does put Diglett into the ground), fainting plays the faint and holds there, and a send-out grows the Pokemon out of the ball and plays the entrance. Damage plays nothing -- the set has no reaction animation in it, and the engine's own flash, blink and HP drain already say so. The eyes blink and go dizzy, and Charmander's tail flame and Weezing's gas are drawn over the body",
|
||||
"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 / STANDARD / DIORAMA / DIORAMA-MR, off by default; a save that stored the old toggle as true comes back on STANDARD). STANDARD is the mode below. DIORAMA is one presentation instead of a ladder: the world is always the model on the table, cut to an invisible BOX centred on the view -- a square slab of world with a HARD edge, because a flat world is a thing with sides -- which V-CURVE turns into a BALL whose rim is a gradient fade into the same sky (the cut reaches terrain, cast, grass, water and the forest's beams alike), with a staged fight ignoring both and cutting a vertical PILLAR about the arena -- always dissolved at the rim -- and framing the model to it: the fight lifted out of the map as a floating disc. The grips take hold of it: one hand carries the model through the room, both hands turn it and open the viewport out. The left stick's click throws V-CURVE to its top rung and back instead of stepping views; there is no 2D diorama and no first-person one, so the VOXEL ladder is held on an orbit rung while the mode runs and the Pokedex stays away. DIORAMA-MR is the same with the background keyed pure green (no bands, no sun, no haze) for a mixed-reality capture",
|
||||
"VR STANDARD (the row's second rung): 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 tabletop the right stick zooms; under STANDARD a squeezed grip drags the table's height, and in a DIORAMA the grips take the model itself -- one carries it, both turn it and resize the viewport, and the left stick's click throws V-CURVE instead; 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",
|
||||
"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",
|
||||
"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 = {
|
||||
"needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly",
|
||||
"water reflections additionally need a READABLE depth canvas; a driver without one draws the flat animated water this mode always drew",
|
||||
"WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water",
|
||||
"a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line",
|
||||
"a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen",
|
||||
"the STADIUM rungs need the Pokemon Stadium battle models, and the mod ships none of them -- they are that game's data. Press STADIUM ROM on the OPTIONS menu to pick one with the system file dialog, or drop one in a baseroms/ folder beside the game. It must be Pokemon Stadium (US) 1.0 (md5 ed1378bc12115f71209a77844965ba50) -- every offset in the reader is keyed to that cartridge, and anything else is refused or builds wrong models; either way the 151 models are built out of it on a loading screen, in about ten seconds, into the save directory, and the ROM itself is not kept. Until then the two rungs are simply not on the row. Once built, a rung declines per POKEMON rather than per battle: a species with no pack, a standing substitute doll, and the trainer's own pic before the send-out each fall back to the flat card on that side alone, with the other side keeping its model",
|
||||
"the STADIUM rungs size a Pokemon by its own model against the set's median, with the range compressed -- the authored heights span sixteenfold, from Caterpie to Gyarados, and a shared over-the-shoulder shot cannot hold that. The order and the feel of the differences survive; the literal ratios do not",
|
||||
"three species -- Exeggutor, Tangela and Magmar -- have standby loops that are corrupt in the source extraction, and are held at their bind pose so they stand still rather than coming apart",
|
||||
"the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it",
|
||||
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
|
||||
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
|
||||
"terrain meshes are cached per map, so the first frame after entering a large map costs a build",
|
||||
"1ST and 3RD need 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 and 3RD, 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",
|
||||
"3RD's boom shortens against whatever stands behind the player, so backing into a wall walks the camera in to their shoulders; squeezed all the way in it draws as 1ST until they step clear",
|
||||
"3RD in VR is 1ST in VR: a headset that seats its wearer three cells behind their own body is a well-known way to make people ill, so the boom is declined while a headset is live",
|
||||
"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 = {
|
||||
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
|
||||
{ who = "pret/pokestadium", for_ = "the decompilation the STADIUM extractor was written against -- the bone matrix chain and where scale is applied (func_800143C0), the rotation basis (func_8000F730), the animation and texture-animation samplers (func_80016FBC / func_80017540), the battle context slots and the move-id constants. No code or data from it is included or redistributed here; see README.md" },
|
||||
{ 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.37 <2.0.0", modApi = 2 },
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Pokemon Stadium (US) — battle model export
|
||||
|
||||
> **Built on [pret/pokestadium](https://github.com/pret/pokestadium).** This
|
||||
> pipeline is original code, but it could not have been written without that
|
||||
> project's decompilation: the bone matrix chain and the fact that scale is
|
||||
> kept out of it (`func_800143C0`), the rotation basis (`func_8000F730`), the
|
||||
> animation and texture-animation samplers (`func_80016FBC`, `func_80017540`),
|
||||
> the battle context slots, and the move-id constants all came from reading
|
||||
> it. **No code or data from that project is vendored here or required to run
|
||||
> this** — see the mod's [README](../README.md#acknowledgements--pretpokestadium),
|
||||
> and get anything you want to reuse from upstream under its own terms.
|
||||
>
|
||||
> No ROM data is committed either: everything below is generated from a
|
||||
> cartridge you supply.
|
||||
|
||||
All 151 battle Pokemon plus 64 other models from the same segment, in standard
|
||||
formats. Regenerate straight from the ROM — stdlib only, no `make init`, no
|
||||
splat, no crunch64:
|
||||
|
||||
```bash
|
||||
model_extract/pipeline/build.py
|
||||
```
|
||||
|
||||
Put a US 1.0 ROM in [baseroms/](baseroms/) (`.z64`, `.n64` or `.v64`), or pass
|
||||
`--rom=PATH`. See [pipeline/README.md](pipeline/README.md) for the module layout,
|
||||
how the ROM is unpacked, and the generated-effects notes.
|
||||
|
||||
```
|
||||
viewer.html browse everything in the browser — open it directly
|
||||
manifest.json every model + what each of its animations is used for
|
||||
moves.json all 165 moves + the animation each species plays for them
|
||||
glb/025_pikachu.glb glTF 2.0 binary: mesh, skeleton, skin, animations, textures
|
||||
glb/x152_model.glb non-Pokemon models from the same segment (props, trophies…)
|
||||
textures/025_pikachu/ the same textures as loose PNGs, named <n>_<w>x<h>.png
|
||||
js/ viewer payloads, one per model, plus index.js and moves.js
|
||||
```
|
||||
|
||||
`viewer.html` has a filterable picker for every model plus prev/next/random, a
|
||||
**move picker** that jumps to whichever animation the current Pokemon plays for
|
||||
that move, per-animation playback with a frame scrubber, an eye/texture-animation
|
||||
selector, and texture/lighting/wireframe/skeleton toggles. It reads `js/`, not
|
||||
`glb/`, because browsers block `fetch` of local files from `file://` — script
|
||||
injection is what lets the page work when you just double-click it.
|
||||
|
||||
Sizes: 73 MB `glb/`, 54 MB `js/`, 7.4 MB `textures/`. Two flags if you want less:
|
||||
`--no-js` skips the viewer payloads and `viewer.html` (leaving the glTF export
|
||||
alone), and `--manifest-only` rebuilds just `manifest.json`.
|
||||
|
||||
`.glb` files are self-contained and open directly in Blender, Maya, Unity, Unreal,
|
||||
three.js, Godot, Windows 3D Viewer, macOS Quick Look, and https://gltf-viewer.donmccurdy.com.
|
||||
Source file `N.bin` holds species `N + 1`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Y up, +Z front, right-handed — glTF standard.
|
||||
- Animations are authored at **30 fps**; keyframe times are `frame / 30`.
|
||||
- Units are game units. Models are authored 10x and scaled down by the
|
||||
`model_root` node, matching the geo layout's scale command.
|
||||
- Textures are `CLAMP_TO_EDGE`, materials are `alphaMode: MASK` with cutoff 0.5
|
||||
(N64 RGBA5551 has one bit of alpha). `doubleSided` follows the display list's
|
||||
cull mode.
|
||||
|
||||
## Skeleton
|
||||
|
||||
The game keeps bone scale *out* of the matrix chain (`func_800143C0` in
|
||||
[src/12D80.c](../src/12D80.c)): scale accumulates in its own stack, a bone's local
|
||||
translation is pre-multiplied by the parent's accumulated scale, and the
|
||||
accumulated scale is applied to the rows of the finished world matrix only at
|
||||
draw time. glTF node TRS instead propagates scale multiplicatively to children,
|
||||
so a 1:1 node mapping would be wrong wherever a non-uniformly scaled bone has
|
||||
descendants.
|
||||
|
||||
Each game bone is therefore exported as two nodes:
|
||||
|
||||
| node | role |
|
||||
| --- | --- |
|
||||
| `boneNN` | pivot: `translation = t * accScale(parent)`, `rotation = R`, scale 1 |
|
||||
| `boneNN_scale` | leaf child holding `scale = accScale(bone)`, so it cannot propagate |
|
||||
|
||||
The skin binds to the `boneNN_scale` nodes, whose world matrices then equal the
|
||||
game's draw matrices exactly. Vertices are already in bone-local space and each is
|
||||
rigidly bound to one bone, so all inverse bind matrices are identity.
|
||||
|
||||
This was verified by parsing each exported `.glb` back and diffing every joint
|
||||
matrix against a reference implementation of the game's own math, over the bind
|
||||
pose and four sampled frames of every animation. Worst-case disagreement is
|
||||
~1e-2 game units on models spanning 20–40 units, entirely from storing rotations
|
||||
as spec-normalised `SHORT` quaternions.
|
||||
|
||||
## Animation semantics
|
||||
|
||||
Per-species battle data lives in `assets/us/70D3A0.bin`, 0xB90 bytes per species,
|
||||
DMA'd into the battle system by `func_84302658` via the `D_80075BD0[species-1]`
|
||||
pointer table. It is an array of 0x10-byte entries; byte 0 of each entry is an
|
||||
index into that Pokemon's animation list and byte 1 indexes the auxiliary list:
|
||||
|
||||
- **entries 0–164** — one per move, in the move ID order of
|
||||
[oldnotes/stadium1/constants/move_constants.s](../oldnotes/stadium1/constants/move_constants.s).
|
||||
Entry *n* gives the animation played when the Pokemon uses move *n + 1*.
|
||||
- **entries 165+** — fixed battle contexts (idle, hit, faint, …).
|
||||
|
||||
Every one of the 151 species' tables indexes only animations that exist in that
|
||||
species' list, which is what confirms the layout.
|
||||
|
||||
`manifest.json` reports, for each animation, the exact list of moves that trigger
|
||||
it plus which context slots reference it. `moves.json` inverts that: every move,
|
||||
and the animation each of the 151 species plays for it.
|
||||
|
||||
One caveat when reading move data: the table is **dense**. Every species has a
|
||||
row for every move, including moves it can never learn, and those unreachable
|
||||
rows overwhelmingly point at the species' generic reaction animation — the same
|
||||
one the `hit` slot uses. So "118 species play Thunderbolt" is an artifact, not a
|
||||
fact about the game. `moves.json` marks each row with `differsFromDefault` and
|
||||
gives a `speciesWithOwnAnimation` count per move; treat those as the signal. The `animationSlots` section carries an
|
||||
`evidence` field per slot:
|
||||
|
||||
- `code` — the battle code in `src/fragments/62` names the slot outright.
|
||||
- `data` — inferred from what the referenced animation actually does, measured
|
||||
across all 151 species. For example slot 167 is labelled `faint` because its
|
||||
animation always ends far from the standing pose (the model drops to
|
||||
0.03–0.84x idle height, or leaves the frame entirely for fliers), while slot
|
||||
168's animation always ends at exactly idle height.
|
||||
|
||||
`endBehavior` reports what the animation player does past the last frame
|
||||
(`func_80016FBC`): every animation in the game wraps back to `loopStartFrame`, so
|
||||
one-shots like `faint` are ended by the battle state machine switching animation,
|
||||
not by the player clamping. glTF has no loop flag, so importers will loop clips by
|
||||
default.
|
||||
|
||||
The common layout, consistent across nearly every species:
|
||||
|
||||
| animation | role |
|
||||
| --- | --- |
|
||||
| 0 | idle / standby loop (all 151 species) |
|
||||
| 1 | second idle-length animation, rarely referenced by the context slots |
|
||||
| 2 | hit / damage reaction (149–151 species across slots 166, 178–181) |
|
||||
| 3 … n-3 | attack animations, selected per move |
|
||||
| n-2 | faint (slots 167, 177) |
|
||||
| n-1 | entrance / return-to-idle cycle (slots 168, 183) |
|
||||
|
||||
## Texture animations (blinking, dizzy eyes)
|
||||
|
||||
The second animation list in the model root is a *texture* animation, not a
|
||||
skeletal one (`src/18140.c`). Geo command `0x23` carries a channel index at
|
||||
offset `0x02`; when it is `>= 0`, `func_800176DC` replaces that material's
|
||||
texture every frame from a per-frame stream of texture-table indices.
|
||||
|
||||
Charmander's eyes are the clearest example — texture 2 is the open eye, 3 and 4
|
||||
are blink frames, and 5–7 are the dizzy swirl:
|
||||
|
||||
| aux animation | frames | texture stream |
|
||||
| --- | --- | --- |
|
||||
| 0 | 10 | `2 2 3 3 4 4 4 3 3 2` — a blink |
|
||||
| 2 | 122 | cycles `5 6 7` — confusion swirl |
|
||||
| 4 | 18 | a slower blink |
|
||||
|
||||
The viewer plays these. Each skeletal animation is paired with the texture
|
||||
animation the battle table most often sets alongside it, and the `eyes` dropdown
|
||||
overrides that. glTF 2.0 has no texture-swap animation channel, so this data
|
||||
lives in `js/` and `moves.json` rather than the `.glb` — the `.glb` files carry
|
||||
the first frame's texture on each material.
|
||||
|
||||
## Known gaps
|
||||
|
||||
- **Move effect visuals are not here.** Geo command `0x24` does not draw
|
||||
anything: `func_80014CB8` just records an attachment point (an id plus a world
|
||||
position) on the Pokemon, and the battle system spawns particles there. Ids
|
||||
1–14 are generic and used by nearly every species. So Charmander's tail flame,
|
||||
beams, explosions and the like are drawn by the effect system in the battle
|
||||
fragments and are not present in these model files — Charmander's texture set
|
||||
contains eyes, claws, teeth and skin, and no flame.
|
||||
- **The Poke Ball throw/open model was not found.** It is not in this segment,
|
||||
no other `assets/us/**.bin` contains a model fragment, and scanning the ROM
|
||||
ranges of fragments 62–64 for embedded model headers found none. What does
|
||||
exist is Poke Ball *2D* artwork in fragment 29
|
||||
(`fragments/29/fragment29_unk_bin_*`, flagged in the splat yaml). The throw is
|
||||
most likely built from raw display lists rather than a geo-layout model.
|
||||
- Files 151–214 are exported as `x<file>_model` with generic names. They are
|
||||
props, trophies, minigame pieces and similar; only Surfing Pikachu (file 152,
|
||||
the same 37-bone / 723-triangle rig as Pikachu) is named with confidence. They
|
||||
carry no battle table, so their animations are left unnamed.
|
||||
@@ -0,0 +1,5 @@
|
||||
baserom.z64
|
||||
*.z64
|
||||
*.n64
|
||||
*.v64
|
||||
*:Zone.Identifier
|
||||
@@ -0,0 +1,25 @@
|
||||
# Put the ROM here
|
||||
|
||||
`pipeline/build.py` looks for a Pokemon Stadium (US 1.0) ROM in this folder:
|
||||
|
||||
model_extract/baseroms/baserom.z64
|
||||
|
||||
`.z64`, `.n64` and `.v64` byte orders are all accepted — the pipeline detects the
|
||||
magic and normalises on load. Any ROM file dropped in this folder is picked up.
|
||||
|
||||
Expected md5 of the US 1.0 ROM: `ed1378bc12115f71209a77844965ba50`. A different
|
||||
ROM still runs, but the build prints a warning since the offsets are keyed to
|
||||
this revision.
|
||||
|
||||
Search order (first hit wins):
|
||||
|
||||
1. `model_extract/baseroms/baserom.z64`
|
||||
2. `model_extract/baseroms/us/baserom.z64`
|
||||
3. `baseroms/us/baserom.z64` at the repo root — the location `make init` uses
|
||||
4. any `*.z64` / `*.n64` / `*.v64` in this folder
|
||||
|
||||
Or point at one explicitly:
|
||||
|
||||
model_extract/pipeline/build.py --rom=/path/to/baserom.z64
|
||||
|
||||
The ROM is not included and is not tracked by git.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Export pipeline
|
||||
|
||||
End-to-end: `baserom.z64` in, everything in `model_extract/` out. **Stdlib only** —
|
||||
no `make init`, no splat, no crunch64, no build directory.
|
||||
|
||||
```bash
|
||||
model_extract/pipeline/build.py # finds the ROM automatically
|
||||
model_extract/pipeline/build.py --rom=/path/to.z64 --out=/tmp/out
|
||||
```
|
||||
|
||||
The ROM is looked for in `model_extract/baseroms/` first, so this folder stands
|
||||
on its own; the repo's own `baseroms/us/` is the fallback. Search order:
|
||||
|
||||
1. `model_extract/baseroms/baserom.z64`
|
||||
2. `model_extract/baseroms/us/baserom.z64`
|
||||
3. `baseroms/us/baserom.z64` at the repo root — where `make init` expects it
|
||||
4. any `*.z64` / `*.n64` / `*.v64` sitting in `model_extract/baseroms/`
|
||||
|
||||
`.z64`, `.n64` and `.v64` all work — the byte order is detected from the magic
|
||||
and normalised on load. See [../baseroms/README.md](../baseroms/README.md).
|
||||
|
||||
| flag | effect |
|
||||
| --- | --- |
|
||||
| `--only=3,91` | restrict to those model file numbers (fast iteration) |
|
||||
| `--no-glb` | skip the glTF binaries and PNG dumps |
|
||||
| `--no-js` | skip the viewer payloads and `viewer.html` |
|
||||
| `--no-effects` | skip the generated fire/gas stand-ins |
|
||||
|
||||
## Modules
|
||||
|
||||
| file | does |
|
||||
| --- | --- |
|
||||
| `rom.py` | byte-order fixup (.z64/.v64/.n64), md5 check, archive unpacking, Yay0 and PERS-SZP decompression |
|
||||
| `fragment.py` | FRAGMENT module → geo layout walk, F3DEX2 execution, textures, skeleton, animations |
|
||||
| `battle.py` | per-species battle tables, move names, animation-context slot meanings |
|
||||
| `glb.py` | glTF 2.0 binary writer |
|
||||
| `effects.py` | **generated** fire/gas stand-ins (see below) |
|
||||
| `build.py` | driver: ties it together, writes manifests |
|
||||
|
||||
## Getting from ROM to models without the build system
|
||||
|
||||
Three steps, all in `rom.py`:
|
||||
|
||||
1. **Byte order.** `.z64` is native; `.v64` swaps byte pairs; `.n64` reverses
|
||||
words. Detected from the magic and normalised on load.
|
||||
2. **Archive.** The segment at `0x920000` starts with
|
||||
`u32 tag, u32 0, u32 totalSize, u32 fileCount`, then one
|
||||
`{u32 offset, u32 size, u32 pad[2]}` record per file. Only the top three
|
||||
bytes of the first word are reliably zero — the model archive puts a nonzero
|
||||
value in the low byte, which is the quirk `tools/unpack_asset.py` works
|
||||
around too.
|
||||
3. **Decompression.** Each entry is `PERS-SZP` (an 8-byte magic plus a header
|
||||
size, wrapping a Yay0 stream). The Yay0 decoder is ~30 lines: a bitstream
|
||||
where a 1 copies a literal byte and a 0 pulls a (distance, length) pair.
|
||||
|
||||
Verified by decompressing all 215 entries and diffing against what
|
||||
`make init` produces: **215/215 byte-identical**.
|
||||
|
||||
The battle tables need one more hop — `D_80075BD0[species - 1]` lives in the main
|
||||
code segment, so `Rom.vram_to_rom` converts `0x80075BD0` using the segment's
|
||||
`start`/`vram` from the splat yaml.
|
||||
|
||||
## Generated effects
|
||||
|
||||
`effects.py` produces **original, procedurally generated** fire and gas. It is
|
||||
not extracted game data, and everything it emits is tagged `generated: true` in
|
||||
the manifest, the viewer payloads and the PNG filenames (`*_fx.png`).
|
||||
|
||||
This exists because the real effects are not in the model files at all. Geo
|
||||
command `0x08` attaches a callback (`func_80014A60` calls `node->unk_10`), and
|
||||
the model supplies only two empty display lists and zeroed scratch buffers for
|
||||
it to fill. The callback lives in another fragment and has not been ported, so
|
||||
there is no flame mesh or flame texture to extract — Charmander's texture set is
|
||||
eyes, claws, teeth and skin.
|
||||
|
||||
The stand-ins are anchored to the exact bone the callback hangs off, so they sit
|
||||
where the real effect would and follow the animation:
|
||||
|
||||
| callback | species | stand-in |
|
||||
| --- | --- | --- |
|
||||
| `0x810000D8` | Charmander, Charmeleon, Charizard, Magmar, Moltres | tail/crest flame |
|
||||
| `0x81000108` | Ponyta, Rapidash, Moltres wings | small flame |
|
||||
| `0x810000E0` | Gastly (only) | gas cloud |
|
||||
|
||||
Both are looping flipbooks built from tileable value noise, drawn on a pair of
|
||||
crossed quads — glTF cannot billboard, so crossed quads are the portable way to
|
||||
make them read from any angle. Sizes are expressed as a fraction of the model's
|
||||
**height** and divided by the anchor bone's accumulated scale, so an effect comes
|
||||
out the intended size wherever in the skeleton it hangs. Seeds derive from the
|
||||
species number, so a given Pokemon always generates the same effect.
|
||||
|
||||
In the viewer they draw in a second pass with depth writes off — additive for
|
||||
fire, alpha for gas — and there is a *generated effects* toggle to hide them.
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Per-species battle data.
|
||||
|
||||
`func_84302658` in src/fragments/62 DMAs a 0xB90-byte table per species out of
|
||||
the 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. It is an
|
||||
array of 0x10-byte entries: byte 0 is an index into that Pokemon's animation
|
||||
list, byte 1 indexes the auxiliary (texture) animation list.
|
||||
|
||||
entries 0..164 one per move, so entry n drives move n + 1
|
||||
entries 165+ fixed battle contexts
|
||||
|
||||
Every one of the 151 species' tables indexes only animations that species
|
||||
actually has, which is what confirms the layout.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
|
||||
STRIDE = 0xB90
|
||||
ENTRY = 0x10
|
||||
N_MOVES = 165
|
||||
|
||||
# `evidence` records how far each label can be trusted:
|
||||
# code - the battle code in src/fragments/62 names the slot outright
|
||||
# data - inferred from what the referenced animation does, measured over all
|
||||
# 151 species
|
||||
CONTEXT_SLOTS = {
|
||||
165: ('idle', 'code',
|
||||
'The standby loop. func_8432B0A4 restores this slot whenever the Pokemon '
|
||||
'returns to neutral, and it resolves to animation 0 for all 151 species.'),
|
||||
166: ('attack_default', 'data',
|
||||
'Resolves to animation 2 for 149/151 species, the same animation slots '
|
||||
'178-181 use in the reaction paths. Called "hit" until the move table '
|
||||
'was read against it: it is the animation most of a species\' MOVES '
|
||||
'play, the default attack rather than a damage reaction (the name has '
|
||||
'to match lib/StadiumPack.lua\'s CONTEXT and StadiumBuild\'s CONTEXTS).'),
|
||||
167: ('faint', 'data',
|
||||
'The referenced animation always ends far from the standing pose - the '
|
||||
'model collapses to 0.03-0.84x its idle height, or leaves the frame '
|
||||
'entirely for fliers.'),
|
||||
168: ('entrance', 'code+data',
|
||||
'The default slot in func_8430506C / func_8432AF70. The referenced '
|
||||
'animation ends at exactly idle height, so it is a full cycle that '
|
||||
'settles back into the standby pose.'),
|
||||
169: ('reaction_169', 'data', 'Resolves to the idle animation for 139/151 species.'),
|
||||
170: ('reaction_170', 'data', 'Split between the idle and hit animations.'),
|
||||
171: ('reaction_171', 'data', 'Resolves to the idle animation for 138/151 species.'),
|
||||
172: ('reaction_172', 'data', 'Resolves to the idle animation for 148/151 species.'),
|
||||
173: ('reaction_173', 'data', 'Resolves to the hit animation for 97/151 species.'),
|
||||
174: ('reaction_174', 'data', 'Resolves to the idle animation for 138/151 species.'),
|
||||
175: ('struggle', 'code', 'Passed as slot 0xAF to func_84305A74.'),
|
||||
176: ('idle_alt', 'code',
|
||||
'Substituted for the idle slot when battle flag 0x200 is set.'),
|
||||
177: ('faint_alt', 'data', 'Same animation as slot 167 for almost every species.'),
|
||||
178: ('flinch', 'code',
|
||||
'Used when the incoming move is one of the two listed in D_84384598.'),
|
||||
179: ('reaction_179', 'data', 'Resolves to the hit animation for all 151 species.'),
|
||||
180: ('reaction_180', 'data', 'Resolves to the hit animation for all 151 species.'),
|
||||
181: ('reaction_181', 'data', 'Resolves to the hit animation for all 151 species.'),
|
||||
182: ('reaction_182', 'data',
|
||||
'Resolves to animation 0 for 136 species and animation 1 for the other 15.'),
|
||||
183: ('entrance_alt', 'data', 'Same animation as slot 168 for every species.'),
|
||||
184: ('idle_return', 'code', 'Passed as slot 0xB8 to func_84305A74.'),
|
||||
}
|
||||
|
||||
MOVE_CONSTANTS = 'oldnotes/stadium1/constants/move_constants.s'
|
||||
|
||||
|
||||
def load_move_names(repo_root='.'):
|
||||
"""Move IDs come from the repo's own extracted constants when available."""
|
||||
path = os.path.join(repo_root, MOVE_CONSTANTS)
|
||||
names = {}
|
||||
if os.path.exists(path):
|
||||
for line in open(path, encoding='utf-8', errors='replace'):
|
||||
# the file also carries an ABC_* section indexing moves alphabetically
|
||||
# by their Japanese names; only the real move IDs are wanted
|
||||
if ' EQU ' not in line or line.startswith('ABC_'):
|
||||
continue
|
||||
name, val = line.split(' EQU ')
|
||||
val = val.split(';', 1)[0].strip()
|
||||
if val:
|
||||
names[int(val, 0)] = name.strip().replace('_', ' ').title()
|
||||
return {i: names.get(i, f'Move {i}') for i in range(1, N_MOVES + 1)}
|
||||
|
||||
|
||||
class BattleTables:
|
||||
def __init__(self, rom):
|
||||
from rom import BATTLE_DATA, PTR_TABLE_VRAM
|
||||
self.rom = rom
|
||||
self.base = BATTLE_DATA
|
||||
self.ptr_table = rom.vram_to_rom(PTR_TABLE_VRAM)
|
||||
|
||||
def offset(self, species):
|
||||
raw = self.rom.u32(self.ptr_table + (species - 1) * 4)
|
||||
return self.base + (raw & 0xFFFFFF)
|
||||
|
||||
def rows(self, species):
|
||||
"""Returns [(animIndex, auxIndex)] for every entry, aux 0xFF -> -1."""
|
||||
o = self.offset(species)
|
||||
out = []
|
||||
for e in range(STRIDE // ENTRY):
|
||||
anim = self.rom.data[o + e * ENTRY]
|
||||
aux = self.rom.data[o + e * ENTRY + 1]
|
||||
out.append((anim, -1 if aux == 0xFF else aux))
|
||||
return out
|
||||
|
||||
|
||||
SPECIES = {}
|
||||
_NAMES = (
|
||||
"Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise "
|
||||
"Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate "
|
||||
"Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen "
|
||||
"NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat "
|
||||
"Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian "
|
||||
"Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra "
|
||||
"Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude "
|
||||
"Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio "
|
||||
"Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby "
|
||||
"Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung "
|
||||
"Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking "
|
||||
"Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras "
|
||||
"Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl "
|
||||
"Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split()
|
||||
for _i, _n in enumerate(_NAMES):
|
||||
SPECIES[_i + 1] = _n
|
||||
@@ -0,0 +1,345 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
End-to-end export: baserom.z64 -> glb / textures / viewer payloads / manifests.
|
||||
|
||||
model_extract/pipeline/build.py [--rom PATH] [--out DIR] [options]
|
||||
|
||||
Stdlib only. Nothing here needs `make init`, splat or crunch64 -- the ROM is
|
||||
read, decompressed and parsed directly.
|
||||
|
||||
Options:
|
||||
--no-js skip the viewer payloads and viewer.html
|
||||
--no-glb skip the glTF binaries and PNG dumps
|
||||
--no-effects skip the generated fire/gas stand-ins
|
||||
--only N[,N] restrict to these model file numbers (for quick iteration)
|
||||
"""
|
||||
import base64
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
REPO = os.path.abspath(os.path.join(HERE, '..', '..'))
|
||||
|
||||
import battle
|
||||
import effects as fx_gen
|
||||
import fragment
|
||||
import glb as glb_mod
|
||||
import rom as rom_mod
|
||||
|
||||
N_POKEMON = 151
|
||||
EXTRA_NAMES = {152: 'Surfing Pikachu'} # only the one identified with confidence
|
||||
|
||||
# Where to look for the ROM, in order. model_extract/baseroms/ comes first so the
|
||||
# folder can stand on its own; the repo's own baseroms/ is the fallback.
|
||||
BASEROMS = os.path.join(os.path.dirname(HERE), 'baseroms')
|
||||
ROM_CANDIDATES = [
|
||||
os.path.join(BASEROMS, 'baserom.z64'),
|
||||
os.path.join(BASEROMS, 'us', 'baserom.z64'),
|
||||
os.path.join(REPO, 'baseroms', 'us', 'baserom.z64'),
|
||||
]
|
||||
|
||||
|
||||
def find_rom():
|
||||
for p in ROM_CANDIDATES:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
if os.path.isdir(BASEROMS): # any ROM dropped in the folder
|
||||
for f in sorted(os.listdir(BASEROMS)):
|
||||
if f.lower().endswith(('.z64', '.n64', '.v64')):
|
||||
return os.path.join(BASEROMS, f)
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------- bind extent
|
||||
|
||||
def bind_extent(data):
|
||||
"""World-space bounding box of the bind pose, used to size the effects."""
|
||||
def trs(t, r, s):
|
||||
S = lambda v: math.sin(v / 32768 * math.pi)
|
||||
C = lambda v: math.cos(v / 32768 * math.pi)
|
||||
sx, cx = S(r[0]), C(r[0]); sy, cy = S(r[1]), C(r[1]); sz, cz = S(r[2]), C(r[2])
|
||||
return [cy*cz*s[0], cy*sz*s[0], -sy*s[0], 0,
|
||||
(sx*sy*cz-cx*sz)*s[1], (sx*sy*sz+cx*cz)*s[1], sx*cy*s[1], 0,
|
||||
(cx*sy*cz+sx*sz)*s[2], (cx*sy*sz-sx*cz)*s[2], cx*cy*s[2], 0,
|
||||
t[0], t[1], t[2], 1]
|
||||
|
||||
def mul(a, b):
|
||||
r = [0]*16
|
||||
for c in range(4):
|
||||
for i in range(4):
|
||||
r[c*4+i] = a[i]*b[c*4] + a[4+i]*b[c*4+1] + a[8+i]*b[c*4+2] + a[12+i]*b[c*4+3]
|
||||
return r
|
||||
|
||||
root = trs([0, 0, 0], [0, 0, 0], data['rootScale'])
|
||||
acc, uns, mats = [], [], []
|
||||
for b in data['bones']:
|
||||
pa = acc[b['parent']] if b['parent'] >= 0 else [1.0, 1.0, 1.0]
|
||||
pu = uns[b['parent']] if b['parent'] >= 0 else root
|
||||
u = mul(pu, trs([b['t'][k]*pa[k] for k in range(3)], b['r'], [1, 1, 1]))
|
||||
a = [pa[k]*b['s'][k] for k in range(3)]
|
||||
m = list(u)
|
||||
for k in range(4):
|
||||
m[k] *= a[0]; m[4+k] *= a[1]; m[8+k] *= a[2]
|
||||
acc.append(a); uns.append(u); mats.append(m)
|
||||
|
||||
lo = [1e9]*3; hi = [-1e9]*3
|
||||
for p in data['prims']:
|
||||
for i, bi in enumerate(p['skin']):
|
||||
m = mats[bi]
|
||||
x, y, z = p['pos'][i*3:i*3+3]
|
||||
w = (m[0]*x+m[4]*y+m[8]*z+m[12], m[1]*x+m[5]*y+m[9]*z+m[13],
|
||||
m[2]*x+m[6]*y+m[10]*z+m[14])
|
||||
for k in range(3):
|
||||
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
|
||||
# height, not the largest dimension: sizing off the max would scale Moltres'
|
||||
# flames to its wingspan
|
||||
extent = (hi[1] - lo[1]) if lo[0] <= hi[0] else 1.0
|
||||
# how much each bone scales its own local space, so effects can compensate
|
||||
scales = [math.sqrt(m[0]*m[0] + m[1]*m[1] + m[2]*m[2]) for m in mats]
|
||||
return extent, scales
|
||||
|
||||
|
||||
# ------------------------------------------------------------ animation names
|
||||
|
||||
# Which context name wins when several claim the same animation. The battle
|
||||
# table points many slots at one clip, and these are the ones worth naming.
|
||||
NAME_PREF = ['idle', 'attack_default', 'faint', 'entrance', 'struggle', 'flinch']
|
||||
|
||||
|
||||
def label_animations(data, rows, moves):
|
||||
"""Name each animation after what the battle table uses it for, and pair it
|
||||
with the texture animation that table most often sets alongside it.
|
||||
|
||||
Mutates `data['anims']`, giving each a `name` and an `aux`, and hands back
|
||||
the per-animation context and move lists the manifest reports. Factored out
|
||||
of main() because the mod's packer (tools/stadium_pack.py) has to label them
|
||||
exactly the same way for its output to be comparable with the Lua extractor
|
||||
that reads the same ROM at runtime.
|
||||
"""
|
||||
entries = [r[0] for r in rows]
|
||||
aux = [r[1] for r in rows]
|
||||
uses = [[] for _ in data['anims']]
|
||||
move_uses = [[] for _ in data['anims']]
|
||||
for e, ai in enumerate(entries):
|
||||
if ai >= len(uses):
|
||||
continue
|
||||
if e < battle.N_MOVES:
|
||||
move_uses[ai].append(moves[e + 1])
|
||||
elif e in battle.CONTEXT_SLOTS:
|
||||
uses[ai].append(battle.CONTEXT_SLOTS[e][0])
|
||||
pairs = [collections.Counter() for _ in data['anims']]
|
||||
for e, ai in enumerate(entries):
|
||||
if ai < len(pairs) and 0 <= aux[e] < len(data['auxAnims']):
|
||||
pairs[ai][aux[e]] += 1
|
||||
for i, a in enumerate(data['anims']):
|
||||
ctx = sorted(set(uses[i]))
|
||||
named = [n for n in NAME_PREF if n in ctx]
|
||||
a['name'] = (named[0] if named else 'attack' if move_uses[i]
|
||||
else ctx[0] if ctx else f'anim{i}')
|
||||
a['aux'] = pairs[i].most_common(1)[0][0] if pairs[i] else -1
|
||||
seen = {}
|
||||
for a in data['anims']:
|
||||
n = seen.get(a['name'], 0)
|
||||
seen[a['name']] = n + 1
|
||||
if n:
|
||||
a['name'] = f'{a["name"]}_{n + 1}'
|
||||
return uses, move_uses
|
||||
|
||||
|
||||
def attach_effects(data, species, raw=False):
|
||||
"""Append generated fire/gas prims + their flipbook textures.
|
||||
|
||||
`raw` matches fragment.extract's: the flipbook frames are already RGBA8, so
|
||||
they are stored as-is rather than encoded, for the packer that wants pixels.
|
||||
"""
|
||||
if not data.get('fx'):
|
||||
return 0
|
||||
extent, bone_scale = bind_extent(data)
|
||||
made = fx_gen.build_for(species, data['fx'], extent, bone_scale)
|
||||
for e in made:
|
||||
first = len(data['textures'])
|
||||
for i, frame in enumerate(e['frames']):
|
||||
rec = dict(index=-1, w=e['w'], h=e['h'], generated=True)
|
||||
if raw:
|
||||
rec['rgba'] = frame
|
||||
else:
|
||||
rec['png'] = ('data:image/png;base64,' + base64.b64encode(
|
||||
fragment.png(e['w'], e['h'], frame)).decode())
|
||||
data['textures'].append(rec)
|
||||
g = e['geo']
|
||||
data['prims'].append(dict(
|
||||
tex=first, cull=0, texAnim=-1, texMap={},
|
||||
generated=True, effect=e['kind'],
|
||||
blend='add' if e['kind'] == 'fire' else 'alpha',
|
||||
fxFrames=list(range(first, first + len(e['frames']))),
|
||||
pos=g['pos'], uv=g['uv'], nrm=g['nrm'], skin=g['skin'], idx=g['idx']))
|
||||
return len(made)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- main
|
||||
|
||||
def main(argv):
|
||||
args = {a.split('=')[0]: (a.split('=', 1)[1] if '=' in a else True) for a in argv}
|
||||
rom_path = args.get('--rom') or find_rom()
|
||||
outdir = args.get('--out') or os.path.join(REPO, 'model_extract')
|
||||
want_js = '--no-js' not in args
|
||||
want_glb = '--no-glb' not in args
|
||||
want_fx = '--no-effects' not in args
|
||||
only = {int(x) for x in args['--only'].split(',')} if '--only' in args else None
|
||||
|
||||
if not rom_path or not os.path.exists(rom_path):
|
||||
sys.exit('ROM not found. Put a Pokemon Stadium (US 1.0) ROM at\n'
|
||||
f' {os.path.join(BASEROMS, "baserom.z64")}\n'
|
||||
'or pass --rom=PATH. Searched:\n '
|
||||
+ '\n '.join(ROM_CANDIDATES))
|
||||
|
||||
print(f'reading {rom_path}')
|
||||
rom = rom_mod.Rom(rom_path)
|
||||
print(f' md5 {rom.md5}' + ('' if rom.is_expected_us else ' (NOT the expected US 1.0 ROM)'))
|
||||
|
||||
blobs = rom_mod.pokemon_models(rom)
|
||||
print(f' {len(blobs)} model fragments')
|
||||
tables = battle.BattleTables(rom)
|
||||
moves = battle.load_move_names(REPO)
|
||||
|
||||
for sub in ('glb', 'textures', 'js'):
|
||||
os.makedirs(os.path.join(outdir, sub), exist_ok=True)
|
||||
|
||||
manifest = dict(
|
||||
source='Pokemon Stadium (US) 1.0', romMd5=rom.md5,
|
||||
generator='model_extract/pipeline/build.py',
|
||||
coordinateSystem='Y up, +Z front, units are game units (models authored 10x, '
|
||||
'baked into the model_root node scale)',
|
||||
frameRate=30,
|
||||
generatedEffects='Prims and textures tagged generated:true are NOT extracted '
|
||||
'game data -- see pipeline/effects.py.',
|
||||
animationSlots={str(k): dict(name=v[0], evidence=v[1], description=v[2])
|
||||
for k, v in battle.CONTEXT_SLOTS.items()},
|
||||
pokemon=[], extra=[])
|
||||
move_rows, anim_names, index, fx_count = {}, {}, [], 0
|
||||
|
||||
for fileno, blob in enumerate(blobs):
|
||||
if only is not None and fileno not in only:
|
||||
continue
|
||||
try:
|
||||
data = fragment.extract(blob, f'{fileno}.bin')
|
||||
except Exception as exc:
|
||||
print(f' skip {fileno}: {exc}')
|
||||
continue
|
||||
species = data['species']
|
||||
pokemon = fileno < N_POKEMON
|
||||
|
||||
if pokemon:
|
||||
rows = tables.rows(species)
|
||||
uses, move_uses = label_animations(data, rows, moves)
|
||||
slug = f'{species:03d}_{battle.SPECIES.get(species, str(species)).lower()}'
|
||||
data['name'] = battle.SPECIES.get(species, f'#{species}')
|
||||
move_rows[species] = [[rows[e][0], rows[e][1]]
|
||||
for e in range(battle.N_MOVES)]
|
||||
anim_names[species] = [a['name'] for a in data['anims']]
|
||||
else:
|
||||
slug = f'x{fileno:03d}_model'
|
||||
data['name'] = EXTRA_NAMES.get(fileno, f'Model {fileno}')
|
||||
for i, a in enumerate(data['anims']):
|
||||
a['name'] = f'anim{i}'
|
||||
a['aux'] = 0 if data['auxAnims'] else -1
|
||||
|
||||
nfx = attach_effects(data, species) if want_fx else 0
|
||||
fx_count += nfx
|
||||
|
||||
pngs = [base64.b64decode(t['png'].split(',', 1)[1]) for t in data['textures']]
|
||||
if want_glb:
|
||||
with open(os.path.join(outdir, 'glb', slug + '.glb'), 'wb') as fp:
|
||||
fp.write(glb_mod.build_glb(data, pngs))
|
||||
texdir = os.path.join(outdir, 'textures', slug)
|
||||
os.makedirs(texdir, exist_ok=True)
|
||||
for i, (t, p) in enumerate(zip(data['textures'], pngs)):
|
||||
tag = '_fx' if t.get('generated') else ''
|
||||
with open(os.path.join(texdir, f'{i:02d}_{t["w"]}x{t["h"]}{tag}.png'), 'wb') as fp:
|
||||
fp.write(p)
|
||||
if want_js:
|
||||
with open(os.path.join(outdir, 'js', slug + '.js'), 'w') as fp:
|
||||
fp.write('PKMN_LOAD(' + json.dumps(data, separators=(',', ':')) + ');\n')
|
||||
|
||||
entry = dict(
|
||||
species=species, name=data['name'], slug=slug,
|
||||
group='pokemon' if pokemon else 'extra',
|
||||
sourceFile=f'{fileno}.bin', glb=f'glb/{slug}.glb',
|
||||
textureDir=f'textures/{slug}',
|
||||
triangles=sum(len(p['idx']) // 3 for p in data['prims']),
|
||||
vertices=sum(len(p['pos']) // 3 for p in data['prims']),
|
||||
bones=len(data['bones']), textures=len(data['textures']),
|
||||
generatedEffects=nfx,
|
||||
animations=[dict(
|
||||
index=i, name=a['name'], frames=a['frames'],
|
||||
seconds=round(a['frames'] / 30.0, 3),
|
||||
endBehavior='clamp' if (a['flags'] & 2) else 'wrap',
|
||||
loopStartFrame=a['loopStart'],
|
||||
**(dict(contexts=sorted(set(uses[i])),
|
||||
moves=sorted(set(move_uses[i])),
|
||||
moveCount=len(set(move_uses[i]))) if pokemon else {}))
|
||||
for i, a in enumerate(data['anims'])])
|
||||
(manifest['pokemon'] if pokemon else manifest['extra']).append(entry)
|
||||
index.append(dict(species=species, name=data['name'], slug=slug,
|
||||
group=entry['group'], triangles=entry['triangles'],
|
||||
bones=entry['bones'], animations=len(data['anims'])))
|
||||
print(f' {slug:<22} {len(data["anims"]):2d} anims {entry["triangles"]:5d} tris'
|
||||
+ (f' +{nfx} effect' if nfx else ''))
|
||||
|
||||
# ---- move index ------------------------------------------------------
|
||||
moves_out = []
|
||||
if move_rows:
|
||||
default_anim = {p['species']: next(
|
||||
(a['index'] for a in p['animations'] if 'attack_default' in a.get('contexts', [])), -1)
|
||||
for p in manifest['pokemon']}
|
||||
for mid in range(1, battle.N_MOVES + 1):
|
||||
users, tally, ndiff = [], collections.Counter(), 0
|
||||
for sp in sorted(move_rows):
|
||||
ai, ax = move_rows[sp][mid - 1]
|
||||
name = anim_names[sp][ai] if ai < len(anim_names[sp]) else f'anim{ai}'
|
||||
diff = ai != default_anim.get(sp, -1)
|
||||
ndiff += diff
|
||||
tally[name] += 1
|
||||
users.append(dict(species=sp, animation=ai, animationName=name,
|
||||
aux=ax, differsFromDefault=diff))
|
||||
moves_out.append(dict(
|
||||
id=mid, name=moves[mid], speciesWithOwnAnimation=ndiff,
|
||||
animationNames=[dict(name=n, species=c) for n, c in tally.most_common()],
|
||||
users=users))
|
||||
with open(os.path.join(outdir, 'moves.json'), 'w') as fp:
|
||||
json.dump(dict(source=manifest['source'], note=(
|
||||
'Entry n of the per-species battle table (0-indexed) selects the '
|
||||
'animation played when that Pokemon uses move n+1. The table is dense '
|
||||
'- every species has a row for every move, including moves it can '
|
||||
'never learn - and those unreachable rows overwhelmingly point at the '
|
||||
'species\' generic reaction animation. Use differsFromDefault.'),
|
||||
moves=moves_out), fp, indent=1)
|
||||
|
||||
with open(os.path.join(outdir, 'manifest.json'), 'w') as fp:
|
||||
json.dump(manifest, fp, indent=2)
|
||||
|
||||
if want_js:
|
||||
with open(os.path.join(outdir, 'js', 'index.js'), 'w') as fp:
|
||||
fp.write('window.PKMN_INDEX = ' + json.dumps(index, separators=(',', ':')) + ';\n')
|
||||
if moves_out:
|
||||
with open(os.path.join(outdir, 'js', 'moves.js'), 'w') as fp:
|
||||
fp.write('window.PKMN_MOVES = ' + json.dumps(
|
||||
[dict(id=m['id'], name=m['name'],
|
||||
bySpecies={str(u['species']): [u['animation'], u['aux']]
|
||||
for u in m['users']}) for m in moves_out],
|
||||
separators=(',', ':')) + ';\n')
|
||||
viewer_src = os.path.join(REPO, 'tools/model_viewer/viewer.html')
|
||||
if os.path.exists(viewer_src):
|
||||
with open(viewer_src) as s, open(os.path.join(outdir, 'viewer.html'), 'w') as d:
|
||||
d.write(s.read())
|
||||
|
||||
print(f'\n{len(manifest["pokemon"])} Pokemon + {len(manifest["extra"])} other models'
|
||||
f', {fx_count} generated effects')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main(sys.argv[1:])
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generated stand-in effects.
|
||||
|
||||
IMPORTANT: nothing in this file is extracted game data. The real tail flame,
|
||||
mane fire and gas are drawn by procedural callbacks that live in another
|
||||
fragment (geo command 0x08 -> func_80014A60 calls node->unk_10, and the model
|
||||
file supplies only two empty display lists plus zeroed scratch buffers). Those
|
||||
callbacks have not been ported, so the models genuinely contain no flame mesh
|
||||
and no flame texture.
|
||||
|
||||
What follows is an original, procedurally generated replacement: looping
|
||||
flipbook textures plus a pair of crossed quads anchored to the bone the
|
||||
callback hangs off. It is meant to make the models look right in the viewer,
|
||||
and it is tagged `generated: true` everywhere it appears so it is never
|
||||
mistaken for ripped content.
|
||||
"""
|
||||
import math
|
||||
|
||||
# geo cmd 0x08 callback ids -> which effect to stand in for. The grouping is the
|
||||
# game's own: every species sharing a callback shares an effect.
|
||||
FIRE_TAIL = 0x810000D8 # Charmander, Charmeleon, Charizard, Magmar, Moltres
|
||||
FIRE_SMALL = 0x81000108 # Ponyta, Rapidash, Moltres wings
|
||||
AURA = 0x810000E0 # Gastly, Koffing, Weezing, Vaporeon, Articuno, Moltres
|
||||
|
||||
|
||||
class Rng:
|
||||
"""Deterministic PRNG so a given species always generates the same effect."""
|
||||
|
||||
def __init__(self, seed):
|
||||
self.s = seed & 0xFFFFFFFF or 0x9E3779B9
|
||||
|
||||
def next(self):
|
||||
x = self.s
|
||||
x ^= (x << 13) & 0xFFFFFFFF
|
||||
x ^= x >> 17
|
||||
x ^= (x << 5) & 0xFFFFFFFF
|
||||
self.s = x & 0xFFFFFFFF
|
||||
return self.s
|
||||
|
||||
def unit(self):
|
||||
return self.next() / 0x100000000
|
||||
|
||||
|
||||
def _lattice(rng, w, h):
|
||||
return [[rng.unit() for _ in range(w)] for _ in range(h)]
|
||||
|
||||
|
||||
def _smooth(t):
|
||||
return t * t * (3 - 2 * t)
|
||||
|
||||
|
||||
def _sample(grid, x, y):
|
||||
"""Bilinear value noise on a torus, so the field tiles in both axes."""
|
||||
h, w = len(grid), len(grid[0])
|
||||
x0, y0 = int(math.floor(x)) % w, int(math.floor(y)) % h
|
||||
x1, y1 = (x0 + 1) % w, (y0 + 1) % h
|
||||
fx, fy = _smooth(x - math.floor(x)), _smooth(y - math.floor(y))
|
||||
a = grid[y0][x0] + (grid[y0][x1] - grid[y0][x0]) * fx
|
||||
b = grid[y1][x0] + (grid[y1][x1] - grid[y1][x0]) * fx
|
||||
return a + (b - a) * fy
|
||||
|
||||
|
||||
def _fbm(grids, x, y, scale):
|
||||
"""Sum octaves of tileable noise."""
|
||||
total, amp, norm = 0.0, 1.0, 0.0
|
||||
for i, g in enumerate(grids):
|
||||
f = scale * (2 ** i)
|
||||
total += _sample(g, x * f, y * f) * amp
|
||||
norm += amp
|
||||
amp *= 0.5
|
||||
return total / norm
|
||||
|
||||
|
||||
def _ramp(stops, t):
|
||||
t = max(0.0, min(1.0, t))
|
||||
for i in range(len(stops) - 1):
|
||||
a, b = stops[i], stops[i + 1]
|
||||
if t <= b[0]:
|
||||
k = 0.0 if b[0] == a[0] else (t - a[0]) / (b[0] - a[0])
|
||||
return tuple(int(a[1 + j] + (b[1 + j] - a[1 + j]) * k) for j in range(4))
|
||||
return tuple(stops[-1][1:])
|
||||
|
||||
|
||||
FIRE_RAMP = [ # intensity -> RGBA
|
||||
(0.00, 0, 0, 0, 0),
|
||||
(0.30, 120, 24, 8, 90),
|
||||
(0.52, 226, 78, 16, 205),
|
||||
(0.74, 252, 176, 44, 245),
|
||||
(1.00, 255, 246, 214, 255),
|
||||
]
|
||||
|
||||
GAS_RAMP = [
|
||||
(0.00, 0, 0, 0, 0),
|
||||
(0.34, 52, 26, 78, 70),
|
||||
(0.60, 96, 52, 140, 140),
|
||||
(0.82, 148, 96, 196, 190),
|
||||
(1.00, 208, 176, 236, 215),
|
||||
]
|
||||
|
||||
|
||||
def fire_frames(seed, w=32, h=64, frames=8, wisp=1.0):
|
||||
"""Upward-advected noise plume. Scrolling by an exact multiple of the noise
|
||||
lattice over the frame count makes the loop seamless."""
|
||||
rng = Rng(seed)
|
||||
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
|
||||
out = []
|
||||
for f in range(frames):
|
||||
t = f / frames
|
||||
buf = bytearray(w * h * 4)
|
||||
for y in range(h):
|
||||
v = y / (h - 1) # 0 at the base, 1 at the tip
|
||||
# plume envelope: wide and hot at the base, pinched at the tip
|
||||
taper = max(0.0, 1.0 - v) ** 0.42
|
||||
for x in range(w):
|
||||
u = (x / (w - 1)) * 2 - 1 # -1 .. 1 across the flame
|
||||
radial = (1.0 - min(1.0, abs(u) / max(0.10, taper * 0.95))) ** 0.7
|
||||
if radial <= 0:
|
||||
continue
|
||||
n = _fbm(grids, x / w, (y / h) - t, 3.0)
|
||||
lick = 0.55 + 0.75 * (n - 0.5) * wisp
|
||||
inten = radial * (0.55 + 0.8 * taper) * lick
|
||||
inten -= 0.16 * v # cool towards the tip
|
||||
if inten <= 0.02:
|
||||
continue
|
||||
r, g, b, a = _ramp(FIRE_RAMP, inten)
|
||||
i = ((h - 1 - y) * w + x) * 4 # +Y in texture space is up
|
||||
buf[i:i+4] = bytes((r, g, b, a))
|
||||
out.append(bytes(buf))
|
||||
return w, h, out
|
||||
|
||||
|
||||
def gas_frames(seed, w=48, h=48, frames=10):
|
||||
"""Slow swirling haze that fades out towards the rim."""
|
||||
rng = Rng(seed)
|
||||
grids = [_lattice(rng, 8, 8), _lattice(rng, 16, 16), _lattice(rng, 32, 32)]
|
||||
out = []
|
||||
for f in range(frames):
|
||||
t = f / frames
|
||||
buf = bytearray(w * h * 4)
|
||||
ang = t * 2 * math.pi
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
dx = (x / (w - 1)) * 2 - 1
|
||||
dy = (y / (h - 1)) * 2 - 1
|
||||
d = math.hypot(dx, dy)
|
||||
if d >= 1.0:
|
||||
continue
|
||||
falloff = (1.0 - d) ** 0.85
|
||||
# rotate the sample point so the haze churns without popping
|
||||
sx = dx * math.cos(ang) - dy * math.sin(ang)
|
||||
sy = dx * math.sin(ang) + dy * math.cos(ang)
|
||||
n = _fbm(grids, sx * 0.5 + 0.5, sy * 0.5 + 0.5 - t, 2.5)
|
||||
inten = falloff * (0.78 + 1.30 * (n - 0.44))
|
||||
if inten <= 0.03:
|
||||
continue
|
||||
r, g, b, a = _ramp(GAS_RAMP, inten)
|
||||
i = (y * w + x) * 4
|
||||
buf[i:i+4] = bytes((r, g, b, a))
|
||||
out.append(bytes(buf))
|
||||
return w, h, out
|
||||
|
||||
|
||||
def crossed_quads(bone, length, width, axis='y', centred=False):
|
||||
"""Two quads at right angles so the effect reads from any angle -- the
|
||||
portable stand-in for a billboard, since glTF cannot billboard.
|
||||
|
||||
`axis` picks which bone-local direction the quad grows along. Bone-local +X
|
||||
runs down the limb, so a flame laid out along X comes out lying sideways;
|
||||
'y' is that same quad rotated 90 degrees left about Z, which stands it up.
|
||||
`centred` straddles the origin instead of growing from it."""
|
||||
pos, uv, nrm, skin, idx = [], [], [], [], []
|
||||
for q in range(2):
|
||||
base = len(pos) // 3
|
||||
for (s, t) in ((0, 0), (1, 0), (1, 1), (0, 1)):
|
||||
a = (s - 0.5) * width
|
||||
b = (t - 0.5) * length if centred else t * length
|
||||
if axis == 'x':
|
||||
p = (b, a, 0.0) if q == 0 else (b, 0.0, a)
|
||||
else: # (x, y) -> (-y, x)
|
||||
p = (-a, b, 0.0) if q == 0 else (0.0, b, a)
|
||||
pos += list(p)
|
||||
uv += [s, 1.0 - t]
|
||||
nrm += [0.0, 0.0, 1.0] if q == 0 else [1.0, 0.0, 0.0]
|
||||
skin.append(bone)
|
||||
idx += [base, base + 1, base + 2, base, base + 2, base + 3]
|
||||
return dict(pos=pos, uv=uv, nrm=nrm, skin=skin, idx=idx)
|
||||
|
||||
|
||||
# desired size as a fraction of the model's world-space extent
|
||||
SIZES = {
|
||||
'fire_tail': (0.40, 0.22), # length, width
|
||||
'fire_small': (0.075, 0.042),
|
||||
'gas': (1.05, 1.05),
|
||||
}
|
||||
|
||||
|
||||
def build_for(species, fx, extent, bone_scale):
|
||||
"""Returns [{kind, bone, geo, w, h, frames}] for one model, or [].
|
||||
|
||||
`extent` is the model's world-space size and `bone_scale[i]` how much bone i
|
||||
already scales its local space; dividing by it keeps every effect the size we
|
||||
asked for regardless of where in the skeleton it hangs."""
|
||||
out = []
|
||||
for node in fx:
|
||||
cb, bone = node['callback'], node['bone']
|
||||
if bone < 0 or bone >= len(bone_scale):
|
||||
continue
|
||||
k = bone_scale[bone] or 1.0
|
||||
if cb == FIRE_TAIL:
|
||||
fl, fw = SIZES['fire_tail']
|
||||
w, h, fr = fire_frames(species * 7919 + 1, 32, 64, 8)
|
||||
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
|
||||
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
|
||||
elif cb == FIRE_SMALL:
|
||||
fl, fw = SIZES['fire_small']
|
||||
w, h, fr = fire_frames(species * 6271 + bone, 24, 40, 8, wisp=1.25)
|
||||
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y')
|
||||
out.append(dict(kind='fire', bone=bone, geo=geo, w=w, h=h, frames=fr))
|
||||
elif cb == AURA and species == 92: # Gastly only
|
||||
fl, fw = SIZES['gas']
|
||||
w, h, fr = gas_frames(species * 5237 + 3, 48, 48, 10)
|
||||
geo = crossed_quads(bone, extent * fl / k, extent * fw / k, axis='y', centred=True)
|
||||
out.append(dict(kind='gas', bone=bone, geo=geo, w=w, h=h, frames=fr))
|
||||
return out
|
||||
@@ -0,0 +1,747 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Model extraction: FRAGMENT module -> geometry, textures, skeleton, animations.
|
||||
|
||||
Self-contained copy of tools/model_viewer/extract_model.py, taking raw bytes so
|
||||
it can be fed straight from the ROM. See ../README.md for the format notes.
|
||||
"""
|
||||
import json, os, struct, sys, zlib
|
||||
|
||||
BASE = 0x8FF00000
|
||||
|
||||
# ---------------------------------------------------------------- geo layout
|
||||
|
||||
CMD_SIZES = {
|
||||
0x00:0x08, 0x01:0x04, 0x02:0x08, 0x03:0x08, 0x04:0x04, 0x05:0x04, 0x06:0x04,
|
||||
0x07:0x08, 0x08:0x0C, 0x09:0x04, 0x0A:0x08, 0x0B:0x18, 0x0C:0x04, 0x0D:0x04,
|
||||
0x0E:0x04, 0x0F:0x04, 0x10:0x04, 0x11:0x04, 0x12:0x04, 0x13:0x08, 0x14:0x0C,
|
||||
0x15:0x0C, 0x16:0x04, 0x17:0x14, 0x18:0x08, 0x19:0x08, 0x1A:0x04, 0x1B:0x10,
|
||||
0x1C:0x10, 0x1D:0x1C, 0x1E:0x08, 0x1F:0x18, 0x20:0x14, 0x21:0x10, 0x22:0x08,
|
||||
0x23:0x10, 0x24:0x04, 0x25:0x04, 0x26:0x14,
|
||||
}
|
||||
|
||||
|
||||
class Fragment:
|
||||
def __init__(self, data, name='<bytes>'):
|
||||
self.d = data if isinstance(data, (bytes, bytearray)) else open(data, 'rb').read()
|
||||
self.name = name if isinstance(data, (bytes, bytearray)) else str(data)
|
||||
if self.d[8:0x10] != b'FRAGMENT':
|
||||
raise ValueError(f'{self.name}: not a FRAGMENT module')
|
||||
self.hdrSize, self.relocOff, self.sizeRom, self.sizeRam = struct.unpack_from('>4I', self.d, 0x10)
|
||||
|
||||
def off(self, ptr):
|
||||
return None if ptr == 0 else ptr - BASE
|
||||
|
||||
def u8(self, o): return self.d[o]
|
||||
def s8(self, o): return struct.unpack_from('>b', self.d, o)[0]
|
||||
def u16(self, o): return struct.unpack_from('>H', self.d, o)[0]
|
||||
def s16(self, o): return struct.unpack_from('>h', self.d, o)[0]
|
||||
def u32(self, o): return struct.unpack_from('>I', self.d, o)[0]
|
||||
def s32(self, o): return struct.unpack_from('>i', self.d, o)[0]
|
||||
def ptr(self, o): return self.off(self.u32(o))
|
||||
|
||||
def root(self):
|
||||
"""The entry stub ends with `lui rX, hi; addiu rX, rX, lo` loading the root struct."""
|
||||
for o in range(0x20, 0x80, 4):
|
||||
w = self.u32(o)
|
||||
if (w >> 26) != 0x0F: # lui
|
||||
continue
|
||||
reg = (w >> 16) & 0x1F
|
||||
w2 = self.u32(o + 4)
|
||||
if (w2 >> 26) == 0x09 and ((w2 >> 21) & 0x1F) == reg: # addiu rX, rX, imm
|
||||
return ((self.u16(o + 2) << 16) + self.s16(o + 6)) - BASE
|
||||
raise RuntimeError('could not locate root struct')
|
||||
|
||||
def ptr_list(self, o):
|
||||
out = []
|
||||
while True:
|
||||
p = self.ptr(o)
|
||||
if p is None:
|
||||
return out
|
||||
out.append(p)
|
||||
o += 4
|
||||
|
||||
|
||||
# --------------------------------------------------------------- F3DEX2 exec
|
||||
|
||||
def signed(v, bits):
|
||||
m = 1 << (bits - 1)
|
||||
return (v ^ m) - m
|
||||
|
||||
|
||||
class Model:
|
||||
"""Walks the geo layout, executes the display lists, accumulates draw data."""
|
||||
|
||||
def __init__(self, frag):
|
||||
self.f = frag
|
||||
r = frag.root()
|
||||
self.species = frag.u16(r)
|
||||
self.geoLayouts = frag.ptr_list(frag.ptr(r + 0x08))
|
||||
self.anims = frag.ptr_list(frag.ptr(r + 0x0C))
|
||||
self.auxAnims = frag.ptr_list(frag.ptr(r + 0x10))
|
||||
|
||||
self.textures = [] # {fmt, siz, w, h, texels, data}
|
||||
self.tluts = [] # palettes: {count, data, dl}
|
||||
self.bones = [] # {parent, boneId, chan, t, r, s}
|
||||
self.boneById = {}
|
||||
self.prims = [] # {tex, cull, verts:[...], tris:[...]}
|
||||
self.primsByKey = {}
|
||||
self.vtxBase = None
|
||||
self.rootScale = [1.0, 1.0, 1.0]
|
||||
self.fx = [] # geo cmd 0x08 procedural effect nodes
|
||||
self.warnings = []
|
||||
|
||||
# ---- textures -------------------------------------------------------
|
||||
def read_texture_table(self, off, count):
|
||||
f = self.f
|
||||
for i in range(count):
|
||||
o = off + i * 0xC
|
||||
self.textures.append(dict(
|
||||
fmt=f.u8(o), siz=f.u8(o + 1), w=f.s16(o + 2),
|
||||
h=f.u16(o + 4), texels=f.u16(o + 6), data=f.ptr(o + 8)))
|
||||
|
||||
def read_tlut_table(self, off, count):
|
||||
"""Palettes reuse the texture-record layout: the count sits in the width
|
||||
field, the palette data in the next word, and unk_08 is the DL that loads
|
||||
it (src/12D80.c func_80015B20). The DL is authoritative, so run it."""
|
||||
f = self.f
|
||||
for i in range(count):
|
||||
o = off + i * 0xC
|
||||
rec = dict(count=f.u16(o + 2), data=f.ptr(o + 4), dl=f.ptr(o + 8))
|
||||
dl = rec['dl']
|
||||
if dl is not None:
|
||||
for _ in range(16):
|
||||
w0, w1 = struct.unpack_from('>II', f.d, dl)
|
||||
op = w0 >> 24
|
||||
if op == 0xFD: # G_SETTIMG
|
||||
rec['data'] = f.off(w1)
|
||||
elif op == 0xF0: # G_LOADTLUT
|
||||
rec['count'] = ((w1 >> 14) & 0x3FF) + 1
|
||||
elif op == 0xDF:
|
||||
break
|
||||
dl += 8
|
||||
self.tluts.append(rec)
|
||||
|
||||
# ---- geo layout -----------------------------------------------------
|
||||
def build(self):
|
||||
self.curTex = -1
|
||||
self.curTlut = -1
|
||||
self.curMat = None
|
||||
self.curTexAnim = -1
|
||||
# Mirrors gCurGraphNodeList in src/geo_layout.c: stack[-1] is the slot the
|
||||
# next node command writes to, and a node's parent -- and the bone whose
|
||||
# matrix is live -- is the slot *below* it (func_80017AC4).
|
||||
self.stack = [-1]
|
||||
# The RSP vertex cache persists across display lists: a bone's list often
|
||||
# preloads verts that the *next* bone's list then indexes, which is how
|
||||
# these models get blended joints. Each slot remembers the bone whose
|
||||
# matrix was current when it was loaded.
|
||||
self.vbuf = [None] * 64
|
||||
self.walk(self.geoLayouts[0])
|
||||
|
||||
def walk(self, o, depth=0):
|
||||
f = self.f
|
||||
if depth > 32:
|
||||
return
|
||||
while True:
|
||||
cmd = f.u8(o)
|
||||
size = CMD_SIZES.get(cmd)
|
||||
if size is None:
|
||||
self.warnings.append(f'unknown geo cmd {cmd:#04x} at {o:#x}')
|
||||
return
|
||||
if cmd == 0x01 or cmd == 0x04: # end / return
|
||||
return
|
||||
if cmd in (0x00, 0x03): # branch (with return)
|
||||
self.walk(f.ptr(o + 4), depth + 1)
|
||||
elif cmd == 0x02: # jump (no return)
|
||||
o = f.ptr(o + 4)
|
||||
continue
|
||||
elif cmd == 0x05: # open node
|
||||
self.stack.append(self.stack[-1])
|
||||
elif cmd == 0x06: # close node
|
||||
self.stack.pop()
|
||||
elif cmd == 0x17: # model header
|
||||
self.read_texture_table(f.ptr(o + 8), f.s16(o + 2))
|
||||
if f.ptr(o + 0xC):
|
||||
self.read_tlut_table(f.ptr(o + 0xC), f.s16(o + 4))
|
||||
self.vtxBase = f.ptr(o + 0x10)
|
||||
self.nVerts = f.s16(o + 6)
|
||||
elif cmd == 0x08: # procedural effect callback
|
||||
self.fx.append(dict(bone=self.curBone(), callback=f.u32(o + 4),
|
||||
arg=f.ptr(o + 8)))
|
||||
elif cmd == 0x1C: # uniform scale node
|
||||
self.rootScale = [f.s32(o + 4) / 65536.0, f.s32(o + 8) / 65536.0,
|
||||
f.s32(o + 0xC) / 65536.0]
|
||||
elif cmd == 0x1D: # bone / joint node
|
||||
idx = len(self.bones)
|
||||
self.bones.append(dict(
|
||||
parent=self.curBone(), boneId=f.u8(o + 1), flags=f.u8(o + 2),
|
||||
chan=f.s8(o + 3),
|
||||
t=[f.s16(o + 4), f.s16(o + 6), f.s16(o + 8)],
|
||||
r=[f.s16(o + 0xA), f.s16(o + 0xC), f.s16(o + 0xE)],
|
||||
s=[f.s32(o + 0x10) / 65536.0, f.s32(o + 0x14) / 65536.0,
|
||||
f.s32(o + 0x18) / 65536.0]))
|
||||
self.boneById[f.u8(o + 1)] = idx
|
||||
self.stack[-1] = idx
|
||||
elif cmd == 0x23: # set texture / material
|
||||
self.curTex = f.s16(o + 8)
|
||||
self.curTlut = f.s16(o + 0xA)
|
||||
self.curMat = f.ptr(o + 4)
|
||||
# offset 0x02 is the texture-animation channel; -1 means static.
|
||||
# func_800176DC swaps this material's texture per frame from the
|
||||
# auxiliary animation's channel stream.
|
||||
self.curTexAnim = f.s16(o + 2)
|
||||
elif cmd == 0x22: # display list on current bone
|
||||
self.run_dl(f.ptr(o + 4), self.curBone())
|
||||
elif cmd == 0x1E: # display list on named bone
|
||||
self.run_dl(f.ptr(o + 4), self.boneById.get(f.s16(o + 2), self.curBone()))
|
||||
elif cmd in (0x20, 0x21): # display list + own transform
|
||||
self.run_dl(f.ptr(o + (0x10 if cmd == 0x20 else 0xC)), self.curBone())
|
||||
o += size
|
||||
|
||||
def curBone(self):
|
||||
return self.stack[-2] if len(self.stack) >= 2 else -1
|
||||
|
||||
# ---- display lists --------------------------------------------------
|
||||
def run_dl(self, o, bone, depth=0):
|
||||
if o is None or depth > 8:
|
||||
return
|
||||
f = self.f
|
||||
vbuf = self.vbuf
|
||||
cull = 0x400
|
||||
while True:
|
||||
w0, w1 = struct.unpack_from('>II', f.d, o)
|
||||
op = w0 >> 24
|
||||
o += 8
|
||||
if op == 0xDF: # G_ENDDL
|
||||
return
|
||||
if op == 0xDE: # G_DL
|
||||
self.run_dl(f.off(w1), bone, depth + 1)
|
||||
if (w0 >> 16) & 0xFF: # branch, not call
|
||||
return
|
||||
continue
|
||||
if op == 0x01: # G_VTX
|
||||
n = (w0 >> 12) & 0xFF
|
||||
v0 = ((w0 & 0xFFF) >> 1) - n
|
||||
a = f.off(w1)
|
||||
for i in range(n):
|
||||
p = a + i * 0x10
|
||||
if 0 <= v0 + i < len(vbuf):
|
||||
vbuf[v0 + i] = (
|
||||
f.s16(p), f.s16(p + 2), f.s16(p + 4), # position
|
||||
f.s16(p + 8) / 32.0, f.s16(p + 10) / 32.0, # s, t (S10.5)
|
||||
f.s8(p + 12), f.s8(p + 13), f.s8(p + 14), # normal
|
||||
f.u8(p + 15), # alpha
|
||||
bone) # owning bone
|
||||
continue
|
||||
if op == 0xD9: # G_GEOMETRYMODE
|
||||
cull = (cull & (w0 & 0xFFFFFF)) | w1
|
||||
continue
|
||||
if op in (0x05, 0x06): # G_TRI1 / G_TRI2
|
||||
prim = self.prim_for(self.curTex, self.curTlut, self.curMat,
|
||||
self.curTexAnim, cull & 0x600)
|
||||
|
||||
def emit(a, b, c):
|
||||
tri = []
|
||||
for idx in (a, b, c):
|
||||
v = vbuf[idx] if idx < len(vbuf) else None
|
||||
if v is None:
|
||||
return
|
||||
j = prim['_remap'].get(v)
|
||||
if j is None:
|
||||
j = len(prim['verts'])
|
||||
prim['_remap'][v] = j
|
||||
prim['verts'].append(v)
|
||||
tri.append(j)
|
||||
if (cull & 0x200) and not (cull & 0x400):
|
||||
tri.reverse()
|
||||
prim['tris'].append(tri)
|
||||
|
||||
emit(((w0 >> 16) & 0xFF) // 2, ((w0 >> 8) & 0xFF) // 2, (w0 & 0xFF) // 2)
|
||||
if op == 0x06:
|
||||
emit(((w1 >> 16) & 0xFF) // 2, ((w1 >> 8) & 0xFF) // 2, (w1 & 0xFF) // 2)
|
||||
continue
|
||||
# everything else (SETTILE / sync / ...) is state we reconstruct
|
||||
# from the texture table instead, so it is skipped.
|
||||
|
||||
def prim_for(self, tex, tlut, mat, texAnim, cull):
|
||||
key = (tex, tlut, mat, texAnim, cull)
|
||||
p = self.primsByKey.get(key)
|
||||
if p is None:
|
||||
p = dict(tex=tex, tlut=tlut, mat=mat, texAnim=texAnim, cull=cull,
|
||||
verts=[], tris=[], _remap={})
|
||||
self.primsByKey[key] = p
|
||||
self.prims.append(p)
|
||||
return p
|
||||
|
||||
def tile_palette(self, mat):
|
||||
"""CI4 selects a 16-entry block of the TLUT via the render tile's palette
|
||||
field; read it from the material display list's final G_SETTILE."""
|
||||
if mat is None:
|
||||
return 0
|
||||
pal = 0
|
||||
for _ in range(16):
|
||||
w0, w1 = struct.unpack_from('>II', self.f.d, mat)
|
||||
op = w0 >> 24
|
||||
if op == 0xF5 and ((w1 >> 24) & 7) == 0: # G_SETTILE, render tile
|
||||
pal = (w1 >> 20) & 0xF
|
||||
elif op == 0xDF:
|
||||
break
|
||||
mat += 8
|
||||
return pal
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- animations
|
||||
|
||||
def bitfield(f, base, index, bits):
|
||||
"""src/F420.c func_80010500: signed `bits`-wide field at bit index*bits.
|
||||
|
||||
C integer division truncates toward zero; Python's floors. That only differs
|
||||
for negative indices, which is exactly what an empty channel produces, so the
|
||||
truncating form is used here to match the hardware."""
|
||||
bitpos = index * bits
|
||||
word = bitpos // 16 if bitpos >= 0 else -((-bitpos) // 16) # C: trunc to zero
|
||||
rem = bitpos - word * 16 # C: sign follows bitpos
|
||||
o = base + word * 2
|
||||
v = (f.u16(o) << 16) | f.u16(o + 2)
|
||||
v = (v << (rem & 31)) & 0xFFFFFFFF # MIPS masks the shift to 5 bits
|
||||
return signed(v >> (32 - bits), bits)
|
||||
|
||||
|
||||
class Animation:
|
||||
"""src/17300.c. Two sampling modes: packed per-frame streams (default) and
|
||||
hermite keyframes (flags & 8)."""
|
||||
|
||||
def __init__(self, frag, off):
|
||||
f = self.f = frag
|
||||
self.off = off
|
||||
# The flags live in the LOW byte of the u16 at +0 -- reading the byte
|
||||
# AT +0 gets the always-zero high byte, which silently turns every
|
||||
# hermite animation (flags & 8: Pidgeot, Dodrio, Exeggutor, Tangela,
|
||||
# Magmar) into a packed-stream read of keyframe tables.
|
||||
self.flags = f.u16(off)
|
||||
self.startFrame= f.u16(off + 4)
|
||||
self.loopStart = f.u16(off + 6)
|
||||
self.nChannels = f.u16(off + 8)
|
||||
self.nFrames = f.u16(off + 0xA)
|
||||
self.chanTable = f.ptr(off + 0xC)
|
||||
self.scaleData = f.ptr(off + 0x10)
|
||||
self.rotData = f.ptr(off + 0x14)
|
||||
self.transData = f.ptr(off + 0x18)
|
||||
|
||||
def chan(self, i):
|
||||
o = self.chanTable + i * 0xA
|
||||
f = self.f
|
||||
return dict(nScale=f.u8(o), nRot=f.u8(o + 1), nTrans=f.u8(o + 2),
|
||||
interp=f.u8(o + 3), oScale=f.u16(o + 4),
|
||||
oRot=f.u16(o + 6), oTrans=f.u16(o + 8))
|
||||
|
||||
# -- packed stream sampling (flags & 8 == 0) --------------------------
|
||||
# A count of 0 means the component has no stream. In the ROM that only
|
||||
# ever happens in HERMITE animations, where a count under 2 means "the
|
||||
# offset field IS the constant value" -- no packed animation of any of the
|
||||
# 151 species carries an empty channel, so the bind-pose fallback here is
|
||||
# dead code kept as a safety net.
|
||||
def _trans_packed(self, c, frame):
|
||||
if c['nTrans'] == 0:
|
||||
return None
|
||||
bits = 16 if (self.flags & 4) else 12
|
||||
if c['nTrans'] == 1:
|
||||
# (s16) casts both ways: func_80016848 reads the u16 offset field
|
||||
# back as a signed constant.
|
||||
return float(signed(c['oTrans'], 16) if (self.flags & 4)
|
||||
else signed((c['oTrans'] * 16) & 0xFFFF, 16) >> 4)
|
||||
i = c['oTrans'] + min(frame, c['nTrans'] - 1)
|
||||
return float(bitfield(self.f, self.transData, i, bits))
|
||||
|
||||
def _rot_packed(self, c, frame):
|
||||
if c['nRot'] == 0:
|
||||
return None
|
||||
if c['nRot'] == 1:
|
||||
return signed((c['oRot'] * 16) & 0xFFFF, 16)
|
||||
i = c['oRot'] + min(frame, c['nRot'] - 1)
|
||||
return signed((bitfield(self.f, self.rotData, i, 12) * 16) & 0xFFFF, 16)
|
||||
|
||||
def _scale_packed(self, c, frame):
|
||||
if c['nScale'] == 0:
|
||||
return None
|
||||
if c['nScale'] == 1:
|
||||
return c['oScale'] / 1000.0
|
||||
i = c['oScale'] + min(frame, c['nScale'] - 1)
|
||||
return self.f.s16(self.scaleData + i * 2) / 1000.0
|
||||
|
||||
# -- hermite keyframe sampling (flags & 8) ----------------------------
|
||||
def _hermite(self, base, n, frame, wide):
|
||||
f = self.f
|
||||
stride = 8 if wide else 6
|
||||
|
||||
def key(i):
|
||||
o = base + i * stride
|
||||
return (f.s16(o), f.s16(o + 2), f.s16(o + 4),
|
||||
f.s16(o + 6) if wide else f.s16(o + 4))
|
||||
|
||||
k0 = key(0)
|
||||
if k0[0] >= frame:
|
||||
return float(k0[1])
|
||||
last = key(n - 1)
|
||||
if frame >= last[0]:
|
||||
return float(last[1])
|
||||
i = 0
|
||||
while i < n - 2:
|
||||
if frame < key(i + 1)[0]:
|
||||
break
|
||||
i += 1
|
||||
a, b = key(i), key(i + 1)
|
||||
x = (frame - a[0]) / 30.0
|
||||
y = 30.0 / (b[0] - a[0])
|
||||
x2, x3 = x * x, x * x * x
|
||||
y2, y3 = y * y, y * y * y
|
||||
return (a[1] * (2 * x3 * y3 - 3 * x2 * y2 + 1)
|
||||
+ b[1] * (-2 * x3 * y3 + 3 * x2 * y2)
|
||||
+ (a[3] if wide else a[2]) * (x3 * y2 - 2 * x2 * y + x)
|
||||
+ b[2] * (x3 * y2 - x2 * y))
|
||||
|
||||
def _trans_key(self, c, frame):
|
||||
if c['nTrans'] < 2:
|
||||
return float(signed(c['oTrans'], 16))
|
||||
return self._hermite(self.transData + c['oTrans'] * 2, c['nTrans'], frame, c['interp'] & 1)
|
||||
|
||||
def _rot_key(self, c, frame):
|
||||
if c['nRot'] < 2:
|
||||
deg = signed(c['oRot'], 16) / 10.0
|
||||
else:
|
||||
deg = self._hermite(self.rotData + c['oRot'] * 2, c['nRot'], frame, c['interp'] & 2) / 10.0
|
||||
deg %= 360.0
|
||||
# func_80016DE0 returns s16: the f32 -> s16 cast WRAPS an angle above
|
||||
# 180 degrees to its negative twin. Same binary angle either way, but
|
||||
# the packer stores i16 with clamping, so an unwrapped 350-degree
|
||||
# value would pin at 32767 (= 180 degrees) instead.
|
||||
return signed(int(deg / 360.0 * 65536.0) & 0xFFFF, 16)
|
||||
|
||||
def _scale_key(self, c, frame):
|
||||
if c['nScale'] < 2:
|
||||
return signed(c['oScale'], 16) / 100.0
|
||||
return self._hermite(self.scaleData + c['oScale'] * 2, c['nScale'], frame, c['interp'] & 4) / 100.0
|
||||
|
||||
def sample_trs(self, chanIndex, frame, bind=None):
|
||||
"""Returns (translation, rotation, scale) triples for one bone. Components
|
||||
whose channel carries no data fall back to the bone's bind value."""
|
||||
base = chanIndex * 3
|
||||
if base < 0 or base + 2 >= self.nChannels:
|
||||
return None
|
||||
cs = [self.chan(base + i) for i in range(3)]
|
||||
if self.flags & 8:
|
||||
out = ([self._trans_key(c, frame) for c in cs],
|
||||
[self._rot_key(c, frame) for c in cs],
|
||||
[self._scale_key(c, frame) for c in cs])
|
||||
else:
|
||||
out = ([self._trans_packed(c, frame) for c in cs],
|
||||
[self._rot_packed(c, frame) for c in cs],
|
||||
[self._scale_packed(c, frame) for c in cs])
|
||||
if bind is None:
|
||||
bind = ([0, 0, 0], [0, 0, 0], [1.0, 1.0, 1.0])
|
||||
return tuple([v if v is not None else bind[k][i] for i, v in enumerate(comp)]
|
||||
for k, comp in enumerate(out))
|
||||
|
||||
|
||||
class AuxAnimation:
|
||||
"""Texture animation (src/18140.c). Same header shape as the skeletal
|
||||
animations, but each channel is a per-frame stream of texture-table indices
|
||||
that func_800176DC substitutes into a material."""
|
||||
|
||||
def __init__(self, frag, off):
|
||||
f = self.f = frag
|
||||
self.flags = f.u16(off) # low byte, same layout as Animation
|
||||
self.startFrame= f.u16(off + 4)
|
||||
self.loopStart = f.u16(off + 6)
|
||||
self.nChannels = f.u16(off + 8)
|
||||
self.nFrames = f.u16(off + 0xA)
|
||||
self.chanTable = f.ptr(off + 0xC)
|
||||
self.data = f.ptr(off + 0x10)
|
||||
|
||||
def sample(self, chan, frame):
|
||||
"""func_80017540: index into the stream, clamped to the channel length."""
|
||||
if not (0 <= chan < self.nChannels) or self.chanTable is None:
|
||||
return None
|
||||
o = self.chanTable + chan * 4
|
||||
count, base = self.f.u16(o), self.f.u16(o + 2)
|
||||
if count == 0:
|
||||
return None
|
||||
i = base + (frame if frame < count else count - 1)
|
||||
return self.f.u8(self.data + i)
|
||||
|
||||
def track(self, chan):
|
||||
n = max(1, self.nFrames)
|
||||
return [self.sample(chan, i) for i in range(n)]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ textures
|
||||
|
||||
def rgba5551(p):
|
||||
return (((p >> 11) & 0x1F) * 255 // 31, ((p >> 6) & 0x1F) * 255 // 31,
|
||||
((p >> 1) & 0x1F) * 255 // 31, 255 if (p & 1) else 0)
|
||||
|
||||
|
||||
def decode_texture(f, tex, tlut=None, palette=0):
|
||||
"""Returns (w, h, RGBA8 bytes) for the N64 texture formats these models use."""
|
||||
w, h, fmt, siz, addr = tex['w'], tex['h'], tex['fmt'], tex['siz'], tex['data']
|
||||
out = bytearray(w * h * 4)
|
||||
d = f.d
|
||||
n = w * h
|
||||
|
||||
def nibble(i):
|
||||
return (d[addr + i // 2] >> (0 if i & 1 else 4)) & 0xF
|
||||
|
||||
if fmt == 0 and siz == 2: # RGBA16 (5/5/5/1)
|
||||
for i in range(n):
|
||||
out[i*4:i*4+4] = bytes(rgba5551(struct.unpack_from('>H', d, addr + i * 2)[0]))
|
||||
elif fmt == 0 and siz == 3: # RGBA32
|
||||
out[:] = d[addr:addr + n * 4]
|
||||
elif fmt == 2: # CI4 / CI8 -> RGBA16 palette
|
||||
pal = []
|
||||
if tlut is not None and tlut['data'] is not None:
|
||||
base = tlut['data'] + (palette * 16 * 2 if siz == 0 else 0)
|
||||
for i in range(16 if siz == 0 else 256):
|
||||
pal.append(bytes(rgba5551(struct.unpack_from('>H', d, base + i * 2)[0])))
|
||||
if not pal:
|
||||
pal = [b'\xff\x00\xff\xff'] * 256
|
||||
for i in range(n):
|
||||
idx = nibble(i) if siz == 0 else d[addr + i]
|
||||
out[i*4:i*4+4] = pal[idx % len(pal)]
|
||||
elif fmt == 3: # IA16 / IA8 / IA4
|
||||
for i in range(n):
|
||||
if siz == 2:
|
||||
v = struct.unpack_from('>H', d, addr + i * 2)[0]
|
||||
l, a = v >> 8, v & 0xFF
|
||||
elif siz == 1:
|
||||
v = d[addr + i]
|
||||
l, a = (v >> 4) * 17, (v & 0xF) * 17
|
||||
else:
|
||||
v = nibble(i)
|
||||
l, a = ((v >> 1) * 255) // 7, 255 if (v & 1) else 0
|
||||
out[i*4:i*4+4] = bytes((l, l, l, a))
|
||||
elif fmt == 4: # I8 / I4
|
||||
for i in range(n):
|
||||
l = d[addr + i] if siz == 1 else nibble(i) * 17
|
||||
out[i*4:i*4+4] = bytes((l, l, l, 255))
|
||||
else:
|
||||
for i in range(n): # unsupported: magenta
|
||||
out[i*4:i*4+4] = b'\xff\x00\xff\xff'
|
||||
return w, h, bytes(out)
|
||||
|
||||
|
||||
def png(w, h, rgba):
|
||||
"""Minimal PNG encoder (no PIL dependency)."""
|
||||
raw = b''.join(b'\x00' + rgba[y*w*4:(y+1)*w*4] for y in range(h))
|
||||
|
||||
def chunk(tag, data):
|
||||
c = tag + data
|
||||
return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF)
|
||||
|
||||
return (b'\x89PNG\r\n\x1a\n'
|
||||
+ chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 6, 0, 0, 0))
|
||||
+ chunk(b'IDAT', zlib.compress(raw, 9))
|
||||
+ chunk(b'IEND', b''))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- main
|
||||
|
||||
def unique(seq):
|
||||
"""The distinct values of `seq`, in the order they first appear.
|
||||
|
||||
Used where a set used to be. A set of small ints iterates in hash-slot
|
||||
order, which is stable across runs but is neither insertion nor sort order
|
||||
and is a CPython implementation detail -- and here it decided the order
|
||||
textures get REGISTERED in, and so their indices in the packed file. Order
|
||||
of appearance is a property of the data instead of the interpreter, which
|
||||
is what lets the Lua extractor produce the same file.
|
||||
"""
|
||||
seen, out = set(), []
|
||||
for v in seq:
|
||||
if v in seen:
|
||||
continue
|
||||
seen.add(v)
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
def dedupe_fx(nodes):
|
||||
"""The geo layout's effect callbacks, once each, IN THE ORDER THEY APPEAR.
|
||||
|
||||
A geo layout can name the same callback on the same bone more than once
|
||||
(the walk visits a subtree twice), so these have to be deduplicated, and it
|
||||
used to be done by dropping them through a set. That was a real bug rather
|
||||
than a style point: the set held tuples containing strings, so its iteration
|
||||
order moved with PYTHONHASHSEED, and the generated flames of every species
|
||||
carrying more than one -- Ponyta, Rapidash and Moltres -- came out in a
|
||||
different order, with different seeds and therefore different pixels, on
|
||||
different runs of the same build.
|
||||
|
||||
Order of appearance is the game's own order, it is stable, and it is what
|
||||
the Lua extractor can reproduce.
|
||||
"""
|
||||
seen, out = set(), []
|
||||
for node in nodes:
|
||||
key = (node['bone'], node['callback'], node['arg'])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(dict(node))
|
||||
return out
|
||||
|
||||
|
||||
def extract(path, name=None, raw=False):
|
||||
"""`raw=True` carries each texture's decoded RGBA8 bytes as `rgba` instead
|
||||
of encoding a PNG data URI into `png`.
|
||||
|
||||
The viewer and the glTF export both want a PNG, so that stays the default.
|
||||
The mod's own packer wants the pixels: it stores them uncompressed, so that
|
||||
its Lua counterpart -- which has no zlib whose output is guaranteed to
|
||||
agree with this one's byte for byte -- can be checked against it exactly.
|
||||
"""
|
||||
f = Fragment(path, name or str(path))
|
||||
m = Model(f)
|
||||
m.build()
|
||||
|
||||
import base64
|
||||
auxAnims = [AuxAnimation(f, o) for o in m.auxAnims]
|
||||
|
||||
texIndexMap, texOut = {}, []
|
||||
|
||||
def register(texIdx, tlut, pal):
|
||||
key = (texIdx, tlut, pal)
|
||||
if key in texIndexMap:
|
||||
return texIndexMap[key]
|
||||
if not (0 <= texIdx < len(m.textures)):
|
||||
return -1
|
||||
texIndexMap[key] = len(texOut)
|
||||
tl = m.tluts[tlut] if 0 <= tlut < len(m.tluts) else None
|
||||
w, h, rgba = decode_texture(f, m.textures[texIdx], tl, pal)
|
||||
rec = dict(index=texIdx, w=w, h=h)
|
||||
if raw:
|
||||
rec['rgba'] = rgba
|
||||
else:
|
||||
rec['png'] = ('data:image/png;base64,'
|
||||
+ base64.b64encode(png(w, h, rgba)).decode())
|
||||
texOut.append(rec)
|
||||
return texIndexMap[key]
|
||||
|
||||
for p in m.prims:
|
||||
if not p['tris']:
|
||||
continue
|
||||
pal = m.tile_palette(p['mat'])
|
||||
register(p['tex'], p['tlut'], pal)
|
||||
# An animated material can swap to any texture its channel names, so all
|
||||
# of them have to be decoded up front.
|
||||
if p['texAnim'] >= 0:
|
||||
for a in auxAnims:
|
||||
for t in unique(a.track(p['texAnim'])):
|
||||
if t is not None:
|
||||
register(t, p['tlut'], pal)
|
||||
|
||||
prims = []
|
||||
for p in m.prims:
|
||||
if not p['tris']:
|
||||
continue
|
||||
pos, uv, nrm, skin = [], [], [], []
|
||||
pal = m.tile_palette(p['mat'])
|
||||
ti = texIndexMap.get((p['tex'], p['tlut'], pal), -1)
|
||||
# texture-table index -> slot in texOut, for the animated swap
|
||||
texMap = {}
|
||||
if p['texAnim'] >= 0:
|
||||
for a in auxAnims:
|
||||
for t in unique(a.track(p['texAnim'])):
|
||||
if t is not None and (t, p['tlut'], pal) in texIndexMap:
|
||||
texMap[t] = texIndexMap[(t, p['tlut'], pal)]
|
||||
tw, th = (m.textures[p['tex']]['w'], m.textures[p['tex']]['h']) if ti >= 0 else (32, 32)
|
||||
for v in p['verts']:
|
||||
pos += [v[0], v[1], v[2]]
|
||||
uv += [v[3] / tw, v[4] / th]
|
||||
nrm += [v[5] / 127.0, v[6] / 127.0, v[7] / 127.0]
|
||||
skin.append(v[9])
|
||||
prims.append(dict(tex=ti, cull=p['cull'], texAnim=p['texAnim'],
|
||||
texMap={str(k): v for k, v in sorted(texMap.items())},
|
||||
pos=pos, uv=uv, nrm=nrm, skin=skin,
|
||||
idx=[i for t in p['tris'] for i in t]))
|
||||
|
||||
def compress(values, nd):
|
||||
"""Constant tracks collapse to a scalar; most channels never move."""
|
||||
r = [round(v, nd) for v in values]
|
||||
return r[0] if all(v == r[0] for v in r) else r
|
||||
|
||||
anims = []
|
||||
for i, off in enumerate(m.anims):
|
||||
a = Animation(f, off)
|
||||
nf = max(1, a.nFrames)
|
||||
tracks = []
|
||||
for b in m.bones:
|
||||
ch = b['chan']
|
||||
bind = (b['t'], b['r'], b['s'])
|
||||
if ch < 0 or a.sample_trs(ch, 0, bind) is None:
|
||||
tracks.append(None)
|
||||
continue
|
||||
samples = [a.sample_trs(ch, fr, bind) for fr in range(nf)]
|
||||
tracks.append(dict(
|
||||
t=[compress([s[0][k] for s in samples], 3) for k in range(3)],
|
||||
r=[compress([s[1][k] for s in samples], 0) for k in range(3)],
|
||||
s=[compress([s[2][k] for s in samples], 5) for k in range(3)]))
|
||||
anims.append(dict(index=i, frames=nf, flags=a.flags,
|
||||
channels=a.nChannels, loopStart=a.loopStart, tracks=tracks))
|
||||
|
||||
auxOut = []
|
||||
for i, a in enumerate(auxAnims):
|
||||
auxOut.append(dict(index=i, frames=max(1, a.nFrames), flags=a.flags,
|
||||
loopStart=a.loopStart,
|
||||
channels=[a.track(c) for c in range(a.nChannels)]))
|
||||
|
||||
return dict(
|
||||
species=m.species,
|
||||
name=SPECIES.get(m.species, f'#{m.species}'),
|
||||
file=os.path.basename(f.name),
|
||||
rootScale=m.rootScale,
|
||||
bones=[dict(parent=b['parent'], boneId=b['boneId'], chan=b['chan'],
|
||||
flags=b['flags'], t=b['t'], r=b['r'], s=b['s']) for b in m.bones],
|
||||
textures=texOut,
|
||||
prims=prims,
|
||||
anims=anims,
|
||||
auxAnims=auxOut,
|
||||
fx=dedupe_fx(m.fx),
|
||||
warnings=m.warnings,
|
||||
)
|
||||
|
||||
|
||||
SPECIES = {}
|
||||
_NAMES = (
|
||||
"Bulbasaur Ivysaur Venusaur Charmander Charmeleon Charizard Squirtle Wartortle Blastoise "
|
||||
"Caterpie Metapod Butterfree Weedle Kakuna Beedrill Pidgey Pidgeotto Pidgeot Rattata Raticate "
|
||||
"Spearow Fearow Ekans Arbok Pikachu Raichu Sandshrew Sandslash NidoranF Nidorina Nidoqueen "
|
||||
"NidoranM Nidorino Nidoking Clefairy Clefable Vulpix Ninetales Jigglypuff Wigglytuff Zubat "
|
||||
"Golbat Oddish Gloom Vileplume Paras Parasect Venonat Venomoth Diglett Dugtrio Meowth Persian "
|
||||
"Psyduck Golduck Mankey Primeape Growlithe Arcanine Poliwag Poliwhirl Poliwrath Abra Kadabra "
|
||||
"Alakazam Machop Machoke Machamp Bellsprout Weepinbell Victreebel Tentacool Tentacruel Geodude "
|
||||
"Graveler Golem Ponyta Rapidash Slowpoke Slowbro Magnemite Magneton Farfetchd Doduo Dodrio "
|
||||
"Seel Dewgong Grimer Muk Shellder Cloyster Gastly Haunter Gengar Onix Drowzee Hypno Krabby "
|
||||
"Kingler Voltorb Electrode Exeggcute Exeggutor Cubone Marowak Hitmonlee Hitmonchan Lickitung "
|
||||
"Koffing Weezing Rhyhorn Rhydon Chansey Tangela Kangaskhan Horsea Seadra Goldeen Seaking "
|
||||
"Staryu Starmie MrMime Scyther Jynx Electabuzz Magmar Pinsir Tauros Magikarp Gyarados Lapras "
|
||||
"Ditto Eevee Vaporeon Jolteon Flareon Porygon Omanyte Omastar Kabuto Kabutops Aerodactyl "
|
||||
"Snorlax Articuno Zapdos Moltres Dratini Dragonair Dragonite Mewtwo Mew").split()
|
||||
for _i, _n in enumerate(_NAMES):
|
||||
SPECIES[_i + 1] = _n
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
src = sys.argv[1] if len(sys.argv) > 1 else 'assets/us/pokemon_models/24.bin'
|
||||
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(here, 'model.js')
|
||||
data = extract(src)
|
||||
body = json.dumps(data, separators=(',', ':'))
|
||||
with open(dst, 'w') as fp:
|
||||
fp.write('window.PKMN_MODEL = ' + body + ';\n')
|
||||
tris = sum(len(p['idx']) // 3 for p in data['prims'])
|
||||
print(f"{data['name']} (#{data['species']}) bones={len(data['bones'])} prims={len(data['prims'])} "
|
||||
f"tris={tris} textures={len(data['textures'])} anims={len(data['anims'])}")
|
||||
print(f"frames per anim: {[a['frames'] for a in data['anims']]}")
|
||||
if data['warnings']:
|
||||
print('warnings:', data['warnings'][:5])
|
||||
print(f'wrote {dst} ({os.path.getsize(dst)/1024:.0f} KB)')
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
glTF 2.0 binary writer.
|
||||
|
||||
Each game bone becomes two nodes -- a pivot carrying translation/rotation and a
|
||||
leaf carrying the accumulated scale -- because the game keeps scale out of the
|
||||
matrix chain while glTF propagates it to children. See ../README.md.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
|
||||
ND = 7 # decimals kept on node rest transforms
|
||||
|
||||
|
||||
def quat_from_euler(r):
|
||||
"""The game's rotation is Rx*Ry*Rz in row-vector form (src/F420.c
|
||||
func_8000F730); build that basis as glTF columns and convert."""
|
||||
sx, cx = math.sin(r[0] / 32768 * math.pi), math.cos(r[0] / 32768 * math.pi)
|
||||
sy, cy = math.sin(r[1] / 32768 * math.pi), math.cos(r[1] / 32768 * math.pi)
|
||||
sz, cz = math.sin(r[2] / 32768 * math.pi), math.cos(r[2] / 32768 * math.pi)
|
||||
# rows of the game matrix become the columns of the glTF rotation
|
||||
m = ((cy*cz, sx*sy*cz - cx*sz, cx*sy*cz + sx*sz),
|
||||
(cy*sz, sx*sy*sz + cx*cz, cx*sy*sz - sx*cz),
|
||||
(-sy, sx*cy, cx*cy))
|
||||
tr = m[0][0] + m[1][1] + m[2][2]
|
||||
if tr > 0:
|
||||
s = math.sqrt(tr + 1.0) * 2
|
||||
w = 0.25 * s
|
||||
x = (m[2][1] - m[1][2]) / s
|
||||
y = (m[0][2] - m[2][0]) / s
|
||||
z = (m[1][0] - m[0][1]) / s
|
||||
elif m[0][0] > m[1][1] and m[0][0] > m[2][2]:
|
||||
s = math.sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2
|
||||
w = (m[2][1] - m[1][2]) / s
|
||||
x = 0.25 * s
|
||||
y = (m[0][1] + m[1][0]) / s
|
||||
z = (m[0][2] + m[2][0]) / s
|
||||
elif m[1][1] > m[2][2]:
|
||||
s = math.sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2
|
||||
w = (m[0][2] - m[2][0]) / s
|
||||
x = (m[0][1] + m[1][0]) / s
|
||||
y = 0.25 * s
|
||||
z = (m[1][2] + m[2][1]) / s
|
||||
else:
|
||||
s = math.sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2
|
||||
w = (m[1][0] - m[0][1]) / s
|
||||
x = (m[0][2] + m[2][0]) / s
|
||||
y = (m[1][2] + m[2][1]) / s
|
||||
z = 0.25 * s
|
||||
n = math.sqrt(x*x + y*y + z*z + w*w) or 1.0
|
||||
return [x/n, y/n, z/n, w/n]
|
||||
|
||||
|
||||
def pose(bones, sample_fn):
|
||||
"""Returns (pivotT, pivotQ, jointS) for every bone at one instant."""
|
||||
acc, pt, pq, js = [], [], [], []
|
||||
for i, b in enumerate(bones):
|
||||
t, r, s = sample_fn(i, b)
|
||||
pa = acc[b['parent']] if b['parent'] >= 0 else (1.0, 1.0, 1.0)
|
||||
pt.append([t[0]*pa[0], t[1]*pa[1], t[2]*pa[2]])
|
||||
pq.append(quat_from_euler(r))
|
||||
a = (pa[0]*s[0], pa[1]*s[1], pa[2]*s[2])
|
||||
acc.append(a)
|
||||
js.append(list(a))
|
||||
return pt, pq, js
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ glTF build
|
||||
|
||||
class Glb:
|
||||
def __init__(self):
|
||||
self.buf = bytearray()
|
||||
self.views = []
|
||||
self.accessors = []
|
||||
|
||||
def view(self, data, target=None):
|
||||
while len(self.buf) % 4:
|
||||
self.buf.append(0)
|
||||
off = len(self.buf)
|
||||
self.buf += data
|
||||
v = dict(buffer=0, byteOffset=off, byteLength=len(data))
|
||||
if target:
|
||||
v['target'] = target
|
||||
self.views.append(v)
|
||||
return len(self.views) - 1
|
||||
|
||||
def accessor(self, data, ctype, atype, count, target=None,
|
||||
minmax=None, normalized=False):
|
||||
a = dict(bufferView=self.view(data, target), componentType=ctype,
|
||||
count=count, type=atype)
|
||||
if normalized:
|
||||
a['normalized'] = True
|
||||
if minmax:
|
||||
a['min'], a['max'] = minmax
|
||||
self.accessors.append(a)
|
||||
return len(self.accessors) - 1
|
||||
|
||||
def floats(self, values, atype, target=None, minmax=None):
|
||||
n = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16}[atype]
|
||||
return self.accessor(struct.pack(f'<{len(values)}f', *values),
|
||||
5126, atype, len(values) // n, target, minmax)
|
||||
|
||||
def finish(self, gltf):
|
||||
gltf['buffers'] = [dict(byteLength=len(self.buf))]
|
||||
gltf['bufferViews'] = self.views
|
||||
gltf['accessors'] = self.accessors
|
||||
js = json.dumps(gltf, separators=(',', ':')).encode()
|
||||
js += b' ' * (-len(js) % 4)
|
||||
bin_ = bytes(self.buf) + b'\0' * (-len(self.buf) % 4)
|
||||
return (struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(bin_))
|
||||
+ struct.pack('<II', len(js), 0x4E4F534A) + js
|
||||
+ struct.pack('<II', len(bin_), 0x004E4942) + bin_)
|
||||
|
||||
|
||||
def build_glb(data, pngs):
|
||||
bones = data['bones']
|
||||
nb = len(bones)
|
||||
g = Glb()
|
||||
gltf = dict(asset=dict(version='2.0',
|
||||
generator='pokestadium tools/model_viewer/export_gltf.py'))
|
||||
|
||||
# ---- nodes: root scale, then a pivot/joint pair per bone ----------------
|
||||
bind_t, bind_q, bind_s = pose(bones, lambda i, b: (b['t'], b['r'], b['s']))
|
||||
nodes = [dict(name='model_root', scale=[round(v, 6) for v in data['rootScale']])]
|
||||
pivot_id = [0] * nb
|
||||
joint_id = [0] * nb
|
||||
for i, b in enumerate(bones):
|
||||
pivot_id[i] = len(nodes)
|
||||
nodes.append(dict(name=f'bone{b["boneId"]:02d}',
|
||||
translation=[round(v, ND) for v in bind_t[i]],
|
||||
rotation=[round(v, ND) for v in bind_q[i]]))
|
||||
joint_id[i] = len(nodes)
|
||||
nodes.append(dict(name=f'bone{b["boneId"]:02d}_scale',
|
||||
scale=[round(v, ND) for v in bind_s[i]]))
|
||||
nodes[pivot_id[i]]['children'] = [joint_id[i]]
|
||||
for i, b in enumerate(bones):
|
||||
parent = pivot_id[b['parent']] if b['parent'] >= 0 else 0
|
||||
nodes[parent].setdefault('children', []).append(pivot_id[i])
|
||||
|
||||
# ---- textures / materials ---------------------------------------------
|
||||
images, samplers, textures, materials = [], [], [], []
|
||||
if pngs:
|
||||
samplers.append(dict(magFilter=9729, minFilter=9729,
|
||||
wrapS=33071, wrapT=33071)) # LINEAR, CLAMP
|
||||
for i, blob in enumerate(pngs):
|
||||
images.append(dict(mimeType='image/png',
|
||||
bufferView=g.view(blob), name=f'tex{i:02d}'))
|
||||
textures.append(dict(sampler=0, source=i))
|
||||
|
||||
prims_out = []
|
||||
for p in data['prims']:
|
||||
nv = len(p['pos']) // 3
|
||||
pos = [float(v) for v in p['pos']]
|
||||
mn = [min(pos[k::3]) for k in range(3)]
|
||||
mx = [max(pos[k::3]) for k in range(3)]
|
||||
attrs = dict(
|
||||
POSITION=g.floats(pos, 'VEC3', 34962, (mn, mx)),
|
||||
NORMAL=g.floats([float(v) for v in p['nrm']], 'VEC3', 34962),
|
||||
TEXCOORD_0=g.floats([float(v) for v in p['uv']], 'VEC2', 34962),
|
||||
JOINTS_0=g.accessor(
|
||||
struct.pack(f'<{nv*4}H', *[v for j in p['skin'] for v in (j, 0, 0, 0)]),
|
||||
5123, 'VEC4', nv, 34962),
|
||||
WEIGHTS_0=g.floats([v for _ in range(nv) for v in (1.0, 0.0, 0.0, 0.0)],
|
||||
'VEC4', 34962),
|
||||
)
|
||||
idx = g.accessor(struct.pack(f'<{len(p["idx"])}H', *p['idx']),
|
||||
5123, 'SCALAR', len(p['idx']), 34963)
|
||||
blend = p.get('blend')
|
||||
mat = dict(
|
||||
name=f'mat{len(materials):02d}',
|
||||
alphaMode='BLEND' if blend else 'MASK',
|
||||
doubleSided=bool(blend) or not (p['cull'] & 0x400),
|
||||
pbrMetallicRoughness=dict(metallicFactor=0.0, roughnessFactor=0.9),
|
||||
)
|
||||
if blend:
|
||||
# generated effects are unlit so they read as emissive fire/gas
|
||||
mat['emissiveFactor'] = [1.0, 1.0, 1.0]
|
||||
else:
|
||||
mat['alphaCutoff'] = 0.5
|
||||
if p['tex'] >= 0:
|
||||
mat['pbrMetallicRoughness']['baseColorTexture'] = dict(index=p['tex'])
|
||||
if blend:
|
||||
mat['emissiveTexture'] = dict(index=p['tex'])
|
||||
materials.append(mat)
|
||||
prims_out.append(dict(attributes=attrs, indices=idx,
|
||||
material=len(materials) - 1))
|
||||
|
||||
skin_node = len(nodes)
|
||||
nodes.append(dict(name=data['name'], mesh=0, skin=0))
|
||||
|
||||
ident = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
|
||||
gltf['skins'] = [dict(joints=joint_id, skeleton=0,
|
||||
inverseBindMatrices=g.floats(ident * nb, 'MAT4'))]
|
||||
gltf['meshes'] = [dict(name=data['name'], primitives=prims_out)]
|
||||
|
||||
# ---- animations --------------------------------------------------------
|
||||
anims = []
|
||||
for a in data['anims']:
|
||||
nf = a['frames']
|
||||
times = [round(fr / 30.0, 6) for fr in range(nf)] # authored at 30 fps
|
||||
|
||||
def sample_fn(i, b, _a=a):
|
||||
tr = _a['tracks'][i]
|
||||
if not tr:
|
||||
return b['t'], b['r'], b['s']
|
||||
pick = lambda c, fr: (c if isinstance(c, (int, float))
|
||||
else c[min(fr, len(c) - 1)])
|
||||
return ([pick(c, sample_fn.fr) for c in tr['t']],
|
||||
[pick(c, sample_fn.fr) for c in tr['r']],
|
||||
[pick(c, sample_fn.fr) for c in tr['s']])
|
||||
|
||||
seq_t = [[] for _ in range(nb)]
|
||||
seq_q = [[] for _ in range(nb)]
|
||||
seq_s = [[] for _ in range(nb)]
|
||||
for fr in range(nf):
|
||||
sample_fn.fr = fr
|
||||
pt, pq, js = pose(bones, sample_fn)
|
||||
for i in range(nb):
|
||||
if seq_q[i] and sum(x*y for x, y in zip(seq_q[i][-1], pq[i])) < 0:
|
||||
pq[i] = [-v for v in pq[i]] # keep quaternions continuous
|
||||
seq_t[i].append(pt[i]); seq_q[i].append(pq[i]); seq_s[i].append(js[i])
|
||||
|
||||
channels, samplers_a = [], []
|
||||
cache = {}
|
||||
|
||||
def time_accessor(keys):
|
||||
if keys not in cache:
|
||||
t = times if keys == nf else [times[0], times[-1]]
|
||||
cache[keys] = g.floats(t, 'SCALAR', minmax=([t[0]], [t[-1]]))
|
||||
return cache[keys]
|
||||
|
||||
for i in range(nb):
|
||||
for seq, path, node, dflt in (
|
||||
(seq_t[i], 'translation', pivot_id[i], nodes[pivot_id[i]]['translation']),
|
||||
(seq_q[i], 'rotation', pivot_id[i], nodes[pivot_id[i]]['rotation']),
|
||||
(seq_s[i], 'scale', joint_id[i], nodes[joint_id[i]]['scale'])):
|
||||
const = all(v == seq[0] for v in seq)
|
||||
# A constant channel can only be dropped when it already equals the
|
||||
# node's rest value; otherwise the node would sit in its bind pose.
|
||||
if const and [round(c, ND) for c in seq[0]] == dflt:
|
||||
continue
|
||||
if const:
|
||||
seq = [seq[0], seq[0]]
|
||||
time_acc = time_accessor(len(seq))
|
||||
flat = [c for v in seq for c in v]
|
||||
if path == 'rotation':
|
||||
out = g.accessor(
|
||||
struct.pack(f'<{len(flat)}h',
|
||||
*[max(-32768, min(32767, round(c * 32767)))
|
||||
for c in flat]),
|
||||
5122, 'VEC4', len(seq), normalized=True)
|
||||
else:
|
||||
out = g.floats(flat, 'VEC3')
|
||||
samplers_a.append(dict(input=time_acc, output=out,
|
||||
interpolation='LINEAR'))
|
||||
channels.append(dict(sampler=len(samplers_a) - 1,
|
||||
target=dict(node=node, path=path)))
|
||||
if channels:
|
||||
anims.append(dict(name=a['name'], channels=channels, samplers=samplers_a))
|
||||
if anims:
|
||||
gltf['animations'] = anims
|
||||
|
||||
gltf['nodes'] = nodes
|
||||
gltf['scenes'] = [dict(nodes=[0, skin_node])]
|
||||
gltf['scene'] = 0
|
||||
if images:
|
||||
gltf['images'] = images
|
||||
gltf['samplers'] = samplers
|
||||
gltf['textures'] = textures
|
||||
gltf['materials'] = materials
|
||||
return g.finish(gltf)
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Raw ROM access for the Pokemon Stadium (US) model export.
|
||||
|
||||
Everything here is stdlib-only: byte-order fixup, the Yay0 decompressor, the
|
||||
PERS-SZP wrapper the assets use, and the little archive format that packs many
|
||||
files into one segment. That is all it takes to get from baserom.z64 to model
|
||||
data, so the export does not need `make init`, splat or crunch64.
|
||||
"""
|
||||
import hashlib
|
||||
import struct
|
||||
|
||||
# ROM offsets taken from pokestadium-us.yaml.
|
||||
POKEMON_MODELS = 0x920000 # archive of the 215 battle models
|
||||
BATTLE_DATA = 0x70D3A0 # per-species battle tables, indexed by D_80075BD0
|
||||
MAIN_ROM = 0x1000 # main code segment ...
|
||||
MAIN_VRAM = 0x80000400 # ... and where it lands in RAM
|
||||
PTR_TABLE_VRAM = 0x80075BD0 # D_80075BD0[species - 1] -> offset into BATTLE_DATA
|
||||
|
||||
US_MD5 = 'ed1378bc12115f71209a77844965ba50'
|
||||
|
||||
|
||||
class Rom:
|
||||
def __init__(self, path):
|
||||
data = bytearray(open(path, 'rb').read())
|
||||
magic = struct.unpack_from('>I', data, 0)[0]
|
||||
if magic == 0x37804012: # .v64, byte-swapped pairs
|
||||
data[0::2], data[1::2] = data[1::2], data[0::2]
|
||||
elif magic == 0x40123780: # .n64, word-reversed
|
||||
data = bytearray(b''.join(data[i:i+4][::-1] for i in range(0, len(data), 4)))
|
||||
elif magic != 0x80371240: # .z64, native big endian
|
||||
raise ValueError(f'{path}: not an N64 ROM (magic {magic:#010x})')
|
||||
self.data = bytes(data)
|
||||
self.md5 = hashlib.md5(self.data).hexdigest()
|
||||
|
||||
@property
|
||||
def is_expected_us(self):
|
||||
return self.md5 == US_MD5
|
||||
|
||||
def u32(self, o):
|
||||
return struct.unpack_from('>I', self.data, o)[0]
|
||||
|
||||
def vram_to_rom(self, vram):
|
||||
return MAIN_ROM + (vram - MAIN_VRAM)
|
||||
|
||||
# ---- archive ---------------------------------------------------------
|
||||
def archive(self, off):
|
||||
"""Segments that hold many files start with
|
||||
u32 tag, u32 0, u32 totalSize, u32 fileCount
|
||||
followed by fileCount { u32 offset, u32 size, u32 pad[2] } records,
|
||||
all relative to the start of the segment (tools/unpack_asset.py).
|
||||
Only the top three bytes of the first word are reliably zero -- the
|
||||
model archive puts a nonzero value in the low byte."""
|
||||
if (self.u32(off) & 0xFFFFFF00) != 0 or self.u32(off + 4) != 0:
|
||||
return [self.data[off:]]
|
||||
count = self.u32(off + 12)
|
||||
if not 0 < count < 4096:
|
||||
return [self.data[off:]]
|
||||
out = []
|
||||
for i in range(count):
|
||||
rec = off + 0x10 + i * 0x10
|
||||
start, size = self.u32(rec), self.u32(rec + 4)
|
||||
out.append(self.data[off + start: off + start + size])
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------- decompression
|
||||
|
||||
def yay0_decompress(src):
|
||||
"""Nintendo Yay0. Header: magic, decompressed size, link offset, chunk
|
||||
offset; then a bitstream where a 1 copies one literal byte and a 0 pulls a
|
||||
(distance, length) pair from the link table."""
|
||||
if src[:4] != b'Yay0':
|
||||
raise ValueError('not Yay0')
|
||||
size, link_off, chunk_off = struct.unpack_from('>3I', src, 4)
|
||||
out = bytearray(size)
|
||||
mask_p, link_p, chunk_p, pos = 0x10, link_off, chunk_off, 0
|
||||
mask, bits = 0, 0
|
||||
while pos < size:
|
||||
if bits == 0:
|
||||
mask = struct.unpack_from('>I', src, mask_p)[0]
|
||||
mask_p += 4
|
||||
bits = 32
|
||||
if mask & 0x80000000:
|
||||
out[pos] = src[chunk_p]
|
||||
chunk_p += 1
|
||||
pos += 1
|
||||
else:
|
||||
link = struct.unpack_from('>H', src, link_p)[0]
|
||||
link_p += 2
|
||||
dist = link & 0x0FFF
|
||||
count = link >> 12
|
||||
if count == 0:
|
||||
count = src[chunk_p] + 0x12
|
||||
chunk_p += 1
|
||||
else:
|
||||
count += 2
|
||||
copy = pos - dist - 1
|
||||
for _ in range(count): # overlapping runs are legal
|
||||
out[pos] = out[copy]
|
||||
pos += 1
|
||||
copy += 1
|
||||
mask = (mask << 1) & 0xFFFFFFFF
|
||||
bits -= 1
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decompress(blob):
|
||||
"""Unwrap whatever container an asset arrived in."""
|
||||
if blob[:8] == b'PERS-SZP':
|
||||
header = struct.unpack_from('>I', blob, 8)[0]
|
||||
return yay0_decompress(blob[header:])
|
||||
if blob[:4] == b'Yay0':
|
||||
return yay0_decompress(blob)
|
||||
return blob
|
||||
|
||||
|
||||
def pokemon_models(rom):
|
||||
"""Returns the decompressed model fragments, indexed by file number."""
|
||||
return [decompress(b) for b in rom.archive(POKEMON_MODELS)]
|
||||
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.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user