Compare commits

...

21 Commits

Author SHA1 Message Date
DramaticShape e3c13edda7 add smooth turning, removing collision sound effect 2026-08-02 22:42:46 -04:00
DramaticShape c0c180fd01 horde mode initial commit 2026-08-02 21:01:11 -04:00
DramaticShape 2ce44586c2 Merge pull request #64 from DramaticShape/pcvr
Added PC VR mode. Enable SteamVR, launch the game, toggle the VR menu option to "ON". Tested over virtual desktop/Quest 3. Standalone VR is not supported (too potato).

| 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 | step the VOXEL angle ladder (same as the "3" key) |
| right stick up / down | *diorama only* — zoom the model |
| right stick left / right | *1ST only* — snap-turn 45° |
| grip squeeze + raise / lower that hand | *diorama only* — drag the table's height |
| head | *1ST and battles* — look; FreeMove walks where you look |
| left hand | *1ST and battles* — the Pokédex: menus, dialogs and the 2D battle screen on its screen |
2026-08-02 13:18:09 -04:00
DramaticShape e8caa4f537 final push 2026-08-02 13:10:30 -04:00
DramaticShape a8c2d8ce5b menu fix, sky rendering, pokedex size 2026-08-02 13:10:20 -04:00
DramaticShape 395f51d268 fixed dll paths for vr 2026-08-02 12:29:14 -04:00
DramaticShape 1fa29a831f update sky rendering, select button changes views 2026-08-02 12:16:03 -04:00
DramaticShape 44f729e8c2 battle effect fixes 2026-08-02 11:26:54 -04:00
DramaticShape f1063abd0f add vr 2026-08-02 02:42:31 -04:00
DramaticShape b0d37cd8e6 Merge pull request #56 from DramaticShape/back-sprite-transparency-fix
v1.5.0
2026-08-02 01:23:13 -04:00
DramaticShape 3f7210bfcd mobile spin fix 2026-08-02 01:20:39 -04:00
DramaticShape 9ef8644bff add first person mode 2026-08-02 01:16:42 -04:00
DramaticShape e6d4059c38 fix water again on android 2026-08-02 00:53:38 -04:00
DramaticShape 1a283d6771 bump version 2026-08-02 00:14:13 -04:00
DramaticShape a7c9541ac4 pokecenter machines, water occlusion 2026-08-02 00:01:39 -04:00
DramaticShape 91cc2d6f51 bills, pewter gym, register, celadon mansion, tables, garbage cans 2026-08-01 23:48:23 -04:00
DramaticShape c82598b24c Merge pull request #51 from DramaticShape/back-sprite-transparency-fix
Back sprite transparency fix
2026-08-01 16:44:57 -04:00
DramaticShape 47363b8d23 iterate version 2026-08-01 16:41:03 -04:00
DramaticShape 752653e243 update oak's pc 2026-08-01 16:20:04 -04:00
DramaticShape 6887f5d951 updates to oak's lab 2026-08-01 16:03:32 -04:00
DramaticShape a140980b1d seal transparent back sprites 2026-08-01 15:19:26 -04:00
84 changed files with 17251 additions and 565 deletions
+698
View File
@@ -1,5 +1,607 @@
# Changelog
## 1.5.4
### Added
- **HORDE MODE.** Enter the konami code -- Up Up Down Down Left Right
Left Right B A -- standing in the overworld, and Kanto turns on you.
The sky drops to a starless violet night, the Lavender Town theme
comes up, the camera locks into the player's own head, a voxel
handgun with working iron sights appears in their right hand, and
waves of people walk out of the dark to kill them. The map's own NPCs
join in. Every mob that falls screams as a random Pokemon. Score goes
up per kill and per wave cleared; there is no pausing. When the health
runs out, a GAME OVER card offers the score and PRESS A -- and pressing
it puts the map, the cell, the facing, the camera rung, the hour, the
music and every NPC back exactly as they were.
The code is read off GAME BOY BUTTONS rather than off keys, so it
works on a keyboard, a pad, a phone's touch overlay and a pair of VR
controllers without knowing which one is in use. 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 hand; aim
down the sights on right click or the left trigger (in VR you aim by
pointing, and the sights are real geometry to line up).
The crowd walks the same grid the game walks, chases along a flow
field swept out from the player -- so they come through doorways and
around buildings rather than into walls -- and they follow the player
through a door into the building on the far side of it.
- **The horde's gun, on the Game Boy's own sound hardware.** The
gunshot, the dry fire, the magazine dropping out and seating, and the
slide coming home are all authored as channel programs and
synthesized by the game's emulated APU (`lib/HordeSfx.lua`). No audio
files ship with the mod. Three shot variants round-robin so a fast
trigger finger overlaps its own echoes instead of cutting them off.
### Changed
- **SMOOTH TURN**, a new options row under VR (OFF by default), turns the
right stick into a continuous turn instead of the 45-degree snap. The
snap stays the default deliberately -- a software turn moves the world
past a head that did not move, which is the most reliable way to make
somebody ill in a headset -- but it costs continuity, so the choice is
the player's. The row only exists while VR is ON: a comfort setting for
a device that is not plugged in decides nothing.
- **The wall bump is silent in first person**, on the flat screen and in
VR alike. On the grid a blocked step is a discrete event -- you pressed
a direction, the game refused, and the bump answers you once. The free
walk has no such moment: the body slides along whatever it grazes, so
walking a fence line or rounding a doorframe is blocked on one axis
continuously and the sound came out as a rattle rather than as an
answer. The grid walk keeps its own bump untouched.
- **`FirstPerson.fovScale`** is a new seam on the first-person rig: a
plain multiplier on the field of view, for anything that wants to
narrow the lens without owning the camera. Horde mode's iron sights
ease it from 65 degrees to 40. It is folded into the orbit blend and
into the shadow signature, so a lens that narrows while the player
stands still still re-fits the sun's box.
- **VR gains an aim pose and a fire action** (`lib/VRXR.lua`). The fire
action is suggested ALONGSIDE START on the right trigger rather than
instead of it -- bindings are suggested once, before the session
attaches its action sets, so they cannot be swapped when a mode
starts, and OpenXR is happy for two actions to share an input.
Outside horde mode nothing reads it and the trigger is START exactly
as it was.
## 1.5.2
### Added
- **The Pokédex in hand is a quarter larger.** Its voxel pitch went from
1.1 cm to 1.375 cm (the body from about 10x15 cm to about 12x19 cm),
because the screen carries every menu in first person and was
squint-small at the old size. The attachment -- flush along the left
controller -- is unchanged.
- **Left stick click steps the VOXEL ladder.** In VR the click now makes
exactly the step the "3" key (and the pad's SELECT) makes -- the same
function, handed across, so the ladder walk, the FULL step-over and
the TILT/GBC FX clearing can never drift from the key's. It used to
toggle first/third person against a remembered return rung.
- **The floating panel shows the same picture at every window size.**
The GB-frame region used to be copied into the headset's panel
pixel-for-pixel, and the panel's swapchain image has a fixed size --
so a window scaled past it (fullscreen above all) ran the frame off
the copy's edge and cut the START menu out of the panel. The region
is now blitted OUT of the window and SCALED into the swapchain image
at the frame's own aspect: identical picture, identical near-square
ratio, whatever size or shape the window takes.
- **Menus stay inside the GB frame while a headset is live.** The
engine's new zoom-aware anchoring docks the START menu to the
WINDOW's edge -- and both VR screens (the floating panel and the
Pokédex) crop the window to the near-square GB frame, so a docked
menu was cropped away with the border it hugged. While a headset is
live the mod answers the engine's own "hold the anchors" predicate
with yes, and every menu blits where it was drawn: the START menu's
classic slot, flush with the frame's right edge, which is the right
edge of everything the headset shows.
- **The sky's dither is glued to the sky.** The gradient's bands were
already read by true elevation, but the GBC checker between them kept
SCREEN-cell parity -- so a head's pitch or roll slid the world-fixed
band edges over a screen-fixed checkerboard and the whole gradient
shimmered as the pattern recomputed. The ray path is now a computed
SKYBOX: each pixel's ray lands in a cell of the sky's own angular
grid (azimuth columns, elevation rows, sized to match the diorama's
pixel grid on screen), and the band, the checker's parity and the
twilight glow -- now measured by the angle to the sun's own direction
-- are all answered from that cell's centre. The screen grid
quantises nothing, so the picture behaves exactly like a
nearest-filtered texture on a dome: its cells slide smoothly with the
world, no motion of the head recomputes the pattern, and only the
clock moves the sky.
- **VR works from an installed release.** The OpenXR loader used to be
looked for only against the working directory and the game's source --
right for the dev tree, wrong for a release install, where importing
the mod lands it in the game's SAVE DIRECTORY (or keeps it zipped) and
the VR row silently failed to start. The search now also asks the
mount that actually holds the mod and the save directory itself; and
if nothing on disk answers -- the mod imported as an archive -- the
DLL is copied once out of the mod into the save directory and loaded
from there, so every install shape reaches the headset.
- **SELECT walks the VOXEL ladder.** In free roam, the pad's SELECT
button makes exactly the step hotkey 3 makes -- OFF through the angle
rungs to 1ST and round, stepping over FULL, clearing TILT and GBC FX
on every press like the key does. For the machines with no number row:
the phone's touch pad and a controller. SELECT has no overworld job in
Gen 1 -- its work is all in-menu, and menus keep it untouched.
- **PCVR, in-process, through OpenXR -- with no change to the parent
app.** A new **VR** row (OFF / ON, off by default; on the OPTIONS menu
and the mod manager's page). The whole stack rides LuaJIT's FFI from
inside the mod: the Khronos OpenXR loader ships in `assets/vr/` (with
its Apache-2.0 license alongside), the session binds to LOVE's own
OpenGL context, each eye is rendered by the mod's existing scene pass
under a placed camera built from the tracked pose, and the finished
canvases are blitted straight into the runtime's swapchain images.
Works with any Windows OpenXR runtime (SteamVR, Oculus, WMR).
- **What you see mirrors the VOXEL ladder.** On the orbit rungs the
world is a TABLETOP DIORAMA hung at the RUNG'S own viewing angle and
at the scale that reproduces the flat screen's framing -- step onto
35 and the model presents at 35 degrees, onto 75 and it rises toward
eye level, easing between rungs; lean in and the town grows, walk
around the table and honest occlusion shows you the far side of the
buildings. On **1ST** you stand inside the world at life size (a
tile is a stride), the headset steers the same yaw and pitch the
flat screen's mouse does, and FreeMove walks where you look.
- **VR controllers.** One OpenXR action set, suggested onto Touch,
Index and WMR (plus the khr/simple fallback), rebindable in the
runtime's own UI. In 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, and **clicking
the left stick** steps the VOXEL angle ladder exactly as the "3"
key does. In the diorama: **right stick up/down** zooms the
model, and **squeezing a grip** while moving that hand up or down
drags the whole table with it. No controller button leaves VR --
both directions belong to the VR row alone, so no mid-fight click
can eject you from the headset.
- **Battles happen ON the world -- from the flat game's own seat.**
When a staged fight starts, the headset fades to black and comes
back seated in the flat battle's over-the-shoulder shot: on the same
camera line (your mon near-left, the foe far-right), pulled in to
the wide rig's standing distance at life scale, turned to face the
arena -- and fades back to wherever you were when the fight ends.
Both mons stand on their arena cells in the VR eyes' own view, yawed
per eye, casting real shadows, wearing the hit flash -- and the MOVE
ANIMATIONS play out there with them: the engine's own effects layer,
caught on a canvas and stood on a billboard that faces the eye the
way the mon cards do (effects are 2D drawings, and a drawing must
face the eye that is looking), with the classic layout's two slot
marks pinned where each arena cell lands on that plane along the
eye's own ray -- so a burst authored at a slot sits exactly over the
mon standing in for it, per eye, and a projectile crossing the frame
crosses the arena. (The flat screen keeps its
composed battle shot, untouched.) And the battle camera's parallax
drift holds still while a headset is watching: the sway is a flat
screen's depth cue, and inside VR it read as the world lurching.
- **A voxel POKEDEX along your left controller.** A hand-authored
voxel model of the series' own field guide -- red slab, lens, LEDs,
hinge, d-pad, dark screen bezel -- laid flush along the left
controller's grip pose (a full quarter turn forward, so holding the
controller is holding the device) through the same XR-to-world
mapping as the eyes, at its real hand size wherever the camera is.
It rides in FIRST PERSON and in the BATTLE seat; the diorama does
without it -- a hand-sized device hovering over a tabletop town is
clutter, and the floating panel serves there. In first person its
screen carries EVERYTHING the flat screen shows -- menus, dialogs,
shops, wipes -- and the floating billboard is retired outright:
raise your hand to read, lower it to play. In a staged fight the
screen is the 2D battle -- text, menus, HP bars, and any party or
bag screen opened over it -- and there too the floating billboard is
gone entirely: the fight owns the view, the reading is in the hand.
(No tracked left controller still gets the floating panel -- the UI
must be readable somewhere.) Drawn by the scene's own pass with real
depth, per eye; dark when nothing is showing.
- **The floating panel wears the GB frame.** The quad used to show
the whole window -- monitor-wide, mostly mirror -- and now crops to
the 160x144 letterbox where everything the flat screen has to say
actually lives, so the panel presents near-square (10:9) at 1:1
pixel aspect. To keep a battle's HUDs inside that frame, the HUD
blocks stay in their classic GB slots for as long as a headset is
live instead of snapping out to the window's edges.
- **The sky is anchored in space.** On the flat screen the band
gradient hangs off the frame; inside a headset that meant the sky
(and the sun and moon with it) rode the player's head. The VR eyes
now hang the gradient over a fixed slice of elevation above the
world's real horizon and drop the fixed-slice fallback entirely, so
tilting your head slides the frame across a sky that stays put --
and the discs, already projected through each eye's true camera,
stand still over their own azimuth. The gradient is a true SKYBOX:
each eye hands the sky shader its own ray fan (head rotation plus
frustum tangents), and every pixel takes its band -- and its GBC
checker dither -- from the TRUE elevation of its own view ray, so
no motion of the head, pitch, yaw or roll, in the diorama or in
first person, moves a band by a pixel; only the clock recolours
them. The FLAT screen's first person gets the very same treatment:
the placed rig builds its own ray fan from the basis its view is
made of, so mouse-look pitch slides the frame over a sky that
stays put there too, dither and all -- while the orbit rungs keep
their classic frame-hung painting. And the SUN AND MOON are no
longer painted on the frame at all (in first person on the flat
screen included): the cell art is baked to a texture once per
palette and hung on a quad IN THE WORLD, projected through the
camera like any geometry -- no per-frame cell snapping (the
jitter), no pattern squared to the canvas (the face that turned
with the head).
- **The VR row exists only where VR can.** Off Windows -- the Android
build above all -- the row is absent from the OPTIONS menu and the
mod manager's page both (the loader and the GL interop are Win32),
and a stored vr=true that migrated over in a save is ignored instead
of read, so a phone never tries to start a session or force the
battle rows.
- **VR owns the battle rows.** While the VR row is ON, staged battles
are REQUIRED (3D-BTL answers ON whatever it was set to) and BACK
SPRITES is held OFF -- the battle seat, the pokedex screen and the
effects plane all assume both mons standing on the world -- and both
rows leave the OPTIONS menu for the duration, because a switch that
decides nothing reads as broken. Both come back, at their stored
values, the moment VR goes off.
- **First person snap-turns.** Flicking the right stick left or right
steps the view 45 degrees, once per flick (it re-arms at centre) --
a snap rather than a smooth spin, because smooth software yaw is
the classic VR comfort mistake. The turn steps the XR-to-world
mapping itself, so the eyes, the walk direction and the pokedex in
your hand all agree about which way the world now faces.
- **The cast holds one pose for the headset.** Sprite cards lean back
by the camera pitch on the flat screen; a head that roams the table
has no one pitch to match, so every VR frame leans the cards at the
top rung's near-upright 75 degrees instead -- whatever orbit rung
the ladder is on -- and the flat screen keeps leaning with the rung.
- **Menus, dialogs, battles and wipes float on a panel** (an OpenXR
quad layer fed from the window), so everything the flat screen can
show, the headset can read. The window itself becomes the VR mirror
-- the left eye, fitted to the window -- and every existing
keyboard, mouse and pad input keeps working alongside the XR
controllers.
- The two eyes share one shadow map, one pose capture and one glint
step per frame (VoxelScene.render's `eyes` path), so they can never
disagree about anything but their viewpoint. xrWaitFrame paces the
app at headset rate while FixedStep keeps game logic at its own 60
Hz; vsync is handed off while the session runs and put back after.
- Failure is a status, never a crash: no runtime, no headset, no GL
interop, or a session lost mid-play all land back on the flat screen
with the reason printed (`XR_ERROR_FORM_FACTOR_UNAVAILABLE` means
"plug the headset in, then toggle the row"). Needs the mod running
from a real folder (the FFI cannot load a DLL out of an archive).
## 1.5.0
### Added
- **1ST: a first-person camera, played like a modern one.** A seventh
rung on the VOXEL ladder (hotkey 3 walks it; the OPTIONS row carries
it). Stepping onto it dives the camera from wherever the orbit was
into the player's own head over half a second, and stepping off flies
it back out. The rig rides the same placed-camera seam the staged
battle proved out, so the sky's bands meet the horizon, the sun and
moon hang where their shadows say, and the water reflects at eye
level -- all through math that was already there.
- **Free look.** Relative mouse motion (the cursor is captured while
the rung is on; left click is A, right click is B), the right
stick at a rate with a squared response curve, or a touch dragged
across any open screen -- the overlay's d-pad and buttons still
work, and a second finger can drag the view while the first
walks. Pitch clamps short of straight up and straight down.
- **Free movement.** While 1ST drives, the grid walk is replaced by
a continuous, camera-relative one: push forward and you go where
you look, at any angle, sliding along whatever you graze. The left
stick's raw deflection, the touch d-pad's true vector, or the held
keys (forward / backpedal / strafe) all steer it. The grid is
still the game: the walk asks the engine's own collision the same
per-cell questions a grid step asks, the logical cell tracks the
body, and every cell crossed runs the engine's own landing
pipeline -- warps, encounters, spinners, gates, poison, repel, the
step counters. Walking off the map edge, into a ledge or into a
boulder hands the push to the engine's own handlers, so
connections cross, ledges hop and boulders shove exactly as
themselves. Speed is the grid walker's own (bike included), so
distance per second and encounters per tile are unchanged.
- **Billboards seen from inside the world.** Character cards stop
leaning and start turning: upright, yawed about their feet to face
the eye, wearing the frame their pose shows *this* viewer -- walk
behind an NPC and you see their back, circle to a flank and you
get the profile, exactly the four frames Gen 1 drew. The authored
figures (the couch sitters) turn the same way, about their own
middle. The sun pass swaps frames in step, so a card never reads
its own shadow through a mirror-flipped record of itself. The
player's own card is left out of the camera draw -- the eye stands
in it -- but still casts its shadow on the ground ahead.
- The shadow map's box follows the look (the orbit's fit reaches far
north and barely south, which is wrong for a head facing south);
the world curve is declined outright while the head owns the
camera; and the whole rung falls back to the 75-degree orbit on
hardware without the 3D pass.
## 1.4.3
### Added
- **The furniture of the whole game goes through the building
pipeline.** 1.4.1 put four drawings through it; this is the rest of
the rooms. Every one of them is the same read -- the drawing's own
bands say what is a top seen from above, what is a face seen head-on,
and where the thing ends on the floor -- and every one of them
replaces a pinned box that wore its drawing as a decal. The pins all
stay as the degradation path, neutralized wherever a template stamps.
- **The bookcase, the commonest piece of furniture in the game** --
58 placements across two drawings on the town-house atlas (books
and a bowl on each shelf at the west end of eighteen homes, books
on both at the east), plus Red's and the Copycat's pair. Pinned
`desk` it was a 24px box with the books painted on its flat front.
Modelled it is 23 voxels of cabinet with its top seen from above,
and every book, bowl and door panel sunk a voxel behind the frame
the drawing seals it in.
- **Celadon's display cabinets** -- the tall one with the trophy
behind its glass and the short one beside it, band for band the
same object as the town house's on another atlas, which is what
makes the pair read as one line of furniture: 23 voxels and 15,
exactly the 8 rows of drawing between them.
- **The dining table, everywhere it is drawn** -- the generic town
house's at 18 placements, Red's and the Copycat's, and the chief's
long table at four cells wide. All of them the lab table's read at
a different width, all of them 6 voxels, all of them standing on
the ground line their legs are drawn stopping at rather than on
the grid's floor.
- **The stool at every one of those tables** -- 94 placements on the
house atlas alone, ten more in Red's and the Copycat's, and the Fan
Club's four members' chairs, a different drawing that is
pixel-identical from the seat down. The first template with no base
piece at all: a stool is drawn mid-cell over its own floor, so it
is a desk-set of exactly one part, seat lid over legs with the
floor showing between them.
- **The Pokemon Center's healing machine** -- two variants, 24
placements, plus the Indigo Plateau lobby's pair. A wall-height
cabinet with its monitor perched on the front of its top face,
drawn across two map rows because it towers over the 16px band
behind it, which the volume path could only read as more wall. The
hoses leaving its side are modelled as hoses, at the elevation and
the depth the two stacked motifs put them; the west machine's
keyboard is a shelf at counter height wearing its own top-view art.
- **Bill's desk, and the Silph president's** -- the same drawing in
both rooms. Its terminal is drawn in 2:1 isometric, turned 45
degrees to the map, and builds as a cube rather than the slab a 2:1
reading gives; the kinked dark run between keyboard and computer is
raised to the keyboard's height and reads as the cable it is. The
desk stops at its own two cells because the artist drew its apron
into the walkable cell in front, sharing tiles with the chair
pushed up to it -- so the chair is modelled as a part of the desk.
- **The Bike Shop's open toolbox.** The drawing looks down INTO the
tray, which is why every solid treatment failed it -- as a
`billboard` the whole cell went up as one 10-voxel slab wearing the
drawing as a decal. `tray` builds four walls, a floor and air
between them, with the lid standing open on its hinge.
What the template language grew to carry them: `tray`; a `desk` band
that lays its top face flat as a lid; the `box`, `flat` and `iso`
part kinds; `stretch` for a band mapped over a deeper plot than it
was drawn on; `inset` for a pane sunk by hand; `panes = false` where
the global recess pass has the polarity backwards; a `wall` element
so a template can keep the band behind it solid; `plane` for a height
the drawing states elsewhere; and `scrub`/`keep`/`support`, which let
a template model a surface while leaving an object standing on it to
its own standee -- Red's potted plant on the dining table.
- **Round bins: the `can` class.** Vermilion Gym's switch puzzle stands
fifteen galvanised trash cans in a row, and the S.S. Anne redraws the
same object pixel for pixel as its galley barrels. Left to the thin
standee pool they were flat discs on edge -- fifteen coins standing
in a row; pinned a plain `cylinder` the drawing's base arc revolves
too and they came out as barrels balanced on a three-voxel stem.
`can` is the round hull cut at both ends, hollowed and tapered: the
drawn mouth ellipse projects across the top and down the well so you
look into the bin, the drawn base ellipse is ground contact rather
than body, and the plan narrows toward the floor. The two ellipses
are measured off the pixels; the height, the well and the taper are
authored, and the entry says why.
- **The rock gyms' boulders are round.** 87 placements over Pewter's
walls and maze and Bruno's clusters, and every one of them was a
square bar wearing a boulder texture in relief -- the repeat-aware
scenery path extruding the whole drawing as one course. Each cell is
now a hull whose plan is its own drawn width profile turned in depth:
a dome full-width from the drawn shoulder down, tapering over the top
five rows exactly where the art tapers, with the floor's corner
diamonds opening between them the way the drawing has them. Still
16px, so nothing standing on or beside a rock moves.
- **The potted plant stands as a plant.** The most repeated interior
prop in the game -- 78 placements over 13 maps, six per Pokemon
Center -- and its urn was rendering as a hollow black frame, because
the drawing's foot lies flush on the block's bottom edge and the
background vote took the plant's own darks away with the floor. Named
outright as light and white instead, it stands as one organic
silhouette 32px tall over its two stacked cells, crown overhanging
the stem. `planter` carries the same reading for a round drawing
stacked two cells high on one cell of plot.
- **Bicycles, in both places the Bike Shop draws them.** The six on the
showroom floor get their own pool at two voxels rather than the thin
pool's five: a bike is a line drawing, and at five voxels every
stroke closes the gap to its neighbour with its own side faces, so
from any angle but dead-on the air inside the frames filled in and
the six came out as one dark lump. And the two against the north wall
get `mounted`, a new authored-mask escape for a thing drawn INTO a
wall band: it holds the wall's plane as a thin per-pixel slab instead
of standing up as a sprite card, and it keeps its drawn elevation, so
a bicycle hung clear of the floor stays hung. Its mask is measured
rather than hand-drawn -- the plain panel tile composited across the
same grid and the background flooded in through the pixels that still
match it, which separates bicycle from stripe exactly.
- **The Marts' cash register is a machine, not a decal.** An authored
figure may now state a `depth`, which makes it an object rather than
a person: a per-pixel solid standing on the counter instead of the
flat card that turned edge-on with the camera. And the drawing is not
a box -- its black linework packs two facings, an L of base and arm
around a keypad that is the machine's deck seen from above. `flat`
lays that rect horizontal in the notch of the L, and `thin` gives the
receipt curl a paper's thickness where the body's would have made it
a wedge.
- **Shelf fronts have relief.** Everything the `bookcase` collapse is
used for is a shelf, a rack or a display case, and all of them seal
their contents behind the drawing's own black frame -- so those
regions now sink a voxel, the same rule a facade's window panes are
recessed by, and the books stand in the shelf instead of being
painted on it. A tileset that borrows the collapse for something that
is not a shelf says `bookcase_relief = false`: the League's masonry
and pilasters, whose courses are the wall itself, and Bill's
transporter drums, whose light regions are a lit barrel.
### Changed
- **Class heights now follow the models under them.** A tileset's
`heights` gets stools at 5 and tables at 6 in the houses, Bill's desk
at 8, and cans at 9 -- each of them the drawn elevation the new
template or hull stands at, so whoever sits on a stool sits on the
seat, and whatever object sprite stands on a table lands on the
modelled top rather than three voxels over it or under it.
- The healing machines' two flanks leave the `wall` pin for the thin
standee pool. They are equipment standing beside the console -- a
pair of pipes and a keyboard -- and as wall each was boxed into a
solid 16px half-cell wearing its drawing in relief.
### Fixed
- **Water no longer hides behind water.** The reflective pass writes no
depth -- the depth canvas is detached for the length of it so the
shader can read it -- so nothing put a lake in the buffer and no lake
could occlude another; the sheets were simply painted in mesh order.
Flat water never showed it, one plane, a farther sheet always landing
farther down the screen. The world curve ends that: it drops the far
side of the map into the near field of view, and a sea a hundred and
fifty tiles away came out rasterised on top of the pond at the
player's feet, tall grass and all -- water and terrain "from the
other side of the map", not reflected but there. The water meshes now
go down flat first, through the ordinary scene shader with depth
writes on, and the reflective pass draws over what survived. The
buffer holds the surface, so the pass's own test throws the far sheet
away; the reflection copy holds it too, so a ray grazing another part
of the lake reads water rather than the void behind it; and a frame
that cannot run the pass at all is unchanged, because the flat draw
is the fallback that was already there.
- **Reflections under the world curve.** The bend tips the world away
and the things standing on it do not lean with it -- and a lake is
one of those things. Reflected off the bowl the bend makes, the far
half of a pond was a mirror tilted twenty degrees: it threw the ray
past the vertical, where the sky ramp's own measure swings from one
end to the other across a single column, and hard-edged patches of
the wrong sky stamped into the water; the same tilt sent the
screen-space march grazing along the bank rather than over it, which
is what smeared the dock and the roofs across the harbour. What the
water reflects is now worked out in the flat world, exactly as it
would be with the curve off, and every marched sample is bent on its
way to the screen by the vertex stage's own displacement -- so the
ray is straight where it should be and lands where the geometry did.
The wave columns are read on the flat sheet too: the relief walk is
built on an even slab over a level plane, and in the curved world
that slab is a bowl, which handed back a column a pixel or three off
per fragment -- a patch of noise in the middle of a pond.
- **Merged runs tore open under the curve.** A quad's interior is the
chord of a parabola its neighbours draw the arc of, so a long run
hangs below the short quads butted against it. Nothing bounded a
run's length, and the ones that ran away were those wearing a
constant texel -- a roof's black eave outline, its fascia, its shaded
underside -- because a flat run has no art to break it. At 102px
across a gym the eave tore off the roof and the slot showed the
building's dark interior through it. Runs now stop at the next 8px
lattice line, which is the lattice buildings are stamped on and the
one every other quad in the scene already ends on, so every join is
vertex-for-vertex and the bend carries them together. It costs quads
whether the curve is on or not -- Cerulean's object stream goes from
35.7k to 41.6k -- and that is deliberate: the mesh is cached per map
and built over seconds, so meshing for the curve's sake only when the
curve is on would mean rebuilding every live map on a keypress.
## 1.4.1
### Added
- **Furniture through the building pipeline.** The band-table voxelizer
that models whole buildings from their own drawings (lib/Buildings.lua)
now reads interior furniture too, and the first four drawings are in:
- **F01, the starter-ball table in Oak's lab** -- the tabletop's 16
drawn rows lay flat over a 16px plot (1:1, the first template that
never cycles), the black/#555/black edge band folds into the slab's
own rim, and the base extrudes with its corner feet. Six voxels
tall, exactly the drawn elevation.
- **F03, the empty north table beside it** -- the same band table on a
grid two tiles narrower.
- **F02, the lab's computer desk** -- the first DESK-SET template: the
drawing segments into PARTS, each classified by the surface it
depicts. The monitor and the computer tower stand upright on the
desk wearing their own drawn tops as lids; the keyboards and the
mouse lie flat in front of them; the sheet of paper on the right
lies flat across the desk. Flat parts keep the drawing's own rule --
drawn row IS depth row, the same 1:1 the tabletop is drawn with --
so an object's height on the drawing is its position on the desk.
The Hall of Fame's recording machine is this drawing tile for tile
on the GYM atlas, and models identically for free.
- **F04, the Center PC** -- the desk-set read again: a Mac-style unit
with its screen and drive slot in relief, standing at the back of a
low white-topped desk with its keyboard lying at the front edge.
Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART
tileset shares the atlas.
Two measurements had to stop being assumptions for furniture to fit
the pipeline: the GROUND LINE is now read off the drawing (a building
ends on the black threshold row it stands on; a table's legs stop two
rows short of theirs, and extruding against the grid floated them in
the air), and a template may name its PLOT (`depth`) when the matched
grid runs past it onto the walkable floor the legs merely stand on.
Both are identities for every existing building.
- **The Center couch has a backrest.** The couch is drawn from above --
back-and-arm strip down the west side, cushions and seams on the east
-- and rendered as one seat-high box. The new `backrest` class raises
the drawn back strip to 12px over the 8px seat, in every Center and
the Celadon Hotel. The man sitting on it keeps his seat: the figure
anchor now scans under his card for the tallest authored upright (his
cushion) instead of reading the corner tile, which is the backrest
now.
### Changed
- **Sprites ride at the height the art actually stands.** Class heights
can now be overridden per tileset (a tileset entry's `heights`), and
DOJO's lab tables use it: they are drawn 6px tall, not the default
table's 12, so the starter balls sit exactly on the modelled tabletop
-- and the volume-built north tables drop to the same height, keeping
every table in the room level.
- The Center PC's old rendering -- a 12px table box with the unit as a
flat standee on it -- retires wherever the F04 template stamps; the
pins stay only as the degradation path when the shape profile is
absent.
## 1.4.0
### Added
@@ -116,6 +718,72 @@
ever lands half-way between two pixels and changing a wavelength moves the
speed with it.
- **AA, a new options row: OFF / 2X / 4X.** Everything else in this game is
flat art blitted at whole pixels. This mode's world 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 where a hard stair-step is not a
stylistic choice -- a roof ridge, a ledge lip, a tree's silhouette against
the sky, the leaning card of a character. At the shallow rungs, where the
diorama reads most like a photograph of a model, they crawl as the camera
drifts.
The row is SUPERSAMPLING: the whole pass renders into a canvas larger than
the window and is folded back down at the end. The ladder is samples per
display pixel, so 2X is a canvas root-two wider and taller and 4X one
exactly twice the size -- an honest 2x2 box.
Two alternatives were tried against what this pass already is, and both
lost:
- **MSAA** would have taken the water with it. The reflections read the
frame's own depth buffer as a texture, and a multisampled depth
attachment is not something a fragment shader in this dialect can sample.
The row would have quietly switched the WATER row off.
- **An edge filter** (FXAA and its relatives) works from the finished
colour alone, so it 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 larger has neither problem, and nothing in the frame had to be
taught about it: every pass already measures itself in the canvas it was
handed, so the sky's dither, the water's ray march, the shadow lookups and
the camera itself come out the same picture at a higher sample rate. It
antialiases the geometry, the alpha-cut outline of a sprite card, the
wireframe and the reflections at once, because none of them know it is
happening.
And it softens the ARTWORK with them, which is worth saying plainly. 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 it is a row and not something that is simply on.
Two things are quoted in DISPLAY pixels rather than canvas ones and are
multiplied up to match: the voxel wireframe's line width -- left alone it
would fold down to half a line, so turning the smoothing up would appear to
fade the grid out -- and the scale the overworld's FX closures draw at.
The fold is a shader rather than a scaled draw, because the void this pass
renders into is a transparent BLACK: averaging a straight-alpha edge against
it drags the colour toward black as well as toward transparent, and the
engine's composite then multiplies by that alpha a second time. Every
silhouette against the sky would have come out ringed with a dark fringe --
the exact artefact the row exists to remove. So the taps are premultiplied
before they are averaged and divided back out after.
The staged battle gets it too, on its own canvas: the arena is folded back
to the window's pixel size before the depth-of-field pass and the HUDs go
on, so the world is smoothed and the pics, panels and text box stay the
chunky GB art they are.
OFF by default, and **FULL neither sets it nor takes the row away** -- it
is the one row that is not a knob on the look but on what the look COSTS,
and only the player knows what their machine can carry. No hotkey, for the
same reason: it is set once, not flicked while walking.
### Changed
@@ -216,6 +884,36 @@
depth16 as the floor every GLES3 device can read. Refused all four, the
reflections are lost and nothing else, exactly as before.
- **Under BACK SPRITES some of your own Pokemon were see-through -- Pikachu,
Seel, Dewgong, Chansey, Jigglypuff -- with the arena showing through the
middle of them.** Those back pics are drawn as OUTLINES: everything inside
the ink is the lightest shade, the decoder keys that shade to nothing, and
on hardware it did not matter because the field behind them was white too.
BattlePics already put that paper back by flooding the background inward and
filling whatever it could not reach, and along the bottom of a figure it told
a narrow opening (a belly the drawing ran out of, sealed) from a wide one (a
stride, left open for the world to show through). Right for a mon standing
on the map -- but the pinned back pic is not on the map, it is on the text
box with its feet on row 96, and there is white box under its lowest row
rather than arena. Every one of those mons leaks out through an opening far
too wide to read as a drain, so the flood walked straight up inside them.
A pic on the box is now told so, and its bottom edge seals: nothing reaches
it from below at any width, and the rule stops being a heuristic -- paper is
whatever the background cannot walk to from the left, the right or the top.
Twelve of the game's 151 back pics turn on this; the other 139 come back
byte-identical, and no front pic is touched at all.
**And a hole is filled with the pic's own paper rather than with white.**
Shade 0 is only white while the pic is still grays, and pics arrive here
after the bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK
across the whole screen while the blackout text is up. A hardcoded white
belly would have been the one lit thing on a blacked-out mon. The lightest
shade still standing in the pic is that colour, and every one of the game's
battler pics keeps at least one such pixel -- an eye, a highlight down a
cheek -- so what goes back is the baked shade itself.
### Known
- Screen-space reflections can only reflect what is in the frame. A tree just
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DramaticShape
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+53 -48
View File
@@ -3,40 +3,7 @@
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.
Water is a surface rather than a texture lying in a hole. It is a field of
one-pixel-wide voxel columns, each standing a whole number of pixels tall and
rising and falling as waves — found by walking the view ray through them in
the shader, so a crest hides what is behind it and shows you its lit side,
with no extra geometry anywhere.
And it reflects. The sky it stands under, in the same bands, the same dither
and off the same clock, so the lake and the sky above it meet at the
waterline with no seam. The sun or moon hanging in it, at the size the
painted disc is drawn, craters and all. Whoever is standing beside it —
walkers, NPCs, the two Pokémon in a staged battle. And on **FULL**, a
screen-space ray march adds the rest of what is on screen: the shoreline, the
trees behind it, the buildings across the bay. How much of it shows is
Fresnel, so the top rung is a mirror and a looking-straight-down rung is a
pond, off the same water.
And battles fought on that world rather than on a white field. When
something picks a fight the map's NPCs are culled, the engine's own wipe
plays over the empty map, and the battle draws over the nearest patch of
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 and VR.
## Controls
@@ -45,27 +12,65 @@ 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 → 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 |
| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
on the world whether or not the free-roam camera is pitched over.
## VR
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 **VR** options row (OFF / ON, off by default) drives a PCVR headset
through OpenXR on Windows — SteamVR, Oculus or WMR.
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.
The **SMOOTH TURN** row appears under it while VR is ON (OFF by
default): ON turns the right stick into a continuous turn instead of the
45° snap. The snap is the default deliberately — a software turn moves
the world past a head that did not move, which is the most reliable way
to make somebody ill in a headset — but it costs continuity, so the
choice is yours.
### 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 | step the VOXEL angle ladder (same as the "3" key) |
| right stick up / down | *diorama only* — zoom the model |
| right stick left / right | *1ST only* — snap-turn 45°, or turn smoothly with **SMOOTH TURN** on |
| grip squeeze + raise / lower that hand | *diorama only* — drag the table's height |
| head | *1ST and battles* — look; FreeMove walks where you look |
| left hand | *1ST and battles* — the Pokédex: menus, dialogs and the 2D battle screen on its screen |
## Licenses
This mod is released under the **MIT License** — see [`LICENSE`](LICENSE).
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.
+188
View File
@@ -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.
+1336 -87
View File
File diff suppressed because it is too large Load Diff
+244
View File
@@ -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
+13 -3
View File
@@ -124,6 +124,14 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
BattleCam.DOLLY_PERIOD = 37
-- Hold the rig perfectly still (VR sets this while a session runs). The
-- drift exists to give a FLAT screen the depth cue the picture cannot
-- have; a headset gets real parallax from the player's own head, and a
-- picture that sways on its own inside VR reads as the world lurching --
-- on the floating panel especially, where the battle screen is watched
-- from a fixed seat.
BattleCam.still = false
BattleCam.t = 0
function BattleCam.reset()
@@ -160,12 +168,14 @@ function BattleCam.rig(arena, groundY)
local R = BattleCam.rigFor(arena)
local mx, mz = arena.mid[1], arena.mid[2]
local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
local yaw = BattleCam.still and 0
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
local c, s = math.cos(yaw), math.sin(yaw)
-- 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 = BattleCam.still 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
+106 -25
View File
@@ -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.
@@ -154,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
@@ -161,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.
@@ -169,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)
@@ -191,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
@@ -219,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
@@ -231,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
@@ -242,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
@@ -255,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
+97 -2
View File
@@ -42,6 +42,7 @@ 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")
@@ -209,6 +210,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
@@ -427,7 +513,16 @@ function BattleScene.render(state, arena, textures, token)
-- its own canvas slot: this renders at the window's pixel size and the
-- free-roam pass does too, but the two are alive at different moments
-- and a shared slot would reallocate on every battle entry and exit
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
--
-- AA, if the row asks for it, renders it larger still and folds it back
-- to pw x ph below (see AntiAlias). The framing is untouched by that:
-- the lens was widened by the window's RATIO to the letterbox and the
-- rig solved in the GB's own frame, so a bigger canvas is more samples
-- of the identical shot -- which is why the pins below still measure in
-- pw and ph, and why the HUDs and the depth of field, drawn onto the
-- folded canvas afterwards, stay the chunky GB art they are.
local rw, rh = AntiAlias.expand(pw, ph)
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
return
end
Voxel3D.draw(terrain, atlasFor(host), nil)
@@ -500,7 +595,7 @@ function BattleScene.render(state, arena, textures, token)
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
+632 -16
View File
@@ -68,6 +68,47 @@ local RECESS_MAX = 24
local SHADE = { top = 0.95, south = 1.0, north = 0.68,
side = 0.78, bottom = 0.5 }
-- ------- how far a merged run may reach: the tile lattice
--
-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a
-- straight projection a run may be as long as it likes -- a straight line
-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT
-- STRAIGHT. It drops every vertex by the square of its distance from the
-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola
-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below
-- the short quads butted against it, and the join tears open.
--
-- Nothing bounded a run's length before, and the runs that ran away were
-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its
-- fascia, the shaded underside -- because a flat run has no art to break
-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some
-- three world pixels under the roof surface beside it: the eave tore off
-- the roof and the drop showed the building's dark interior through the
-- slot. (Strip runs, the drawing marching along the atlas, break at the
-- tileset's own boundaries and were never the problem.)
--
-- So a run stops at the next 8px lattice line. Buildings are stamped at
-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the
-- scene -- terrain, props, this -- now ends on the same lines, every join
-- is vertex-for-vertex, and the bend carries them together. What is left
-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a
-- world pixel at any rung.
--
-- It costs quads on a dense city map (Cerulean's object stream goes from
-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them
-- whether the curve is on or not, which is the deliberate trade: the mesh
-- is cached per map and built asynchronously over seconds, so meshing for
-- the curve's sake only when the curve is on would mean rebuilding every
-- live map on a keypress.
local CELL = 8
-- How far a run starting at `a` may go before it crosses the next lattice
-- line. Floor-mod, so the awning's negative z lands on the same lines the
-- positive side does.
local function runCap(a)
return CELL - a % CELL
end
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
@@ -170,6 +211,39 @@ local function read(t, data, perRow)
local inside = {}
for i = 0, W * H - 1 do inside[i] = not outside[i] end
-- `scrub` names pixel rects where the drawing paints an object standing
-- ON the surface (Red's potted plant on the dining tabletop). The object
-- keeps its own standee -- the template's `keep` leaves its tiles
-- unclaimed -- so the band beneath it is the one surface the drawing
-- implies but never paints clear: every rect pixel takes the field
-- shade, sourced from the first field texel outside the rects, and the
-- model's top comes out as the plain surface the object sat on.
if t.scrub then
local function inRect(x, y)
for _, r in ipairs(t.scrub) do
if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then
return true
end
end
return false
end
local donor = nil
for i = 0, W * H - 1 do
if col[i] == GREY and inside[i]
and not inRect(i % W, math.floor(i / W)) then
donor = i
break
end
end
for i = 0, W * H - 1 do
if inRect(i % W, math.floor(i / W)) then
col[i] = GREY
ax[i], ay[i] = ax[donor], ay[donor]
inside[i] = true
end
end
end
return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
end
@@ -190,7 +264,24 @@ local function measure(sp, t)
top[x] = r
end
local wallH = H - roofRows
-- The drawing's own ground line: the row after the last drawn one. A
-- building ends on the black threshold row it stands on (ground == H),
-- but furniture is drawn standing on open floor -- the lab table's
-- legs stop two rows short of its grid -- and extruding against H
-- would float it that far above its own plot.
local ground = roofRows
for sy = H - 1, roofRows, -1 do
local drawn = false
for sx = 0, W - 1 do
if sp.inside[sy * W + sx] then drawn = true break end
end
if drawn then
ground = sy + 1
break
end
end
local wallH = ground - roofRows
local ytop = wallH - 1 + t.slab
-- Side faces must not come out as slabs of outline black: where the
@@ -259,6 +350,14 @@ local function measure(sp, t)
end
end
-- The pane rule reads a LIGHT region the drawing seals behind a BLACK
-- frame. A drawing built the other way round -- the healing machine's
-- dark screens sealed behind their own white bezels -- inverts under
-- it: every lit edge sinks and the black panes stand proud, a black
-- lattice a voxel off the face. `panes = false` says the drawing does
-- not carry the rule's polarity, so the facade stays flush.
if t.panes == false then recess = {} end
-- One representative texel per shade, taken from the building's own art:
-- the roof's fascia and its undersides are geometry the drawing implies
-- but never paints, and they must still wear its palette (and pick up
@@ -279,20 +378,510 @@ local function measure(sp, t)
-- sprite taller than its footprint -- the tower's 16-row drawing
-- stands on the 8 rows of it that are actually on the map, and D = H
-- would have pushed its body 64px south into the town plaza.
return { top = top, ytop = ytop, D = #t.tiles * 8,
-- `depth` (in tile rows) names the plot when the grid runs PAST it
-- onto ground the drawing merely stands its legs on: the lab table's
-- third row is the walkable cell the player faces it from, and the
-- full-grid depth would stand the model in their path.
-- `depth` names the plot in TILE ROWS, which is the right grain for a
-- building. `depthPx` names it in voxels, for an object whose real
-- depth is not a whole tile row -- the Bike Shop toolbox is a box
-- standing in the middle of its own cell, not a thing that fills a plot.
return { top = top, ytop = ytop,
D = t.depthPx or ((t.depth or #t.tiles) * 8),
ground = ground,
recess = recess, interior = interior, shadeTexel = shadeTexel }
end
-- ----------------------------------------------------------------- build --
-- A desk with separately-classified objects on it (a template's `parts`
-- list): the methodology's region classification at part granularity.
-- Upright parts anchor their drawn bottom row to the desk's top plane
-- and wear their own drawn tops as lids; flat parts (a keyboard, a
-- sheet of paper) lie one voxel proud at drawn row = depth row -- the
-- same 1:1 the tabletop itself is drawn with, so an object's height ON
-- the drawing is its position ON the desk. The desk is the lab-table
-- slab + base; its lid is the one synthesized surface in the model
-- (the objects cover every drawn pixel of the tabletop), continued
-- from the sibling tables' pattern in the drawing's own shades.
-- tools/building_voxels.py `build_desk_set` is the reference twin.
local function deskSetModel(sp, pr, t)
local W, H, D = sp.W, sp.H, pr.D
local ground = pr.ground
local col, inside = sp.col, sp.inside
local vox = {}
local function key(x, y, z) return (y * D + z) * W + x end
local function put(x, y, z, i) vox[key(x, y, z)] = i end
-- de-outline walk bounded to the part, so a part's side faces show
-- its own material and never the neighbour's (the sprite-wide walk
-- the facade path uses would cross the black seam between units)
local function interiorAt(sx, sy, lo, hi)
local i = sy * W + sx
if col[i] ~= BLACK then return sx end
local step = sx < math.floor((lo + hi) / 2) and 1 or -1
for d = 1, 3 do
local nx = sx + step * d
if nx >= lo and nx <= hi then
local ni = sy * W + nx
if inside[ni] and col[ni] ~= BLACK then return nx end
end
end
return sx
end
-- The parts list, shared by every base piece: a desk plane or an
-- open tray rim alike, `plane` is simply the height they ride.
local ytop = 0
local function buildParts(plane)
for _, p in ipairs(t.parts) do
Budget.tick()
local x0, x1 = p.x[1], p.x[2]
if p.kind == "flat" then
-- drawn row = depth row by default; `z` renames the origin when
-- the flat sits below the desk's own drawn top span (the Center
-- PC's keyboard). `at` names the sheet's own height when it does
-- not lie on the desk plane (the healing machine's keyboard is a
-- shelf mounted on the cabinet's side); `thick` gives it a body
-- -- layers below the sheet repeating each column's own texel,
-- the same continuation rule every synthesized surface follows.
local r0 = p.rows[1]
local z0 = p.z or r0
local atY = p.at or plane
local thick = p.thick or 1
if atY > ytop then ytop = atY end
for sy = r0, p.rows[2] do
local z = z0 + (sy - r0)
if z >= 0 and z < D then
for sx = x0, x1 do
if inside[sy * W + sx] then
for y = math.max(0, atY - thick + 1), atY do
put(sx, y, z, sy * W + sx)
end
end
end
end
end
elseif p.kind == "box" then
-- A BOX part is a drawn rect standing at its own drawn
-- elevation -- equipment attached to the machine rather than an
-- object on the desk plane. The rows are face-on art: the top
-- row's drawn height IS the box's top (ground - 1 - r0,
-- measured), and the box runs down to `base` (default the drawn
-- extent; 0 continues it to the floor, the legs-continue rule).
-- Height beyond the drawn rows fills the way a roof band does:
-- rows before `cycle` map 1:1 from the top, rows after it 1:1
-- from the bottom -- the healing machine hoses' foot lands ON
-- the floor -- and the cycle window repeats between.
local r0, r1 = p.rows[1], p.rows[2]
local c0 = p.cycle and p.cycle[1] or r1
local c1 = p.cycle and p.cycle[2] or r1
local pz = p.z or 0
local pd = p.depth
local top = pr.ground - 1 - r0
local bot = p.base or (pr.ground - 1 - r1)
local nTop, nBot = c0 - r0, r1 - c1
if top > ytop then ytop = top end
for y = bot, top do
local k, j = top - y, y - bot
local sy
if k < nTop then
sy = r0 + k
elseif j < nBot then
sy = r1 - j
else
sy = c0 + (k - nTop) % (c1 - c0 + 1)
end
for sx = x0, x1 do
local i = sy * W + sx
if inside[i] then
local ix = interiorAt(sx, sy, x0, x1)
for z = pz, pz + pd - 1 do
if z >= 0 and z < D then
local px = (z == pz or z == pz + pd - 1) and sx or ix
put(sx, y, z, sy * W + px)
end
end
end
end
end
elseif p.kind == "iso" then
-- An ISO part is drawn in 2:1 isometric -- a box TURNED 45
-- degrees to the map, so one rhombus carries its top, its front
-- and its side at once and no band or facade split can reach
-- them. Un-projecting it is that projection run backwards: the
-- box stands as a real diamond in plan and every voxel wears the
-- texel the drawing paints where that voxel projects TO. The
-- drawn top lands on the top, the screen on the screen-facing
-- side and the flank on the flank, and nothing is segmented by
-- hand -- which is the only way to get this right, because the
-- three faces meet on a diagonal no rectangle can name.
--
-- Everything but the depth centre falls out of the drawn rect,
-- because the projection fixes it: the half-width is the drawn
-- rhombus's x radius, HALF that again its z radius (2:1 is what
-- makes it isometric), the near corner's drawn row is the base
-- rhombus's front tip, and whatever drawn height is left once
-- that rhombus is accounted for is the box's own height. Bill's
-- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall --
-- which puts its left corner's vertical edge at drawn rows
-- 4..10, exactly where the drawing paints one.
--
-- `plan` is the one thing the drawing CANNOT state: 2:1 is the
-- projection, not the object, so reading rz as the plan radius
-- too builds a box half as deep as it is wide -- a slab, not the
-- cube the drawing depicts. `plan` names the real z radius and
-- the drawn row is scaled into it, so a cube is `plan = rx` and
-- the drawing still lands on it pixel for pixel.
local pr0, pr1 = p.rows[1], p.rows[2]
local rx = math.floor((x1 - x0 + 1) / 2)
local rz = math.floor(rx / 2)
local plan = p.plan or rz
local oy = pr1 - rz
local h = oy - rz - pr0
local ytp = plane + h
if ytp > ytop then ytop = ytp end
for sx = x0, x1 do
-- doubled, so a rect of even width keeps its centre between
-- two columns instead of limping one to the left
local dx2 = 2 * sx - (x0 + x1)
for dz = -plan, plan do
local z = p.z + dz
local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx
if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then
-- the plan row scaled back into the drawn rhombus
local dzs = math.floor((2 * dz * rz + plan) / (2 * plan))
for y = 0, h do
local sy = oy + dzs - y
local i = sy * W + sx
if sy >= pr0 and sy <= pr1 and inside[i] then
put(sx, plane + y, z, i)
end
end
end
end
end
else
local tr0, tr1 = p.top[1], p.top[2]
local fr0, fr1 = p.facade[1], p.facade[2]
local pd = p.depth
-- `rise` lifts a part off the desk's top plane and `z` names its
-- back-most depth row (the field a flat part already carries). An
-- object STANDING on a desk needs neither: it starts on the plane
-- at the plot's back. The healing machine's console needs both --
-- it stands in the FRONT map row of a grid whose back row is the
-- wall band it leans against, and its screen head is MOUNTED on
-- the console's front two voxels above the body's top. Both come
-- off the drawing, not off taste.
local base = plane + (p.rise or 0)
local pz = p.z or 0
local ytp = base + (fr1 - fr0)
if ytp > ytop then ytop = ytp end
-- `inset` sinks an authored pane one voxel: the pane rule
-- applied by hand, for a part whose screen IS sealed behind its
-- own black frame while the template's `panes = false` (set for
-- the polarity-inverted panel elsewhere in the same drawing)
-- blocks the global pass. Same mechanism as a recess: the front
-- voxel is simply not placed.
local ins = p.inset
for sx = x0, x1 do
-- the lid: the part's drawn top laid across its depth from the
-- back, last row continuing forward; the front lid row is the
-- facade's own top row -- the drawn front-top edge. `stretch`
-- maps the drawn band over the whole depth instead, the tray's
-- rule: for a part authored DEEPER than its drawing (the house
-- stool grown past its drawn seat), clamping would print the
-- last row as a long smear off the back band's edge.
for z = pz, pz + pd - 1 do
local front = z == pz + pd - 1
local sy
if front then
sy = fr0
elseif p.stretch then
sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1)
/ (pd - 1)), tr1)
else
sy = math.min(tr0 + z - pz, tr1)
end
while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
if ok and z >= 0 and z < D then
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
end
end
-- the body: facade rows anchored to the part's own base
for sy = fr0 + 1, fr1 do
local y = base + (fr1 - sy)
local i = sy * W + sx
if inside[i] then
local ix = interiorAt(sx, sy, x0, x1)
for z = pz, pz + pd - 1 do
if z >= 0 and z < D then
if z == pz + pd - 1 then
local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2]
and sy >= ins.rows[1] and sy <= ins.rows[2]
if not sunk and not pr.recess[i] then put(sx, y, z, i) end
elseif z == pz then
put(sx, y, z, i)
else
put(sx, y, z, sy * W + ix)
end
end
end
end
end
end
end
end
end
-- A TRAY is an open container -- the drawing looks down INTO it, so its
-- top-view band is not a lid but the inside of the box, and the model
-- has to be hollow. Bands, all measured 1:1 like any other band table:
-- `top` is the opening (drawn row -> depth row), `front` the near wall
-- seen face-on (drawn row -> elevation), `x` the box's outer span and
-- `inner` the opening's, so the difference between them is the wall.
-- Four walls stand to the rim, the floor slab lies `floor` voxels thick
-- under the opening, and the cavity between them is left as AIR -- which
-- is the whole point, and what an extruded facade can never be. Parts (a
-- standing lid) then ride the rim like any object on a desk's plane.
if t.tray then
local tr = t.tray
local top0 = tr.top[1]
local fr0, fr1 = tr.front[1], tr.front[2]
local bx0, bx1 = tr.x[1], tr.x[2]
local ix0, ix1 = tr.inner[1], tr.inner[2]
local floor = tr.floor or 0
local plane = fr1 - fr0 + 1 -- the rim: the wall's height
-- Which drawn row lies at depth z. The far rim is the band's first
-- row and the near rim the front wall's own, and the drawn inside
-- STRETCHES over whatever depth is between them: a box deeper than
-- its drawing has rows to spare is the ordinary case once the plot
-- stops being the grid, and the alternative -- running out of rows
-- and repeating the last one -- would print the wrench twice.
local lo, hi = top0 + 1, tr.top[2] - 1 -- the drawn inside
local span = math.max(1, D - 3) -- interior depth rows - 1
local function trayRow(z)
if z == 0 then return top0 end
if z == D - 1 then return fr0 end
return lo + math.floor((z - 1) * (hi - lo) / span)
end
for sx = bx0, bx1 do
Budget.tick()
for z = 0, D - 1 do
local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1
for y = 0, (hollow and floor or plane - 1) do
if hollow or y == plane - 1 then
-- the opening seen from above: the tray's own floor and
-- whatever lies in it -- and the rim is the same band where
-- the wall meets it
local i = trayRow(z) * W + sx
if inside[i] then put(sx, y, z, i) end
else
-- the wall below the rim: the front band folded up it, the
-- drawn face on the front and back layers and the de-outlined
-- interior between, exactly as a facade extrudes.
--
-- NO recess pass here, and it must stay that way: a pane sinks
-- by DELETING its front voxel so the one behind becomes the
-- pane, and a container's wall is one voxel thick -- there is
-- nothing behind it, so the front panel simply opened a hole
-- straight into the box and you could see the wrench through it.
local sy = fr1 - y
local i = sy * W + sx
if inside[i] then
local px = (z == 0 or z == D - 1) and sx
or interiorAt(sx, sy, bx0, bx1)
put(sx, y, z, sy * W + px)
end
end
end
end
end
if plane > ytop then ytop = plane end
buildParts(plane)
return { at = function(x, y, z)
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
return nil
end
return vox[key(x, y, z)]
end,
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
end
-- No base piece at all: the drawing IS its parts (the house stool -- a
-- seat and its legs, nothing under them but floor). The plane the parts
-- anchor to is the ground itself.
if not t.desk then
buildParts(0)
return { at = function(x, y, z)
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then
return nil
end
return vox[key(x, y, z)]
end,
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
end
-- The desk's top plane. Usually the drawing states it: the fascia and
-- base rows it paints below the objects ARE the front face, and their
-- row count is the height. Bill's desk paints neither inside its grid
-- -- its apron is drawn into the WALKABLE cell in front, and that cell
-- is left out on purpose so the chair standing there keeps its own
-- tiles -- so `plane` names the height directly and the body below the
-- lid is synthesized: the band table's own rim treatment, a shaded box
-- closed by the outline where it meets the floor, in the drawing's
-- shades via shadeTexel.
local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
local b0, b1 = t.desk.base[1], t.desk.base[2]
local plane = (b1 - b0 + 1) + (f1 - f0 + 1)
-- The desk's own PLOT, when the grid holds more than the desk. Bill's
-- grid runs on into the walkable cell, because the drawing puts the
-- desk's apron AND the chair pushed up to it in the same tiles -- so
-- the desk box has to stop at its own cell (`depth`) and stand on its
-- own ground line rather than the grid's, which the chair's feet set
-- eight rows lower. The base band's last row IS that ground line by
-- definition, and for every desk drawn inside its own grid it is the
-- measured one to the row (lab table, lab computers, Center PC, the
-- Bike Shop toolbox), so this changes nothing for them.
-- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk
-- is shallower than a tile row and leans against something: the
-- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows
-- plus the front edge -- standing against the wall band, so its box
-- runs z 16..25 of a 32-deep plot.
local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D
local dz0 = t.desk.z or 0
local dz1 = dz0 + deskD - 1
local deskG = b1 + 1
-- ...and the desk's COLUMNS (`x`), when the grid is wider than the
-- desk: the healing machine's grid carries its flanking hoses and
-- keyboard, and the cabinet is only the middle 16 columns.
local dx0 = t.desk.x and t.desk.x[1] or 0
local dx1 = t.desk.x and t.desk.x[2] or W - 1
-- The WALL element: the band the machine backs onto, whose tiles this
-- grid claims. The drawing shows it only as the stripe background
-- around the tower (the same standing as the potted plants' floor),
-- so the block cycles the drawing's own stripe unit -- real pixels of
-- column `x`, rows `cycle` -- at wall-band height over the back plot,
-- exactly what the neighbouring cells' `wall` pins render.
if t.wall then
local wl = t.wall
local c0, c1 = wl.cycle[1], wl.cycle[2]
local cn = c1 - c0 + 1
local wx = wl.x or 0
for y = 0, wl.h - 1 do
Budget.tick()
local sy = c0 + (wl.h - 1 - y) % cn
for sx = 0, W - 1 do
for z = 0, wl.depthPx - 1 do
put(sx, y, z, sy * W + wx)
end
end
end
end
-- the base band, extruded exactly like every lab table's
for sy = b0, b1 do
Budget.tick()
local y = deskG - 1 - sy
for sx = dx0, dx1 do
if inside[sy * W + sx] then
local ix = interiorAt(sx, sy, dx0, dx1)
for z = dz0, dz1 do
local px = (z == dz0 or z == dz1) and sx or ix
put(sx, y, z, sy * W + px)
end
end
end
end
for i in pairs(pr.recess) do
local sy = math.floor(i / W)
local sx = i % W
if sy >= b0 and sy <= b1 and sx >= dx0 and sx <= dx1 then
vox[key(sx, deskG - 1 - sy, dz1)] = nil
end
end
-- the slab: fascia rows wrap every side
for sy = f0, f1 do
Budget.tick()
local y = plane - 1 - (sy - f0)
for sx = dx0, dx1 do
for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end
end
end
if t.desk.top then
-- The lid wears the desk's own drawn top band -- the drawing DOES
-- paint this tabletop (the healing machine's white top face with
-- its lit west and shaded east strips), so nothing is synthesized
-- where it is visible: band rows map back-to-front, the first
-- fascia row is the drawn front-top edge, same rule as an upright
-- part's lid. Where a part's drawing occludes the band (the monitor
-- standing on it), the lid continues the nearest strip BESIDE the
-- part -- still the drawing's own pixels, the same sibling-pattern
-- rule every synthesized lid follows.
local tr0, tr1 = t.desk.top[1], t.desk.top[2]
for z = dz0, dz1 do
Budget.tick()
local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1)
for sx = dx0, dx1 do
local px = sx
for _, p in ipairs(t.parts) do
local px0, px1 = p.x[1], p.x[2]
local r0, r1
if p.kind == "flat" or p.kind == "iso" or p.kind == "box" then
r0, r1 = p.rows[1], p.rows[2]
else
r0, r1 = p.top[1], p.facade[2]
end
if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then
px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1)
px = math.max(dx0, math.min(dx1, px))
break
end
end
put(sx, plane - 1, z, sy * W + px)
end
end
else
-- the lid continues the sibling tables' top -- black rim, white
-- highlight courses along the north and west, grey field
local field = t.desk.lid == "white" and WHITE or GREY
for sx = dx0, dx1 do
for z = dz0, dz1 do
local shade = field
if sx == dx0 or sx == dx1 or z == dz0 or z == dz1 then
shade = BLACK
elseif sx == dx0 + 1 or z == dz0 + 1 then
shade = WHITE
end
put(sx, plane - 1, z, pr.shadeTexel[shade])
end
end
end
if plane > ytop then ytop = plane end
buildParts(plane)
return { at = function(x, y, z)
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
return vox[key(x, y, z)]
end,
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
end
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
-- order -- roof first, so it overwrites the walls it intersects, and walls
-- are trimmed to its underside so nothing pokes through the surface.
local function model(sp, pr, t)
if t.parts then return deskSetModel(sp, pr, t) end
local W, H, D = sp.W, sp.H, pr.D
local slab, roofRows = t.slab, t.roofRows
local top, ytop = pr.top, pr.ytop
local top, ytop, ground = pr.top, pr.ytop, pr.ground
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
-- columns undrawn in the roof band; they carry no roof at all, and the
@@ -367,16 +956,18 @@ local function model(sp, pr, t)
-- the awning: the band juts two voxels past the walls, front and back
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
local sy = H - 1 - y
local sy = ground - 1 - y
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
return sy * W + x
end
return nil
end
-- the facade, extruded straight back over the footprint
-- the facade, extruded straight back over the footprint. Rows map
-- against the measured ground line, not the grid's last row: the two
-- differ only for furniture standing on open floor (see measure).
if z < 0 or z >= D then return nil end
local sy = H - 1 - y
local sy = ground - 1 - y
local i = sy * W + x
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
-- the drawing's last row is the ground the building stands on, so
@@ -464,7 +1055,8 @@ local function emit(m, sp, atlasW, atlasH)
local function runX(y, z, dx, dy, dz, x)
local i0 = ci(x, y, z)
local strip, n = nil, 1
while true do
local cap = runCap(x)
while n < cap do
local nx = x + n
local i = ci(nx, y, z)
if not i or ci(nx + dx, y + dy, z + dz) then break end
@@ -556,8 +1148,8 @@ local function emit(m, sp, atlasW, atlasH)
while z <= zmax do
local i = ci(x, y, z)
if i and not ci(x + d, y, z) then
local n = 1
while z + n <= zmax do
local n, cap = 1, runCap(z)
while n < cap and z + n <= zmax do
local j = ci(x, y, z + n)
if j ~= i or ci(x + d, y, z + n) then break end
n = n + 1
@@ -666,7 +1258,7 @@ function Buildings.build(S, map, data, perRow)
end
built = models[key]
end
Buildings.stamp(S, map, built, tx, ty, bw, bh)
Buildings.stamp(S, map, built, tx, ty, bw, bh, t)
end
end
end
@@ -676,9 +1268,24 @@ end
-- One placement: claim its tiles (so the detector leaves them alone and
-- the mesher paints ground under them) and copy the model into place.
function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
local shape = { class = "building", h = 0, art = "building",
flat = false, authored = true }
--
-- Two template fields alter what a claim means, for a drawing that
-- carries a STANDEE on its surface (Red's potted plant on the dining
-- table). `keep` names tile ids the stamp must NOT claim: their authored
-- pins stay live, so the standee scan still stands the object exactly as
-- it always did. `support` is the model's top plane in voxels: the claim
-- shape carries it as its height, which is what tells that scan the
-- standee's shelf -- a plain claim stays at h = 0, and Structures treats
-- a building claim with height as a full model (skip, never a second
-- box; see its support branches).
function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t)
local shape = { class = "building", h = (t and t.support) or 0,
art = "building", flat = false, authored = true }
local keep = nil
if t and t.keep then
keep = {}
for _, id in ipairs(t.keep) do keep[id] = true end
end
-- the ground the building stands on: the commonest flat tile around its
-- feet, so a house on a path keeps its path
@@ -704,9 +1311,18 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
for r = 0, bh - 1 do
for c = 0, bw - 1 do
local k = keyOf(tx + c, ty + r)
S.shapeAt[k] = shape
S.skip[k] = true
S.ground[k] = best or false
if keep and keep[S.tileAt[k]] then
-- unclaimed by request: the tile keeps its pin (the plant's
-- cutout pool) and the standee scan finds it there. Only the
-- ground is set now, so the scan's own claim of these tiles has
-- the building's floor to paint when no flat tile touches a
-- cluster ringed by its own furniture.
S.ground[k] = best or false
else
S.shapeAt[k] = shape
S.skip[k] = true
S.ground[k] = best or false
end
end
end
+15 -5
View File
@@ -853,14 +853,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
@@ -1132,8 +1142,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
+763
View File
@@ -0,0 +1,763 @@
-- Voxel world mode: the first-person camera -- the 1ST rung.
--
-- Every other rung is the same camera at a different pitch: an orbit over
-- the view centre, described by one number. 1ST is a different rig
-- entirely: the eye stands in the player's own head, the view direction is
-- the player's to steer -- mouse, right stick or a touch drag -- and the
-- rig rides the placed-camera seam (Voxel3D.camera) that the staged battle
-- already proved out. Everything downstream of that seam -- the shader
-- uniforms, project(), the sky's vanishing line, the water's lean -- reads
-- eye and focus the same way it always has.
--
-- What this module owns:
--
-- the ATTITUDE yaw and pitch, fed by whichever look input speaks:
-- relative mouse motion, the right stick's rate, or a
-- touch dragged across open screen. All three drive the
-- same two numbers, so they compose instead of fighting.
--
-- the BLEND easing between the orbit and the head. Stepping onto
-- the rung dives the camera from wherever the orbit was
-- into the player's eyes over half a second; stepping off
-- flies it back out. Mid-blend the rig is a straight lerp
-- of the two cameras -- eye, focus, fov, up -- through
-- the same placed-camera record.
--
-- the MOVE INTENT the analog vector FreeMove walks the player by,
-- gathered here because it is made of the same devices:
-- the left stick's raw axes, the touch d-pad's true
-- deflection, or the held keys, rotated by this camera's
-- yaw so "forward" means "where I am looking".
--
-- Deliberately NOT here: movement itself (lib/FreeMove.lua, which owns the
-- collision walk and the grid the game logic still lives on), and the
-- billboard math that faces cards at this eye (VoxelScene, which owns
-- every other card matrix too).
--
-- Everything the module reaches -- the mouse's relative mode, the wrapped
-- love handlers, the touch overlay's hit test -- is pcall-guarded the same
-- way the 3D pass is: headless runs and drivers without a mouse simply
-- never see the input, and the rung falls back to holding the 75-degree
-- orbit.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel = V.require("VoxelState")
local Voxel3D = V.require("Voxel3D")
local WorldCurve = V.require("WorldCurve")
local FirstPerson = {}
-- ------- the rig's numbers
--
-- EYE_HEIGHT stands the eye near the top of the 16px sprite -- the head,
-- not the hat tip -- above the same ground-plus-lift the character card
-- stands on, so surfing bobs and ledge hops carry the view with them.
--
-- FOV is wider than the diorama's ~53 degrees: inside the world, the
-- diorama's lens reads as a keyhole. 65 vertical is the modern-shooter
-- middle ground.
--
-- FOCUS_DIST is short on purpose: the placed-camera branch derives its
-- near plane from |eye - focus| (dist * 0.05), and the eye walks within
-- 2-3 world pixels of a wall face when sliding along it -- a far focus
-- would push the near plane through the wall and clip a hole in it.
FirstPerson.EYE_HEIGHT = 13
FirstPerson.FOV = math.rad(65)
FirstPerson.FOCUS_DIST = 24
-- Pitch limits, in radians below horizontal (positive looks DOWN). The
-- world has no ceiling and the sky's bands sit low, so looking far up
-- shows the void above the gradient; the up-range is clamped tighter than
-- the down-range for that reason, not a technical one.
FirstPerson.PITCH_DOWN = math.rad(70)
FirstPerson.PITCH_UP = -math.rad(50)
FirstPerson.PITCH_DEFAULT = math.rad(10)
-- how long the dive into (and out of) the head takes, in seconds
FirstPerson.BLEND_TIME = 0.45
-- ------- look input tuning
--
-- MOUSE_SENS is radians per relative-mode count -- about 0.18 degrees per
-- count, the conventional shooter default. STICK rates are radians per
-- second at full deflection, with a squared response curve so small
-- deflections aim and full ones turn. TOUCH_TURN is what one full screen
-- width of drag turns, mobile-shooter convention.
FirstPerson.MOUSE_SENS = 0.0032
FirstPerson.STICK_YAW = 3.5
FirstPerson.STICK_PITCH = 2.4
FirstPerson.STICK_DEAD = 0.18
FirstPerson.TOUCH_TURN = 2.2 * math.pi
FirstPerson.MOVE_DEAD = 0.25
-- ------- state
--
-- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite
-- faces), pi/2 east -- the same convention VoxelScene.YAW uses, so a
-- facing converts to a yaw by table lookup.
FirstPerson.yaw = 0
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
FirstPerson.blend = 0
-- A multiplier on the first-person field of view, for anything that wants
-- to narrow the lens without owning the rig: 1 is the ordinary 65
-- degrees, and horde mode's iron sights ease it down toward 40 while the
-- player is looking down them (lib/HordeGun). Kept here rather than in
-- the caller because the fov is folded into the orbit blend below, and
-- because signature() has to know -- a lens that narrows while the player
-- stands still still has to re-fit the shadow box.
FirstPerson.fovScale = 1
local wasEngaged = false
local stick = { x = 0, y = 0 } -- right stick, latest event values
local mouseDX, mouseDY = 0, 0 -- relative counts since last update
local lookTouch = nil -- { id, x, y } of the claimed finger
local touchMove = nil -- the touch d-pad's analog deflection
local captured = false -- mouse relative mode engaged by us
-- the placed-camera record this module last handed to Voxel3D, so passes
-- that key behaviour off "is the first-person rig the one drawing" (the
-- billboard yaw, the frame remap) can ask by identity rather than by mode
-- -- the battle's own placed camera must never read as first person
local rig = nil
local FACING_ANGLE = {
down = 0,
right = math.pi / 2,
up = math.pi,
left = -math.pi / 2,
}
local FACING_ORDER = { "down", "right", "up", "left" }
local function wrapPi(a)
return (a + math.pi) % (2 * math.pi) - math.pi
end
local function ease(t)
return t * t * (3 - 2 * t)
end
-- ------- gates
-- Whether the 1ST rung is selected and the 3D pass can carry it.
function FirstPerson.engaged()
return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available()
end
-- Whether first person should be READING the player's inputs right now:
-- engaged, with the overworld on top of the stack (a menu, a dialog or a
-- battle above it owns the buttons, exactly as it does for grid walking).
function FirstPerson.driving()
if not FirstPerson.engaged() then return false end
local ok, top, ow = pcall(function()
local Game = require("src.core.Game")
return Game.stack and Game.stack:top(), Game.overworld
end)
return ok and top ~= nil and top == ow
end
-- The eased blend, 0 at the orbit and 1 in the head.
function FirstPerson.blendEased()
return ease(FirstPerson.blend)
end
-- The blend, but only while the free-roam pass's own rig is the placed
-- camera. The battle scene places a camera of its own through the same
-- seam, and its cards must keep their stage lean rather than yawing at a
-- first-person eye that is not looking at them.
function FirstPerson.cardBlend()
if not rig or Voxel3D.camera ~= rig then return 0 end
return ease(FirstPerson.blend)
end
-- A VR eye stepping into the rig's shoes: the VR pass builds its own
-- placed cameras (one per eye) and hands each one here as it draws, so
-- everything keyed to "the first-person rig is drawing" -- the billboard
-- yaw, the frame remap, the hidden player card -- answers for that eye.
-- In the diorama (blend 0) adoption is inert: cardBlend still reports
-- zero and the cards keep their lean.
function FirstPerson.adoptVReye(record)
rig = record
end
-- Whether the player's own card should be left out of the camera draw:
-- deep enough into the blend that the card would fill the lens from
-- inside. The sun pass keeps drawing it either way -- a first-person
-- player still throws a shadow on the ground ahead.
function FirstPerson.hidePlayer()
return FirstPerson.cardBlend() > 0.9
end
-- ------- attitude
-- Apply a look delta, in radians. Everything that turns the head funnels
-- through here, so the clamps live once.
function FirstPerson.lookBy(dyaw, dpitch)
FirstPerson.yaw = wrapPi(FirstPerson.yaw + dyaw)
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
math.min(FirstPerson.PITCH_DOWN,
FirstPerson.pitch + dpitch))
end
-- The view direction's flat compass facing, for everything that still
-- thinks in the grid's four directions: the cell A interacts with, the
-- sprite the sun sees, the direction a blocked slide bonks in.
function FirstPerson.compassFacing()
local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
if math.abs(s) > math.abs(c) then
return s > 0 and "right" or "left"
end
return c > 0 and "down" or "up"
end
-- The unit look direction, and its flat (ground-plane) part.
local function lookDir()
local cp = math.cos(FirstPerson.pitch)
return math.sin(FirstPerson.yaw) * cp,
-math.sin(FirstPerson.pitch),
math.cos(FirstPerson.yaw) * cp
end
function FirstPerson.lookFlat()
return math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
end
-- ------- billboards seen from inside the world
--
-- The diorama's cards face south and lean back by the camera's pitch --
-- correct for a camera that always stands south. An eye that can stand
-- ANYWHERE sees a south-facing card edge-on from the east, so in first
-- person every card yaws about its feet to face the eye (cylindrical
-- billboarding: upright, never tipping). VoxelScene blends its matrices
-- between the two by cardBlend.
-- The yaw that turns a card's south-facing normal toward the eye.
function FirstPerson.cardYaw(wx, wz)
local eye = rig and rig.eye
if not eye then return 0 end
local dx, dz = eye[1] - wx, eye[3] - wz
if dx * dx + dz * dz < 1e-9 then return 0 end
return math.atan2(dx, dz)
end
-- Which of the four sprite frames an entity shows THIS eye: its facing
-- rotated into the viewer's own frame, quantised. The flat game's frames
-- are "how this pose looks from the south", so the apparent facing is the
-- pose rotated by where the viewer actually stands -- walk behind an NPC
-- and you see their back, circle to their flank and you see the profile,
-- exactly as the four frames Gen 1 drew intend.
function FirstPerson.apparentFacing(facing, wx, wz)
local eye = rig and rig.eye
local phi = FACING_ANGLE[facing]
if not (eye and phi) then return facing end
local dx, dz = eye[1] - wx, eye[3] - wz
if dx * dx + dz * dz < 1e-9 then return facing end
local rel = wrapPi(phi - math.atan2(dx, dz))
local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
return FACING_ORDER[idx + 1]
end
-- ------- the move intent
--
-- The analog vector FreeMove walks by, in CAMERA space: mx strafes (+
-- right), mz advances (+ forward). Whichever device is actually deflected
-- answers -- the left stick's raw axes first (the engine quantises them to
-- a d-pad; the raw pair is the analog truth), then a touch d-pad finger,
-- then the held keys. Magnitude caps at 1.
function FirstPerson.moveVector()
local ok, Game = pcall(require, "src.core.Game")
local input = ok and Game.input or nil
local ax = input and input.stickAxis or nil
if ax then
local mag = math.sqrt(ax.x * ax.x + ax.y * ax.y)
if mag > FirstPerson.MOVE_DEAD then
local t = math.min(1, (mag - FirstPerson.MOVE_DEAD)
/ (1 - FirstPerson.MOVE_DEAD))
return ax.x / mag * t, -ax.y / mag * t
end
end
if touchMove then
local mag = math.sqrt(touchMove.x * touchMove.x
+ touchMove.y * touchMove.y)
if mag > FirstPerson.MOVE_DEAD then
local t = math.min(1, mag)
return touchMove.x / mag * t, -touchMove.y / mag * t
end
end
if input then
local mx = (input:isDown("right") and 1 or 0)
- (input:isDown("left") and 1 or 0)
local mz = (input:isDown("up") and 1 or 0)
- (input:isDown("down") and 1 or 0)
if mx ~= 0 or mz ~= 0 then
local mag = math.sqrt(mx * mx + mz * mz)
return mx / mag, mz / mag
end
end
return 0, 0
end
-- Rotate a camera-space move into world space: forward is the flat look
-- direction, strafe-right is its right hand. (cross(forward, up) with
-- forward = (sin y, 0, cos y) and up = +Y lands right on (-cos y, 0,
-- sin y): face south and your right hand points west.)
function FirstPerson.moveWorld(mx, mz)
local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw)
return -c * mx + s * mz, s * mx + c * mz
end
-- ------- the tick
-- Runs from the pipeline's update hook, every frame whatever the level --
-- the same tick VoxelState eases the orbit on. Owns the blend, the mouse
-- capture lifecycle, and the frame's stick-rate look.
function FirstPerson.update(dt)
local engagedNow = FirstPerson.engaged()
-- entering the rung: the head starts looking the way the sprite faces,
-- pitched gently down -- the reading pose of the flat game
if engagedNow and not wasEngaged then
local ok, facing = pcall(function()
local Game = require("src.core.Game")
return Game.overworld and Game.overworld.player
and Game.overworld.player.facing
end)
FirstPerson.yaw = (ok and FACING_ANGLE[facing]) or 0
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
end
wasEngaged = engagedNow
-- the blend, held at flat until there is terrain to dive into -- the
-- same wait Voxel.update keeps for the orbit tween, for the same reason
local target = engagedNow and 1 or 0
if target > FirstPerson.blend and FirstPerson.blend == 0
and not Voxel.ready then
target = 0
end
local step = dt / FirstPerson.BLEND_TIME
if FirstPerson.blend < target then
FirstPerson.blend = math.min(target, FirstPerson.blend + step)
elseif FirstPerson.blend > target then
FirstPerson.blend = math.max(target, FirstPerson.blend - step)
end
if FirstPerson.blend <= 0 and rig then
-- fully out: let go of the placed camera (unless a battle already
-- swapped its own in, which is not ours to clear)
if Voxel3D.camera == rig then Voxel3D.camera = nil end
rig = nil
end
-- mouse capture follows engagement: captured whenever the rung is on and
-- the window has focus, released the moment either ends. Checked against
-- the live mode rather than toggled on edges, so a capture lost to the
-- OS (alt-tab) re-arms itself on the next focused frame.
local wantCapture = engagedNow
if wantCapture and love.window and love.window.hasFocus then
local okF, focus = pcall(love.window.hasFocus)
wantCapture = okF and focus or false
end
if love.mouse and love.mouse.setRelativeMode then
local okM, isRel = pcall(love.mouse.getRelativeMode)
if okM and isRel ~= wantCapture then
pcall(love.mouse.setRelativeMode, wantCapture)
end
captured = wantCapture
end
local driving = FirstPerson.driving()
-- The mouse's counts, accumulated by the wrapped handler since the last
-- tick; dropped unread while something else owns the screen.
--
-- The yaw sign is NEGATED, here and in every look input below: yaw grows
-- south -> east -> north (the world runs +X east, +Z south, and the
-- direction is (sin yaw, cos yaw)), which seen from behind the eye is a
-- LEFT turn -- so "move the mouse right, look right" means subtracting.
local dx, dy = mouseDX, mouseDY
mouseDX, mouseDY = 0, 0
if driving and (dx ~= 0 or dy ~= 0) then
FirstPerson.lookBy(-dx * FirstPerson.MOUSE_SENS,
dy * FirstPerson.MOUSE_SENS)
end
-- the right stick is a rate: radians per second, squared response so
-- the first half of the throw aims and the rest turns
if driving then
local rx, ry = stick.x, stick.y
local function curve(v)
local a = math.abs(v)
if a < FirstPerson.STICK_DEAD then return 0 end
a = (a - FirstPerson.STICK_DEAD) / (1 - FirstPerson.STICK_DEAD)
return (v < 0 and -1 or 1) * a * a
end
local cy, cp = curve(rx), curve(ry)
if cy ~= 0 or cp ~= 0 then
-- negated yaw for the same reason as the mouse above
FirstPerson.lookBy(-cy * FirstPerson.STICK_YAW * dt,
cp * FirstPerson.STICK_PITCH * dt)
end
end
end
-- ------- the rig itself
-- The orbit camera's eye/focus/fov/up for the frame's centre -- the same
-- arithmetic Voxel3D.viewProjection runs, restated here because the blend
-- needs both ends as DATA. Kept textually tiny so the two cannot drift:
-- focus on the centre, eye FOCAL*vh away at the pitch, up perpendicular
-- in the YZ plane.
local function orbitRig(cx, cy, vh)
local a = Voxel.angle
local dist = Voxel.FOCAL * vh
return { cx, dist * math.cos(a), cy + dist * math.sin(a) },
{ cx, 0, cy },
2 * math.atan(1 / (2 * Voxel.FOCAL)),
{ 0, math.sin(a), -math.cos(a) }
end
local lastEye = nil -- frozen head pose for player-less frames
-- Build this frame's placed camera and hand it to Voxel3D, plus the scene
-- centre the curve and the depth reference should use. `me` is the
-- player's posed entry (px, py, gh, lift) or nil (a Fly animation), and
-- (cx, cy) the orbit's own view centre.
--
-- Returns nil with the blend fully out, which is the caller's signal to
-- leave the orbit in charge.
function FirstPerson.frame(me, cx, cy, vw, vh)
local b = FirstPerson.blend
if b <= 0 then
if rig and Voxel3D.camera == rig then Voxel3D.camera = nil end
rig = nil
return nil
end
local e = ease(b)
local head
if me then
head = { me.px + 8,
(me.gh or 0) + (me.lift or 0) + FirstPerson.EYE_HEIGHT,
me.py + 8 }
lastEye = head
else
head = lastEye or { cx, FirstPerson.EYE_HEIGHT, cy }
end
local lx, ly, lz = lookDir()
local fpFocus = { head[1] + lx * FirstPerson.FOCUS_DIST,
head[2] + ly * FirstPerson.FOCUS_DIST,
head[3] + lz * FirstPerson.FOCUS_DIST }
local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh)
local function mix(p, q)
return { p[1] + (q[1] - p[1]) * e,
p[2] + (q[2] - p[2]) * e,
p[3] + (q[3] - p[3]) * e }
end
local up = mix(oUp, { 0, 1, 0 })
local ul = math.sqrt(up[1] * up[1] + up[2] * up[2] + up[3] * up[3])
if ul > 1e-6 then up[1], up[2], up[3] = up[1] / ul, up[2] / ul, up[3] / ul
else up = { 0, 1, 0 } end
-- the world curve eases out with the blend: standing inside the world,
-- the bend that sells the diorama reads as the ground falling away. A
-- true zero (curve declined) needs the field present -- nil would let
-- Voxel3D fall back to the setting
local k = WorldCurve.k(vh) * (1 - e)
rig = {
eye = mix(oEye, head),
focus = mix(oFocus, fpFocus),
fov = oFov + (FirstPerson.FOV * FirstPerson.fovScale - oFov) * e,
up = up,
curve = k,
}
Voxel3D.camera = rig
-- the scene centre walks from the orbit's view centre to the head, so
-- the curve's focus, the depth reference and the glint's travel follow
-- the camera that is actually in charge
local sx = cx + (head[1] - cx) * e
local sy = cy + (head[3] - cy) * e
return rig, sx, sy
end
-- Where the shadow pass should centre its box: pushed along the flat look
-- so the fitted frustum -- built for an orbit that always looks north --
-- covers the ground THIS camera sees. The push is strongest looking
-- south (the direction the orbit's box barely reaches) and scales with
-- the blend.
function FirstPerson.shadowCenter(sx, sy, vh)
local e = FirstPerson.cardBlend()
if e <= 0 then return sx, sy end
local fx, fz = FirstPerson.lookFlat()
local ShadowMap = V.require("ShadowMap")
local cap = (ShadowMap.FAR_CAP or 2.5) * vh
return sx + fx * 0.6 * vh * e,
sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e
end
-- The first-person facts a shadow signature has to include: the sun's
-- box is fitted around this camera, so turning the head or walking the
-- blend has to re-fit it even standing still.
function FirstPerson.signature()
local b = FirstPerson.blend
if b <= 0 then return "" end
return table.concat({
math.floor(b * 64),
math.floor(FirstPerson.yaw * 64),
math.floor(FirstPerson.pitch * 64),
math.floor(FirstPerson.fovScale * 64),
}, ",")
end
-- ------- input capture
--
-- The seams: relative mouse motion has no Game handler at all (the
-- engine's love.mousemoved only feeds the mouse-as-touch debug path), the
-- right stick's axes are explicitly ignored by Input, and a touch
-- anywhere off the overlay's controls dies in TouchControls. Each wrap
-- forwards everything it does not claim, and claims only while first
-- person is actually driving -- so with the rung off, every byte flows
-- exactly where it always did.
local installed = false
function FirstPerson.install()
if installed then return end
installed = true
local Game = require("src.core.Game")
-- ------- right stick
do
local inner = Game.gamepadaxis
function Game:gamepadaxis(joystick, axis, value)
if axis == "rightx" then stick.x = value
elseif axis == "righty" then stick.y = value end
return inner(self, joystick, axis, value)
end
end
-- generic (non-gamepad) sticks: axes 1/2 are the left stick by SDL
-- convention and Input already claims them; 3/4 are the usual right
-- pair on the same class of device. Real gamepads are excluded -- they
-- already spoke through the mapped rightx/righty above, and their RAW
-- axis 3 is as likely a trigger as a stick.
--
-- Two more exclusions, both learned the hard way on Android, where this
-- wrap runs BEFORE the engine's own generic-joystick guards:
--
-- the accelerometer arrives as a joystick named for what it is, with
-- gravity pinning an axis well past any deadzone -- the same device
-- Game:joystickaxis refuses for movement (#459), refused here by the
-- same name test, or the view spins on its own the moment 1ST opens.
--
-- and a raw axis is only BELIEVED after it has been seen near centre
-- once. A stick at rest sits at zero, so a real one earns trust with
-- its first touch; a gravity-pinned sensor axis or a trigger resting
-- at an extreme never centres and so never steers the look.
local function isAccelerometer(joystick)
local ok, name = pcall(function() return joystick:getName() end)
return ok and type(name) == "string"
and name:lower():find("accelerometer", 1, true) ~= nil
end
local rawCentred = {}
do
local inner = Game.joystickaxis
function Game:joystickaxis(joystick, axis, value)
local mapped = joystick and joystick.isGamepad and joystick:isGamepad()
if not mapped and (axis == 3 or axis == 4)
and not isAccelerometer(joystick) then
if math.abs(value) < 0.3 then rawCentred[axis] = true end
if rawCentred[axis] then
if axis == 3 then stick.x = value else stick.y = value end
end
end
return inner(self, joystick, axis, value)
end
end
-- ------- mouse
--
-- love.mousemoved rather than a Game method, because the engine has no
-- Game:mousemoved to wrap -- the callback in the project's main.lua is
-- the one place relative counts arrive. Claimed only while captured;
-- pass-through otherwise, including the mouse-as-touch path.
do
local inner = love.mousemoved
love.mousemoved = function(x, y, dx, dy, istouch)
if captured and not istouch then
mouseDX = mouseDX + (dx or 0)
mouseDY = mouseDY + (dy or 0)
return
end
if inner then return inner(x, y, dx, dy, istouch) end
end
end
-- While the mouse is captured there is no cursor to click UI with, so
-- the buttons become GB buttons: left is A, right is B -- through the
-- overlay's own press path, which a rebind can never detach. What WE
-- pressed is remembered per button, so the release always reaches the
-- overlay even if the capture ended while the button was down --
-- otherwise a click that outlives the rung strands A held forever.
--
-- HORDE MODE re-reads the same two buttons as a weapon: left fires,
-- right holds the sights. Claimed BEFORE the A/B mapping below rather
-- than on top of it, so a click during the mode never also lands as a
-- GB button -- otherwise the A that ends the GAME OVER card would be
-- spent by the shot that ended the run.
local mouseHeld = {}
local MOUSE_BTN = { [1] = "a", [2] = "b" }
local function hordeMouse(button, down)
local Horde = V.require("Horde")
if not Horde.playing() then return false end
if button == 1 then
if down then V.require("HordeGun").fire() end
return true
elseif button == 2 then
V.require("HordeGun").setAds(down)
return true
end
return false
end
do
local inner = love.mousepressed
love.mousepressed = function(x, y, button, istouch, presses)
if captured and not istouch and hordeMouse(button, true) then return end
if captured and not istouch and MOUSE_BTN[button] then
local Input = require("src.core.Input")
mouseHeld[button] = true
Input:overlayPressed(MOUSE_BTN[button])
return
end
if inner then return inner(x, y, button, istouch, presses) end
end
end
do
local inner = love.mousereleased
love.mousereleased = function(x, y, button, istouch, presses)
-- a release always reaches whoever owns the press: the horde's
-- aim-hold has to let go even if the mode ended mid-click
if not mouseHeld[button] and hordeMouse(button, false) then return end
if mouseHeld[button] then
local Input = require("src.core.Input")
mouseHeld[button] = nil
Input:overlayReleased(MOUSE_BTN[button])
return
end
if inner then return inner(x, y, button, istouch, presses) end
end
end
-- ------- touch
--
-- A finger on open screen -- not on the overlay's d-pad or buttons --
-- becomes the look drag. One finger owns the look at a time; every
-- other touch flows to TouchControls untouched, so a thumb can drag the
-- view while the other walks the d-pad. That d-pad finger is also read
-- back ANALOG here: TouchControls quantises it to four directions for
-- the grid game, but the deflection it quantised is exactly the move
-- vector a free walk wants.
local TouchControls = require("src.core.TouchControls")
local function dpadVector(x, y)
local ok, v = pcall(function()
local L = TouchControls:layout()
local dz = L.dpad
local half = dz.w * 0.65
return { x = math.max(-1, math.min(1, (x - dz.cx) / half)),
y = math.max(-1, math.min(1, (y - dz.cy) / half)) }
end)
return ok and v or nil
end
do
local inner = Game.touchpressed
function Game:touchpressed(id, x, y)
if FirstPerson.driving() then
local onControl = nil
pcall(function() onControl = TouchControls:hitTest(x, y) end)
if not onControl and not lookTouch then
-- HORDE MODE: a tap on open screen is a SHOT, fired on the press
-- rather than on a release that turned out not to be a drag --
-- a shooter that waits to find out whether you meant it is a
-- shooter that misses. The same finger still becomes the look
-- drag below, so aiming and firing are one gesture.
if V.require("Horde").playing() then
V.require("HordeGun").fire()
end
lookTouch = { id = id, x = x, y = y }
return
end
inner(self, id, x, y)
if onControl == "dpad" and TouchControls.dpadTouch == id then
touchMove = dpadVector(x, y)
end
return
end
return inner(self, id, x, y)
end
end
do
local inner = Game.touchmoved
function Game:touchmoved(id, x, y)
if lookTouch and lookTouch.id == id then
local w = 1280
pcall(function() w = love.graphics.getWidth() end)
local per = FirstPerson.TOUCH_TURN / math.max(320, w)
if FirstPerson.driving() then
-- negated yaw for the same reason as the mouse (see update):
-- drag right, look right, the mobile-shooter convention
FirstPerson.lookBy(-(x - lookTouch.x) * per,
(y - lookTouch.y) * per)
end
lookTouch.x, lookTouch.y = x, y
return
end
if touchMove and TouchControls.dpadTouch == id then
touchMove = dpadVector(x, y) or touchMove
end
return inner(self, id, x, y)
end
end
do
local inner = Game.touchreleased
function Game:touchreleased(id, x, y)
if lookTouch and lookTouch.id == id then
lookTouch = nil
return
end
if TouchControls.dpadTouch == id then touchMove = nil end
return inner(self, id, x, y)
end
end
-- a reset that drops held input state drops ours with it
do
local inner = Game.focus
function Game:focus(f)
lookTouch, touchMove = nil, nil
stick.x, stick.y = 0, 0
mouseDX, mouseDY = 0, 0
return inner(self, f)
end
end
-- a disconnected controller cannot send the centering event for whatever
-- its stick last held -- the engine drops all input state here, and the
-- look rate (plus the raw axes' earned trust) goes with it
do
local inner = Game.joystickremoved
function Game:joystickremoved(joystick)
stick.x, stick.y = 0, 0
rawCentred[3], rawCentred[4] = nil, nil
return inner(self, joystick)
end
end
end
return FirstPerson
+353
View File
@@ -0,0 +1,353 @@
-- Voxel world mode: free movement for the first-person rung.
--
-- The engine walks a grid: sixteen frames per cell, four directions,
-- input locked mid-step. Inside a first-person camera that gait reads as
-- riding a rail, so while 1ST drives, this module replaces the WALK and
-- nothing else: the player's position becomes continuous, steered by the
-- camera's own yaw -- push forward and you go where you look, at any
-- angle, sliding along whatever you graze.
--
-- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact
-- about cells -- what blocks, what warps, what rustles, what bites -- and
-- this module keeps the player's logical cell synced to wherever the free
-- walk stands, then reuses the engine's own machinery for every one of
-- those questions:
--
-- passability the same isWalkableCell / water-while-surfing /
-- tile-pair / entity-occupancy verdicts Collision
-- hands the grid walker, asked per cell the player's
-- body overlaps.
--
-- cell arrival OverworldState:onStepComplete, the same landing
-- pipeline a grid step runs -- warps, spinners, gates,
-- forced currents, poison, repel, encounters, the
-- step counters -- fired once per cell crossed, which
-- is exactly the rate a grid walk fires it.
--
-- the special pushes walking off the map edge, into a ledge, or into
-- a boulder hands the quantised direction straight to
-- checkEdgeExit / checkLedgeHop / checkBoulderPush,
-- the engine's own handlers, which validate and stage
-- everything themselves (connections, the hop arc,
-- the two-push arm). While any of those animates a
-- scripted grid move, this module stands aside and
-- adopts the result.
--
-- Nothing here writes save state, rolls encounters, or decides what a
-- warp does -- it moves a point, keeps the cell honest, and lets the
-- engine be the engine. Stepping off the rung snaps the point to its
-- cell and hands the walk back to the grid, and with the rung off this
-- module costs one gate check per frame.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local FirstPerson = V.require("FirstPerson")
local FreeMove = {}
-- The body: a circle in the ground plane. Small enough to walk every
-- one-cell corridor the grid game has (half a cell is 8), big enough to
-- keep the eye's near plane out of wall faces when sliding along them.
FreeMove.RADIUS = 5.5
-- World pixels per fixed 60Hz frame -- the grid walker's own speeds (16
-- frames per 16px cell on foot, 8 on the bike), so distance covered per
-- second is unchanged and the encounter rate per tile crossed stays the
-- game's own.
FreeMove.WALK = 1.0
FreeMove.BIKE = 2.0
local EPS = 0.01
-- the free position (player centre, world px) and the px/py we last wrote
-- -- if they differ from the player's, something else (a warp, a script)
-- moved them, and the free walk adopts rather than fights
local pos = nil
local lastPx, lastPy = nil, nil
local function adopt(p)
pos = { x = p.px + 8, z = p.py + 8 }
lastPx, lastPy = p.px, p.py
end
function FreeMove.drop()
pos = nil
end
-- named for the suite: the module's live position, nil while dropped
function FreeMove._pos()
return pos
end
-- ------- the per-cell verdict
--
-- The same questions Collision.canMove asks for a grid step, asked of one
-- cell from the player's current standing. The player's OWN cell never
-- blocks -- the body must always be free to leave wherever it stands
-- (a warp mat, the water it is surfing, a cell an NPC just stepped
-- against).
local function pairBlocked(map, surfing, sx, sy, tx, ty)
local Game = require("src.core.Game")
local tp = Game.data and Game.data.field and Game.data.field.tilePairs
if not tp then return false end
local list = surfing and tp.water or tp.land
if not list or #list == 0 then return false end
local tileset = map.def.tileset
local a = map:cellTile(sx, sy)
local b = map:cellTile(tx, ty)
for _, p in ipairs(list) do
if p.tileset == tileset
and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then
return true
end
end
return false
end
-- Why (cx, cy) refuses the player's body, or nil when it may enter:
-- "bounds" | "tile" | "entity", the grid verdict's own names.
local function blockedCell(state, p, cx, cy)
if cx == p.cellX and cy == p.cellY then return nil end
local map = state.map
if not map:inBounds(cx, cy) then return "bounds" end
if not map:isWalkableCell(cx, cy) then
if not (p.surfing and map:isWaterCell(cx, cy)) then return "tile" end
end
if pairBlocked(map, p.surfing, p.cellX, p.cellY, cx, cy) then
return "tile"
end
local Collision = require("src.world.Collision")
if Collision.occupied(state.entities, cx, cy, p) then return "entity" end
return nil
end
FreeMove._blockedCell = blockedCell -- named for the suite
-- ------- the slide
--
-- One axis at a time, clamped at the first refusing cell's face: the
-- classic axis-separated walk, which is where wall-sliding comes from --
-- the blocked axis stops and the free one keeps going. Returns the
-- refusal ("bounds"/"tile"/"entity") when this axis was clamped.
local function slideX(state, p, dx)
if dx == 0 then return nil end
local r = FreeMove.RADIUS
local nx = pos.x + dx
local z0 = math.floor((pos.z - r + EPS) / 16)
local z1 = math.floor((pos.z + r - EPS) / 16)
local hit = nil
local edge = dx > 0 and math.floor((nx + r) / 16)
or math.floor((nx - r) / 16)
for zc = z0, z1 do
hit = blockedCell(state, p, edge, zc)
if hit then break end
end
if hit then
if dx > 0 then nx = math.min(nx, edge * 16 - r - EPS)
else nx = math.max(nx, (edge + 1) * 16 + r + EPS) end
end
pos.x = nx
return hit
end
local function slideZ(state, p, dz)
if dz == 0 then return nil end
local r = FreeMove.RADIUS
local nz = pos.z + dz
local x0 = math.floor((pos.x - r + EPS) / 16)
local x1 = math.floor((pos.x + r - EPS) / 16)
local hit = nil
local edge = dz > 0 and math.floor((nz + r) / 16)
or math.floor((nz - r) / 16)
for xc = x0, x1 do
hit = blockedCell(state, p, xc, edge)
if hit then break end
end
if hit then
if dz > 0 then nz = math.min(nz, edge * 16 - r - EPS)
else nz = math.max(nz, (edge + 1) * 16 + r + EPS) end
end
pos.z = nz
return hit
end
-- ------- the blocked push
--
-- The grid game's blocked step is where half its verbs live: the map-edge
-- crossing, the ledge hop, the boulder shove, and the route-gate warp
-- fired by collision. Hand the engine the quantised direction and let its
-- own handlers decide -- each one validates itself (checkLedgeHop matches
-- the tile pair, checkEdgeExit checks the bounds), so calling them on
-- every firm push is safe. Returns true when one of them took the frame
-- over.
--
-- The one verb NOT restated here is the bonk. On the grid a blocked step
-- is a discrete event -- you pressed a direction, the game refused, and
-- the bump answers you once. A free walk has no such moment: the body
-- SLIDES along whatever it grazes, so a player walking a fence line or
-- rounding a doorframe is blocked on one axis continuously, and the same
-- sound comes out as a rattle for as long as they keep walking. It is
-- feedback for a refusal that is not happening. The grid walk keeps its
-- own bump (the engine's, in OverworldController) untouched.
local function pushSpecials(state, dir, why)
local p = state.player
p.facing = dir -- the handlers read the push off the facing
if why == "bounds" and state:checkEdgeExit(dir) then return true end
if state:checkLedgeHop(dir) then return true end
if state:checkBoulderPush(dir) then return true end
if why ~= "entity" and state:canCollisionWarp() then
local Game = require("src.core.Game")
local Warp = require("src.world.Warp")
local w = Warp.onCollision(state.map, Game.data.field.warpCarpets,
p.cellX, p.cellY, dir)
if w then
state:takeWarp(w.def)
return true
end
end
return false
end
-- ------- the tick
--
-- Runs in place of OverworldState:handleInput while first person drives
-- (see install below), which means it inherits every gate the grid walk
-- has: never during scripted moves, transitions, or with anything above
-- the overworld on the stack.
function FreeMove.tick(state)
local p = state.player
-- a grid move is animating -- a ledge hop, a spinner slide, a scripted
-- walk -- or a cutscene owns the player: stand aside, adopt the result
if p.moving or p.inputLocked then
FreeMove.drop()
return
end
if not pos or p.px ~= lastPx or p.py ~= lastPy then adopt(p) end
local Game = require("src.core.Game")
local input = Game.input
-- the head is the facing: what A talks to, what the sun's card shows,
-- which way a bonk points
p.facing = FirstPerson.compassFacing()
-- HORDE MODE takes both of these away for as long as it runs: there is
-- no pausing (START), and nobody stops to read a sign with the horde
-- coming (A, which is also the button the mode's own GAME OVER card
-- wants left unspent). Everything below -- the walk, the wall slide and
-- the blocked-push verbs, warps included -- keeps working, because the
-- crowd has to be able to follow the player through a door.
local suppressed = V.require("Horde").suppressWorldInput()
if not suppressed and input:wasPressed("a") then
state:interact()
return
end
if not suppressed and input:wasPressed("start") then
require("src.core.Sound").play(Game.data, "Start_Menu")
require("src.ui.Screens").push(Game, "StartMenu")
return
end
local mx, mz = FirstPerson.moveVector()
local wx, wz = FirstPerson.moveWorld(mx, mz)
-- Cycling Road's downhill pull, the free-walk restatement of the grid
-- path's simulated PAD_DOWN: south drift with nothing held, braked by
-- holding A or B exactly as the Route 17 sign promises
local moving = (mx ~= 0 or mz ~= 0)
if not moving and Game.save and Game.save.onBike then
local fm = Game.data.field.forcedMovement
local braking = input:isDown("a") or input:isDown("b")
if fm and not braking then
for _, m in ipairs(fm.slopeMaps or {}) do
if m == state.map.id then
wx, wz, moving = 0, 1, true
break
end
end
end
end
if not moving then return end
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
or FreeMove.WALK
local dx, dz = wx * speed, wz * speed
local hitX = slideX(state, p, dx)
local hitZ = slideZ(state, p, dz)
-- the walk cycle: the wall-bonk clock animates the legs of a player the
-- grid thinks is standing still, refreshed while the free walk covers
-- ground (Player:update ticks animClock off it; walkPhase reads it)
p.bumpFrames = 2
p.px, p.py = pos.x - 8, pos.z - 8
lastPx, lastPy = p.px, p.py
-- the cell the body stands in; crossing into a new one IS a step
local ncx = math.floor(pos.x / 16)
local ncy = math.floor(pos.z / 16)
if ncx ~= p.cellX or ncy ~= p.cellY then
p.cellX, p.cellY = ncx, ncy
state:onStepComplete()
-- a warp or a battle may have moved the world out from under the
-- walk; the adopt check on the next tick picks the pieces up
return
end
-- a firm push into something that refused: the engine's own blocked-step
-- verbs, aimed the way the push leans
local hit, dir
if hitX and (not hitZ or math.abs(dx) >= math.abs(dz)) then
hit, dir = hitX, (dx > 0 and "right" or "left")
elseif hitZ then
hit, dir = hitZ, (dz > 0 and "down" or "up")
end
if hit and math.max(math.abs(dx), math.abs(dz)) > 0.4 * speed then
if pushSpecials(state, dir, hit) then
FreeMove.drop()
return
end
-- the push handlers may have turned the facing; the head still rules
p.facing = FirstPerson.compassFacing()
end
end
-- ------- the seam
--
-- OverworldState:handleInput is the one choke point where the grid walk
-- reads the pad -- the same seam the engine's own Cycling Road pull and
-- collision warps live behind -- so replacing the walk means wrapping it
-- and nothing else. Every gate ABOVE the call (scripted moves, trainer
-- engagement, transitions, anything on the stack) still applies to the
-- free walk, because the wrap sits below them all.
function FreeMove.install()
local OverworldState = require("src.world.OverworldController")
if OverworldState.dramaticShapeFreeMoveHook then return end
local inner = OverworldState.handleInput
function OverworldState:handleInput()
if not FirstPerson.driving() then
if pos then
-- stepping off the rung: back onto the grid, on the cell the
-- free walk stood in
local p = self.player
p.px, p.py = p.cellX * 16, p.cellY * 16
FreeMove.drop()
end
return inner(self)
end
return FreeMove.tick(self)
end
OverworldState.dramaticShapeFreeMoveHook = true
end
return FreeMove
+707
View File
@@ -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
+94
View File
@@ -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
+107
View File
@@ -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
+511
View File
@@ -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
+484
View File
@@ -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
+553
View File
@@ -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
+262
View File
@@ -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
+44 -3
View File
@@ -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,45 @@ function Mat4.rotateX(a)
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)
+172 -3
View File
@@ -68,7 +68,18 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY,
OverworldBattle.LABEL,
{ true, false }, { "ON", "OFF" })
-- Whether the VR row is ON -- read lazily, because VR requires modules
-- that sit above this one. While it is, this mode stops being optional:
-- the headset's battle seat, the pokedex screen and the effects plane
-- all assume a fight standing on the world, and a white-field battle
-- inside a headset is exactly the flat screen VR exists to replace.
local function vrOn()
local ok, vr = pcall(V.require, "VR")
return ok and vr and vr.enabled and vr.enabled() or false
end
function OverworldBattle.enabled()
if vrOn() then return true end
return OverworldBattle.setting:get() and true or false
end
@@ -98,12 +109,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
@@ -448,6 +487,23 @@ function OverworldBattle.update(dt)
-- 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,6 +562,104 @@ 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 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()
@@ -668,6 +822,8 @@ local texturing = nil
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]
@@ -831,11 +987,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
@@ -940,7 +1102,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
@@ -1089,6 +1251,13 @@ 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)
+223
View File
@@ -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
+275 -46
View File
@@ -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);
@@ -310,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]
@@ -383,6 +459,36 @@ function Sky.discRadius(h, cell, body)
return r * cell, r
end
-- One disc's worth of cell art -- shared verbatim by the screen-space
-- painter below (the flat screen) and by the BAKE the VR eyes texture
-- their world-anchored quad with (Sky.discImage). `plot(dx, dy, c)` gets
-- every kept cell in disc-local cell coordinates and its 0..255 colour.
local function discCells(r, moon, shades, twilight, plot)
local core = shades[1]
local main = shades[twilight and 3 or 2]
local craterR = math.max(1, math.floor(r / 5))
for dy = -r, r do
for dx = -r, r do
local d = math.sqrt(dx * dx + dy * dy)
if d <= r + 0.1 then
local c = d <= r * 0.5 and core or main
-- dithered rim: the outer ring keeps only one parity of its cells
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
if moon then
for _, cr in ipairs(MOON_CRATERS) do
local cdx = dx - math.floor(cr[1] * r + 0.5)
local cdy = dy - math.floor(cr[2] * r + 0.5)
if cdx * cdx + cdy * cdy <= craterR * craterR then
c = shades[3]
end
end
end
if keep then plot(dx, dy, c) end
end
end
end
end
local function paintDisc(body, edge, cell, w, h)
local g = love.graphics
if not (body and body.y and g.setScissor) then return end
@@ -393,39 +499,70 @@ local function paintDisc(body, edge, cell, w, h)
local bx = math.floor(body.x / cell) * cell + cell / 2
local by = math.floor(body.y / cell) * cell + cell / 2
if by - r * cell > edge then return end -- wholly below the horizon point
local core = shades[1]
local main = shades[twilight and 3 or 2]
local sx, sy, sw, sh = g.getScissor()
g.setScissor(0, 0, math.ceil(w), math.floor(edge))
local craterR = math.max(1, math.floor(r / 5))
for dy = -r, r do
for dx = -r, r do
local d = math.sqrt(dx * dx + dy * dy)
if d <= r + 0.1 then
local c = d <= r * 0.5 and core or main
-- dithered rim: the outer ring keeps only one parity of its cells
local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
if body.moon then
for _, cr in ipairs(MOON_CRATERS) do
local cdx = dx - math.floor(cr[1] * r + 0.5)
local cdy = dy - math.floor(cr[2] * r + 0.5)
if cdx * cdx + cdy * cdy <= craterR * craterR then
c = shades[3]
end
end
end
if keep then
g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
g.rectangle("fill", bx + dx * cell - cell / 2,
by + dy * cell - cell / 2, cell, cell)
end
end
end
end
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).
--
@@ -436,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))
@@ -464,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
@@ -474,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 * Sky.GLOW_REACH))
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
@@ -513,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
+1115 -162
View File
File diff suppressed because it is too large Load Diff
+202 -40
View File
@@ -71,6 +71,21 @@ local FALLBACK_HEIGHTS = {
-- body builds from the bark rows and the drawn ellipse projects onto
-- the hull's round top
stump = 16,
-- the same hull cut at both ends, hollowed and tapered: an OPEN bin
-- standing on a floor (the Vermilion Gym trash cans). The drawn mouth
-- ellipse projects onto the round top and down the well, the drawn base
-- ellipse is ground contact rather than body, and the plan narrows toward
-- the floor. Height is AUTHORED (the profile's can_height, which this
-- pin must be kept equal to so anything riding a can lands on its rim) --
-- the drawing's own straight run is only a couple of rows, because a GB
-- cell spends most of itself on the opening
can = 9,
-- round scenery drawn ONE cell wide and TWO cells TALL, standing on one
-- cell of plot: the Pokemon Centers' potted plants. Carved as one
-- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is
-- the object's height, not its depth. BOTH cells take the class; the
-- group build anchors on the north one (Structures.buildCylinders)
planter = 32,
billboard = 16,
signpost = 16,
post = 16,
@@ -83,10 +98,18 @@ local FALLBACK_HEIGHTS = {
bed = 7,
stool = 8,
counter = 8,
-- the raised back band of low seating: the Center couch's west strip
-- is drawn from above like the rest of the couch, but depicts the
-- back and arm rising over the 8px seat
backrest = 12,
table = 12,
desk = 24,
prop = 16,
cutout = 16,
-- a vehicle drawn SIDE-ON: the showroom bicycles. Standee height like
-- every other cutout pool -- what differs is the thickness (see
-- Structures' PINNED_DEPTH)
bike = 16,
console = 16,
relief = 3,
bookcase = 32,
@@ -123,6 +146,8 @@ local ART = {
cylinder = "cylinder",
canopy = "canopy",
stump = "cylinder",
can = "cylinder",
planter = "planter",
billboard = "billboard",
-- signposts share the billboard treatment but as their own pool at a
-- 2-voxel depth: a sign is a thin plate on a stick, and the standard
@@ -145,6 +170,9 @@ local ART = {
-- profile archetype Structures builds real steps for -- rising flights
-- for stairs leading up, sunken stairwells for stairs leading down
bed = "top",
-- a backrest's art is the couch seen from above, so like the bed it
-- rides the top face of its taller box
backrest = "top",
stool = "billboard",
-- half-cell furniture: a service counter, a low couch. One 8px band,
-- so exactly the drawing's bottom row stands up as the front and
@@ -158,6 +186,13 @@ local ART = {
desk = "upright",
prop = "billboard",
cutout = "billboard",
-- a bicycle is a LINE drawing seen side-on, and its negative space --
-- the air inside the frame, between the wheel and the fork -- is what
-- makes it read as a bicycle at all. Its own pool at two voxels: any
-- thicker and the side faces of neighbouring strokes close those gaps
-- from every angle but dead-on, and six of them in a showroom come out
-- as one dark lump (which is what the 5px `prop` pool gave)
bike = "billboard",
-- a machine standing on furniture: the billboard treatment with
-- body, plus the one-object contract `cutout` has -- the drawing is
-- ringed by the furniture it sits on, and those edges must not be
@@ -176,6 +211,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 +331,24 @@ function TileShape.forMap(map)
if cache[id] then return cache[id] end
local heights = TileShape.heights()
-- Per-tileset height overrides (a tileset entry's `heights`): the class
-- vocabulary is global but the drawings are not -- the DOJO lab tables
-- are drawn 6px tall where the default `table` is 12 -- and the height
-- a sprite RIDES at (VoxelScene.groundAt) must be the height the art
-- actually stands, or the starter balls float over their own table.
-- Same gate as the global list: known classes, numbers only.
do
local s = load()
local entry = s and s.tilesets and s.tilesets[id]
local over = entry and entry.heights
if type(over) == "table" then
for class, h in pairs(over) do
if type(h) == "number" and FALLBACK_HEIGHTS[class] then
heights[class] = h
end
end
end
end
local authored = authoredGroups(id, heights)
local count = math.floor((tileset.imageWidth or 128) / 8)
* math.floor((tileset.imageHeight or 48) / 8)
@@ -405,68 +459,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 +683,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
+781
View File
@@ -0,0 +1,781 @@
-- VR: the conductor -- one call per game frame that runs the whole
-- headset side, and the row that switches it on.
--
-- The shape of a VR frame, from the pipeline's update hook (which ticks
-- every frame whatever is on the stack, which is exactly what a headset
-- needs -- the world must keep arriving through menus, dialogs and
-- battles):
--
-- poll the runtime's events (begin the session when it says READY)
-- xrWaitFrame <- BLOCKS until the headset wants a frame;
-- with vsync handed off (set to 0 while the
-- session runs) this is what paces the whole
-- app at headset rate, while FixedStep keeps
-- the game's own logic at its 60 Hz
-- locate the two eyes
-- render the world once per eye (VoxelScene.render's `eyes` path:
-- shared shadow map, shared pose capture, per-eye cameras from VRRig)
-- blit each eye canvas into its swapchain image (VRGL)
-- copy the window's front buffer into the UI quad when a menu, dialog,
-- battle or wipe is what the flat screen is showing
-- xrEndFrame with the projection layer and/or the quad
--
-- WHICH VR YOU GET mirrors the VOXEL ladder, deliberately: on the orbit
-- rungs the world is a TABLETOP DIORAMA pinned below and ahead of where
-- your head started -- lean in, walk around it; on 1ST you stand inside
-- at life scale, the HMD steers FirstPerson's yaw and pitch, and FreeMove
-- walks where you look exactly as it does on the flat screen. A STAGED
-- FIGHT takes the camera from both: the headset snaps -- through a fade
-- to black and back -- to the flat battle's own over-the-shoulder seat
-- (VRRig.battleMount), and returns the same way when the fight ends;
-- the 2D battle screen lights up on the POKEDEX in the tracked left
-- hand (lib/Pokedex.lua) and NO floating panel is submitted at all --
-- the fight itself owns the view. The flat window keeps
-- running as the mirror (left eye when the world is up), so menus stay
-- usable at the desk and every existing input keeps working alongside
-- the XR controllers.
--
-- Failure is a status, never a crash: no runtime, no headset, no GL
-- interop, or a mid-session loss all land back on the flat screen with
-- the reason readable off VR.status().
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local Voxel = V.require("VoxelState")
local Voxel3D = V.require("Voxel3D")
local VoxelScene = V.require("VoxelScene")
local FirstPerson = V.require("FirstPerson")
local BattleCam = V.require("BattleCam")
local VRRig = V.require("VRRig")
local VRXR = V.require("VRXR")
local VRGL = V.require("VRGL")
local Pokedex = V.require("Pokedex")
local VR = {}
-- the row: plain OFF/ON. No hotkey -- the engine's display keys are
-- spoken for, and a headset is not something to toggle by accident.
VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" })
-- How the right stick turns you in first person. OFF is the 45-degree
-- SNAP this mod shipped with and the reason for it is comfort, not
-- taste: a software turn moves the world past a head that did not move,
-- which is vection with no vestibular signal to match it, and it is the
-- single most reliable way to make somebody ill in a headset. A snap
-- gives the inner ear nothing to disagree with.
--
-- But snap turning is not free either -- it costs continuity, and the
-- players who have their sea legs generally want the stick. So it is a
-- row rather than a decision: OFF by default, on for anyone who asks,
-- and the row only exists while there is a headset to use it in.
VR.smoothTurn = ModSetting.new("smoothturn", "SMOOTH TURN",
{ false, true }, { "OFF", "ON" })
-- radians per second at full deflection, with a squared response so the
-- first half of the throw aims and the rest turns -- the same curve
-- FirstPerson gives the flat screen's right stick
VR.SMOOTH_TURN_RATE = 2.2
-- Where the diorama's UI panel floats vs first person's. These are the
-- FALLBACK screens: wherever the pokedex is up and lit -- first
-- person's menus, a battle's 2D scene -- no quad is submitted at all
-- (see updateQuad), and these serve only the diorama and the no-tracked-
-- controller case.
local QUAD_DIORAMA = { pos = { 0, 0.1, -1.0 }, width = 0.8 }
local QUAD_FP = { pos = { 0, 0, -1.4 }, width = 1.1 }
local started = false -- start() succeeded this enablement
local failed = nil -- start() failed; wait for a re-toggle
local wasOn = false
local savedVsync = nil
local fboCache = setmetatable({}, { __mode = "k" }) -- canvas -> GL FBO id
local mirrorSrc = nil -- last left-eye canvas, for the window
local mirrorCanvas = nil
local status = "off"
-- the diorama's live adjustments: the right stick's zoom (a multiplier on
-- the model's size) and the grab-drag's height (metres of world travel)
local zoom = 1
local heightOff = 0
local held = {} -- GB buttons this module is holding down
local lastHandY = nil -- the gripping hand's height, last frame
-- First person's SNAP TURN: the right stick flicked left or right steps
-- the whole XR-to-world mapping 45 degrees at a time (a smooth software
-- turn is the classic comfort mistake -- vection with no vestibular
-- signal; a snap is instant and the head does the rest). The offset
-- turns the mapping itself, so the eyes, the walk direction and the
-- pokedex all agree about which way the world now faces.
local SNAP_TURN = math.rad(45)
local fpYawOff = 0 -- accumulated snaps, radians
local snapArmed = true -- re-arms when the stick returns to centre
local function wrapPi(a)
return (a + math.pi) % (2 * math.pi) - math.pi
end
-- The battle snap, made a FADE rather than a cut: when a fight is staged
-- on the world (or stops being), black rises over both eyes, the camera
-- swaps mounts behind it, and black lifts. A teleport inside VR is the
-- one camera move that should never be SEEN happening -- the world
-- sliding to a new seat reads as the room moving.
local FADE_TIME = 0.35 -- seconds each way: out, then back in
local camMode = "explore" -- "explore" (diorama / 1ST) or "battle"
local fadeAlpha = 0 -- the black over the eyes right now
-- The staged fight to look at, if there is one: arena, floor height.
local function battleStage()
local ok, arena, groundY = pcall(function()
return V.require("OverworldBattle").stage()
end)
if not ok then return nil end
return arena, groundY
end
-- the palette closure the engine hands drawWorld; stashed there (see
-- main.lua) because the VR frame renders from update, where no ctx exists
VR.paletteFor = nil
-- Whether this platform can do VR AT ALL: the shipped loader and the GL
-- interop are Win32 (openxr_loader.dll, wgl), so only Windows qualifies.
-- Everywhere else -- Android above all -- the row is not offered on any
-- menu, and a stored vr=true is ignored rather than read: a save that
-- migrated over from the desktop must not leave a phone trying to start
-- an OpenXR session (or silently forcing the battle rows). Headless runs
-- have no love.system and answer true, which costs nothing: enabling VR
-- there stops at VRXR.start like it always did.
function VR.supported()
local ok, os = pcall(function() return love.system.getOS() end)
if not ok or not os then return true end
return os == "Windows"
end
function VR.enabled()
return VR.supported() and VR.setting:get() == true
end
function VR.active()
return started and VRXR.isRunning()
end
function VR.status()
if not VR.enabled() then return "off" end
if failed then return failed end
return VRXR.status()
end
-- Let go of every input this module was holding: the GB buttons pressed
-- through the overlay path, and the synthetic left stick. Runs when the
-- session ends and whenever a frame has no controller state to read.
local function releaseInputs()
local ok, Game = pcall(require, "src.core.Game")
if not ok or not Game.input then return end
for btn in pairs(held) do
pcall(function() Game.input:overlayReleased(btn) end)
held[btn] = nil
end
pcall(function()
Game.input:gamepadaxis(nil, "leftx", 0)
Game.input:gamepadaxis(nil, "lefty", 0)
end)
lastHandY = nil
end
local function shutdown(reason)
if started then
VRXR.stop()
started = false
end
if savedVsync ~= nil then
pcall(love.window.setVSync, savedVsync)
savedVsync = nil
end
-- the placed camera may still be a VR eye's; the orbit must get the
-- pass back clean
Voxel3D.camera = nil
mirrorSrc = nil
releaseInputs()
BattleCam.still = false
VoxelScene.spriteLean = nil
Pokedex.clear()
-- the horde's gun too: its VR frame is a matrix built from a hand pose,
-- and a stale one left behind would pin the model to wherever the
-- controller was when the session died -- on the FLAT screen, where the
-- view model should have taken over
V.require("HordeGun").clear()
zoom, heightOff = 1, 0
fpYawOff, snapArmed = 0, true
camMode, fadeAlpha = "explore", 0
status = reason or "off"
end
VR.shutdown = shutdown -- named for the probe driver
-- Whether the flat screen is showing something the world pass cannot: a
-- menu, a dialog, a battle, a transition wipe. The quad and the pokedex's
-- screen both key on it.
local function uiShowing()
local ok, showing = pcall(function()
local Game = require("src.core.Game")
local top = Game.stack and Game.stack:top()
return top ~= Game.overworld
or (Game.overworld and Game.overworld.transitioning) or false
end)
return ok and showing or false
end
-- ------- the pokedex's screen
--
-- What the device in the hand shows during a battle: the flat window --
-- which IS the 2D battle screen for as long as the battle state draws --
-- copied into a canvas the scene pass can texture with, cropped by UV to
-- the battle's own letterbox so the screen wears the GB frame edge to
-- edge. Menus over the battle (the party, the bag) ride along for free:
-- they are the flat screen too, and reading them on the device in your
-- hand is exactly the point.
local dexCanvas = nil
local function dexScreen()
local ok, out = pcall(function()
local ww, wh = love.graphics.getPixelDimensions()
if not (ww and ww > 0 and wh and wh > 0) then return nil end
if not (dexCanvas and dexCanvas:getWidth() == ww
and dexCanvas:getHeight() == wh) then
dexCanvas = love.graphics.newCanvas(ww, wh)
pcall(dexCanvas.setFilter, dexCanvas, "nearest", "nearest")
end
local fbo = fboCache[dexCanvas]
if not fbo then
fbo = VRGL.canvasFBO(dexCanvas)
fboCache[dexCanvas] = fbo
end
if not (fbo and VRGL.copyFrontToCanvas(fbo, ww, wh)) then return nil end
local BattleScene = V.require("BattleScene")
local lx, ly, s = BattleScene.letterbox()
return { dexCanvas,
lx / ww, ly / wh,
(lx + BattleScene.GB_W * s) / ww,
(ly + BattleScene.GB_H * s) / wh }
end)
return ok and out or nil
end
-- ------- the world, once per eye
local function renderWorld(views, ctl)
local ok, Game = pcall(require, "src.core.Game")
local ow = ok and Game.overworld or nil
if not (ow and ow.map and ow.camera and Voxel.active()
and Voxel3D.available()) then
return false
end
local vw, vh = 320, 288
pcall(function() vw, vh = Game.renderer:worldViewSize() end)
-- Whatever the camera does, the CARDS hold the top rung's near-upright
-- lean: a head that roams has no one pitch for them to match, and 75
-- degrees is the pose that reads as "standing" from anywhere. Cleared
-- on shutdown, so the flat screen leans with the rung as ever.
VoxelScene.spriteLean = math.rad(75)
local pivot, anchor, scale, mountYaw
local fp = FirstPerson.engaged()
local battle, battleFloor
if camMode == "battle" then battle, battleFloor = battleStage() end
if battle then
-- the over-the-shoulder seat the flat battle shot stands in, pulled
-- close enough for a headset's own lens (see VRRig.battleMount), at
-- life scale, turned to face the arena
local rec = BattleCam.rig(battle, battleFloor)
pivot, mountYaw = VRRig.battleMount(rec.eye, rec.focus)
anchor = { 0, 0, 0 }
scale = VRRig.FP_SCALE
elseif fp then
local p = ow.player
local gh = 0
pcall(function() gh = VoxelScene.groundAt(ow.map, p.cellX, p.cellY) end)
pivot = VRRig.fpPivot(p.px, p.py, gh, FirstPerson.EYE_HEIGHT)
anchor = { 0, 0, 0 }
scale = VRRig.FP_SCALE
-- the snap turn is a yaw on the MAPPING, same seam the battle mount
-- turns through
if fpYawOff ~= 0 then mountYaw = fpYawOff end
-- the HMD is the head: its yaw and pitch (plus the snaps) become
-- FirstPerson's, so FreeMove walks where you look and A talks to
-- what you face
local yaw, pitch = VRRig.headYawPitch(views[1].pose.quat)
FirstPerson.yaw = wrapPi(yaw + fpYawOff)
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
math.min(FirstPerson.PITCH_DOWN, pitch))
else
-- The table presents the world exactly as the flat screen does at
-- rest: the pivot sits VIEW_DIST away along the RUNG'S own angle
-- (stepping rungs re-tilts the model, easing with the rung tween),
-- at the scale that reproduces the flat framing -- then the player's
-- own adjustments go on top: the stick's zoom, the grip's height.
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
anchor = VRRig.dioramaAnchor(Voxel.angle, heightOff)
scale = VRRig.dioramaScale(vh, Voxel.FOCAL) / zoom
end
-- The pokedex, on the tracked left hand, under this very mapping --
-- but only where it earns its keep: FIRST PERSON, where its screen is
-- every menu, dialog and wipe the flat screen shows (and the floating
-- billboard is retired outright -- see updateQuad), and the BATTLE
-- seat, where its screen is the fight's own 2D scene. The diorama
-- does without: a hand-sized device hovering over a tabletop town is
-- clutter, and the panel serves there. No hand tracked, no device.
local hand = ctl and ctl.handl or nil
if hand and (battle or fp) then
Pokedex.place(hand, pivot, anchor, scale, mountYaw)
if uiShowing() then
local scr = dexScreen()
if scr then
Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5])
end
elseif V.require("Horde").active then
-- HORDE MODE's readout, on the device already in the player's left
-- hand. It cannot be a flat overlay: the eye buffers have
-- ASYMMETRIC frusta, so the same canvas pixel is a different ANGLE
-- in each eye and a 2D HUD drawn into both tears down the middle.
-- The Pokedex is real geometry both eyes see from their own
-- position, so the stereo is correct by construction -- and it is
-- already tracked, already lit, and already the thing this mod
-- puts information on. (The gun wore it briefly and that was
-- worse: a screen on the slide sits exactly where the iron sights
-- need to be looked through.)
--
-- The UV rect goes over the usual way up: v = 0 at the TOP, which
-- is how the device's screen quad reads every other texture it
-- wears. An inverted rect was tried first, on the theory that a
-- self-drawn canvas samples from the bottom -- it does not here,
-- and it stood the readout on its head.
local tex = V.require("HordeHud").panelTexture()
if tex then Pokedex.screen(tex, 0, 0, 1, 1) end
end
else
Pokedex.clear()
end
-- and the horde's gun on the tracked RIGHT hand, under the same
-- mapping. The AIM pose where the runtime offers one -- the barrel
-- should point where the player is pointing, not along their wrist --
-- and the grip pose as the fallback. Placed here rather than in the
-- draw because the shot is traced down the model's own axis, so the
-- matrix has to exist before anything can be hit with it.
do
local HordeGun = V.require("HordeGun")
local right = ctl and (ctl.aimr or ctl.handr) or nil
if right and fp and not battle and V.require("Horde").active then
HordeGun.place(right, pivot, anchor, scale, mountYaw)
else
HordeGun.clear()
end
end
local eyes = {}
for i = 1, 2 do
local v = views[i]
eyes[i] = {
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw),
w = v.w, h = v.h,
slot = i == 1 and "vrL" or "vrR",
-- the battle seat is a placed shot, not the first-person rig: the
-- cards keep their stage lean rather than yawing at this eye, and
-- the player's own card stays visible in it
adopt = not battle,
}
end
eyes.cx, eyes.cy = pivot[1], pivot[3]
local okR, canvases = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
VR.paletteFor, eyes)
if not (okR and type(canvases) == "table" and canvases[1] and canvases[2])
then
return false
end
-- the snap's fade, over the finished eyes: plain black at this moment's
-- strength, drawn before the blit so the headset never sees the swap.
-- A full-frame fill is the ONE 2D thing that is safe to draw into an
-- eye buffer -- it covers everything, so it does not matter that the
-- two frusta disagree about where any given pixel points.
if fadeAlpha > 0 then
pcall(function()
for i = 1, 2 do
local c = canvases[i]
love.graphics.setCanvas(c)
love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha))
love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight())
end
love.graphics.setCanvas()
love.graphics.setColor(1, 1, 1, 1)
end)
end
for i = 1, 2 do
local canvas = canvases[i]
local tex, tw, th = VRXR.acquireEye(i)
if tex then
local fbo = fboCache[canvas]
if not fbo then
fbo = VRGL.canvasFBO(canvas)
fboCache[canvas] = fbo
end
if fbo then
VRGL.blitToTexture(fbo, canvas:getWidth(), canvas:getHeight(),
tex, tw, th)
end
end
VRXR.releaseEye(i)
end
mirrorSrc = canvases[1]
return true
end
-- ------- the UI panel
-- Whether the flat screen is showing something the world pass cannot: a
-- menu, a dialog, a battle, a transition wipe -- or everything, when the
-- world pass is off entirely.
local function wantQuad(worldUp)
if not worldUp then return true end
return uiShowing()
end
local function updateQuad(worldUp, fp)
if not wantQuad(worldUp) then return nil end
-- Wherever the pokedex is up and lit -- first person's menus, the
-- battle seat's 2D fight -- it IS the screen, and no floating
-- billboard is submitted at all. (No tracked left hand still gets
-- the panel: the UI must be readable somewhere.)
if Pokedex.frame and Pokedex.frame.tex then return nil end
local tex, qw, qh = VRXR.acquireQuad()
if not tex then return nil end
local ww, wh = qw, qh
pcall(function() ww, wh = love.graphics.getPixelDimensions() end)
-- The panel wears the GB FRAME, not the window: everything the flat
-- screen has to say lives in the 160x144 letterbox (the world around
-- it is just the mirror's picture). The frame region is blitted OUT
-- of the window and SCALED into the swapchain image -- never copied
-- pixel-for-pixel, because the swapchain's size is fixed at session
-- start and a fullscreened window outgrows it, running the frame (and
-- the START menu flush with its right edge) off the copy. Scaled, the
-- panel shows the identical picture at the identical ratio whatever
-- size the window is. Source coordinates are GL's, origin bottom-left.
local crop = nil
local copied = false
pcall(function()
local BattleScene = V.require("BattleScene")
local lx, ly, s = BattleScene.letterbox()
local wpx = math.ceil(BattleScene.GB_W * s)
local hpx = math.ceil(BattleScene.GB_H * s)
local sx = math.max(0, math.floor(lx))
local sy = math.max(0, math.floor(wh - ly - hpx))
wpx = math.min(wpx, ww - sx)
hpx = math.min(hpx, wh - sy)
if wpx < 1 or hpx < 1 then return end
-- fitted to the swapchain image at the REGION's own aspect: the
-- crop then presents exactly that rect, so the panel's shape is the
-- GB frame's at any window and any swapchain size
local fit = math.min(qw / wpx, qh / hpx)
local dw = math.max(1, math.floor(wpx * fit))
local dh = math.max(1, math.floor(hpx * fit))
if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then
copied = true
crop = { 0, 0, dw, dh }
end
end)
if not copied then
-- no letterbox to cut (or the blit refused): the old whole-window
-- copy, clamped, is still a readable panel
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
end
VRXR.releaseQuad()
local base = fp and QUAD_FP or QUAD_DIORAMA
if not crop then return base end
return { pos = base.pos, width = base.width, crop = crop }
end
-- ------- the controllers
--
-- The mapping the mod ships (rebindable in the runtime's own UI):
--
-- both modes left stick moves (through the engine's own stick path,
-- so it grid-walks the diorama and free-walks 1ST);
-- A/B are A/B; either trigger is START; clicking the
-- LEFT stick steps the VOXEL angle ladder exactly as
-- the "3" key (and the pad's SELECT) does.
-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick.
-- diorama only right stick up/down zooms the model; squeezing a grip
-- and moving that hand up or down drags the whole table
-- with it.
--
-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
-- no controller button does it. VR.leave below stays as the API for it.
-- The left stick click makes EXACTLY the step the "3" key makes: one
-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing
-- TILT and GBC FX in the save -- by calling the very function the key
-- and the pad's SELECT button already share. main.lua installs it below
-- (cycleVoxel is a local of that file); the free-roam gate is the
-- registry's own, inside it, so a click over a menu or mid-warp is a
-- no-op exactly like the key.
VR.cycleVoxel = nil -- cycleVoxel(game), set by main.lua
function VR.stepView()
pcall(function()
if not VR.cycleVoxel then return end
VR.cycleVoxel(require("src.core.Game"))
end)
end
-- Leave VR: the VR row toggled back off and persisted, exactly as if
-- stepped on the OPTIONS menu, so the next update tears the session down
-- and the flat screen takes the picture back. Deliberately bound to NO
-- controller button (a click that ejects you from the headset is a trap
-- mid-fight); kept as the one programmatic door out.
function VR.leave()
pcall(function()
local Game = require("src.core.Game")
VR.setting:setIndex(VR.setting:read() + 1, Game)
end)
end
local function setGB(inp, btn, down)
if down and not held[btn] then
held[btn] = true
inp:overlayPressed(btn)
elseif not down and held[btn] then
held[btn] = nil
inp:overlayReleased(btn)
end
end
local function driveControls(ctl, dt, fp)
if not ctl then
releaseInputs()
return
end
local ok, Game = pcall(require, "src.core.Game")
if not (ok and Game.input) then return end
local inp = Game.input
-- HORDE MODE re-reads the right hand as a weapon: the trigger fires
-- (its own OpenXR action, suggested alongside START on the same input
-- -- see VRXR.setupInput), and B reloads. START is dropped rather than
-- forwarded, because the mode does not pause. Everything else -- the
-- stick's walk, the snap turn, A -- keeps working, so the player can
-- still move and look while they are being chased.
local Horde = V.require("Horde")
if Horde.playing() then
local Gun = V.require("HordeGun")
if ctl.fireChanged and ctl.fire then Gun.fire() end
if ctl.bChanged and ctl.b then Gun.reload() end
setGB(inp, "a", ctl.a)
setGB(inp, "b", false)
setGB(inp, "start", false)
else
setGB(inp, "a", ctl.a)
setGB(inp, "b", ctl.b)
setGB(inp, "start", ctl.start)
end
-- the left stick, through the engine's OWN stick handler: it quantises
-- to the grid d-pad for the diorama, and FirstPerson.moveVector reads
-- the same raw pair for the free walk. OpenXR's +Y is up; the engine's
-- lefty is +down.
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
-- the left stick click: the VOXEL ladder ordinarily, and the way out of
-- horde mode while it runs (the rung is locked there, so the click has
-- nothing else to do, and a headset has no ESCAPE key)
if ctl.toggleChanged and ctl.toggle then
if Horde.active then Horde.askExit() else VR.stepView() end
end
-- first person's turn on the right stick. SMOOTH TURN ON makes it a
-- rate -- hold and the world rotates under you -- and OFF (the
-- default) makes it a 45-degree snap per flick: see the row's own
-- reasoning where it is declared. Either way the offset turns the
-- MAPPING, so the eyes, the walk direction, the pokedex and the gun
-- all agree about which way the world now faces.
if fp and camMode ~= "battle" and VR.smoothTurn:get() == true then
local sx = ctl.lookX or 0
local a = math.abs(sx)
if a > 0.2 then
a = (a - 0.2) / 0.8
-- increasing yaw turns LEFT in this mod's compass, so a stick
-- pushed right subtracts -- the same sign the snap below uses
fpYawOff = wrapPi(fpYawOff
- (sx > 0 and 1 or -1) * a * a
* VR.SMOOTH_TURN_RATE * (dt or 0))
end
snapArmed = true -- so a switch back to snap mid-flick re-arms
elseif fp and camMode ~= "battle" then
local sx = ctl.lookX or 0
if math.abs(sx) > 0.65 then
if snapArmed then
snapArmed = false
-- increasing yaw turns LEFT in this mod's compass, so a stick
-- pushed right subtracts
fpYawOff = wrapPi(fpYawOff + (sx > 0 and -SNAP_TURN or SNAP_TURN))
end
elseif math.abs(sx) < 0.35 then
snapArmed = true
end
end
if not fp and camMode ~= "battle" then
local zy = ctl.lookY or 0
if math.abs(zy) > 0.15 then
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
end
-- the grab-drag: while a grip is squeezed, the table follows that
-- hand's height, metre for metre
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
local y = (gr >= gl) and ctl.handrY or ctl.handlY
if math.max(gl, gr) > 0.6 and y then
if lastHandY then
heightOff = math.max(-1.5, math.min(1.5, heightOff + (y - lastHandY)))
end
lastHandY = y
else
lastHandY = nil
end
else
lastHandY = nil
end
end
-- ------- the per-frame drive
function VR.update(dt)
local on = VR.enabled()
if not on then
if wasOn then
shutdown("off")
failed = nil
end
wasOn = false
return
end
if not wasOn then failed = nil end -- a fresh toggle earns a fresh try
wasOn = true
if failed then return end
if not started then
local qw, qh = 1024, 768
pcall(function() qw, qh = love.graphics.getPixelDimensions() end)
if VRXR.start(qw, qh) then
started = true
status = "session created"
print("[DRAMATIC_SHAPE] VR: " .. VRXR.status())
else
failed = VRXR.status()
print("[DRAMATIC_SHAPE] VR unavailable: " .. failed
.. " -- fix that, then toggle the VR row to retry")
return
end
end
if not VRXR.poll() then
-- the runtime took the session away (headset off, runtime shut down)
shutdown("session lost")
failed = "session lost -- toggle VR off and on to retry"
return
end
if not VRXR.isRunning() then return end
-- the headset paces the app now; vsync would fight it
if savedVsync == nil then
savedVsync = 1
pcall(function() savedVsync = love.window.getVSync() end)
pcall(love.window.setVSync, 0)
end
-- the battle camera holds still for as long as a headset is watching:
-- its drift is a flat screen's depth cue, and a swaying picture inside
-- VR reads as the world lurching
BattleCam.still = true
-- The battle snap's fade: while the camera the frame WANTS is not the
-- one it is showing, black rises; at full black the mount swaps; then
-- black lifts. Driven here, on game time, so a fight that ends during
-- the fade just turns it around.
local want = battleStage() and "battle" or "explore"
if want ~= camMode then
fadeAlpha = math.min(1, fadeAlpha + (dt or 0) / FADE_TIME)
if fadeAlpha >= 1 then camMode = want end
else
fadeAlpha = math.max(0, fadeAlpha - (dt or 0) / FADE_TIME)
end
local time, should = VRXR.waitFrame()
if not time then return end
-- the controllers, before the world renders: the frame the toggle
-- flips rungs on should be the frame that renders the new rig. The
-- state is kept in hand for renderWorld too -- the pokedex stands on
-- the same frame's left-hand pose.
local ctl = VRXR.input(time)
driveControls(ctl, dt, FirstPerson.engaged())
local worldUp = false
if should then
local views = VRXR.locateViews(time)
if views then
worldUp = renderWorld(views, ctl)
end
end
local quadPose = updateQuad(worldUp, FirstPerson.engaged())
VRXR.endFrame(time, worldUp or nil, quadPose)
end
-- ------- the window while a headset owns the picture
-- The flat window becomes the mirror: the left eye, fitted to the window.
-- Returns nil when there is nothing to mirror (the caller draws the flat
-- path as ever).
function VR.mirror(sw, sh)
if not (VR.active() and mirrorSrc) then return nil end
if not (mirrorCanvas and mirrorCanvas:getWidth() == sw
and mirrorCanvas:getHeight() == sh) then
local ok, c = pcall(love.graphics.newCanvas, sw, sh)
if not ok then return nil end
mirrorCanvas = c
end
local ok = pcall(function()
love.graphics.setCanvas(mirrorCanvas)
love.graphics.clear(0, 0, 0, 1)
local mw, mh = mirrorSrc:getDimensions()
local s = math.min(sw / mw, sh / mh)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mirrorSrc, (sw - mw * s) / 2, (sh - mh * s) / 2, 0, s, s)
love.graphics.setCanvas()
end)
pcall(love.graphics.setCanvas)
return ok and mirrorCanvas or nil
end
-- window resize, hot reload: the eye canvases are Voxel3D's and go with
-- its invalidate; ours is the mirror and the FBO ids learned from dead
-- canvases
function VR.invalidate()
if mirrorCanvas and mirrorCanvas.release then
pcall(mirrorCanvas.release, mirrorCanvas)
end
mirrorCanvas, mirrorSrc = nil, nil
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
dexCanvas = nil
Pokedex.invalidate()
V.require("HordeGun").invalidate()
V.require("HordeHud").invalidate()
for k in pairs(fboCache) do fboCache[k] = nil end
end
return VR
+237
View File
@@ -0,0 +1,237 @@
-- VR: the raw OpenGL this mod is otherwise proud to never need.
--
-- OpenXR hands over its swapchain images as GL TEXTURE IDS, and LOVE never
-- exposes the GL names behind its own canvases -- so getting a rendered
-- eye from a love Canvas into a headset means dropping below LOVE for a
-- few calls a frame: discover the canvas's framebuffer, blit it into the
-- swapchain texture, and put the pipeline back exactly as LOVE believes it
-- to be. Everything here is that, and only that.
--
-- Three rules keep this safe:
--
-- discovery over spelunking. The canvas's FBO id is read from the
-- driver with documented queries (bind the canvas THROUGH LOVE, ask
-- GL_DRAW_FRAMEBUFFER_BINDING) rather than from LOVE's internals, so a
-- LOVE patch cannot move it out from under us.
--
-- restore what LOVE caches. LOVE tracks the bound framebuffer and skips
-- redundant binds, so raw binds must end back at the exact binding LOVE
-- thinks is current -- the default framebuffer, 0, since every call
-- here runs between LOVE passes -- or LOVE's next draw lands in ours.
--
-- pcall at the rim, ffi inside. The FFI setup can fail (headless, a GL
-- context without FBO entry points); it fails ONCE, at load(), and
-- callers see `nil, reason` rather than an error mid-frame.
local VRGL = {}
local ffi = nil
local gl = nil -- opengl32 exports (GL 1.1 + wgl)
local ext = {} -- post-1.1 entry points via wglGetProcAddress
local ready = false
local reason = nil
local fbo = nil -- our scratch framebuffer, made once
local GL = {
FRAMEBUFFER = 0x8D40,
READ_FRAMEBUFFER = 0x8CA8,
DRAW_FRAMEBUFFER = 0x8CA9,
DRAW_FRAMEBUFFER_BINDING = 0x8CA6,
COLOR_ATTACHMENT0 = 0x8CE0,
COLOR_BUFFER_BIT = 0x4000,
NEAREST = 0x2600,
LINEAR = 0x2601,
TEXTURE_2D = 0x0DE1,
FRONT = 0x0404,
BACK = 0x0405,
}
VRGL.GL = GL
local CDEF = [[
typedef void (__stdcall *PROC)();
void* wglGetCurrentDC(void);
void* wglGetCurrentContext(void);
PROC wglGetProcAddress(const char*);
unsigned int glGetError(void);
void glGetIntegerv(unsigned int pname, int* params);
void glReadBuffer(unsigned int mode);
void glFlush(void);
void glCopyTexSubImage2D(unsigned int target, int level, int xoffset,
int yoffset, int x, int y, int width, int height);
void glBindTexture(unsigned int target, unsigned int texture);
typedef void (__stdcall *pfn_glBindFramebuffer)(unsigned int, unsigned int);
typedef void (__stdcall *pfn_glGenFramebuffers)(int, unsigned int*);
typedef void (__stdcall *pfn_glDeleteFramebuffers)(int, const unsigned int*);
typedef void (__stdcall *pfn_glFramebufferTexture2D)(unsigned int,
unsigned int, unsigned int, unsigned int, int);
typedef void (__stdcall *pfn_glBlitFramebuffer)(int, int, int, int,
int, int, int, int, unsigned int, unsigned int);
]]
-- One-time FFI setup. Idempotent, and every path out records why it
-- stopped, so VR's status line can say something better than "no".
function VRGL.load()
if ready then return true end
if reason then return false, reason end
local ok, err = pcall(function()
ffi = require("ffi")
-- cdef survives a reload; redefinition is the only error worth eating
pcall(ffi.cdef, CDEF)
gl = ffi.load("opengl32")
local function proc(name, typ)
local p = gl.wglGetProcAddress(name)
if p == nil then error(name .. " not exposed by this GL context", 0) end
return ffi.cast(typ, p)
end
ext.glBindFramebuffer = proc("glBindFramebuffer", "pfn_glBindFramebuffer")
ext.glGenFramebuffers = proc("glGenFramebuffers", "pfn_glGenFramebuffers")
ext.glDeleteFramebuffers =
proc("glDeleteFramebuffers", "pfn_glDeleteFramebuffers")
ext.glFramebufferTexture2D =
proc("glFramebufferTexture2D", "pfn_glFramebufferTexture2D")
ext.glBlitFramebuffer = proc("glBlitFramebuffer", "pfn_glBlitFramebuffer")
end)
if not ok then
reason = "GL interop unavailable: " .. tostring(err)
return false, reason
end
ready = true
return true
end
-- The window's device and GL contexts, which the OpenXR session binds to.
function VRGL.contexts()
if not VRGL.load() then return nil, nil end
return gl.wglGetCurrentDC(), gl.wglGetCurrentContext()
end
-- The GL framebuffer behind a LOVE canvas. Bound through LOVE (so LOVE's
-- own cache stays truthful), read from the driver, then released.
function VRGL.canvasFBO(canvas)
if not VRGL.load() then return nil end
local id = nil
local ok = pcall(function()
love.graphics.setCanvas(canvas)
local out = ffi.new("int[1]")
gl.glGetIntegerv(GL.DRAW_FRAMEBUFFER_BINDING, out)
id = out[0]
love.graphics.setCanvas()
end)
pcall(love.graphics.setCanvas)
return ok and id or nil
end
-- Blit a LOVE canvas's pixels into a GL texture (an XR swapchain image),
-- flipped vertically on the way: LOVE renders its canvases y-down, GL
-- textures composite y-up, and the blit is the one place the two meet.
--
-- `srcFBO` comes from canvasFBO (cache it -- it is stable for the
-- canvas's lifetime). Ends with framebuffer 0 bound, which is the binding
-- LOVE believes in between its passes.
function VRGL.blitToTexture(srcFBO, sw, sh, tex, tw, th)
if not ready then return false end
local ok = pcall(function()
if not fbo then
local out = ffi.new("unsigned int[1]")
ext.glGenFramebuffers(1, out)
fbo = out[0]
end
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
GL.TEXTURE_2D, tex, 0)
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, srcFBO)
ext.glBlitFramebuffer(0, sh, sw, 0, 0, 0, tw, th,
GL.COLOR_BUFFER_BIT, GL.LINEAR)
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
end)
if not ok then pcall(function() ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) end) end
return ok
end
-- Copy the WINDOW's currently displayed image (the front buffer -- the
-- back buffer's contents are undefined after a swap) into a GL texture:
-- the UI quad the headset floats in front of the world. Restores the read
-- buffer to BACK, the default LOVE never changes.
function VRGL.copyFrontBuffer(tex, w, h)
if not ready then return false end
local ok = pcall(function()
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
gl.glReadBuffer(GL.FRONT)
gl.glBindTexture(GL.TEXTURE_2D, tex)
gl.glCopyTexSubImage2D(GL.TEXTURE_2D, 0, 0, 0, 0, 0, w, h)
gl.glBindTexture(GL.TEXTURE_2D, 0)
gl.glReadBuffer(GL.BACK)
end)
if not ok then pcall(function() gl.glReadBuffer(GL.BACK) end) end
return ok
end
-- Blit a REGION of the window's front buffer into a GL texture (an XR
-- swapchain image), SCALED to (dw, dh) at the texture's origin. This is
-- the panel's route: the whole-window copy above is pixel-for-pixel, so
-- a window larger than the swapchain image simply ran off its edges --
-- fullscreen cut the GB frame's own menu off the panel. A scaled blit
-- has no such cliff: the letterbox region lands whole at the texture's
-- own resolution whatever size the window is. Source coordinates are GL
-- window space, origin bottom-left; LINEAR, because the region rarely
-- matches the target size exactly and dropped rows read worse than a
-- soft one. Restores the read buffer and framebuffer LOVE believes in.
function VRGL.copyFrontRegionToTexture(tex, sx, sy, sw, sh, dw, dh)
if not ready then return false end
local ok = pcall(function()
if not fbo then
local out = ffi.new("unsigned int[1]")
ext.glGenFramebuffers(1, out)
fbo = out[0]
end
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
gl.glReadBuffer(GL.FRONT)
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
GL.TEXTURE_2D, tex, 0)
ext.glBlitFramebuffer(sx, sy, sx + sw, sy + sh, 0, 0, dw, dh,
GL.COLOR_BUFFER_BIT, GL.LINEAR)
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
gl.glReadBuffer(GL.BACK)
end)
if not ok then
pcall(function()
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
gl.glReadBuffer(GL.BACK)
end)
end
return ok
end
-- Copy the window's front buffer into a LOVE CANVAS (by its FBO id, from
-- canvasFBO), flipped so the canvas reads top-down exactly like the
-- window: what LOVE then draws from that canvas at (0,0) is the screen,
-- row for row. The battle's VR quad is the caller: it needs the screen as
-- something LOVE can CUT UP (scissored cutouts of the UI), not just as a
-- finished texture -- copyFrontBuffer above is for the finished case.
function VRGL.copyFrontToCanvas(dstFBO, w, h)
if not ready then return false end
local ok = pcall(function()
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
gl.glReadBuffer(GL.FRONT)
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, dstFBO)
ext.glBlitFramebuffer(0, 0, w, h, 0, h, w, 0,
GL.COLOR_BUFFER_BIT, GL.NEAREST)
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
gl.glReadBuffer(GL.BACK)
end)
if not ok then
pcall(function()
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
gl.glReadBuffer(GL.BACK)
end)
end
return ok
end
function VRGL.status()
if ready then return "ok" end
return reason or "not loaded"
end
return VRGL
+248
View File
@@ -0,0 +1,248 @@
-- VR: the pose arithmetic -- how a headset eye becomes one of this mod's
-- cameras. Pure math on purpose: no FFI, no OpenXR types, nothing a
-- headless test cannot hold still. Everything device-shaped stays in
-- VRXR/VRGL; everything world-shaped is here.
--
-- Two ways the world can sit around a headset, and they mirror the VOXEL
-- ladder exactly:
--
-- DIORAMA every orbit rung. The map is a tabletop miniature: a point
-- of the world (the view centre) is pinned VIEW_DIST away
-- along the rung's own viewing angle (dioramaAnchor), at the
-- scale that reproduces the flat screen's framing
-- (dioramaScale) -- so at rest the model presents exactly as
-- the standard view does, and the head moves freely around it
-- -- lean in and the town grows, walk around the table and
-- see the far side of the buildings honest occlusion has been
-- hiding.
--
-- FIRST_PERSON the 1ST rung. The player's head is pinned to where the
-- headset started, at FP_SCALE, so a 16-pixel person stands
-- about 1.6 m tall and a cell is a stride. The HMD's own
-- orientation becomes FirstPerson's yaw and pitch, so movement
-- stays "push forward, go where you look" through the same
-- FreeMove the flat screen uses.
--
-- SPACES AND UNITS. OpenXR LOCAL space is metres, +Y up, -Z the way the
-- head faced at session start. World space is world PIXELS, +Y up, +Z
-- south. The two are aligned axis-for-axis -- "away from you" is north --
-- so the whole mapping is one translate-and-scale:
--
-- worldFromXr(p) = pivot + s * (p - anchor)
--
-- with `pivot` a world point, `anchor` the LOCAL-space point pinned to it,
-- and `s` the scale in px/m. An eye's camera is then
--
-- worldFromEye = T(pivot) * S(s) * T(-anchor) * T(pose.pos) * R(pose.q)
-- view = the same chain inverted piece by rigid piece
--
-- and the VIEW deliberately ends in METRES: it un-scales the world, so eye
-- space -- where the projection's near and far live -- is real-world
-- metres whatever the mode's scale. Depth precision and clip planes stay
-- sane at both 10 px/m and 128 px/m.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local VRRig = {}
-- first person's life size: 10 px/m makes a 16 px tile a 1.6 m stride
VRRig.FP_SCALE = 10
-- How far the diorama's pivot sits from the resting head, in metres --
-- the arm's-length viewing distance the anchor and the scale below are
-- both built around.
VRRig.VIEW_DIST = 0.95
-- Where, in LOCAL metres, the diorama's pivot sits: VIEW_DIST away along
-- the RUNG'S OWN viewing angle. The flat screen's camera looks at the
-- world `a` radians off vertical; putting the pivot at (-d cos a) below
-- and (-d sin a) ahead of the resting head reproduces exactly that line
-- of sight -- step onto the 35 rung and the table presents at 35 degrees,
-- onto 75 and it rises toward eye level, easing between them as the rung
-- tween runs. `heightOff` is the grab-drag adjustment, in metres of world
-- travel (positive drags the world up).
function VRRig.dioramaAnchor(angleRad, heightOff)
local d = VRRig.VIEW_DIST
return { 0,
-d * math.cos(angleRad or 0) + (heightOff or 0),
-d * math.sin(angleRad or 0) }
end
-- The diorama's scale, in world px per metre: the one that makes the
-- table subtend the same field the flat screen frames. The flat camera
-- fits `vh` world pixels in a lens of focal `focal` (Voxel.FOCAL); at
-- VIEW_DIST the same framing needs vh * focal / d pixels to the metre --
-- so the resting head sees the standard view's angle AND its apparent
-- size, and the zoom rows (which change vh) keep working in VR.
function VRRig.dioramaScale(vh, focal)
return math.max(16, (vh or 288) * (focal or 1) / VRRig.VIEW_DIST)
end
-- kept as the test suite's fixed example anchor, and as the fallback for
-- an angle nobody supplied
VRRig.TABLE = { 0, -0.45, -0.75 }
-- ------- the battle mount
--
-- A staged fight snaps the headset to an OVER-THE-SHOULDER seat: the same
-- line the flat battle camera stands on (eye through focus, so the player's
-- mon is near-left and the foe far-right exactly as the flat shot frames
-- them), but pulled in to BATTLE_DIST -- the flat rig is a long lens from
-- fifteen metres back, and a headset's lens is its own eyes, so keeping the
-- distance would shrink the fight to a stage seen from the back row. 66 px
-- is the wide rig's own standing distance: six and a half metres at life
-- scale, close enough to fill the view, far enough to hold both mons in it
-- -- and short enough to stay inside the small rooms the wide rig exists
-- for.
VRRig.BATTLE_DIST = 66
-- Where the head sits for a staged fight, and which way the mapping must
-- turn so that seat FACES it. Returns the pivot (world px -- pin the XR
-- origin here at FP_SCALE) and the yaw for eyeCamera: the flat camera
-- looks along focus - eye, the resting headset looks along XR -Z (world
-- north), and the yaw is what closes that gap.
function VRRig.battleMount(eye, focus)
local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3]
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
if len < 1e-6 then return { eye[1], eye[2], eye[3] }, 0 end
local k = VRRig.BATTLE_DIST / len
-- Ry(yaw) sends XR forward (0,0,-1) to (-sin yaw, 0, -cos yaw); aiming
-- that along the horizontal of focus - eye solves to atan2 of eye - focus
return { focus[1] + dx * k, focus[2] + dy * k, focus[3] + dz * k },
math.atan2(dx, dz)
end
-- eye-space clip planes, in metres (see the unit note above)
VRRig.NEAR = 0.05
VRRig.FAR = 400
-- ------- one eye's camera
-- Build the placed-camera record for one eye.
--
-- pose { pos = {x,y,z} metres, quat = {x,y,z,w} } (OpenXR LOCAL)
-- fov { angleLeft, angleRight, angleUp, angleDown } signed radians
-- pivot {x,y,z} world px pinned to `anchor`
-- anchor {x,y,z} LOCAL metres (VRRig.TABLE, or 0,0,0 for first person)
-- scale world px per metre
-- yaw optional turn of the whole mapping about +Y, radians: the
-- battle mount faces the resting head at the arena with it.
-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor).
--
-- Returns a table shaped for Voxel3D.camera: raw view + proj, the world
-- eye and focus (for setLook, the water's lean, the sky), fov as a
-- vertical span, and the curve declined -- a bent tabletop reads as a
-- broken model, and first person already declines it on the flat screen.
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3]
local q = pose.quat
local R = Mat4.fromQuat(q[1], q[2], q[3], q[4])
-- view = R^T * T(-pos) * T(anchor) * Ry(-yaw) * S(1/s) * T(-pivot)
local view = Mat4.mul(Mat4.transpose(R), Mat4.translate(-px, -py, -pz))
view = Mat4.mul(view, Mat4.translate(anchor[1], anchor[2], anchor[3]))
if yaw and yaw ~= 0 then
view = Mat4.mul(view, Mat4.rotateY(-yaw))
end
view = Mat4.mul(view, Mat4.scale(1 / scale, 1 / scale, 1 / scale))
view = Mat4.mul(view, Mat4.translate(-pivot[1], -pivot[2], -pivot[3]))
local proj = Mat4.fovProjection(fov.angleLeft, fov.angleRight,
fov.angleUp, fov.angleDown,
VRRig.NEAR, VRRig.FAR)
-- The eye's RAY FAN, in world axes: the direction a canvas point
-- (u, v in 0..1, left-to-right and top-to-bottom) looks along is
-- base + u * du + v * dv. The sky reads its per-pixel TRUE elevation
-- off this (a real skybox cannot be painted from any per-frame row
-- mapping -- that is exact only at the view's own azimuth and swims
-- everywhere else). Directions only, so the mapping's scale drops out;
-- the yaw must not (the battle mount and the snap turn swing the world).
local Rw = R
if yaw and yaw ~= 0 then Rw = Mat4.mul(Mat4.rotateY(yaw), R) end
local tl, tr = math.tan(fov.angleLeft), math.tan(fov.angleRight)
local tu, td = math.tan(fov.angleUp), math.tan(fov.angleDown)
-- world columns of the head's rotation: right (X), up (Y), forward (-Z)
local rxc, ryc, rzc = Rw[1], Rw[5], Rw[9]
local uxc, uyc, uzc = Rw[2], Rw[6], Rw[10]
local fxc, fyc, fzc = -Rw[3], -Rw[7], -Rw[11]
local skyRay = {
base = { fxc + rxc * tl + uxc * tu,
fyc + ryc * tl + uyc * tu,
fzc + rzc * tl + uzc * tu },
du = { rxc * (tr - tl), ryc * (tr - tl), rzc * (tr - tl) },
dv = { uxc * (td - tu), uyc * (td - tu), uzc * (td - tu) },
}
-- the eye and its forward, in world pixels: worldFromEye applied to the
-- origin and to -Z
local ax, ay, az = px - anchor[1], py - anchor[2], pz - anchor[3]
-- R's third column is the eye's +Z axis; forward is its negation
local fx, fy, fz = -R[3], -R[7], -R[11]
if yaw and yaw ~= 0 then
local c, s = math.cos(yaw), math.sin(yaw)
ax, az = c * ax + s * az, -s * ax + c * az
fx, fz = c * fx + s * fz, -s * fx + c * fz
end
local ex = pivot[1] + scale * ax
local ey = pivot[2] + scale * ay
local ez = pivot[3] + scale * az
return {
view = view,
proj = proj,
eye = { ex, ey, ez },
focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale },
fov = fov.angleUp - fov.angleDown,
curve = 0,
skyRay = skyRay,
}
end
-- The WORLD model matrix a hand-held prop stands on: worldFromXr (the
-- same mapping the eyes use -- so the prop is exactly where the hand is,
-- whatever mode the mapping is in) composed with the hand's own tracked
-- pose. A mesh authored in METRES rides it straight: the mapping's scale
-- is what turns metres into world pixels, so the prop keeps its real
-- size in the hand at the diorama's scale and at life scale alike.
--
-- model = T(pivot) * S(s) * Ry(yaw) * T(-anchor) * T(hand.pos) * R(hand.quat)
function VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
local m = Mat4.translate(pivot[1], pivot[2], pivot[3])
m = Mat4.mul(m, Mat4.scale(scale, scale, scale))
if yaw and yaw ~= 0 then m = Mat4.mul(m, Mat4.rotateY(yaw)) end
m = Mat4.mul(m, Mat4.translate(-anchor[1], -anchor[2], -anchor[3]))
m = Mat4.mul(m, Mat4.translate(pose.pos[1], pose.pos[2], pose.pos[3]))
local q = pose.quat
return Mat4.mul(m, Mat4.fromQuat(q[1], q[2], q[3], q[4]))
end
-- The flat compass numbers a head orientation implies, for driving
-- FirstPerson (and through it FreeMove) from the HMD: yaw in this mod's
-- convention (0 south, pi/2 east) and pitch positive-down.
function VRRig.headYawPitch(quat)
local R = Mat4.fromQuat(quat[1], quat[2], quat[3], quat[4])
local fx, fy, fz = -R[3], -R[7], -R[11]
local flat = math.sqrt(fx * fx + fz * fz)
local yaw = flat > 1e-6 and math.atan2(fx, fz) or 0
local pitch = -math.asin(math.max(-1, math.min(1, fy)))
return yaw, pitch
end
-- The two pivots. First person pins the player's head; the diorama pins
-- the view centre at the ground plane. `gh` is the ground height under
-- the player (VoxelScene.groundAt), `eyeH` FirstPerson.EYE_HEIGHT.
function VRRig.fpPivot(pxTopLeft, pyTopLeft, gh, eyeH)
return { pxTopLeft + 8, (gh or 0) + (eyeH or 13), pyTopLeft + 8 }
end
function VRRig.dioramaPivot(cx, cy)
return { cx, 0, cy }
end
return VRRig
+1073
View File
File diff suppressed because it is too large Load Diff
+209 -10
View File
@@ -421,7 +421,14 @@ end
-- ---------------------------------------------------------------- camera --
-- An explicit camera, replacing the orbit below for as long as it is set:
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }.
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil,
-- up = {x,y,z} or nil }.
--
-- A caller with matrices of its own -- the VR eyes, whose view comes from
-- a tracked pose and whose projection is an off-centre frustum no
-- eye/focus/fov triple can express -- sets `view` and `proj` instead, and
-- the eye/focus fields stay for everything that reasons about the camera
-- rather than projecting with it (setLook, the sky, the water's lean).
--
-- The orbit is the free-roam camera and it is described entirely by ONE
-- number, the pitch, because that is all a camera following the player over
@@ -437,6 +444,13 @@ end
-- way either way.
Voxel3D.camera = nil
-- This frame's camera RAY FAN, set by viewProjection alongside vp: the
-- world direction a canvas point looks along (see Sky.paint's `ray`).
-- Present for every free-pitch camera -- the VR eyes bring theirs
-- (VRRig.eyeCamera), a placed eye/focus camera gets one built -- and nil
-- for the orbit, whose frame-hung sky is the classic look.
Voxel3D.skyRayLive = nil
-- ------- which way, and how steeply, this camera looks
--
-- Two facts about the view direction, set alongside the eye and the focus
@@ -483,6 +497,15 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
-- question about which way this camera looks, and only these two answer it
Voxel3D.focus = focus
setLook(eye, focus)
-- a camera that brought its own matrices (a VR eye) projects with
-- them; only the clip-space Y flip is added, for the same canvas
-- reason as every other branch here
if cam.view and cam.proj then
Voxel3D.fovY = cam.fov
-- the VR eyes bring their fan with them (VRRig.eyeCamera)
Voxel3D.skyRayLive = cam.skyRay
return Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), cam.proj), cam.view)
end
local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3]
@@ -496,11 +519,47 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
-- the same clip-space Y flip the orbit needs, for the same reason: we
-- bypass LOVE's transform_projection and canvas coordinates run Y down
proj = Mat4.mul(Mat4.scale(1, -1, 1), proj)
-- world up, so the horizon stays level -- a placed camera that rolled
-- with its own pitch would tip the whole arena
return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 }))
-- The camera's RAY FAN, for the sky's skybox path (Sky.paint's `ray`):
-- a placed camera with a FREE PITCH -- the first-person rig, steered
-- by a mouse on the flat screen -- must not hang its gradient off the
-- frame, or looking up and down drags the bands with the view. Built
-- from the very basis the view below is: forward, the true right, the
-- true up, and the symmetric frustum's tangents.
local upv = cam.up or { 0, 1, 0 }
local fx, fy, fz = -dx / dist, -dy / dist, -dz / dist
local crx = fy * upv[3] - fz * upv[2]
local cry = fz * upv[1] - fx * upv[3]
local crz = fx * upv[2] - fy * upv[1]
local crl = math.sqrt(crx * crx + cry * cry + crz * crz)
if crl > 1e-6 then
crx, cry, crz = crx / crl, cry / crl, crz / crl
local cux = cry * fz - crz * fy
local cuy = crz * fx - crx * fz
local cuz = crx * fy - cry * fx
local tanY = math.tan(cam.fov / 2)
local tanX = tanY * (vw / vh)
Voxel3D.skyRayLive = {
base = { fx - crx * tanX + cux * tanY,
fy - cry * tanX + cuy * tanY,
fz - crz * tanX + cuz * tanY },
du = { crx * 2 * tanX, cry * 2 * tanX, crz * 2 * tanX },
dv = { cux * -2 * tanY, cuy * -2 * tanY, cuz * -2 * tanY },
}
else
Voxel3D.skyRayLive = nil
end
-- world up by default, so the horizon stays level -- a placed camera
-- that rolled with its own pitch would tip the whole arena. A caller
-- may hand its own up: the first-person BLEND does, because its far
-- end is the orbit, whose up leans with the pitch -- world up at the
-- orbit's steep end degenerates against a straight-down view.
return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 }))
end
-- the orbit: a fixed pitch per rung, and the classic frame-hung sky --
-- no ray fan wanted
Voxel3D.skyRayLive = nil
local a = Voxel.angle
local focal = Voxel.FOCAL
local dist = focal * vh
@@ -568,6 +627,56 @@ function Voxel3D.horizonY(h)
return (y / w * 0.5 + 0.5) * h
end
-- The horizon as a LINE rather than a row, for a camera that can ROLL --
-- a VR eye. A head tipped sideways tips the true horizon across the
-- canvas, and a sky painted in flat rows then visibly hinges with the
-- head. So: project the flat forward direction (a point ON the vanishing
-- line) and the same direction nudged a hair of world-up (a point just
-- above it); the difference is the canvas direction "down toward the
-- ground", perpendicular to the horizon however the head is tipped.
--
-- Returns (ax, ay, edge, top): a unit axis in canvas pixels pointing from
-- sky toward ground, the horizon's signed distance along it -- a pixel at
-- canvas (x, y) is above the horizon while x*ax + y*ay < edge -- and,
-- when `elev` (radians) is given, the distance the direction that far
-- ABOVE the horizon projects to. `top` is what pins the gradient's far
-- end to a real direction in the sky: extrapolating it linearly from a
-- pixels-per-radian estimate left the bands sliding as a pitch moved the
-- horizon through the frame, because a perspective's rows are tan-spaced,
-- not angle-spaced. nil `top` (the elevated direction is outside this
-- frustum's forward hemisphere) leaves the caller its estimate. nil
-- everything with no horizon in front of this camera.
function Voxel3D.horizonLine(w, h, elev)
local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus
if not (m and eye and focus and w and h and h > 0) then return nil end
local dx = focus[1] - eye[1]
local dz = focus[3] - eye[3]
local len = math.sqrt(dx * dx + dz * dz)
if len < 1e-6 then return nil end
dx, dz = dx / len, dz / len
local function proj(vx, vy, vz)
local x = m[1] * vx + m[2] * vy + m[3] * vz
local y = m[5] * vx + m[6] * vy + m[7] * vz
local ww = m[13] * vx + m[14] * vy + m[15] * vz
if ww <= 1e-6 then return nil end
return (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h
end
local qx, qy = proj(dx, 0, dz)
if not qx then return nil end
local rx, ry = proj(dx, 0.02, dz)
if not rx then return nil end
local ax, ay = qx - rx, qy - ry
local al = math.sqrt(ax * ax + ay * ay)
if al < 1e-6 then ax, ay = 0, 1 else ax, ay = ax / al, ay / al end
local top = nil
if elev then
local ce, se = math.cos(elev), math.sin(elev)
local tx, ty = proj(dx * ce, se, dz * ce)
if tx then top = tx * ax + ty * ay end
end
return ax, ay, qx * ax + qy * ay, top
end
-- ------- the hour's light
--
-- What the scene shader multiplies every surface by (see dayTint in the
@@ -612,12 +721,83 @@ function Voxel3D.skyBody(w, h)
return {
x = (x / ww * 0.5 + 0.5) * w,
y = (y / ww * 0.5 + 0.5) * h,
-- the body's WORLD direction, for the skybox path: a ray-fan caller
-- measures the twilight glow by the angle between a pixel's ray and
-- this, so the glow is pinned to the sky like the bands are (see
-- Sky.paint's glowDir)
dx = b.dx, dy = b.dy, dz = b.dz,
moon = b.moon,
glowAmt = amt,
glowColor = color,
}
end
-- ------- the VR sky's world-anchored pieces
--
-- Both exist because a headset showed the shortcuts: a gradient painted
-- off the frame moved with the head that carried the frame, and a
-- screen-space disc re-snapped its cell grid with every head movement
-- and held its face square to the canvas instead of to the world. The
-- gradient's fix rides the camera record itself (skyRay -- see VRRig and
-- Sky's useRay path); the disc's is below.
-- The sun or moon as a QUAD IN THE WORLD: the baked cell art
-- (Sky.discImage) on a square spanned about the hour's direction, its
-- corners projected through this very eye -- so the disc is pinned to
-- the sky like the terrain is to the ground, stable under every head
-- motion, its face upright over the world. Runs inside beginScene's sky
-- window, before the depth mode is set, so the world draws over it.
local discMesh = nil
local function drawWorldDisc(w, h)
local b = DayNight.body()
if not (b and b.dy and b.dy > 0.005) then return end
local amt = DayNight.glow()
local img = Sky.discImage(b.moon, Sky.discLooming(amt, b.moon))
if not img then return end
local m = Voxel3D.vp
if not m then return end
local hl = math.sqrt(b.dx * b.dx + b.dz * b.dz)
if hl < 1e-6 then return end
-- right = horizontal, perpendicular to the direction; up completes it
local rx, rz = b.dz / hl, -b.dx / hl
local ux = -rz * b.dy
local uy = rz * b.dx - rx * b.dz
local uz = rx * b.dy
local ul = math.sqrt(ux * ux + uy * uy + uz * uz)
if ul < 1e-6 then return end
ux, uy, uz = ux / ul, uy / ul, uz / ul
if uy < 0 then ux, uy, uz = -ux, -uy, -uz end
-- apparent size is an ANGLE, the same fraction of the view the flat
-- screen's disc takes of its frame; the low sun looms exactly as there
local ang = Sky.DISC_FRAC * (Voxel3D.fovY or 1)
if Sky.discLooming(amt, b.moon) then ang = ang * 1.4 end
local k = math.tan(ang)
local verts = {}
local corners = { { -1, -1, 0, 1 }, { 1, -1, 1, 1 },
{ 1, 1, 1, 0 }, { -1, 1, 0, 0 } }
for i, c in ipairs(corners) do
local vx = b.dx + (rx * c[1] + ux * c[2]) * k
local vy = b.dy + (uy * c[2]) * k
local vz = b.dz + (rz * c[1] + uz * c[2]) * k
local x = m[1] * vx + m[2] * vy + m[3] * vz
local y = m[5] * vx + m[6] * vy + m[7] * vz
local ww = m[13] * vx + m[14] * vy + m[15] * vz
if ww <= 1e-6 then return end
verts[i] = { (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h,
c[3], c[4] }
end
pcall(function()
if not discMesh then
discMesh = love.graphics.newMesh(4, "fan", "stream")
end
discMesh:setVertices(verts)
discMesh:setTexture(img)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(discMesh)
end)
end
-- ----------------------------------------------------------------- scene --
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
@@ -672,10 +852,20 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- screen. The sky's dither grid is cut to it, and so is the water's --
-- one number, so the two break up on the same checkerboard.
Voxel3D.cell = w / math.max(1, vw or w)
-- and where the sky's bottom edge lands, which is what the reflection
-- A FREE-PITCH camera's sky is ANCHORED IN SPACE, where the orbit's is
-- glued to the frame. One discriminator: skyRayLive, set by
-- viewProjection above for every camera whose pitch the player steers
-- -- the VR eyes and the flat first-person rig alike. With a fan, the
-- gradient is a SKYBOX (every pixel takes its band, and its GBC
-- checker, from its ray's true elevation -- no motion of the camera
-- moves a band, only the clock recolours them) and the sun or moon
-- hangs in the WORLD (drawWorldDisc). Without one -- the orbit, whose
-- pitch is the rung's -- the classic frame-hung painting stands.
local skyRay = Voxel3D.skyRayLive
local hy = Voxel3D.horizonY(h)
-- where the sky's bottom edge lands, which is what the reflection
-- reads its bands against (see Water). nil when nothing painted bands.
Voxel3D.skyEdge = (sky and sky.bands)
and Sky.region(h, Voxel3D.horizonY(h)) or nil
Voxel3D.skyEdge = (sky and sky.bands) and Sky.region(h, hy) or nil
if sky then
love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true)
-- The sky goes down here, in the one window in this function where a
@@ -688,8 +878,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- are the same size as the world's own and follow every resize and zoom.
-- The banded sky also hangs the hour's sun or moon (skyBody projects it
-- through this very camera); a flat sky has no bands and hangs nothing.
Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell,
sky.bands and Voxel3D.skyBody(w, h) or nil)
if skyRay and sky.bands then
Sky.paint(w, h, sky, nil, Voxel3D.cell, Voxel3D.skyBody(w, h),
nil, nil, skyRay)
drawWorldDisc(w, h)
else
Sky.paint(w, h, sky, hy, Voxel3D.cell,
sky.bands and Voxel3D.skyBody(w, h) or nil)
end
else
love.graphics.clear(0, 0, 0, 0, true, true)
end
@@ -715,7 +911,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
pcall(sh.send, sh, "sunTexel", { texel, texel })
if grid then
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH)
pcall(sh.send, sh, "gridWidth", VoxelGrid.width())
end
-- ordinary shading until the silhouette pass asks for otherwise. Sent
-- every frame rather than once, because a scene that opened mid-ghost --
@@ -1162,6 +1358,9 @@ function Voxel3D.invalidate()
end
canvas, canvasW, canvasH = nil, 0, 0
held = nil
-- the VR sky's disc mesh belongs to this context like the canvases do
if discMesh and discMesh.release then pcall(discMesh.release, discMesh) end
discMesh = nil
ShadowMap.invalidate()
-- the sky is part of this pass and holds a shader of its own
Sky.invalidate()
+13
View File
@@ -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" })
+306 -28
View File
@@ -24,6 +24,9 @@ local Sky = V.require("Sky")
local Water = V.require("Water")
local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight")
local FirstPerson = V.require("FirstPerson")
local BattleBillboard = V.require("BattleBillboard")
local Pokedex = V.require("Pokedex")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -214,6 +217,22 @@ local function frameFor(def, facing, phase, flip)
return frame, mirror
end
-- The facing a pose SHOWS this camera. The flat frames are "how this pose
-- looks from the south", which is where the orbit always stands; a
-- first-person eye stands anywhere, so deep enough into the blend the
-- facing is remapped to how the pose looks from THERE -- walk behind an
-- NPC and their card wears the back sprite. Used by the camera draw and
-- the sun pass BOTH: the card the sun stored and the transform a lit card
-- reads its own shadowing with must describe the same frame, or the
-- mirror-flip half of the pair asks the map about texels the sun filed
-- under the other cheek.
local function viewFacing(p)
if FirstPerson.cardBlend() > 0.5 then
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
end
return p.facing
end
-- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as
-- a decal: its current sprite frame as a single quad, flattened onto the
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
@@ -236,17 +255,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
@@ -258,10 +300,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
@@ -333,7 +388,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()
@@ -462,7 +517,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
@@ -529,9 +590,18 @@ local function drawCast(state, posed, atlasFor)
-- 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
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.colors, p.lift)
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
@@ -583,8 +653,48 @@ end
-- 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)
local plain = true
-- 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()
@@ -607,10 +717,12 @@ function VoxelScene.drawWater(draws, cast)
-- Unconditionally, and OUTSIDE the success branch: beginWater unbinds
-- the shader and the depth mode BEFORE it can discover it cannot go on,
-- so a frame that bails halfway through has to be put back together
-- exactly like one that succeeded -- otherwise the plain draw below (and
-- every pass after it) runs with no shader and no depth test.
-- 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])
@@ -645,6 +757,10 @@ 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())
put(tostring(terrain))
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
for _, p in ipairs(posed) do
@@ -668,9 +784,12 @@ 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, water, nbWater)
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
@@ -717,7 +836,12 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
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(),
@@ -726,12 +850,24 @@ 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)
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
@@ -773,13 +909,53 @@ 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,
water, nbWater)
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
-- 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)
for i, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
@@ -796,7 +972,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
if not Voxel3D.shadowsActive() then
Voxel3D.beginShadows()
for _, p in ipairs(posed) do
drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
p.lift)
end
Voxel3D.endShadows()
@@ -842,7 +1018,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()
@@ -862,14 +1042,54 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude.
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
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
-- 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)
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),
@@ -885,7 +1105,7 @@ 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,
@@ -896,7 +1116,65 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
return Voxel3D.endScene()
-- The VR pokedex in the player's left hand, last of all: a prop over
-- the world drawn with real depth, so leaning it into a wall still
-- occludes honestly. Its frame only exists while a session is live and
-- the left hand is tracked (VR.lua sets it), so every flat frame skips
-- this in one field read. No wireframe and no glass, like the cast:
-- the device is a drawing riding the scene, not part of the terrain.
if Pokedex.frame then
Voxel3D.glass(false)
Voxel3D.seams(false)
Pokedex.draw()
Voxel3D.seams(true)
Voxel3D.glass(true)
end
-- HORDE MODE's handgun, in the same slot and for the same reasons: a
-- prop over the world with real depth, no wireframe and no glass. In VR
-- it rides the tracked right hand (lib/VR placed it this frame); on the
-- flat screen it is carried by the camera, which is why it draws here
-- rather than in the overlay -- a view model that is 2D cannot be
-- occluded by the wall the player just backed into.
do
local HordeGun = V.require("HordeGun")
if HordeGun.visible() then
Voxel3D.glass(false)
Voxel3D.seams(false)
HordeGun.draw()
Voxel3D.seams(true)
Voxel3D.glass(true)
end
end
end -- drawScene
if not eyes then
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil
end
drawScene()
return Voxel3D.endScene()
end
-- The VR frame: the same scene once per eye, each into its own named
-- canvas slot under its own placed camera. `adopt` hands the eye's
-- record to FirstPerson as the live rig, which is what turns the
-- billboards toward THIS eye in first person (cardBlend keys on rig
-- identity -- see FirstPerson) and leaves them leaning in the diorama,
-- where the blend is zero.
local out = {}
for i, eye in ipairs(eyes) do
Voxel3D.camera = eye.camera
if eye.adopt then FirstPerson.adoptVReye(eye.camera) end
if not Voxel3D.beginScene(eye.w, eye.h, cx, cy, vw, vh,
skyFor(state.map), eye.slot) then
return nil
end
drawScene()
out[i] = Voxel3D.endScene()
end
return out
end
return VoxelScene
+25 -3
View File
@@ -32,8 +32,18 @@ local Voxel = {}
-- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate
-- in the table is deliberate: the ladder is a list of what each rung LOOKS
-- like, and two rungs may look the same while meaning different things.
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 }
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" }
--
-- 1ST is the other rung that is more than an angle: the camera steps off its
-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua),
-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung
-- it hands over from -- because the tween in and out of first person starts
-- from whatever the orbit shows, and the lowest rung is the one a dive into
-- a head should start from. Everything angle-derived (the sky's fade, the
-- billboard lean the blend eases away) reads that 75 while the first-person
-- rig owns the actual camera.
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 }
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
"1ST (EXPERIMENTAL)" }
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
-- the rung FULL sits on, so nothing has to hunt for it by label
@@ -43,6 +53,13 @@ function Voxel.isFull(level)
return (level or Voxel.level) == Voxel.FULL_LEVEL
end
-- the rung the first-person camera sits on, likewise
Voxel.FP_LEVEL = 6
function Voxel.isFirstPerson(level)
return (level or Voxel.level) == Voxel.FP_LEVEL
end
-- ------- what the hotkey walks
--
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
@@ -51,7 +68,12 @@ end
-- mid-walk, would silently turn the blur to maximum and flatten the horizon
-- with no indication that a keypress had done so. FULL stays on the OPTIONS
-- row, which is where a preset that changes other rows belongs.
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75
--
-- 1ST is on the path: it changes the camera and only the camera, which is
-- exactly what the key promises -- and the key is also the way back OUT of
-- first person on a keyboard, where the mouse is captured and the OPTIONS
-- menu is a trip.
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST
-- The rung a press moves to from `level`.
--
+88 -11
View File
@@ -381,9 +381,32 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra
--
-- The scene shader's own vertex path, plus the world position the geometry
-- was actually DRAWN at -- after the world curve, because that is the space
-- the depth buffer holds and therefore the space the march has to walk in.
-- (The curve only ever moves Y, so a fragment's world XZ is the same on both
-- sides of it and the ripple can be measured off this one too.)
-- the surface the eye MEETS lives in: which wave column a screen pixel is
-- looking at is a question about the geometry as drawn, and relief() answers
-- it there. (The curve only ever moves Y, so a fragment's world XZ is the
-- same on both sides of it and the ripple can be measured off this one too.)
--
-- WHAT IT REFLECTS is worked out on the other side of the bend, in the FLAT
-- world, and this is the same rule the rest of the mode keeps: the curve
-- tips the world away and the things standing on it do not lean with it (see
-- WorldCurve -- buildings stay upright, shadows are resolved before the bend
-- and ride along). A lake is one of those things. Reflect off the bowl the
-- bend has made instead and the far half of a pond is a mirror tilted twenty
-- degrees: it throws the ray past the vertical, where the sky ramp's own
-- measure -- a screen row, through the frame's matrix -- swings from one end
-- of the ramp to the other across a single column, and the pond comes out
-- with hard-edged patches of the wrong sky stamped into it -- the overhead
-- band and the horizon band abutting in the middle of a lake, which reads as
-- something other than water showing through. The same tilt sends the
-- screen-space march grazing along the bank instead of over it, which is the
-- other half: the dock and the roofs smeared across the harbour.
--
-- So the reflection is taken with the flat view ray about the flat normal,
-- exactly as it would be with the curve off -- and the MARCH still has to
-- walk the world as drawn, because that is what the depth buffer holds. Both
-- at once: the ray is straight in the flat world, and project() bends each
-- sample on its way to the screen, which is the same displacement the vertex
-- stage applies and therefore lands in the same place the geometry did.
local SHADER_SRC = [[
varying float vShade;
varying vec3 vSun;
@@ -435,6 +458,20 @@ uniform vec3 eye;
uniform vec2 screen; // the canvas, in pixels
uniform float cell; // one diorama pixel, in canvas pixels
uniform float pxAngle; // radians of view one screen pixel subtends
// The same bend the vertex stage applied. This stage has to undo it to get
// back to the flat world it reflects in, and re-apply it on every marched
// sample to get back to the screen. Declared in both stages, like `vp`, and
// both are highp here.
uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off
// How far the bend has pushed the world down at world XZ `q` -- the vertex
// stage's own displacement, as a number this stage can add and subtract.
// Zero when the curve is off, which is the shader's "skip it" everywhere.
float bendDrop(vec2 q) {
if (curve.z <= 0.0) return 0.0;
vec2 d = q - curve.xy;
return dot(d, d) * curve.z;
}
// the sun's own pass, exactly as the scene shader reads it
uniform Image sunMap;
@@ -658,10 +695,14 @@ vec3 bodyAt(vec3 d, vec3 c, float parity) {
// clip-space Y flip is already baked into `vp`, and a canvas texture's v runs
// the same way its pixel rows do, so one 0.5x+0.5 answers for both.
//
// This is why the march walks in the world as DRAWN rather than as authored:
// the depth buffer holds the curved world, so a straight line in that space
// is the ray, and a straight line in the flat one would bend through it.
// The point arrives in the FLAT world -- the space the ray is straight in --
// and is bent here, by the same displacement the vertex stage applied, so it
// lands exactly where the geometry it is being compared against landed. That
// split is the whole trick: the reflection is worked out in a world that has
// not been tipped, and every sample of it is tipped on the way to the screen,
// so the march reads the depth buffer it actually has.
vec4 project(vec3 p) {
p.y -= bendDrop(p.xz);
vec4 c = vp * vec4(p, 1.0);
if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0);
return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0);
@@ -915,8 +956,33 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
// the bound canvas's own pixel size, the same units sc is measured in, on
// every display. (`screen` stays in units: skyPos reads it against cell
// and skyEdge, which are unit-measured with it.)
//
// The buffer now holds THIS SURFACE too (VoxelScene draws the water flat
// before the pass that reflects it), which is what makes one lake able to
// hide another -- and it means every fragment here is testing against its
// own depth. That raises the bar on the fragment's own z: gl_FragCoord is
// allowed to be MEDIUMP on GLES (and is, on Adreno), and fp16 near the far
// end of the range steps by about half a thousandth -- which the old test
// against the terrain far behind the surface never felt, and a comparison
// of the surface against itself loses outright. Every fragment failed, the
// pass discarded the whole lake, and Android showed the flat draw
// underneath. So the depth is recomputed HERE, in highp, from the same
// vBent and vp the vertex stage used -- full precision on every driver.
//
// The slack is sized to what remains after that, which is not rounding:
// the buffer holds depth interpolated LINEARLY IN SCREEN SPACE, while the
// recomputation projects the perspective-interpolated vBent -- the exact
// answer. The two agree at the vertices and drift apart across a quad's
// interior, by more the bigger the quad stands on screen; on a phone
// (fit scale 6, water quads hundreds of pixels tall) the drift crosses
// 1e-5 mid-quad, which discarded the middle of every tile row and looked
// like flat water with reflective seams. Anything GENUINELY in front of a
// water pixel is whole world units nearer -- upward of 1e-3 in depth --
// so 2e-4 clears the drift with room while still catching every occluder.
vec2 uv = sc / love_ScreenSize.xy;
if (gl_FragCoord.z > Texel(depthTex, uv).r) discard;
vec4 selfC = vp * vec4(vBent, 1.0);
float selfZ = selfC.z / selfC.w * 0.5 + 0.5;
if (selfZ > Texel(depthTex, uv).r + 2e-4) discard;
// THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of
// its own standing a whole number of pixels tall, and the ray decides
@@ -925,12 +991,23 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
// drawn at, with no smooth shading anywhere across it. (The depth test
// above is the one thing that stays per fragment: that is the hardware's
// own question and it is asked in screen space.)
vec3 view = normalize(vBent - eye);
//
// Answered on the FLAT sheet, which is where the bars are a slab of even
// thickness over a level plane -- the one thing relief() is built on. The
// bend translates every bar straight down by its own column's drop, so the
// field keeps its shape and only its height moves; undo that here and the
// walk is the walk it was written for. Try it in the world as DRAWN
// instead and the slab is a bowl: the backward step up the ray climbs the
// bowl's near side as fast as it climbs out of the water, the walk starts
// inside the sheet, and it hands back a column a pixel or three off -- per
// fragment, differently, which is a patch of noise rather than parallax.
vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z);
vec3 view = normalize(sheet - eye);
vec3 hit;
vec2 col;
float face;
float axis;
relief(vBent, view, hit, col, face, axis);
relief(sheet, view, hit, col, face, axis);
// and the bar's centre, so a column is sampled and reflected from one
// place rather than from wherever inside it the fragment happened to land
vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5);
@@ -1001,7 +1078,7 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) {
vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0));
#ifdef VOXEL_GRID
rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis);
rgb *= 1.0 - gridDark * columnSeam(hit, sheet, axis);
#endif
return vec4(rgb, 1.0) * color;
}
@@ -1192,7 +1269,7 @@ function Water.begin(ctx)
if ctx.grid then
local VoxelGrid = V.require("VoxelGrid")
send("gridDark", VoxelGrid.DARK)
send("gridWidth", VoxelGrid.WIDTH)
send("gridWidth", VoxelGrid.width())
end
Water.sendSky(sh, ctx)
+331 -36
View File
@@ -23,9 +23,16 @@
-- the engine's TILT mode -- is engine plumbing driven by the records
-- below. This file declares; lib/ draws.
--
-- Nothing here reaches collision, movement, triggers or scripts. Voxel
-- mode is purely presentational: it changes what the world LOOKS like and
-- nothing about what it IS.
-- Voxel mode is presentational: it changes what the world LOOKS like and
-- nothing about what it IS. ONE rung is the deliberate exception. 1ST --
-- the first-person camera -- replaces the grid WALK with a free,
-- camera-relative one while it is selected (lib/FreeMove.lua), because a
-- head you can steer with a mouse demands feet that go where it looks.
-- Even there the game is untouched: the walk asks the engine's own
-- collision the same questions a grid step asks, keeps the player's
-- logical cell synced, and fires the engine's own landing pipeline per
-- cell crossed -- warps, encounters, ledges, gates and scripts all run
-- exactly as themselves. Step off the rung and the grid walk is back.
local mod = ...
@@ -82,6 +89,17 @@ local BattleExit = V.require("BattleExit")
local DayNight = V.require("DayNight")
local DayTint = V.require("DayTint")
local Water = V.require("Water")
local AntiAlias = V.require("AntiAlias")
local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove")
local VR = V.require("VR")
-- HORDE MODE: the konami code's minigame. Horde owns the state machine and
-- every hook; the other four are the gun, the crowd, the readout and the
-- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
local Horde = V.require("Horde")
local HordeGun = V.require("HordeGun")
local HordeHud = V.require("HordeHud")
local HordeSfx = V.require("HordeSfx")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -161,6 +179,11 @@ mod.content.render_pipelines:register("voxel", {
-- would fight anyone who changed one deliberately.
applyFull(level)
Voxel.update(dt, level)
-- the first-person head, on the same tick: its blend in and out of the
-- orbit, the mouse capture lifecycle, and the frame's stick-rate look.
-- Unconditional like Voxel.update, because the blend has to keep easing
-- OUT after the rung is left
FirstPerson.update(dt)
-- the day/night clock, on the same always-running tick: Pipelines.update
-- runs whatever the level, so time passes with the mode off, through
-- battles and menus, and a CYCLE evening falls mid-fight exactly as it
@@ -175,6 +198,12 @@ mod.content.render_pipelines:register("voxel", {
-- and the whole battle. Ahead of the active() gate below, because a 3D
-- battle does not require the free-roam mode to be switched on.
OverworldBattle.update(dt)
-- The horde, on the same always-running tick and for the same reason:
-- it owns no pass of the frame, it is a MODE over the overworld, and
-- it has to keep thinking while a warp's wipe covers the screen (the
-- crowd follows the player through the door) and under the GAME OVER
-- card, which is a pushed state that stops everything below it.
Horde.update(dt)
-- VOID FILL picks the block the border ring is made of, and in this
-- mode that ring is BAKED INTO THE MESH rather than drawn each frame.
-- So the option has to reach the cache or nothing happens on screen
@@ -185,6 +214,13 @@ mod.content.render_pipelines:register("voxel", {
-- them announces it. Ahead of the active() gate, so switching it
-- while voxel mode is OFF still invalidates what is cached.
voidFill.check()
-- The whole VR frame -- session lifecycle, xrWaitFrame's pacing, both
-- eye renders, the layer submit -- rides this hook, because it is the
-- one tick that runs through menus, dialogs and battles, which is
-- what a headset needs the world (or at least the UI panel) to do.
-- Ahead of the active() gate: with the mode off, the headset still
-- shows the flat screen on the floating panel.
VR.update(dt)
if not Voxel.active() then return end
local Game = require("src.core.Game")
local ow = Game and Game.overworld
@@ -196,6 +232,19 @@ mod.content.render_pipelines:register("voxel", {
end,
drawWorld = function(ctx)
-- the palette closure, stashed for the VR frame: it renders from the
-- update hook, where no ctx exists to carry one
VR.paletteFor = ctx.paletteFor
-- With a headset running, the window's world pass becomes the MIRROR
-- -- the left eye, fitted to the window -- rather than a third full
-- render of the scene. Everything else about the frame (the UI the
-- engine composites over this) is unchanged, which is exactly what
-- the headset's floating panel photographs.
if VR.active() then
local sw, sh = sceneSize(ctx)
local m = VR.mirror(sw, sh)
if m then return m end
end
-- Terrain and characters are geometry; the field FX stay ordinary 2D
-- draws composited on top, anchored through the same camera the 3D
-- pass used (ctx.drawFx below). The scene renders at the window's
@@ -203,21 +252,42 @@ mod.content.render_pipelines:register("voxel", {
-- a magnified low-res image, while the FX closures keep drawing in
-- world-pixel units.
local sw, sh = sceneSize(ctx)
local canvas = VoxelScene.render(ctx.state, sw, sh,
-- With AA on, the whole pass runs into a canvas BIGGER than the window
-- and is folded back down at the end (see AntiAlias). Nothing between
-- these two lines knows: every pass in the frame measures itself in the
-- canvas it was handed, so the sky's dither, the water's march and the
-- camera itself all come out the same picture at a higher sample rate.
local rw, rh = AntiAlias.expand(sw, sh)
local canvas = VoxelScene.render(ctx.state, rw, rh,
ctx.vw, ctx.vh, ctx.paletteFor)
if not canvas then return nil end -- fall back to the 2D path
if Voxel3D.beginOverlay() then
-- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and
-- they are drawing into the supersampled canvas alongside everything
-- else -- so the scale goes up with it, or the "!" bubble lands the
-- right place at half the size. project() already answers in canvas
-- pixels, so only the scale needs saying.
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
ctx.scale)
ctx.scale * AntiAlias.factor())
-- the horde's readout rides the same overlay, over the FX: health,
-- ammunition, the crosshair and the banners, sized in the same
-- supersampled canvas pixels everything else here is drawn in. A
-- headset never reaches this line (drawWorld returns the mirror
-- above) -- lib/VR draws the same HUD onto each eye instead.
HordeHud.drawFlat(rw, rh, ctx.scale * AntiAlias.factor())
Voxel3D.endOverlay()
end
return canvas
-- and back to the window's own size, which is what the engine composites
-- one canvas pixel to one display pixel. A pass-through when AA is off.
return AntiAlias.resolve(canvas, sw, sh, "world")
end,
invalidate = function()
Voxel3D.invalidate()
OverworldBattle.invalidate()
AntiAlias.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh
VR.invalidate() -- the mirror, and FBO ids of dead canvases
end,
})
@@ -344,10 +414,14 @@ local SETTINGS = {
.. "fraction of the cost." },
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
-- knobs; what a battle is drawn over, and how it is framed, are not that.
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
-- battles (OverworldBattle.enabled answers true regardless of this row)
-- and forbids back sprites (backPinned answers false), so both rows
-- decide nothing there and a dead switch on the menu reads as broken.
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "shot over the shoulder with a slow parallax drift.",
full = true },
when = function() return not VR.enabled() end, full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
-- off the engine draws the classic screen, which is this row's ON already,
-- and a row that no longer decides anything is worse than no row.
@@ -355,17 +429,62 @@ local SETTINGS = {
"Keep your own Pokemon on the battle menu, seen from behind in its "
.. "original slot, instead of standing it on the map facing the foe. "
.. "The foe is still out there on its own tile.",
when = function() return stagedBattles() end, full = true },
when = function() return stagedBattles() and not VR.enabled() end,
full = true },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following -- or SYNC it to the "
.. "clock on the wall, so Kanto's evening falls when yours does." },
-- Marked `full` for the opposite reason the battle rows are: this is not a
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
-- the diorama, not a licence to spend four times the fill rate on the
-- machine it happens to be running on, so it neither sets this nor takes
-- the row away -- the player decides what their hardware can carry, from
-- inside FULL like anywhere else.
{ AntiAlias.setting,
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
.. "lips, a tree against the sky -- by rendering the diorama larger than "
.. "the window and folding it back down. Every edge in the picture "
.. "softens with them, the tileset's own texels included, so the diorama "
.. "reads smoother rather than sharper. 2X costs half again as many "
.. "pixels in each direction and 4X twice, which makes this the most "
.. "expensive row in the mod.",
full = true },
-- `full` for the same reason as AA: not a knob on the look, a question
-- about the hardware on the desk.
{ VR.setting,
"PCVR through OpenXR (SteamVR, Oculus, WMR). The diorama becomes a "
.. "tabletop model your head moves around; the 1ST rung stands you "
.. "inside the world at life size, looking where the headset looks. "
.. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime "
.. "and the mod running from a real folder; without them the row stays "
.. "and the game stays flat, with the reason on the console.",
-- on Windows the row stays even when a runtime is missing (the console
-- says why); off Windows -- mobile above all -- there is no VR to have
-- and the row does not exist
when = function() return VR.supported() end, full = true },
-- Under the VR row and only while it is ON: a comfort setting for a
-- device that is not plugged in decides nothing, and this one is read
-- exclusively by the headset's right stick.
{ VR.smoothTurn,
"Turn smoothly with the right stick instead of snapping 45 degrees a "
.. "flick. OFF by default, and deliberately: a software turn moves the "
.. "world past a head that did not move, which is the most reliable way "
.. "to make somebody ill in a headset. Turn it on if you have your sea "
.. "legs and want the continuity.",
when = function() return VR.enabled() end, full = true },
}
local schema = {}
for i, entry in ipairs(SETTINGS) do
schema[i] = entry[1]:schema(entry[2])
for _, entry in ipairs(SETTINGS) do
-- the VR rows are absent from the mod manager's page too where the
-- platform cannot do VR at all -- the OPTIONS menu's `when` gates are
-- situational (a row hidden for now), this one is existential
local vrOnly = entry[1] == VR.setting or entry[1] == VR.smoothTurn
if not vrOnly or VR.supported() then
schema[#schema + 1] = entry[1]:schema(entry[2])
end
end
mod.options:define(schema)
@@ -412,12 +531,55 @@ local HOTKEYS = {
["9"] = Water.setting,
}
-- One step of the VOXEL angle ladder: everything a "3" press does, named
-- so the pad's SELECT button (below) can make exactly the same step. The
-- gate is the registry's own; the tilt/GBC FX clearing is the engine work
-- the key has always delegated (see the wrap below for why).
local function cycleVoxel(game)
local Pipelines = require("src.render.Pipelines")
-- HORDE MODE holds the rung at 1ST for as long as it runs. Refused HERE
-- rather than at each caller because this one function IS every way a
-- player can step the ladder: the "3" key, the pad's SELECT, and the VR
-- left-stick click all come through it.
if Horde.viewLocked() then return false end
local top = game.stack and game.stack:top()
if not Pipelines.canToggle("voxel", top, game.overworld) then return false end
Pipelines.setLevel("voxel", Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
Pipelines.syncOptions(game.save.options)
-- 3 is the key that used to turn TILT on and sits next to the one that
-- used to turn GBC FX on, and this mod has taken both away. A player who
-- left either running before enabling the mod would otherwise have no
-- way back to off, and both fight the diorama -- so the VOXEL step
-- clears them on EVERY press, not just the press that switches on.
game.save.options.tilt = 0
game.save.options.gbcfx = 0
require("src.render.GBCFX").setLevel(0)
require("src.render.Tilt").setLevel(game.save.options.tilt or 0)
game:writeOptions()
return true
end
-- The VR stick click makes this same step (VR.stepView): the function is
-- a local of this file, so the handoff is explicit rather than a
-- reimplementation drifting out of date in lib/VR.lua.
VR.cycleVoxel = cycleVoxel
do
local Game = require("src.core.Game")
local Pipelines = require("src.render.Pipelines")
local inner = Game.keypressed
function Game:keypressed(key)
-- HORDE MODE owns the keyboard's spare keys while it runs: R reloads,
-- and the mode keys are swallowed rather than left to change the rung
-- or the post-processing out from under a locked camera.
if Horde.active then
if key == "r" then
HordeGun.reload()
return
end
if HOTKEYS[key] then return end
end
local claim = HOTKEYS[key]
local top = self.stack and self.stack:top()
-- A screen with its own key handler gets the key first, exactly as the
@@ -428,32 +590,12 @@ do
-- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER),
-- so the registry's plain "advance one and wrap" is not what it
-- wants; 6 still is. The gate is the registry's own either way.
local stepped = false
-- The whole of 3's step lives in cycleVoxel, because the pad's
-- SELECT button makes the same step (see the handleInput wrap).
if key == "3" then
if Pipelines.canToggle("voxel", top, self.overworld) then
Pipelines.setLevel("voxel",
Voxel.nextHotkeyLevel(Pipelines.level("voxel")))
stepped = true
end
else
stepped = Pipelines.hotkey(key, top, self.overworld) and true
end
if stepped then
if cycleVoxel(self) then return end
elseif Pipelines.hotkey(key, top, self.overworld) then
Pipelines.syncOptions(self.save.options)
-- 3 is the key that used to turn TILT on and sits next to the one
-- that used to turn GBC FX on, and this mod has taken both away.
-- A player who left either running before enabling the mod would
-- otherwise have no way back to off, and both fight the diorama:
-- TILT is the flat fake of what this mode does for real, and GBC
-- FX is a full-screen present pass over the top of it. So the
-- VOXEL key clears them on EVERY press, not just the press that
-- switches the mode on -- cycling back round to OFF leaves them
-- off too, which is the state the key is now the only route to.
if key == "3" then
self.save.options.tilt = 0
self.save.options.gbcfx = 0
require("src.render.GBCFX").setLevel(0)
end
require("src.render.Tilt").setLevel(self.save.options.tilt or 0)
self:writeOptions()
return
@@ -728,12 +870,16 @@ do
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
local hadBattles = OverworldBattle.enabled()
-- the VR row hides the two battle rows while it is on, so stepping
-- it changes the LIST exactly the way 3D-BTL does
local hadVR = VR.enabled()
local wasOn = idAt(self, self.index)
inner(self, dt)
local after = Pipelines.level("voxel")
local crossedFull = after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after))
if crossedFull or OverworldBattle.enabled() ~= hadBattles then
if crossedFull or OverworldBattle.enabled() ~= hadBattles
or VR.enabled() ~= hadVR then
local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows
-- Follow the row the cursor was ON rather than the slot it was in:
@@ -759,6 +905,155 @@ end
-- so this file keeps naming every engine seam the mod touches.
OverworldBattle.install()
-- ------- the first-person rung's inputs and its walk
--
-- 1ST needs two things no other rung does, and each is a named seam:
--
-- FirstPerson.install claims the LOOK inputs the engine ignores: the right
-- stick's axes (Game:gamepadaxis passes them to Input, which returns early
-- on anything but the left pair), relative mouse motion (love.mousemoved --
-- there is no Game handler to wrap; the engine's own callback only feeds
-- the mouse-as-touch debug path, which stays untouched), the mouse buttons
-- while the cursor is captured (A and B -- there is no cursor to click UI
-- with), and any touch that lands off the overlay's controls (a drag on
-- open screen is the look; the d-pad and buttons still go to
-- TouchControls, whose own d-pad finger is also read back analog as the
-- move vector). Every wrap forwards whatever it does not claim, and claims
-- only while 1ST is actually driving.
--
-- FreeMove.install wraps OverworldState:handleInput -- the one choke point
-- where the grid walk reads the pad, and the same seam the engine's own
-- Cycling Road pull lives behind. While 1ST drives, the walk is continuous
-- and camera-relative; the player's logical cell stays synced and every
-- per-cell consequence still runs through the engine's own machinery
-- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The
-- file argues the whole arrangement.
FirstPerson.install()
FreeMove.install()
-- ------- horde mode
--
-- The sounds are registered before anything can ask for one; the hooks go
-- in AFTER FreeMove (and, below, after the SELECT wrap) so the mode's own
-- handleInput reasoning sits outside both of theirs, for the same reason
-- the SELECT hook does.
--
-- The GAME OVER card is a screens-registry record rather than a state
-- this file pushes directly: that is the engine's own seam for a mod
-- owning a screen, and it means the card is reachable by id from a driver
-- or a test without going through a death.
HordeSfx.register(mod)
mod.content.screens:register("HordeGameOver", {
new = function(game) return V.require("HordeGameOver").new(game) end,
})
mod.content.screens:register("HordeExitPrompt", {
new = function(game) return V.require("HordeExitPrompt").new(game) end,
})
-- The gamepad's triggers, which nothing else in the engine or this mod
-- claims: the RIGHT one fires and the LEFT one aims. Read as axes because
-- that is what SDL calls them; the 0.5 crossing is the press. Installed
-- beside the other input wraps, and inert with the mode off.
do
local Game = require("src.core.Game")
if not Game.dramaticShapeHordeTriggers then
local inner = Game.gamepadaxis
local rightDown = false
function Game:gamepadaxis(joystick, axis, value)
if Horde.playing() then
if axis == "triggerright" then
local down = (value or 0) > 0.5
if down and not rightDown then HordeGun.fire() end
rightDown = down
elseif axis == "triggerleft" then
HordeGun.setAds((value or 0) > 0.35)
end
elseif rightDown then
rightDown = false
end
return inner(self, joystick, axis, value)
end
-- and X reloads, the only pad button the overworld does not already use
local innerBtn = Game.gamepadpressed
function Game:gamepadpressed(joystick, button)
if Horde.playing() and button == "x" then
HordeGun.reload()
return
end
return innerBtn(self, joystick, button)
end
Game.dramaticShapeHordeTriggers = true
end
end
-- ------- SELECT walks the angle ladder
--
-- The same step the "3" key makes, on the pad's own button: a phone (and
-- a controller) has no number row, and SELECT has no overworld job in
-- Gen 1 -- its work is all in-menu, which this wrap never sees. The seam
-- is OverworldState:handleInput, the same choke point the free walk
-- replaced: every gate above it -- menus, dialogs, scripted moves,
-- transitions -- already decided the overworld owns the buttons, so a
-- SELECT here is free-roam by construction, exactly like the key. When
-- the step is refused (mid-warp, no 3D pass) the press falls through to
-- the engine's own handling, which is a no-op, as ever.
--
-- Installed AFTER FreeMove.install, deliberately: its wrap must sit
-- OUTSIDE the free walk's, or first person -- where FreeMove.tick takes
-- the frame and never calls further in -- would eat the button, and the
-- one rung SELECT could not step off of would be 1ST itself.
do
local OverworldState = require("src.world.OverworldController")
if not OverworldState.dramaticShapeSelectHook then
local inner = OverworldState.handleInput
function OverworldState:handleInput(...)
local Game = require("src.core.Game")
local input = Game.input
if input and input.wasPressed and input:wasPressed("select") then
if cycleVoxel(Game) then return end
end
return inner(self, ...)
end
OverworldState.dramaticShapeSelectHook = true
end
end
-- ------- the konami code, and everything it turns on
--
-- Installed last of the input seams so its handleInput reasoning sits
-- outside FreeMove's and SELECT's. The detector itself does not live on
-- handleInput at all -- it reads the fixed step's own press queue, which
-- is where keyboard, pad, touch and the VR controllers have all already
-- become the same eight buttons. See lib/Horde.lua.
Horde.install()
-- ------- edge-anchored menus stay in the GB frame while a headset is live
--
-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START
-- menu to the WINDOW's top-right edge. Both VR screens -- the floating
-- panel and the Pokedex -- crop the window to the GB frame, so a menu at
-- the window's edge is cropped away with the border it docked to. The
-- engine's own answer to "a state composes its screen, keep every element
-- inside it" is uiAnchorHold, computed per frame from this predicate; a
-- live headset is exactly that situation for the WHOLE window, so the
-- predicate answers yes for as long as one is. Held menus blit where they
-- were drawn in the 160x144 canvas -- the START menu's 9,0 x 11 slot is
-- already flush with the frame's right edge, which is the right edge of
-- what the headset sees. Off-headset frames fall through untouched.
do
local Game = require("src.core.Game")
if not Game.dramaticShapeAnchorHold then
local inner = Game.uiAnchorsHeldInStack
function Game.uiAnchorsHeldInStack(stack)
if VR.active() then return true end
return inner(stack)
end
Game.dramaticShapeAnchorHold = true
end
end
-- The overworld's own pushBattle is the choke point for a wild encounter or
-- a trainer, and it is wrapped. A battle that arrives some other way -- a
-- link battle, a script pushing a BattleState directly -- reaches this
@@ -860,7 +1155,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod()
end)
mod.exports.version = "1.4.0"
mod.exports.version = "1.5.4"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.4.0",
"version": "1.5.4",
"api": 2,
"entry": "main.lua",
"profile": "content",
+24 -1
View File
@@ -16,18 +16,28 @@ return {
"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 rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
"on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
"on the 1ST rung the wall-bump sound is silent -- the free walk slides along whatever it grazes rather than making a discrete blocked step, so the grid game's bonk came out as a rattle; the grid walk keeps its own",
"while HORDE MODE runs, the world is held still around it: no wild encounters, no trainers walking up, no talking to anyone, no START menu and no changing the camera rung -- doors and warps keep working, because the crowd follows the player through them",
},
added = {
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)",
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
"VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
"VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job",
"a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
"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",
@@ -39,10 +49,23 @@ return {
"the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass",
"menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld",
"terrain meshes are cached per map, so the first frame after entering a large map costs a build",
"1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
"in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
"rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it",
"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 = "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 },
}
BIN
View File
Binary file not shown.
Binary file not shown.
+18
View File
@@ -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>
+10
View File
@@ -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>
+5
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<OpenXRLoaderPackageRoot>$(MSBuildThisFileDirectory)..\..\</OpenXRLoaderPackageRoot>
</PropertyGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Choose>
<When Condition="'$(ApplicationType)|$(ApplicationTypeRevision)' == 'Windows Store|10.0'">
<PropertyGroup>
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)_uwp\release</OpenXRLoaderBinaryRoot>
</PropertyGroup>
</When>
<Otherwise>
<PropertyGroup>
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)\release</OpenXRLoaderBinaryRoot>
</PropertyGroup>
</Otherwise>
</Choose>
<ItemDefinitionGroup>
<Link>
<AdditionalDependencies>%(AdditionalDependencies);$(OpenXRLoaderBinaryRoot)\lib\openxr_loader.lib</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<!-- Copy the OpenXR loader DLL to the output directory and include in packaging -->
<ItemGroup Condition="'$(OpenXRSkipLoaderCopy)'!='true'">
<None Include="$(OpenXRLoaderBinaryRoot)\bin\openxr_loader.dll">
<Link>%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<DeploymentContent>true</DeploymentContent>
</None>
</ItemGroup>
<Target Name="EnsurePropsImported" BeforeTargets="PrepareForBuild">
<Error Condition="'$(OpenXRLoaderPackageRoot)'==''" Text="OpenXRLoaderPackageRoot property missing. Project is malformed. Try removing and re-adding the NuGet reference." />
</Target>
</Project>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<coreProperties xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.openxmlformats.org/package/2006/metadata/core-properties">
<dc:creator>Khronos Group</dc:creator>
<dc:description>Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application</dc:description>
<dc:identifier>OpenXR.Loader</dc:identifier>
<version>1.0.10.2</version>
<keywords>native khronos openxr loader headers</keywords>
<lastModifiedBy>NuGet, Version=5.4.0.3, Culture=neutral, PublicKeyToken=31bf3856ad364e35;Microsoft Windows NT 6.2.9200.0;.NET Framework 4.7.2</lastModifiedBy>
</coreProperties>
+195
View File
@@ -0,0 +1,195 @@
-- Driver: one scene, once per rung of the AA row.
--
-- The AA row is the one setting in this mod whose whole effect is a pixel
-- wide, so it is also the one that cannot be judged from a description. This
-- renders the SAME frame at each rung and writes one PNG per rung; put two of
-- them side by side, magnified, and the row is either doing something or it
-- is not.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \
-- SHOT_DIR=<dir> lovec.exe .
--
-- knobs (env):
-- SHOT_DIR output directory (created if missing) (default "shots/aa")
-- AA_MAP map id (default VIRIDIAN_CITY)
-- AA_SPOT "x,y[,facing]" (default 20,26,up)
-- AA_RUNG the voxel camera rung (default 5, the 75 one)
--
-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the
-- diagonal of a fence and a tree's silhouette against the sky are the edges
-- that stair-step, and 75 degrees is the rung that puts the most of them at an
-- angle to the pixel grid.
--
-- Determinism matters here for the same reason it does in voxel_shots_ab: the
-- three shots differ ONLY by the row under test, or comparing them means
-- nothing. The clock is pinned, the animated tile slots are frozen, the
-- townsfolk are stopped where they stand, and the tilt-shift is held at zero
-- (a gaussian over the frame would smear away the very edges being looked at).
--
-- Nothing here writes the player's options: the row is moved with
-- ModSetting:sync, which moves the cached index and persists nothing.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local OverworldState = require("src.world.OverworldController")
local ROOT = os.getenv("SHOT_DIR") or "shots/aa"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local AntiAlias = V.require("AntiAlias")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY"
local SPOT = os.getenv("AA_SPOT") or "20,26,up"
local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5)
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
if sf == "" then sf = "up" end
OverworldState.rollEncounter = function() return nil end
local NPC = require("src.world.NPC")
if not NPC.dramaticShapeAaFreeze then
local inner = NPC.update
function NPC:update(...)
self.frozen = true
return inner(self, ...)
end
NPC.dramaticShapeAaFreeze = true
end
pcall(love.math.setRandomSeed, 20260801)
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function cameraStill()
local o = game.overworld
local c = o and o.camera
if not c then return true end
local lx, ly, held = nil, nil, 0
for _ = 1, 300 do
if c.x == lx and c.y == ly then
held = held + 1
if held >= 10 then return true end
else
held = 0
lx, ly = c.x, c.y
end
U.wait(1)
end
return false
end
-- The camera PITCH, which is the one that caught this driver out. The tween
-- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the
-- angle stops moving, so the first shot of a run came out at 67 degrees
-- while the two after it were at 75 -- three frames that differ by the
-- camera, in a comparison whose entire subject is a pixel.
local function angleStill()
local last, held = nil, 0
for _ = 1, 600 do
if Voxel.angle == last then
held = held + 1
if held >= 10 then return true end
else
held = 0
last = Voxel.angle
end
U.wait(1)
end
return false
end
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
angleStill()
cameraStill()
-- the sun map is only redrawn when its inputs move, and the AA row is not
-- one of them -- so force one pass at the settled camera rather than
-- comparing a frame against a map fitted a few hundredths of a pixel ago
if ShadowMap.forget then ShadowMap.forget() end
U.wait(20)
end
DayNight.setting:sync("day")
U.teleport(game, MAP, sx, sy, sf)
Pipelines.setLevel("voxel", RUNG)
Pipelines.setLevel("tiltshift", 0)
-- Warm up before the FIRST shot, not just between them.
--
-- Neighbour maps are requested from inside the render itself
-- (VoxelScene.prefetch), so an empty build queue right after a teleport
-- means "nothing has been asked for yet", not "everything is here". The
-- first capture of a run came out with the map beyond Viridian missing --
-- a whole tree line absent from one frame of a three-way comparison, which
-- looks exactly like the row under test doing something enormous. Settling
-- twice lets the first render request the neighbourhood and the second
-- drain it.
settle()
settle()
local shots, missed = 0, 0
for _, samples in ipairs({ 0, 2, 4 }) do
AntiAlias.setting:sync(samples)
settle()
-- AA_TRACE=1 prints the state each shot was taken in. When two shots of a
-- run disagree by more than the row could account for, this is what says
-- which input moved -- it is how the camera-tween and the neighbour-mesh
-- settles above were both found.
if os.getenv("AA_TRACE") == "1" then
local Voxel3D = V.require("Voxel3D")
local o = game.overworld
local cw, chh = Voxel3D.size()
print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f")
:format(samples, Voxel.angle or -1, Voxel3D.fovY or -1,
Voxel3D.cell or -1, cw or 0, chh or 0,
o and o.camera and o.camera.x or -1,
o and o.camera and o.camera.y or -1,
(Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0,
(Voxel3D.eye or {})[3] or 0, AntiAlias.factor()))
end
local path = ("%s/aa_%d.png"):format(ROOT, samples)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then
f:close()
shots = shots + 1
print(("[aa] %s samples=%d"):format(path, samples))
else
missed = missed + 1
print("[aa] capture did not reach disk: " .. path)
end
end
-- left where it was found, so a run cannot leak a rung into the next one
AntiAlias.setting:sync(0)
print(("[aa] %d shots into %s (%d failed to reach disk)")
:format(shots, ROOT, missed))
end
+79
View File
@@ -0,0 +1,79 @@
-- Scratch driver: shots of the Bike Shop showroom, for the bicycle
-- voxelization. Two viewpoints -- the north wall (the two bikes drawn
-- INTO the wall band) and the showroom floor (the six standing bikes).
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bike_shop_shots.lua \
-- SHOT_DIR=.scratchpad/bikes AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/bikes")
.. "/" .. (os.getenv("AB_TAG") or "before")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[bike] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
-- cells: wall bikes ride cell row 0 (tile cols 1-3 and 6-8); the six
-- floor bikes stand in cell columns 0 and 2, rows 1-2 and 4-5
local SCENES = {
{ x = 2, y = 2, face = "up", label = "wall" },
{ x = 3, y = 3, face = "up", label = "room" },
{ x = 2, y = 4, face = "left", label = "floor" },
{ x = 3, y = 6, face = "up", label = "wide" },
-- the two toolboxes, cells (6,6) and (7,7)
{ x = 5, y = 6, face = "right", label = "tools" },
{ x = 6, y = 4, face = "down", label = "tools2" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, "BIKE_SHOP", s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[bike] capture missed: " .. path) end
end
end
print(("[bike] %d shots into %s"):format(shots, ROOT))
end
+77
View File
@@ -0,0 +1,77 @@
-- Scratch driver: shots of Bill's desk, for the `bills_desk`
-- voxelization. The desk fills cells (1,4) and (2,4) of Bill's house
-- with its chair in the walkable cell (1,5) below it, so these are the
-- angles you can actually stand at: head-on from the floor two cells
-- south, and from either flank.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bills_desk_shots.lua \
-- SHOT_DIR=.scratchpad/billsdesk AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/billsdesk")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[bills] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
{ x = 1, y = 6, face = "up", label = "headon" },
{ x = 2, y = 6, face = "up", label = "headon_e" },
{ x = 4, y = 5, face = "left", label = "east" },
{ x = 0, y = 5, face = "right", label = "west" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, "BILLS_HOUSE", s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[bills] capture missed: " .. path) end
end
end
print(("[bills] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+81
View File
@@ -0,0 +1,81 @@
-- Scratch driver: shots of the Celadon chief's house, for the display
-- cabinet and long table voxelizations. Three viewpoints -- the
-- cabinet rank along the north wall, the long table in the middle of
-- the room, and a wide shot with both in frame.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/chief_house_shots.lua \
-- SHOT_DIR=.scratchpad/chief AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/chief")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[chief] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
-- the cabinets occupy cells 2..5 of rows 0-1; the long table cells
-- 2..5 of rows 3-4; the player walks rows 2 and 5
local SCENES = {
{ x = 3, y = 2, face = "up", label = "cabinets" },
{ x = 5, y = 2, face = "up", label = "bookcase" },
{ x = 3, y = 5, face = "up", label = "table" },
{ x = 1, y = 5, face = "right", label = "wide" },
-- the same rank on CELADON_MANSION_1F, where it stands against the
-- interior partition and the grids start on an ODD tile row
{ map = "CELADON_MANSION_1F", x = 2, y = 4, face = "up",
label = "mansion1f" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, s.map or "CELADON_CHIEF_HOUSE", s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[chief] capture missed: " .. path) end
end
end
print(("[chief] %d shots into %s"):format(shots, ROOT))
end
File diff suppressed because it is too large Load Diff
+159
View File
@@ -0,0 +1,159 @@
-- Scratch driver: shots of the 1ST (first-person) rung -- the rig standing
-- in the player's head, billboards yawing to face it, the sky meeting the
-- horizon, the shadow box following the look, water seen from eye level,
-- and an interior with its figures.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/fp_shots.lua \
-- SHOT_DIR=.scratchpad/fpshots lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/fp")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[fp] DRAMATIC_SHAPE mod not loaded")
return love.event.quit()
end
local V = handle.lib
local FirstPerson = V.require("FirstPerson")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local DayNight = V.require("DayNight")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 0
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if FirstPerson.blend >= 1 and Voxel.ready
and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
-- Teleport to the nearest WALKABLE cell: a guessed coordinate inside a
-- building footprint buries the eye in the geometry, which is what the
-- first cut of every Pallet shot did.
local function place(mapId, x, y)
U.teleport(game, mapId, x, y, "down")
local ow = game.stack:top()
local map = ow and ow.map
if not map or map:isWalkableCell(x, y) then return end
for r = 1, 8 do
for dy = -r, r do
for dx = -r, r do
if math.max(math.abs(dx), math.abs(dy)) == r then
local cx, cy = x + dx, y + dy
if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then
U.teleport(game, mapId, cx, cy, "down")
print(("[fp] (%d,%d) not walkable; standing at (%d,%d)")
:format(x, y, cx, cy))
return
end
end
end
end
end
end
-- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west
local SCENES = {
-- Pallet Town, mid-street: houses, the lab, NPCs -- the town seen
-- from inside it, in each compass direction plus a diagonal
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" },
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" },
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" },
{ map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4,
label = "pallet_diag" },
-- the shoreline: water at eye level, which is where the battle pass
-- says a low placed camera reads the reflection wrong -- the shot
-- decides whether 1ST keeps it
{ map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" },
-- looking up: the sky's bands and the horizon line
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
pitch = -math.rad(25), label = "pallet_skyward" },
-- and down: the ground, the feet-level shadow
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
pitch = math.rad(45), label = "pallet_down" },
-- Route 1: grass rows and ledges from inside them
{ map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" },
-- an interior: the Center's counter, machines and couch figures
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi,
label = "center_north" },
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 4, yaw = -math.pi / 2,
label = "center_west" },
}
local shots = 0
for _, s in ipairs(SCENES) do
place(s.map, s.x, s.y)
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
Pipelines.setLevel("tiltshift", 0)
settle()
FirstPerson.yaw = s.yaw
FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT
U.wait(20)
if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then
shots = shots + 1
end
end
-- one mid-blend shot: step the ladder onto 1ST from 75 and catch the
-- dive halfway
place("PALLET_TOWN", 13, 14)
Pipelines.setLevel("voxel", 5)
settle()
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
for _ = 1, 300 do
if FirstPerson.blend >= 0.5 then break end
U.wait(1)
end
if U.shot(game, ROOT .. "/blend_mid.png") then shots = shots + 1 end
-- ------- the free walk, exercised
--
-- Hold forward with the head yawed off-grid and confirm the player
-- GLIDES: the position moves along the look direction, lands off the
-- 16px grid (which no grid step can do), and the logical cell follows.
place("PALLET_TOWN", 13, 14)
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
settle()
local ow = game.stack:top()
local p = ow.player
FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid
FirstPerson.pitch = FirstPerson.PITCH_DEFAULT
local x0, y0, c0x, c0y = p.px, p.py, p.cellX, p.cellY
U.hold(game, "up", 90)
U.wait(5)
local moved = math.abs(p.px - x0) + math.abs(p.py - y0)
print(("[fp] walk: (%.1f,%.1f) cell(%d,%d) -> (%.1f,%.1f) cell(%d,%d)")
:format(x0, y0, c0x, c0y, p.px, p.py, p.cellX, p.cellY))
print(("[fp] walk moved %.1f px; off-grid: %s; diagonal: %s")
:format(moved,
tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0),
tostring(math.abs(p.px - x0) > 8
and math.abs(p.py - y0) > 8)))
if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end
print(("[fp] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+78
View File
@@ -0,0 +1,78 @@
-- Scratch driver: shots of the Pokemon Center healing machines behind
-- the counter, for the center_heal_machine voxelization. The pair
-- stands at cells (1,0):(2,1) and (6,0):(7,1) of every Center; the
-- nurse aisle (row 2) is the row you can actually face them from, and
-- the public floor south of the counter gives the wide view.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/heal_machine_shots.lua \
-- SHOT_DIR=.scratchpad/healshots AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/healmachine")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[heal] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
{ x = 1, y = 2, face = "up", label = "west_head" },
{ x = 2, y = 2, face = "up", label = "west_keyboard" },
{ x = 3, y = 2, face = "left", label = "west_side" },
{ x = 6, y = 2, face = "up", label = "east_head" },
{ x = 3, y = 4, face = "up", label = "wide" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, "VIRIDIAN_POKECENTER", s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[heal] capture missed: " .. path) end
end
end
print(("[heal] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+329
View File
@@ -0,0 +1,329 @@
-- Scratch driver: HORDE MODE, from the code to the card.
--
-- Enters the konami code the way a player does -- as Game Boy button
-- edges on the fixed step, which is the same path the pad, the touch
-- overlay and the VR controllers take -- then photographs the intro
-- banner, the darkened sky, the gun at the hip and down the sights, a
-- wave mid-chase, the muzzle flash, the reload, a run through a door with
-- the crowd behind, and the GAME OVER card. Finishes by pressing A and
-- checking that everything the mode changed came back.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/horde_shots.lua \
-- SHOT_DIR=.scratchpad/horde lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/horde")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[horde] DRAMATIC_SHAPE mod not loaded")
return love.event.quit()
end
local V = handle.lib
local Horde = V.require("Horde")
local Mobs = V.require("HordeMobs")
local Gun = V.require("HordeGun")
local FirstPerson = V.require("FirstPerson")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
local shots = 0
local function shot(name)
if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then
shots = shots + 1
end
end
local function settle(frames)
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(frames or 30)
end
-- ------- the code, entered as edges
--
-- One button per fixed step, exactly as a device delivers them: the
-- driver writes the queue Input:step is about to drain, which is the
-- same array a keypress lands in.
--
-- Each one is RELEASED after. A synthetic inject has no source behind
-- it, so Input:step latches it held (see the branch there) and nothing
-- ever lets go -- which leaves all four directions down for the rest of
-- the run and the player walking into a wall forever. U.tap does the
-- same clear for the same reason.
local function pressCode()
local KONAMI = { "up", "up", "down", "down",
"left", "right", "left", "right", "b", "a" }
for _, btn in ipairs(KONAMI) do
table.insert(game.input.pressQueue, btn)
U.wait(2)
game.input.state[btn] = false
end
end
-- ------- before
U.teleport(game, "PALLET_TOWN", 13, 14, "down")
Pipelines.setLevel("voxel", 3) -- the 35-degree diorama
Pipelines.setLevel("tiltshift", 0)
settle(40)
local ow = game.stack:top()
local p = ow.player
local was = {
map = ow.map.id, cellX = p.cellX, cellY = p.cellY, facing = p.facing,
level = Pipelines.level("voxel"),
npcs = #ow.npcs,
}
shot("00_before")
print(("[horde] before: %s (%d,%d) rung %d, %d npcs")
:format(was.map, was.cellX, was.cellY, was.level, was.npcs))
-- ------- the code lands
pressCode()
U.wait(4)
print("[horde] active: " .. tostring(Horde.active)
.. " state: " .. tostring(Horde.state))
if not Horde.active then
print("[horde] the code did not take -- nothing else here can run")
return love.event.quit()
end
U.wait(20)
shot("01_darkness_approaches") -- the banner, over the darkening
-- and the same announcement at the zoom that used to run it off both
-- edges of the screen: it has to wrap (or shrink) rather than overflow
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 3
Zoom.applyOptions(game.save.options)
end)
Horde.banner("A DARKNESS APPROACHES", 2.6)
U.wait(30)
shot("01b_banner_zoomed")
pcall(function()
game.save.options.zoom = 0
Zoom.applyOptions(game.save.options)
end)
U.wait(10)
print(("[horde] rung is now %d (%s)"):format(Pipelines.level("voxel"),
Pipelines.levelLabel("voxel")))
-- the rung must be locked: the key, SELECT and the VR click all call the
-- one function this refuses through
local Game = require("src.core.Game")
Game.keypressed(game, "3")
print(("[horde] after pressing 3 the rung is %d -- locked: %s")
:format(Pipelines.level("voxel"),
tostring(Pipelines.level("voxel") == Voxel.FP_LEVEL)))
-- ------- the first wave
for _ = 1, 900 do
if Horde.state == "active" and #Horde.session.mobs >= 5 then break end
U.wait(1)
end
FirstPerson.yaw = math.pi -- look north up the street
U.wait(30)
shot("02_wave_hip")
print(("[horde] wave %d, %d mobs standing")
:format(Horde.session.wave, #Horde.session.mobs))
-- ------- START asks the way out
--
-- The same GB button the pad's START, the keyboard's ESCAPE and the
-- touch overlay all press, so this one tap covers every device except
-- the VR stick click (which calls the same Horde.askExit).
U.tap(game, "start")
U.wait(10)
local prompt = game.stack:top()
print(("[horde] START opened a prompt: %s (still active: %s)")
:format(tostring(prompt ~= game.overworld), tostring(Horde.active)))
shot("02b_exit_prompt")
-- NO, and back to the fight
U.tap(game, "b")
U.wait(10)
print(("[horde] answered NO: back on the overworld %s, active %s")
:format(tostring(game.stack:top() == game.overworld),
tostring(Horde.active)))
-- ------- the sights
Gun.setAds(true)
U.wait(20)
shot("03_ads")
Gun.setAds(false)
U.wait(14)
-- ------- the shot
--
-- Aimed deliberately at the nearest mob rather than fired into the
-- street: what is being checked is that the ray finds a body, that the
-- body dies, and that the kill is worth something.
local function aimAtNearest()
local best, bestD
for _, e in ipairs(Horde.session.mobs) do
local dx = (e.npc.px + 8) - (p.px + 8)
local dz = (e.npc.py + 8) - (p.py + 8)
local d = dx * dx + dz * dz
if not bestD or d < bestD then best, bestD = e, d end
end
if not best then return nil end
local dx = (best.npc.px + 8) - (p.px + 8)
local dz = (best.npc.py + 8) - (p.py + 8)
FirstPerson.yaw = math.atan2(dx, dz)
FirstPerson.pitch = 0
return best, math.sqrt(bestD)
end
local scoreWas, killsWas = Horde.session.score, Horde.session.kills
local target, range = aimAtNearest()
print(("[horde] aiming at a mob %s px away, yaw %.2f")
:format(range and ("%.0f"):format(range) or "?", FirstPerson.yaw))
U.wait(6)
Gun.fire()
U.wait(1)
shot("04_muzzle_flash")
-- a mob takes more than one round as the waves stack; keep firing at
-- whatever is nearest until something dies or the magazine is out
for _ = 1, 7 do
if Horde.session.kills > killsWas then break end
U.wait(16)
aimAtNearest()
Gun.fire()
end
U.wait(20)
print(("[horde] fired: ammo %d, score %d -> %d, kills %d -> %d")
:format(select(1, Gun.ammo()), scoreWas, Horde.session.score,
killsWas, Horde.session.kills))
-- ------- the reload, caught mid-dip
for _ = 1, 8 do
Gun.fire()
U.wait(12)
end
Gun.reload()
U.wait(45)
shot("05_reloading")
print("[horde] reloading: " .. tostring(select(3, Gun.ammo())))
for _ = 1, 200 do
if not select(3, Gun.ammo()) then break end
U.wait(1)
end
print(("[horde] reloaded to %d"):format(select(1, Gun.ammo())))
-- ------- through a door, with the crowd behind
local before = #Horde.session.mobs
U.teleport(game, "REDS_HOUSE_1F", 3, 6, "down")
settle(60)
for _ = 1, 400 do
if #Horde.session.mobs > 0 then break end
U.wait(1)
end
U.wait(60)
shot("06_indoors_followed")
print(("[horde] indoors: %d mobs followed (was %d outside)")
:format(#Horde.session.mobs, before))
-- ------- the end
--
-- Drained rather than played out, so the driver finishes in seconds and
-- the card is photographed from the same path a real death takes.
Horde.session.hp = 1
Horde.session.hurtCooldown = 0
Horde.damage(50)
for _ = 1, 400 do
if Horde.state == "gameover" then break end
U.wait(1)
end
U.wait(40)
shot("07_game_over")
print(("[horde] game over at score %d, wave %d, %d kills")
:format(Horde.session and Horde.session.score or -1,
Horde.session and Horde.session.wave or -1,
Horde.session and Horde.session.kills or -1))
-- ------- and back
U.tap(game, "a")
for _ = 1, 600 do
if not Horde.active and not game.stack:top().transitioning then break end
U.wait(1)
end
settle(60)
local now = game.stack:top()
local np = now.player
print(("[horde] restored: %s (%d,%d) facing %s, rung %d, %d npcs")
:format(now.map.id, np.cellX, np.cellY, np.facing,
Pipelines.level("voxel"), #now.npcs))
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
:format(tostring(now.map.id == was.map),
tostring(np.cellX == was.cellX and np.cellY == was.cellY),
tostring(np.facing == was.facing),
tostring(Pipelines.level("voxel") == was.level),
tostring(#now.npcs == was.npcs)))
-- nothing of the mode may be left on any map
local left = 0
for _, def in pairs(game.data.maps) do
for _, obj in ipairs(def.objects or {}) do
if obj.hordeMob then left = left + 1 end
end
end
print(("[horde] horde objects left behind: %d"):format(left))
shot("08_after")
-- ------- and out the other door
--
-- The same restore, reached the other way: enter the code again and
-- leave through START -> YES rather than by dying. This is the path a
-- player who just wants their game back actually takes.
pressCode()
U.wait(6)
if not Horde.active then
print("[horde] second activation refused -- the exit path is untested")
print(("[horde] %d shots into %s"):format(shots, ROOT))
return love.event.quit()
end
for _ = 1, 400 do
if Horde.state == "active" then break end
U.wait(1)
end
U.tap(game, "start")
U.wait(10)
U.tap(game, "up") -- NO -> YES
U.wait(6)
shot("09_exit_yes")
U.tap(game, "a")
for _ = 1, 600 do
if not Horde.active and not game.stack:top().transitioning then break end
U.wait(1)
end
settle(60)
local out = game.stack:top()
local op = out.player
print(("[horde] exited via START/YES: active %s, %s (%d,%d) facing %s, rung %d")
:format(tostring(Horde.active), out.map.id, op.cellX, op.cellY,
op.facing, Pipelines.level("voxel")))
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
:format(tostring(out.map.id == was.map),
tostring(op.cellX == was.cellX and op.cellY == was.cellY),
tostring(op.facing == was.facing),
tostring(Pipelines.level("voxel") == was.level),
tostring(#out.npcs == was.npcs)))
local left2 = 0
for _, def in pairs(game.data.maps) do
for _, obj in ipairs(def.objects or {}) do
if obj.hordeMob then left2 = left2 + 1 end
end
end
print(("[horde] horde objects left behind: %d"):format(left2))
print(("[horde] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+98
View File
@@ -0,0 +1,98 @@
-- Scratch driver: shots of the house dining tables and stools, for the
-- band-table + no-desk-part voxelization. Every generic home places the
-- table at cells (3,3):(4,4) with four stools around it (Blue's house has
-- Daisy seated at hers); Red's and the Copycat's ground floors place the
-- same furniture one cell lower, with the potted plant CUTOUT standing on
-- the tabletop -- the standee the table template must support, not
-- swallow.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/house_furniture_shots.lua \
-- SHOT_DIR=.scratchpad/housefurn AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/housefurn")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[housefurn] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- head on: below Blue's table looking north over a stool at it,
-- Daisy seated at the left one
{ map = "BLUES_HOUSE", x = 3, y = 5, face = "up", label = "blues_front" },
-- from the east, table and both east stools in profile
{ map = "BLUES_HOUSE", x = 6, y = 3, face = "left", label = "blues_side" },
-- from the north wall looking south down over the tabletop
{ map = "BLUES_HOUSE", x = 4, y = 2, face = "down", label = "blues_over" },
-- close beside a stool: seat top, legs and the gap between them
{ map = "BLUES_HOUSE", x = 2, y = 5, face = "up", label = "stool_close" },
-- Red's table head on from the south: the plant cutout standing on
-- the modelled tabletop
{ map = "REDS_HOUSE_1F", x = 4, y = 6, face = "up", label = "reds_front" },
-- and from the east along the stool row, plant in profile
{ map = "REDS_HOUSE_1F", x = 6, y = 4, face = "left", label = "reds_side" },
-- the Fan Club's four members' chairs round the boardroom table:
-- from the south of the west pair, both stools stacked in profile
{ map = "POKEMON_FAN_CLUB", x = 1, y = 5, face = "up",
label = "club_west_pair" },
-- across the table from the west, both pairs and the octagon between
{ map = "POKEMON_FAN_CLUB", x = 0, y = 3, face = "right",
label = "club_across" },
-- close on the east pair from the north, looking down over the seats
{ map = "POKEMON_FAN_CLUB", x = 6, y = 2, face = "down",
label = "club_over" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, s.map, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[housefurn] capture missed: " .. path) end
end
end
print(("[housefurn] %d shots into %s"):format(shots, ROOT))
end
+76
View File
@@ -0,0 +1,76 @@
-- Scratch driver: shots of a Poke Mart's clerk counter, for the cash
-- register voxelization. The register is drawn at cell (1,5) of the 4x4
-- shop layout every Mart shares, in the middle of the counter's east arm,
-- so these are the three angles you can actually stand at: head-on from
-- the aisle, side-on from the east, and over the counter's south arm.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mart_shots.lua \
-- SHOT_DIR=.scratchpad/register AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/register")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[mart] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
{ x = 1, y = 7, face = "up", label = "aisle" },
{ x = 2, y = 5, face = "left", label = "side" },
{ x = 2, y = 6, face = "left", label = "over" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, "VIRIDIAN_MART", s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[mart] capture missed: " .. path) end
end
end
print(("[mart] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+91
View File
@@ -0,0 +1,91 @@
-- Scratch driver: shots of the potted plants, for the plant standee
-- voxelization. Every Center places three side-by-side pairs on its
-- bottom row -- crowns at cells (0,6)/(1,6), (6,6)/(7,6), (12,6)/(13,6),
-- pots below at y=7 -- and INDIGO_PLATEAU_LOBBY (the MART tileset id,
-- same atlas) lines four of them along its hall at cells (12,10)..(15,10).
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/potted_plant_shots.lua \
-- SHOT_DIR=.scratchpad/plants AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/plants")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[plant] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- east of the left pair, looking west along the bottom row: both
-- plants in profile, crown overhang and pot silhouette side-on
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 6, face = "left",
label = "pair_side" },
-- north of the left pair, looking south down over the crowns
{ map = "VIRIDIAN_POKECENTER", x = 1, y = 5, face = "down",
label = "pair_over" },
-- head on: standing below the middle pair looking north at it
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 7, face = "up",
label = "pair_front" },
-- close up beside the east pair's pot
{ map = "VIRIDIAN_POKECENTER", x = 11, y = 7, face = "right",
label = "close" },
-- the Plateau lobby's row of four (MART tileset id), along the row
{ map = "INDIGO_PLATEAU_LOBBY", x = 11, y = 10, face = "right",
label = "lobby_row" },
-- and head on from the hall below
{ map = "INDIGO_PLATEAU_LOBBY", x = 13, y = 12, face = "up",
label = "lobby_front" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, s.map, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[plant] capture missed: " .. path) end
end
end
print(("[plant] %d shots into %s"):format(shots, ROOT))
end
+80
View File
@@ -0,0 +1,80 @@
-- Scratch driver: the Cerulean gym and the houses beside it, shot at
-- several camera rungs with V-CURVE walked OFF..3, to see what the world
-- bend does to a building's roof.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_curve_shots.lua \
-- SHOT_DIR=.scratchpad/roofcurve AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcurve")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[roof] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local WorldCurve = V.require("WorldCurve")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(30)
end
-- the gym door is CERULEAN_CITY (30,19); the bike shop and the row of
-- houses along the west side give a second, smaller roof in frame
local SCENES = {
{ map = "CERULEAN_CITY", x = 30, y = 20, face = "up", label = "gym" },
{ map = "CERULEAN_CITY", x = 27, y = 21, face = "up", label = "gymwide" },
{ map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "house" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
for _, curve in ipairs({ 0, 3 }) do
U.teleport(game, s.map, s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
WorldCurve.setting:setIndex(curve + 1, game)
settle()
local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
game.capturePath = path
U.wait(8)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[roof] capture missed: " .. path) end
end
end
end
print(("[roof] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+47
View File
@@ -0,0 +1,47 @@
-- Scratch probe: how long do a building model's merged quads get?
--
-- A quad's longest world-space edge is what decides how far its CHORD
-- falls below the world curve's parabola, so this is the number that says
-- whether the bend can crack the mesh open.
--
-- BUILD_MAP=CERULEAN_CITY POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_span_probe.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local mapId = os.getenv("BUILD_MAP") or "CERULEAN_CITY"
U.teleport(game, mapId, tonumber(os.getenv("BUILD_X") or "30"),
tonumber(os.getenv("BUILD_Y") or "20"), "up")
U.wait(30)
local V = game.mods.exports["DRAMATIC_SHAPE"]
V = V and V.lib
local Structures = V and V.require("Structures")
local ow = game.overworld
if not (Structures and ow and ow.map) then
print("[span] mod or map unavailable")
love.event.quit()
return
end
local S = Structures.forMap(ow.map)
local hist, worst = {}, 0
for _, q in ipairs(S.objectQuads) do
local dx = math.max(q[1][1], q[2][1], q[3][1], q[4][1])
- math.min(q[1][1], q[2][1], q[3][1], q[4][1])
local dz = math.max(q[1][3], q[2][3], q[3][3], q[4][3])
- math.min(q[1][3], q[2][3], q[3][3], q[4][3])
local dy = math.max(q[1][2], q[2][2], q[3][2], q[4][2])
- math.min(q[1][2], q[2][2], q[3][2], q[4][2])
local span = math.max(dx, dz, dy)
local bucket = span <= 8 and "<=8" or (span <= 16 and "<=16"
or (span <= 32 and "<=32" or (span <= 64 and "<=64" or ">64")))
bucket = bucket .. (q.own and " bld" or " prop")
hist[bucket] = (hist[bucket] or 0) + 1
if span > worst then worst = span end
end
print(("[span] %d object quads, longest edge %d px"):format(#S.objectQuads, worst))
for _, b in ipairs({ "<=8", "<=16", "<=32", "<=64", ">64" }) do
for _, kind in ipairs({ " bld", " prop" }) do
print(("[span] %-10s %d"):format(b .. kind, hist[b .. kind] or 0))
end
end
love.event.quit()
end
+75
View File
@@ -0,0 +1,75 @@
-- Scratch driver: one shot of every OTHER user of the round-hull builder
-- (tree canopies, boulders, hedges, stumps, the Center planter), to check
-- that the `can` class's base cut is the identity it is supposed to be for
-- everything that does not ask for it.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/round_regress_shots.lua \
-- SHOT_DIR=.scratchpad/round AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/round")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then return end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
{ map = "VIRIDIAN_FOREST", x = 16, y = 20, face = "up", label = "forest" },
{ map = "PEWTER_GYM", x = 4, y = 10, face = "up", label = "boulders" },
{ map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "hedges" },
{ map = "VIRIDIAN_POKECENTER", x = 6, y = 5, face = "up", label = "planter" },
{ map = "PALLET_TOWN", x = 5, y = 8, face = "up", label = "trees" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
Pipelines.setLevel("voxel", 5)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s.png"):format(ROOT, s.label)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[round] capture missed: " .. path) end
else
print("[round] teleport failed: " .. s.map)
end
end
print(("[round] %d shots into %s"):format(shots, ROOT))
end
+92
View File
@@ -0,0 +1,92 @@
-- Scratch driver: shots of the `bookcase` class across the tilesets that
-- pin it, for the shelf-front relief. Two of them are NOT shelves --
-- the League's gate walls and the terraces on PLATEAU -- and are here as
-- the control: their courses run edge to edge, so nothing should sink.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shelf_relief_shots.lua \
-- SHOT_DIR=.scratchpad/shelves AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/shelves")
.. "/" .. (os.getenv("AB_TAG") or "before")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[shelf] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
{ map = "OAKS_LAB", x = 7, y = 2, face = "up", label = "dojo_lab" },
{ map = "CELADON_MANSION_2F", x = 2, y = 4, face = "up", label = "mansion_2f" },
{ map = "CELADON_MART_2F", x = 5, y = 5, face = "up", label = "lobby_mart" },
{ map = "MUSEUM_1F", x = 2, y = 4, face = "up", label = "museum" },
{ map = "VIRIDIAN_MART", x = 3, y = 5, face = "up", label = "mart" },
{ map = "SS_ANNE_CAPTAINS_ROOM", x = 5, y = 2, face = "up", label = "ship" },
-- the controls, both of them tilesets that borrow the collapse for
-- something that is NOT a shelf and say so with `bookcase_relief =
-- false`: the League's masonry gate walls, and Bill's transporter
-- drums. Nothing in either may move.
{ map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up", label = "plateau" },
{ map = "BILLS_HOUSE", x = 2, y = 3, face = "up", label = "bills" },
-- the house shelves: pinned `desk`, NOT `bookcase`, so they go
-- through the world mesher's box fold and this relief never reaches
-- them. Here to show the gap.
{ map = "REDS_HOUSE_1F", x = 1, y = 2, face = "up", label = "reds" },
{ map = "BLUES_HOUSE", x = 1, y = 2, face = "up", label = "blues" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
-- twice: the first load of the session has no mesh to settle against
pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
Pipelines.setLevel("voxel", 5)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s.png"):format(ROOT, s.label)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[shelf] capture missed: " .. path) end
else
print("[shelf] teleport failed: " .. s.map)
end
end
print(("[shelf] %d shots into %s"):format(shots, ROOT))
end
+78
View File
@@ -0,0 +1,78 @@
-- Scratch driver: shots of the SS Anne's galley barrels, which are Lt.
-- Surge's trash can redrawn on the ship atlas. Three down the kitchen's
-- east wall at cells (13,5)/(13,7)/(13,9), one in the captain's room at
-- (4,1), and one each in the two ship-interior houses at (7,7).
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/ship_can_shots.lua \
-- SHOT_DIR=.scratchpad/ssanne AB_TAG=after lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/ssanne")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[ship] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
{ map = "SS_ANNE_KITCHEN", x = 12, y = 11, face = "up", label = "galley_up" },
{ map = "SS_ANNE_KITCHEN", x = 12, y = 3, face = "down", label = "galley_down" },
{ map = "SS_ANNE_KITCHEN", x = 11, y = 7, face = "right", label = "galley_side" },
{ map = "SS_ANNE_CAPTAINS_ROOM", x = 4, y = 4, face = "up", label = "captain" },
{ map = "CERULEAN_BADGE_HOUSE", x = 6, y = 7, face = "right", label = "house" },
}
local shots = 0
for _, s in ipairs(SCENES) do
local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face)
if ok then
Pipelines.setLevel("voxel", 5)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s.png"):format(ROOT, s.label)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[ship] capture missed: " .. path) end
else
print("[ship] teleport failed: " .. s.map)
end
end
print(("[ship] %d shots into %s"):format(shots, ROOT))
end
+80
View File
@@ -0,0 +1,80 @@
-- Scratch driver: shots of Vermilion Gym's trash cans, for the trash can
-- voxelization. The fifteen cans stand on odd cell columns 1..9 in cell
-- rows 7, 9 and 11; the sixteenth is up at cell (6,1) beside the leader's
-- platform. Even columns are open floor, so the player can be parked
-- between two cans and look along a row.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/trash_can_shots.lua \
-- SHOT_DIR=.scratchpad/cans AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/cans")
.. "/" .. (os.getenv("AB_TAG") or "before")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[can] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync("day")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
local SCENES = {
-- head on into the middle of the field, cans left, right and ahead
{ x = 4, y = 12, face = "up", label = "field" },
-- close up: standing between two cans of the bottom row
{ x = 2, y = 11, face = "left", label = "close" },
-- along the row, so the cans line up in depth
{ x = 4, y = 13, face = "up", label = "row" },
-- from the north, looking back down over all three rows
{ x = 4, y = 6, face = "down", label = "over" },
}
local shots = 0
for _, s in ipairs(SCENES) do
for _, rung in ipairs({ 3, 5 }) do
U.teleport(game, "VERMILION_GYM", s.x, s.y, s.face)
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
settle()
local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[can] capture missed: " .. path) end
end
end
print(("[can] %d shots into %s"):format(shots, ROOT))
end
+166
View File
@@ -214,6 +214,172 @@ Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.figures, 1,
"the repaint replaces the pattern, so a rescan cannot match it again")
-- ------- a figure with a DEPTH is an object, not a card
--
-- The Marts' cash register: the same authored-mask escape, but a machine
-- set down on a counter is a box seen from the front rather than a
-- face-on icon, so it builds as a per-pixel solid. Driven over a
-- synthetic copy of the counter's east arm, as all nine maps on the MART
-- id draw it at cell (1,5):
--
-- y=9 16 41 the work surface north of it
-- y=10 14 15 the register: keypad and receipt curl
-- y=11 30 31
-- y=12 16 41 the work surface it stands on
T.check(TileShape.figures("POKECENTER")[1].depth == nil,
"the seated man states no depth -- he stays a flat sprite card")
local regs = TileShape.figures("MART")
T.check(type(regs) == "table" and #regs == 1,
"MART carries exactly one figure")
local reg = regs[1]
T.eq(reg.w, 2, "the register is two tiles across")
T.eq(reg.h, 2, "and two tall")
T.eq(reg.n, 150, "the mask claims 150 pixels of the 256 it spans")
T.eq(reg.depth, 12, "its body is 12 voxels deep -- three quarters of the cell")
T.check(reg.thin and reg.thin.rows == 4 and reg.thin.depth == 2,
"the four rows above its drawn top edge are 2-voxel paper")
T.check(reg.flat and reg.flat.x0 == 2 and reg.flat.x1 == 8
and reg.flat.r0 == 4 and reg.flat.r1 == 11,
"and the keypad is a TOP-VIEW rect, not a face")
local MART_ROWS = { [9] = { 16, 41 }, [10] = { 14, 15 },
[11] = { 30, 31 }, [12] = { 16, 41 } }
local martS = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
ground = {}, runs = {}, objectQuads = {} }
for ty, row in pairs(MART_ROWS) do
for i, tile in ipairs(row) do
martS.tileAt[keyOf(1 + i, ty)] = tile
martS.shapeAt[keyOf(1 + i, ty)] = COUNTER
end
end
local martMap = {
tileset = { id = "MART", tilesPerRow = 16,
imageWidth = 128, imageHeight = 48 },
isWalkableCell = function() return false end,
}
Structures.buildFigures(martS, martMap, 2, 3, 9, 12)
T.eq(#martS.figures, 0, "no card was built -- it is a solid")
T.eq(#martS.objectQuads, 351,
"and it landed in the standee channel as 351 quads")
T.eq(martS.tileAt[keyOf(2, 10)], 16,
"its tiles wear the plain work surface now")
T.eq(martS.tileAt[keyOf(3, 11)], 41, "all four of them")
T.eq(martS.shapeAt[keyOf(2, 10)].class, "counter",
"and keep the counter box the machine stands on")
local rx0, rx1, ry0, ry1, rz0, rz1
for _, q in ipairs(martS.objectQuads) do
for c = 1, 4 do
local p = q[c]
rx0 = math.min(rx0 or p[1], p[1]); rx1 = math.max(rx1 or p[1], p[1])
ry0 = math.min(ry0 or p[2], p[2]); ry1 = math.max(ry1 or p[2], p[2])
rz0 = math.min(rz0 or p[3], p[3]); rz1 = math.max(rz1 or p[3], p[3])
end
end
T.eq(ry0, 8, "it stands ON the counter's 8px top plane, not the floor")
T.eq(ry1, 24, "and is its drawn 16px tall")
T.eq(rx0, 18, "west edge at the mask's column 2")
T.eq(rx1, 30, "east edge at column 13, inside its own cell (16..32)")
T.eq(rz1, 96, "its FRONT is the cell's own front edge, where it is drawn")
T.eq(rz0, 84, "and it grows north from there, 4 short of the cell's back")
-- the two thicknesses: the body at 8, the receipt curl at 2, the curl
-- centred in the body's own band rather than flush with its front
local bands = {}
for _, q in ipairs(martS.objectQuads) do
for c = 1, 4 do bands[q[c][3]] = true end
end
for _, z in ipairs({ 84, 89, 91, 96 }) do
T.check(bands[z], "the model has a face at z = " .. z)
end
local curl = {}
for _, q in ipairs(martS.objectQuads) do
local lo = math.min(q[1][2], q[2][2], q[3][2], q[4][2])
if lo >= 21 then for c = 1, 4 do curl[q[c][3]] = true end end
end
T.check(curl[89] and curl[91] and not curl[84] and not curl[96],
"clear of the arm's top only the 2-voxel paper band exists")
-- THE L. The base band (drawn rows 12-15) stands 4 above the counter and
-- the keypad lies on it as a horizontal plate, so the whole machine is
-- exactly three surfaces: a foot, an arm, and a deck in the notch.
local plate, deckTop = {}, 0
for _, q in ipairs(martS.objectQuads) do
local flatQuad = q[1][2] == q[2][2] and q[2][2] == q[3][2]
and q[3][2] == q[4][2]
if flatQuad and q[1][2] == 13 then
plate[#plate + 1] = q
elseif flatQuad and q[1][2] == 12 then
deckTop = deckTop + 1
end
end
T.eq(#plate, 83,
"the keypad lies FLAT: one top quad per masked voxel of the deck")
T.eq(deckTop, 7,
"on the base band's own top, which is 4 voxels up (drawn rows 12-15)")
local dz0, dz1
for _, q in ipairs(martS.objectQuads) do
if q[1][2] == 12 and q[3][2] == 12 then
for c = 1, 4 do
dz0 = math.min(dz0 or q[c][3], q[c][3])
dz1 = math.max(dz1 or q[c][3], q[c][3])
end
end
end
T.eq(dz0, 84, "and that deck runs the body's whole depth")
T.eq(dz1, 96, "-- plain behind the panel, covered by it in front")
local px0, px1, pz0, pz1
for _, q in ipairs(plate) do
for c = 1, 4 do
px0 = math.min(px0 or q[c][1], q[c][1]); px1 = math.max(px1 or q[c][1], q[c][1])
pz0 = math.min(pz0 or q[c][3], q[c][3]); pz1 = math.max(pz1 or q[c][3], q[c][3])
end
end
T.eq(px0, 18, "the deck spans the mask's columns 2..8")
T.eq(px1, 25, "-- the keypad panel and its own black rim")
T.eq(pz1, 96, "the deck reaches the body's front edge")
T.eq(pz0, 84, "and its back -- 8 drawn rows STRETCHED over 12 voxels")
-- the stretch is by whole voxels, centre-sampled: 8 drawn rows over 12
-- voxels of deck doubles every second one and blurs nothing
local perRow16, atlasH16 = 16, 48
local depthRow = {}
for _, q in ipairs(plate) do
local z = math.min(q[1][3], q[2][3], q[3][3], q[4][3])
depthRow[z] = math.floor(q.v * atlasH16)
end
local seen = {}
for z = 84, 95 do
T.check(depthRow[z] ~= nil, "deck voxel at z = " .. z .. " wears a texel")
seen[depthRow[z]] = (seen[depthRow[z]] or 0) + 1
end
T.eq(depthRow[95], 11, "the front voxel wears the keypad's own bottom rim")
T.eq(depthRow[84], 4, "the back one wears its top rim")
local doubled = 0
for _, n in pairs(seen) do
T.check(n == 1 or n == 2, "no drawn row spreads over more than two voxels")
if n == 2 then doubled = doubled + 1 end
end
T.eq(doubled, 4, "exactly four of the eight rows double -- 8 into 12")
-- and the arm still stands its drawn 8 rows above that deck, carrying
-- the paper: nothing in the notch reaches higher than the plate
local armTop, notchTop = 0, 0
for _, q in ipairs(martS.objectQuads) do
for c = 1, 4 do
if q[c][1] >= 25 then armTop = math.max(armTop, q[c][2])
elseif q[c][1] <= 24 then notchTop = math.max(notchTop, q[c][2]) end
end
end
T.eq(armTop, 24, "the arm and its receipt curl reach the drawn 16px")
T.eq(notchTop, 23,
"and west of it only the keys (13) and the paper overhanging them")
-- ------- prop_bg: the shades a pinned prop treats as background
--
-- The potted plants needed this: their pot's olive base is drawn flush on
+61
View File
@@ -0,0 +1,61 @@
-- Scratch driver: exercise the VR stack against the real machine -- the
-- FFI cdefs compile, the loader DLL loads, LOVE's GL context and canvas
-- framebuffers are discoverable, and the OpenXR instance either comes up
-- (headset present) or reports exactly why not (no runtime / no HMD).
-- With a headset connected and the runtime up, it holds a VR session open
-- for ten seconds of frames: diorama first, then the 1ST rung.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/vr_probe.lua \
-- lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[vr] DRAMATIC_SHAPE mod not loaded")
return love.event.quit()
end
local V = handle.lib
local VR = V.require("VR")
local VRGL = V.require("VRGL")
local Voxel = V.require("VoxelState")
U.teleport(game, "PALLET_TOWN", 10, 12, "down")
Pipelines.setLevel("voxel", 2)
U.wait(30)
-- the GL floor the whole thing stands on
local okGL = VRGL.load()
print("[vr] GL interop: " .. tostring(okGL) .. " -- " .. VRGL.status())
local hdc, hglrc = VRGL.contexts()
print("[vr] wgl contexts: " .. tostring(hdc) .. " / " .. tostring(hglrc))
local okC, c = pcall(love.graphics.newCanvas, 64, 64)
if okC then
print("[vr] canvas FBO discovery: " .. tostring(VRGL.canvasFBO(c)))
end
-- the OpenXR stack, driven by the same row the player would use
VR.setting:sync(true)
for _ = 1, 300 do
U.wait(1)
if VR.active() or VR.status():find("XR_ERROR") then break end
end
print("[vr] status: " .. VR.status())
print("[vr] active: " .. tostring(VR.active()))
if VR.active() then
print("[vr] holding a diorama session for ~5s of frames")
U.wait(450)
print("[vr] switching to 1ST for ~5s of frames")
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
U.wait(450)
print("[vr] still active: " .. tostring(VR.active())
.. " -- " .. VR.status())
end
VR.setting:sync(false)
U.wait(10)
print("[vr] after toggle off: " .. VR.status())
love.event.quit()
end
+121
View File
@@ -0,0 +1,121 @@
-- Scratch driver: water shot with V-CURVE walked OFF..3, to see what the
-- world bend does to the reflective pass.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/water_curve_shots.lua \
-- SHOT_DIR=.scratchpad/watercurve AB_TAG=before lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local ROOT = (os.getenv("SHOT_DIR") or "shots/watercurve")
.. "/" .. (os.getenv("AB_TAG") or "after")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[water] DRAMATIC_SHAPE mod not loaded")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local WorldCurve = V.require("WorldCurve")
local Water = V.require("Water")
-- WATER_RUNG=sky drops the screen-space march and leaves the sky path, to
-- tell an artefact of the one from an artefact of the other
if os.getenv("WATER_RUNG") then Water.setting:sync(os.getenv("WATER_RUNG")) end
require("src.world.OverworldController").rollEncounter = function() return nil end
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
DayNight.setting:sync(os.getenv("WATER_TIME") or "night")
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
local function settle()
for _ = 1, 900 do
if ChunkMesher.pending() == 0 then break end
U.wait(1)
end
for _ = 1, 300 do
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
U.wait(1)
end
U.wait(40)
end
-- Stand on the walkable cell just north of the widest run of water on the
-- map, so a scene is picked by where the water actually is rather than by
-- a coordinate guessed off the block list.
local TileShape = V.require("TileShape")
local function shore(map)
local def = map.def
local shapes = TileShape.forMap(map)
local function classAt(cx, cy)
local tx, ty = cx * 2, cy * 2
local s = TileShape.at(map, shapes, map:tileAt(tx, ty), tx, ty)
return s and s.class
end
local best, bestRun = nil, 0
for cy = 1, def.height * 2 - 1 do
local run, start = 0, nil
for cx = 0, def.width * 2 - 1 do
if classAt(cx, cy) == "water" then
start = start or cx
run = run + 1
if run > bestRun and classAt(cx, cy - 1) ~= "water" then
bestRun, best = run, { x = start + math.floor(run / 2), y = cy - 1 }
end
else
run, start = 0, nil
end
end
end
return best
end
local SCENES = {
{ map = "PALLET_TOWN", label = "pallet" },
{ map = "CERULEAN_CITY", label = "cerulean" },
{ map = "VERMILION_CITY", x = 18, y = 27, label = "vermilion" },
{ map = "ROUTE_24", label = "route24" },
{ map = "VIRIDIAN_CITY", label = "viridian" },
}
local shots = 0
for _, s in ipairs(SCENES) do
if not s.x then
-- teleport once so the map is loaded, then let the scan place us
U.teleport(game, s.map, 1, 1, "down")
U.wait(4)
local spot = game.overworld and game.overworld.map and shore(game.overworld.map)
if spot then s.x, s.y = spot.x, spot.y else s.x, s.y = 1, 1 end
print(("[water] %s shore at (%d,%d)"):format(s.label, s.x, s.y))
end
for _, rung in ipairs({ 3, 4 }) do
for _, curve in ipairs({ 0, 3 }) do
U.teleport(game, s.map, s.x, s.y, s.face or "down")
Pipelines.setLevel("voxel", rung)
Pipelines.setLevel("tiltshift", 0)
WorldCurve.setting:setIndex(curve + 1, game)
settle()
local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve)
game.capturePath = path
U.wait(8)
local f = io.open(path, "rb")
if f then f:close() shots = shots + 1
else print("[water] capture missed: " .. path) end
end
end
end
print(("[water] %d shots into %s"):format(shots, ROOT))
love.event.quit()
end
+1217 -13
View File
File diff suppressed because it is too large Load Diff