mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-13 01:40:51 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c404c766cd | |||
| 7b1ac9b1b6 | |||
| 08bcbf7629 | |||
| a3a712205b | |||
| 95771403d9 | |||
| 9542ba94b1 | |||
| f245e8808f | |||
| c79ecbb7ac | |||
| 70243a407b | |||
| 9700806a92 | |||
| f5970d8d9a | |||
| e3c13edda7 | |||
| 6e9b787e41 | |||
| 399a10a124 | |||
| c0c180fd01 | |||
| 2ce44586c2 | |||
| e8caa4f537 | |||
| a8c2d8ce5b | |||
| 395f51d268 | |||
| 1fa29a831f | |||
| 44f729e8c2 | |||
| f1063abd0f | |||
| 3cd3fe431d | |||
| acb2eadeb4 | |||
| 1a69489305 |
+346
@@ -1,5 +1,351 @@
|
||||
# Changelog
|
||||
|
||||
## 1.5.5
|
||||
|
||||
### Added
|
||||
|
||||
- **A third-person camera: the 3RD rung.** The VOXEL ladder's eighth rung
|
||||
(`3` / SELECT walk onto it after 1ST, and it is on the OPTIONS row)
|
||||
stands the camera on a boom behind the player's shoulder. It is the
|
||||
first-person rig with one number added, so everything 1ST already did
|
||||
it does: the look steers on a mouse, the right stick or a touch drag,
|
||||
and the grid walk is replaced by continuous camera-relative movement --
|
||||
push in any direction and you go there, at any angle, with collision,
|
||||
warps, ledges, encounters and scripts still running through the
|
||||
engine's own machinery.
|
||||
|
||||
The boom **collides**: it marches back through the terrain height field
|
||||
and the map's own walkability, so backing into a wall walks the camera
|
||||
in to your shoulders instead of through it, and rounding a corner eases
|
||||
it back out rather than snapping. Squeezed all the way into the head it
|
||||
simply draws as 1ST until you step clear. It also carries a small
|
||||
over-the-shoulder rail offset, which fades out as the boom shortens.
|
||||
|
||||
Every sprite in the world **turns to face it** -- yours, the NPCs', the
|
||||
figures drawn into the furniture -- and shows the frame it would look
|
||||
like from where the camera actually stands, so walking behind someone
|
||||
shows you their back. Your own character is drawn (with the
|
||||
through-the-wall silhouette 1ST had no use for) and **turns to face
|
||||
where they are walking** rather than where the camera looks, so a strafe
|
||||
reads as one; standing still they come back round to the camera's
|
||||
bearing, which is the one A talks along.
|
||||
|
||||
Your card's frame is chosen from your body's **continuous** bearing
|
||||
rather than from the compass direction the grid game stores. An NPC's
|
||||
facing really is one of four directions, but yours is the angle the
|
||||
camera itself is hung off, and quantising it before measuring it against
|
||||
the eye leaves no margin for the shoulder rail's few degrees of offset:
|
||||
in a band just short of each 45-degree boundary the pair read as 135
|
||||
degrees apart and picked the mirrored PROFILE frame. Standing still.
|
||||
Spinning the camera swept four of those bands a revolution, which showed
|
||||
up as the character flicking sideways for a split second.
|
||||
|
||||
In VR the boom is declined outright and 3RD presents as 1ST does: a
|
||||
headset that seats its wearer three cells behind their own body is a
|
||||
well-known way to make people ill. The rung still changes the walk and
|
||||
the sprites the same way.
|
||||
|
||||
- **Every camera zooms, on whatever the machine has.** The mouse wheel,
|
||||
`Q`/`E`, a two-finger pinch and the pad's two stick clicks all reach
|
||||
whichever camera is actually in front of you -- the third-person boom,
|
||||
the staged battle's lens, or the engine's own survey zoom on an orbit
|
||||
rung. One module (`lib/CamControl.lua`) answers "which camera is this
|
||||
aimed at" so the four cameras never race each other for an event, and
|
||||
forwards everything it does not claim. 1ST claims nothing: the eye is in
|
||||
the player's head, and a pinch there would only wind the survey zoom for
|
||||
whenever they stepped back out.
|
||||
|
||||
- **The battle camera is yours to steer.** The right stick, a drag across
|
||||
the screen or the mouse walks the staged shot around the arena and raises
|
||||
the seat; the wheel, `Q`/`E`, a pinch or a stick click work the lens.
|
||||
|
||||
Both axes stop where the composition does. LEFT stops at the shot the rig
|
||||
was solved for, because there is nothing to the left of it. RIGHT ends
|
||||
SIDE-ON -- the eye square to the arena's axis, both Pokemon at the same
|
||||
distance instead of one behind the other -- computed from each rig's own
|
||||
stance rather than written down. DOWN stops at the rig's low stance and
|
||||
UP is 45 degrees above it, raised about the focus at a constant radius so
|
||||
climbing never doubles as zooming. Input accumulates into a goal the eye
|
||||
eases after, so a flick reads as the camera being pushed rather than
|
||||
dragged.
|
||||
|
||||
The lens **opens by exactly the amount the pair spreads**: the solved
|
||||
shot looks along the arena's axis at a shallow angle, which foreshortens
|
||||
the gap between the two mons to less than half its length, and swinging
|
||||
round or climbing un-foreshortens it. Left alone that threw both Pokemon
|
||||
off the edges of the frame at the far end of either range, which made the
|
||||
whole far end unusable.
|
||||
|
||||
Where you leave the camera is where the next battle opens. An angle and a
|
||||
lens you chose are how you want to watch battles, not a fact about one
|
||||
encounter.
|
||||
|
||||
- **Move animations track the camera.** They already slid to follow the
|
||||
pair's midpoint; now they follow its SEPARATION too. Both mons are
|
||||
geometry standing on the map, so the camera sizes them -- and an effects
|
||||
layer that kept the authored 106-pixel spacing through a zoom and a
|
||||
60-degree swing fired its beams into the air beside the Pokemon they were
|
||||
aimed at.
|
||||
|
||||
- **BACK SPRITES locks the battle camera.** That setting pins your own mon
|
||||
to the GB's slot on the menu while the foe stands out on the map, and no
|
||||
angle holds a composition that is half frame and half world. The steer,
|
||||
the climb and the lens all stand down -- in the rig as well as at the
|
||||
inputs, so an angle stored from before the row was switched on cannot
|
||||
leave it steered anyway. The slow drift stays: it was always there under
|
||||
BACK SPRITES and two degrees is not a composition problem.
|
||||
|
||||
- **BATTLE BG is pinned to WHITE and its row comes off the menu.** The row
|
||||
picks what fills the screen AROUND the battle, and this mode fills the
|
||||
window with the map the fight is standing on -- there are no voids left
|
||||
for it to be about. WORLD was actively wrong under it: it makes the
|
||||
battle non-opaque so the engine draws a second, dimmed copy of the
|
||||
overworld beneath the arena pass's own. Pinned rather than merely hidden,
|
||||
so a save written before the mod was installed cannot carry a value the
|
||||
menu can no longer reach. Uninstall and the row is back.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Grass and flowers are closed off at the sides.** Both stand as
|
||||
per-pixel slabs built from runs of lit pixels, and only the front, the
|
||||
back and a lid were ever emitted -- so from any angle off square you
|
||||
looked straight in through the open end of every run and out the far
|
||||
side. At the low cameras this release adds, that is most of the time.
|
||||
Each run now wears end walls in the colour of the pixel they close off.
|
||||
|
||||
Flowers needed more than that, because a flower SWAYS: the mesh spans the
|
||||
union of every animation frame and each frame is cut back out in texture
|
||||
space, so a pixel that drops out of a frame takes the union's wall with
|
||||
it and leaves an interior boundary that never had one. The first cut of
|
||||
this looked solid on the base frame and still had gaps on every other.
|
||||
Every pixel of a flower now carries a cap on all four of its remaining
|
||||
faces: enclosed and invisible while its neighbour is there, and already
|
||||
in place the moment the animation takes that neighbour away.
|
||||
|
||||
- **No more machine-gun bonking in 1ST and 3RD.** The grid walk's collision
|
||||
sound marks a discrete event -- a direction pressed, a step refused. A
|
||||
free walk has no such moment: the body slides along every wall it grazes,
|
||||
continuously, so a corridor taken at a slight angle rang the bonk twice a
|
||||
second from end to end. The wall stopping you is the feedback.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 DramaticShape
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -3,55 +3,8 @@
|
||||
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.
|
||||
|
||||
And the whole thing from inside. The ladder's top rung, **1ST**, dives the
|
||||
camera into the player's own head: free look on the mouse (captured while
|
||||
the rung is on — left click is A, right click is B), the right stick, or a
|
||||
touch dragged across open screen; free movement that goes where you look,
|
||||
at any angle, sliding along walls — the left stick's raw deflection, the
|
||||
touch d-pad's true vector, or WASD as forward/backpedal/strafe. NPCs turn
|
||||
to face the eye wearing the frame their pose shows *this* viewer — walk
|
||||
behind someone and you see their back — and the sky, the shadows and the
|
||||
water reflections all carry over, because the head rides the same placed
|
||||
camera the battle shot proved out.
|
||||
|
||||
Presentational, with one deliberate exception. Every rung but 1ST changes
|
||||
what the world *looks* like and nothing about what it *is*; the battle
|
||||
arena is where the **camera** goes, not where anybody goes. 1ST replaces
|
||||
the grid walk with a free one while it is selected — but even there the
|
||||
game is untouched: the walk asks the engine's own collision the same
|
||||
questions a grid step asks, keeps the player's cell synced, and runs the
|
||||
engine's own landing pipeline per cell crossed, so warps, encounters,
|
||||
ledges, gates and scripts all fire exactly as themselves. Step off the
|
||||
rung and the grid walk is back.
|
||||
The overworld as a voxelized 3D diorama. Also supports experimental
|
||||
first-person, third-person and VR.
|
||||
|
||||
## Controls
|
||||
|
||||
@@ -60,7 +13,8 @@ menu.
|
||||
|
||||
| control | does |
|
||||
| --- | --- |
|
||||
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) |
|
||||
| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → 3RD → OFF (camera pitch) |
|
||||
| `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row |
|
||||
| `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel |
|
||||
| `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
|
||||
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
|
||||
@@ -70,18 +24,108 @@ menu.
|
||||
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
|
||||
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
|
||||
|
||||
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
|
||||
on the world whether or not the free-roam camera is pitched over.
|
||||
## Free-roam cameras (1ST / 3RD)
|
||||
|
||||
Two of the engine's own rows are taken away while this mod is installed:
|
||||
**TILT**, which is the flat fake of what this mode does for real, and **GBC
|
||||
FX**, a full-screen present pass over the top of the diorama. Both are held at
|
||||
off rather than merely hidden — a row that is not there cannot switch off a
|
||||
value an older save arrived with. Uninstall and both come back, at whatever
|
||||
they were last set to.
|
||||
The last two rungs of the **VOXEL** ladder are experimental, and they are
|
||||
the same camera: **1ST** stands it in the player's own eyes, **3RD** pulls
|
||||
it back onto a boom behind their shoulder. Both steer, and on both the grid
|
||||
walk is replaced by continuous camera-relative movement — push in any
|
||||
direction and you go there, at any angle, not just along the four compass
|
||||
lines. Collision, warps, ledges, encounters and scripts all still run
|
||||
through the engine's own machinery.
|
||||
|
||||
Everything the battle screen draws as a box — the two HUD blocks, the text
|
||||
box and the menus over it — sits on frosted glass rather than on the white
|
||||
field it used to have behind it: the world underneath, blurred and laid back
|
||||
down translucent, with the ink flipping white where the ground it lands on is
|
||||
dark. Nothing the engine draws inside a box moves; only the paper is gone.
|
||||
| control | does |
|
||||
| --- | --- |
|
||||
| mouse | look (the cursor is captured; left click is A, right click is B) |
|
||||
| right stick | look |
|
||||
| a touch drag off the overlay's controls | look |
|
||||
| left stick / touch d-pad / arrow keys | walk, relative to where the camera looks |
|
||||
| wheel, `Q` / `E`, pinch, or a stick click | **3RD only** — let the boom out and pull it in (`Q` and left stick click out, `E` and right stick click in) |
|
||||
|
||||
On an **orbit rung** the same wheel, `Q`/`E` and pinch drive the engine's own
|
||||
survey zoom. On **1ST** they do nothing at all: the eye is in your head, and
|
||||
there is no distance to change.
|
||||
|
||||
On **3RD** the boom shortens against whatever is behind you, so backing into
|
||||
a wall walks the camera in to your shoulders rather than through it — squeeze
|
||||
it all the way in and the view is 1ST until you step clear. The character
|
||||
turns to face where they are walking, and every sprite in the world — yours,
|
||||
the NPCs', the figures drawn into the furniture — turns to face the camera
|
||||
and shows the frame it would look like from where the camera actually
|
||||
stands, so walking behind someone shows you their back.
|
||||
|
||||
## The battle camera
|
||||
|
||||
A fight staged on the map (**3D-BTL**, on by default) is shot with a solved
|
||||
over-the-shoulder rig — and you can steer it.
|
||||
|
||||
| control | does |
|
||||
| --- | --- |
|
||||
| right stick, a touch drag, or the mouse | swing the shot around the arena (→) and raise the seat (↑) |
|
||||
| wheel, `Q` / `E`, pinch, or a stick click | the lens (`Q` / left stick click out, `E` / right stick click in) |
|
||||
|
||||
Both axes stop where the composition does. Left stops at the shot the rig was
|
||||
solved for — there is nothing to the left of it. Right ends **side-on**: the
|
||||
eye square to the arena's axis, both Pokémon at the same distance instead of
|
||||
one behind the other. Down stops at the rig's own low stance; up is 45° above
|
||||
it. The lens opens as you swing or climb, by exactly the amount the two
|
||||
Pokémon spread apart, so they stay framed at every angle. Move animations
|
||||
follow the pair's position *and* its separation, so a beam still lands on the
|
||||
Pokémon it was aimed at.
|
||||
|
||||
Where you leave the camera is where the next battle opens.
|
||||
|
||||
**BACK SPRITES locks it.** That setting pins your own Pokémon to the GB's slot
|
||||
on the menu while the foe stands out on the map, and no angle holds a
|
||||
composition that is half frame and half world — so with it on, the shot holds
|
||||
the one the rig was solved for.
|
||||
|
||||
## VR
|
||||
|
||||
The **VR** options row (OFF / ON, off by default) drives a PCVR headset
|
||||
through OpenXR on Windows — SteamVR, Oculus or WMR.
|
||||
|
||||
Both free-roam rungs put the headset in the player's *head*: a boom that
|
||||
seats its wearer three cells behind their own body is a reliable way to make
|
||||
people ill, so **3RD** in VR is **1ST** in VR. The rung still changes the
|
||||
walk and the sprites the same way.
|
||||
|
||||
### 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.
|
||||
@@ -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.
+4
-1
@@ -242,7 +242,10 @@ end
|
||||
-- Whether both mons would be in plain view from the battle camera.
|
||||
function BattleArena.clearance(map, arena)
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local ok, rig = pcall(BattleCam.rig, arena, 0)
|
||||
-- the CANONICAL shot: whether a fight fits somewhere is a fact about the
|
||||
-- ground, so it must not depend on the drift's phase or on where the
|
||||
-- player last swung the camera (see BattleCam.rig's third argument)
|
||||
local ok, rig = pcall(BattleCam.rig, arena, 0, true)
|
||||
if not (ok and rig and rig.eye) then return true end
|
||||
local eye = rig.eye
|
||||
local H = BattleArena.MON_H
|
||||
|
||||
+307
-5
@@ -124,12 +124,257 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
|
||||
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
|
||||
BattleCam.DOLLY_PERIOD = 37
|
||||
|
||||
-- ------- the player's own orbit
|
||||
--
|
||||
-- The drift above is the shot breathing. THIS is the player steering it:
|
||||
-- a right stick, a drag across the screen or the mouse walks the eye
|
||||
-- around the arena's axis, and it stops at both ends.
|
||||
--
|
||||
-- 0 is the shot the rig was solved for and the LEFT stop, because there is
|
||||
-- nothing to the left of it -- the composition below is what the whole
|
||||
-- module exists to land, and past it the two mons start swapping sides.
|
||||
--
|
||||
-- 1 is SIDE-ON: the eye swung round until it is square to the arena's
|
||||
-- north-south axis, where the two mons stand at the same distance instead
|
||||
-- of one behind the other. That is as far as the picture stays a battle
|
||||
-- rather than a diorama with two Pokemon in it, and it is a different angle
|
||||
-- for each rig -- the tele lens starts 28 degrees off the axis and the wide
|
||||
-- one 45 -- so the stop is COMPUTED from the rig rather than written down,
|
||||
-- and retuning either moves its own stop with it.
|
||||
--
|
||||
-- The input is deliberately not 1:1 with the pixels: it accumulates into
|
||||
-- `orbitGoal` and the live angle eases after it, so a flick reads as the
|
||||
-- camera being pushed rather than as the camera being dragged.
|
||||
BattleCam.ORBIT_TIME = 0.22 -- seconds for the eye to catch its goal
|
||||
BattleCam.ORBIT_DRAG = 1.15 -- fraction of the range per screen width
|
||||
BattleCam.ORBIT_STICK = 0.9 -- fraction of the range per second, full tilt
|
||||
BattleCam.ORBIT_MOUSE = 0.0011 -- fraction of the range per mouse count
|
||||
BattleCam.STICK_DEAD = 0.2
|
||||
|
||||
-- ------- and the height it is watched from
|
||||
--
|
||||
-- The same steering on the other axis, with the same shape of stop at each
|
||||
-- end: 0 is the rig's own stance -- the low, near-floor seat the whole
|
||||
-- composition is solved around, and the DOWN stop, because below it the
|
||||
-- camera starts looking up the arena's nose -- and 1 is 45 degrees above
|
||||
-- it, which is high enough to read the ground the fight is standing on
|
||||
-- without becoming the diorama's own top-down.
|
||||
--
|
||||
-- Raised about the FOCUS rather than about the eye, so the aim stays on
|
||||
-- the two mons and only the seat climbs; and at a constant radius, so
|
||||
-- climbing never changes how big anything is -- that is the zoom's job.
|
||||
BattleCam.PITCH_RANGE = math.rad(45)
|
||||
BattleCam.PITCH_TIME = 0.22
|
||||
BattleCam.PITCH_DRAG = 1.6 -- fraction of the range per screen HEIGHT
|
||||
BattleCam.PITCH_STICK = 0.9
|
||||
BattleCam.PITCH_MOUSE = 0.0016
|
||||
|
||||
-- ------- and the player's own zoom
|
||||
--
|
||||
-- How much world the frame holds, as a multiple of the rig's own frameH:
|
||||
-- BELOW one is zoomed in. It has to be the LENS rather than the distance,
|
||||
-- because the rig derives its field of view from frameH and the distance
|
||||
-- together -- so moving the eye alone changes the perspective and not the
|
||||
-- framing, which is exactly what the dolly breath above is for.
|
||||
BattleCam.ZOOM_MIN = 0.45 -- the pair filling the frame
|
||||
BattleCam.ZOOM_MAX = 2.0 -- the fight in its own landscape
|
||||
BattleCam.ZOOM_STEP = 1.15
|
||||
BattleCam.ZOOM_TIME = 0.18
|
||||
|
||||
BattleCam.orbit = 0
|
||||
BattleCam.orbitGoal = 0
|
||||
BattleCam.pitch = 0
|
||||
BattleCam.pitchGoal = 0
|
||||
BattleCam.zoom = 1
|
||||
BattleCam.zoomGoal = 1
|
||||
|
||||
-- Whether the player may steer at all. BACK SPRITES clears it: that
|
||||
-- setting pins the player's own mon to the GB's own slot on the menu
|
||||
-- (OverworldBattle.backPinned) instead of standing it out on the map, so
|
||||
-- half the picture is nailed to the frame and half of it is geometry. Swing
|
||||
-- the camera under that and the two halves come apart -- the foe walks
|
||||
-- around an arena its opponent is not standing in, and the move animations
|
||||
-- that reach between them stretch across the gap. There is no angle that
|
||||
-- composition survives, so the answer is not to allow one.
|
||||
--
|
||||
-- Only the STEER is withheld: the slow drift stays, because it was always
|
||||
-- there under BACK SPRITES and two degrees is not a composition problem.
|
||||
BattleCam.steerable = true
|
||||
|
||||
-- Hold the rig perfectly still (VR sets this while a session runs). The
|
||||
-- drift exists to give a FLAT screen the depth cue the picture cannot
|
||||
-- have; a headset gets real parallax from the player's own head, and a
|
||||
-- picture that sways on its own inside VR reads as the world lurching --
|
||||
-- on the floating panel especially, where the battle screen is watched
|
||||
-- from a fixed seat.
|
||||
BattleCam.still = false
|
||||
|
||||
BattleCam.t = 0
|
||||
|
||||
-- Only the DRIFT's phase, so every fight opens on the same breath. Where
|
||||
-- the player last put the camera is deliberately NOT reset: an angle and a
|
||||
-- lens they chose are how they want to watch battles, not a thing about
|
||||
-- this battle, and having to re-find them every encounter would make them
|
||||
-- not worth setting. They are session state -- a fresh run opens on the
|
||||
-- rig's own shot, which is the one the composition is solved for.
|
||||
function BattleCam.reset()
|
||||
BattleCam.t = 0
|
||||
end
|
||||
|
||||
-- Back to the solved shot, for anything that wants the composition as
|
||||
-- authored rather than as steered.
|
||||
function BattleCam.recentre()
|
||||
BattleCam.orbit, BattleCam.orbitGoal = 0, 0
|
||||
BattleCam.pitch, BattleCam.pitchGoal = 0, 0
|
||||
BattleCam.zoom, BattleCam.zoomGoal = 1, 1
|
||||
end
|
||||
|
||||
-- How far the eye may swing, in radians, before it is square to the arena's
|
||||
-- axis. The rig's own stance decides it: `side` and `back` are the offset
|
||||
-- it starts at, so the bearing it starts on is atan2(side, back) and what
|
||||
-- is left to a quarter turn is the room the player has.
|
||||
function BattleCam.orbitRange(arena)
|
||||
local R = BattleCam.rigFor(arena)
|
||||
return math.max(0, math.pi / 2 - math.atan2(R.side, R.back))
|
||||
end
|
||||
|
||||
-- ------- what the player's inputs reach
|
||||
--
|
||||
-- All four take a signed amount and clamp; positive is RIGHTWARD, toward
|
||||
-- the side-on stop. Returning whether the goal actually moved lets a
|
||||
-- caller tell "steered" from "already against the stop".
|
||||
|
||||
-- Both axes go through here, so the "nothing while BACK SPRITES holds the
|
||||
-- composition" rule and the two stops live in one place each.
|
||||
local function setAxis(key, goal)
|
||||
if not BattleCam.steerable then return false end
|
||||
local was = BattleCam[key]
|
||||
BattleCam[key] = math.max(0, math.min(1, goal))
|
||||
return BattleCam[key] ~= was
|
||||
end
|
||||
|
||||
-- A drag, in fractions of the screen's width (orbit) or height (pitch).
|
||||
function BattleCam.dragOrbit(fraction)
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + (fraction or 0) * BattleCam.ORBIT_DRAG)
|
||||
end
|
||||
|
||||
function BattleCam.dragPitch(fraction)
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + (fraction or 0) * BattleCam.PITCH_DRAG)
|
||||
end
|
||||
|
||||
-- Relative mouse motion, in counts.
|
||||
function BattleCam.mouseOrbit(dx)
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + (dx or 0) * BattleCam.ORBIT_MOUSE)
|
||||
end
|
||||
|
||||
function BattleCam.mousePitch(dy)
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + (dy or 0) * BattleCam.PITCH_MOUSE)
|
||||
end
|
||||
|
||||
-- A stick held for `dt` seconds, as a rate with a squared response -- the
|
||||
-- first half of the throw aims and the rest travels, the same curve the
|
||||
-- free-roam look uses.
|
||||
local function curve(v)
|
||||
local a = math.abs(v or 0)
|
||||
if a < BattleCam.STICK_DEAD then return 0 end
|
||||
a = (a - BattleCam.STICK_DEAD) / (1 - BattleCam.STICK_DEAD)
|
||||
return ((v < 0) and -1 or 1) * a * a
|
||||
end
|
||||
|
||||
function BattleCam.stickOrbit(x, dt)
|
||||
local v = curve(x)
|
||||
if v == 0 then return false end
|
||||
return setAxis("orbitGoal",
|
||||
BattleCam.orbitGoal + v * BattleCam.ORBIT_STICK * (dt or 0))
|
||||
end
|
||||
|
||||
function BattleCam.stickPitch(y, dt)
|
||||
local v = curve(y)
|
||||
if v == 0 then return false end
|
||||
return setAxis("pitchGoal",
|
||||
BattleCam.pitchGoal + v * BattleCam.PITCH_STICK * (dt or 0))
|
||||
end
|
||||
|
||||
-- The zoom, in notches (positive pulls OUT, like every other zoom here).
|
||||
function BattleCam.stepZoom(notches)
|
||||
if not BattleCam.steerable then return false end
|
||||
local was = BattleCam.zoomGoal
|
||||
BattleCam.zoomGoal = math.max(BattleCam.ZOOM_MIN,
|
||||
math.min(BattleCam.ZOOM_MAX,
|
||||
was * (BattleCam.ZOOM_STEP ^ (notches or 0))))
|
||||
return BattleCam.zoomGoal ~= was
|
||||
end
|
||||
|
||||
-- How far apart the two mons READ from the current orbit, as a multiple of
|
||||
-- how far apart they read from the solved shot.
|
||||
--
|
||||
-- The arena's axis runs from one mon to the other, and the solved shot
|
||||
-- looks along it at a shallow 28 degrees, which foreshortens that gap to
|
||||
-- less than half its length. Swing round to square-on and the
|
||||
-- foreshortening is gone: the same two cells now read at their full
|
||||
-- separation, better than twice as wide. Left alone, that threw the pair
|
||||
-- out to the edges of the frame -- half of each mon off-screen at the
|
||||
-- side-on stop, which made the whole far end of the range unusable.
|
||||
--
|
||||
-- Climbing does the same thing on the other axis -- a raised camera looks
|
||||
-- less along the ground and more across it, which un-foreshortens the gap
|
||||
-- again -- so the correction has to answer to both.
|
||||
--
|
||||
-- What it measures is how much of the arena's axis survives projection:
|
||||
-- the axis runs due north-south, the view line points back at the arena at
|
||||
-- plan bearing `beta` and elevation `elev`, and the part of a unit axis
|
||||
-- that lands across the frame rather than along the view is the sine of
|
||||
-- the angle between them. The ratio of that to the solved shot's own is
|
||||
-- the factor the lens opens by -- 1 at the solved shot by construction,
|
||||
-- about 1.9 at side-on, about 1.7 fully raised.
|
||||
--
|
||||
-- Analytic rather than measured off the built rig, so nothing has to
|
||||
-- reason about a camera to ask the question, and so the sun's box (which
|
||||
-- asks through frameH) gets the identical number the lens does.
|
||||
--
|
||||
-- Measured off the STEER alone, deliberately: the drift's own two degrees
|
||||
-- moved this before and must keep moving it by exactly as much, or every
|
||||
-- battle shot that has ever been taken shifts.
|
||||
local function axisSpan(beta, elev)
|
||||
local c = math.cos(elev)
|
||||
local s = math.sin(beta) * c
|
||||
local v = math.sin(elev)
|
||||
return math.sqrt(s * s + v * v)
|
||||
end
|
||||
|
||||
function BattleCam.spread(arena)
|
||||
local R = BattleCam.rigFor(arena)
|
||||
local beta = math.atan2(R.side, R.back)
|
||||
local elev = math.atan2(R.height - R.lookY,
|
||||
math.sqrt((R.side - R.lookX) ^ 2 + R.back ^ 2))
|
||||
local home = axisSpan(beta, elev)
|
||||
if home < 1e-6 then return 1 end
|
||||
return axisSpan(beta + BattleCam.orbit * BattleCam.orbitRange(arena),
|
||||
elev + BattleCam.pitch * BattleCam.PITCH_RANGE) / home
|
||||
end
|
||||
|
||||
-- How much world the frame holds right now: the rig's own reach at the
|
||||
-- player's zoom and at whatever the orbit has done to the pair's spacing,
|
||||
-- or the rig's own alone whenever both are being withheld (VR's fixed
|
||||
-- seat, BACK SPRITES' pinned composition). The sun's box is fitted to this
|
||||
-- too, so a zoomed shot lights exactly the ground it shows -- which is why
|
||||
-- BattleScene asks this rather than multiplying for itself.
|
||||
function BattleCam.frameH(arena)
|
||||
local base = BattleCam.rigFor(arena).frameH
|
||||
if BattleCam.still or not BattleCam.steerable then return base end
|
||||
return base * BattleCam.zoom * BattleCam.spread(arena)
|
||||
end
|
||||
|
||||
local function chase(now, goal, dt, time)
|
||||
if now == goal then return goal end
|
||||
local v = now + (goal - now) * math.min(1, (dt or 0) / time)
|
||||
return (math.abs(goal - v) < 1e-4) and goal or v
|
||||
end
|
||||
|
||||
-- Real frame time, like every other presentational tween in this mod: a
|
||||
-- fast-forwarded battle must not spin the camera.
|
||||
function BattleCam.update(dt)
|
||||
@@ -138,6 +383,14 @@ function BattleCam.update(dt)
|
||||
-- float precision in the sines below
|
||||
local wrap = BattleCam.PAN_PERIOD * BattleCam.DOLLY_PERIOD
|
||||
if BattleCam.t > wrap then BattleCam.t = BattleCam.t - wrap end
|
||||
-- and the steered three easing after whatever the player last asked for,
|
||||
-- which is what keeps a flick of the stick from being a cut
|
||||
BattleCam.orbit = chase(BattleCam.orbit, BattleCam.orbitGoal, dt,
|
||||
BattleCam.ORBIT_TIME)
|
||||
BattleCam.pitch = chase(BattleCam.pitch, BattleCam.pitchGoal, dt,
|
||||
BattleCam.PITCH_TIME)
|
||||
BattleCam.zoom = chase(BattleCam.zoom, BattleCam.zoomGoal, dt,
|
||||
BattleCam.ZOOM_TIME)
|
||||
end
|
||||
|
||||
local function phase(t, period)
|
||||
@@ -155,33 +408,82 @@ end
|
||||
--
|
||||
-- `groundY` is the height of the arena floor, so a fight staged on a ledge
|
||||
-- or a raised walkway is shot from above THAT rather than from inside it.
|
||||
function BattleCam.rig(arena, groundY)
|
||||
-- `canonical` asks for the shot the rig was SOLVED for -- no drift, no
|
||||
-- breath, no steer, no zoom -- from a caller that is reasoning about the
|
||||
-- arena rather than drawing it. BattleArena's clearance test is the one
|
||||
-- that needs it: whether a fight can be staged somewhere is a fact about
|
||||
-- the ground, and answering it through whatever angle the player happened
|
||||
-- to leave the last battle on would pick a different arena depending on
|
||||
-- where they had swung the camera an hour ago.
|
||||
function BattleCam.rig(arena, groundY, canonical)
|
||||
groundY = groundY or 0
|
||||
local R = BattleCam.rigFor(arena)
|
||||
local mx, mz = arena.mid[1], arena.mid[2]
|
||||
-- VR asks for the same stillness for its own reason (see BattleCam.still)
|
||||
local fixed = BattleCam.still or canonical
|
||||
-- and the steer is withheld a second way, on its own: BACK SPRITES holds
|
||||
-- the composition and the DRIFT still runs under it (see steerable)
|
||||
local steered = (not fixed) and BattleCam.steerable
|
||||
|
||||
local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
|
||||
-- The drift, plus wherever the player has steered to. The steer is
|
||||
-- NEGATIVE because the rotation below runs the other way from the bearing
|
||||
-- it turns: rotating (side, back) by +yaw carries the eye back toward the
|
||||
-- arena's own axis, and the room the player has is all on the far side of
|
||||
-- that -- out toward square-on. (orbitRange measures exactly that room.)
|
||||
local steer = steered and -BattleCam.orbit * BattleCam.orbitRange(arena) or 0
|
||||
local yaw = steer + (fixed and 0
|
||||
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD))
|
||||
local c, s = math.cos(yaw), math.sin(yaw)
|
||||
-- the breath scales the whole offset, height included, so the eye moves
|
||||
-- along its own line to the arena and the pitch of the shot never changes
|
||||
local k = 1 + BattleCam.PAN_DOLLY
|
||||
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
|
||||
local k = fixed and 1
|
||||
or 1 + BattleCam.PAN_DOLLY
|
||||
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
|
||||
local dx = (R.side * c - R.back * s) * k
|
||||
local dz = (R.side * s + R.back * c) * k
|
||||
|
||||
local eye = { mx + dx, groundY + R.height * k, mz + dz }
|
||||
local focus = { mx + R.lookX, groundY + R.lookY, mz }
|
||||
|
||||
-- and the climb: the eye swung UP about the focus, at a constant radius.
|
||||
-- About the focus so the aim stays nailed to the two mons and only the
|
||||
-- seat moves, and at a constant radius so climbing never changes how big
|
||||
-- anything is -- that is the lens's job below, and a rig that did both at
|
||||
-- once would have no way to do either on purpose.
|
||||
local lift = steered and BattleCam.pitch * BattleCam.PITCH_RANGE or 0
|
||||
if lift > 0 then
|
||||
local vx, vy, vz = eye[1] - focus[1], eye[2] - focus[2], eye[3] - focus[3]
|
||||
local flat = math.sqrt(vx * vx + vz * vz)
|
||||
local r = math.sqrt(flat * flat + vy * vy)
|
||||
if flat > 1e-6 and r > 1e-6 then
|
||||
local a = math.atan2(vy, flat) + lift
|
||||
-- short of straight down, always: the placed camera's up vector is
|
||||
-- world up, which degenerates against a view looking exactly along it
|
||||
a = math.min(a, math.rad(85))
|
||||
local nf = r * math.cos(a)
|
||||
eye[1] = focus[1] + vx / flat * nf
|
||||
eye[3] = focus[3] + vz / flat * nf
|
||||
eye[2] = focus[2] + r * math.sin(a)
|
||||
end
|
||||
end
|
||||
|
||||
local ex = eye[1] - focus[1]
|
||||
local ey = eye[2] - focus[2]
|
||||
local ez = eye[3] - focus[3]
|
||||
local dist = math.max(1, math.sqrt(ex * ex + ey * ey + ez * ez))
|
||||
local horiz = math.sqrt(ex * ex + ez * ez)
|
||||
|
||||
-- The lens carries the player's zoom: how much world the frame holds is
|
||||
-- the one thing that actually changes the framing here, because the field
|
||||
-- of view is DERIVED from that reach and the distance. Moving the eye
|
||||
-- instead would leave the picture the same size and only change its
|
||||
-- perspective -- which is what the dolly breath above is deliberately
|
||||
-- for, and is not what "zoom" means to anyone holding a wheel.
|
||||
local frameH = fixed and R.frameH or BattleCam.frameH(arena)
|
||||
return {
|
||||
eye = eye,
|
||||
focus = focus,
|
||||
fov = 2 * math.atan((R.frameH / 2) / dist),
|
||||
fov = 2 * math.atan((frameH / 2) / dist),
|
||||
-- the world curve is a free-roam flourish that bends the horizon away
|
||||
-- from the player; a fixed camera on a staged shot has no player to bend
|
||||
-- around, and the bend would tip the arena floor out from under the mons
|
||||
|
||||
+89
-1
@@ -210,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
|
||||
@@ -387,7 +472,10 @@ function BattleScene.render(state, arena, textures, token)
|
||||
local cx, cy = arena.mid[1], arena.mid[2]
|
||||
-- the world extents the sun frustum is fitted to; the camera itself is
|
||||
-- framed by cam.fov, so these only have to describe the ground in shot
|
||||
local vh = BattleCam.rigFor(arena).frameH * ph / (BattleScene.GB_H * s)
|
||||
-- the player's zoom is part of this: the sun's box is fitted to what the
|
||||
-- frame holds, so a shot pulled wide has to light the ground it just
|
||||
-- brought into view rather than the ground the rig alone would have
|
||||
local vh = BattleCam.frameH(arena) * ph / (BattleScene.GB_H * s)
|
||||
local vw = vh * pw / ph
|
||||
|
||||
-- the cards need the camera's eye to face it, so the rig has to be live
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
-- The player's own camera controls: zoom everywhere, and the battle's orbit.
|
||||
--
|
||||
-- This mod has four cameras, and by the time a wheel notch arrives they all
|
||||
-- want it. So one module owns the INPUTS and answers the only question that
|
||||
-- matters -- which camera is this aimed at -- rather than each camera
|
||||
-- growing its own wheel handler and racing the others for the event:
|
||||
--
|
||||
-- a staged battle the camera the fight is shot with (BattleCam): the
|
||||
-- wheel and Q/E work its lens, and the right stick,
|
||||
-- a drag or the mouse walk it around the arena.
|
||||
--
|
||||
-- the 3RD rung the boom behind the player's shoulder
|
||||
-- (ThirdPerson): the wheel, Q/E and a pinch let it
|
||||
-- out and pull it in.
|
||||
--
|
||||
-- an orbit rung the engine's own survey zoom, which the wheel has
|
||||
-- always driven -- so here the module mostly gets
|
||||
-- out of the way, and only ADDS the two keys and the
|
||||
-- pinch that the engine has no handler for.
|
||||
--
|
||||
-- the 1ST rung nothing. The eye is in the player's head; there is
|
||||
-- no distance to change, and a pinch there would
|
||||
-- silently wind the survey zoom for whenever they
|
||||
-- stepped back out. Inputs pass through untouched.
|
||||
--
|
||||
-- Every claim is answered by a GATE rather than by a mode flag, and every
|
||||
-- wrap forwards whatever it does not claim -- so with voxel mode off, and
|
||||
-- on every screen that is not the overworld or a battle, each byte flows
|
||||
-- exactly where it always did.
|
||||
--
|
||||
-- Installed AFTER FirstPerson (see main.lua), which makes these wraps the
|
||||
-- outer ones: a battle's controls get first refusal on the mouse and the
|
||||
-- touch screen, which is right, because while a fight is staged the
|
||||
-- free-roam look is not driving anyway.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local ThirdPerson = V.require("ThirdPerson")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
|
||||
local CamControl = {}
|
||||
|
||||
-- ------- tuning
|
||||
--
|
||||
-- PINCH_SLACK is how far apart two fingers must travel, as a ratio of
|
||||
-- their starting gap, before the gesture counts as a pinch at all -- below
|
||||
-- it a two-finger tap wobbles rather than zooms.
|
||||
--
|
||||
-- SURVEY_PINCH is how many of the engine's integer survey steps one
|
||||
-- doubling of the finger gap is worth. The survey ladder is coarse (whole
|
||||
-- pixels per world pixel), so a pinch has to be geared down or the first
|
||||
-- centimetre of travel crosses the whole range.
|
||||
CamControl.PINCH_SLACK = 0.02
|
||||
CamControl.SURVEY_PINCH = 2.2
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- A fight staged on the map, drawn and on screen. Asked of the shot rather
|
||||
-- than of the battle state, because the shot is exactly "there is a 3D
|
||||
-- battle in front of the player right now" -- with 3D-BTL off, or on a map
|
||||
-- with no arena, the engine's own flat battle screen is up and its camera
|
||||
-- is not ours to steer.
|
||||
-- BACK SPRITES also closes it, through BattleCam.steerable: that setting
|
||||
-- nails the player's own mon to the GB's slot on the menu while the foe
|
||||
-- stands out on the map, and no camera angle holds a composition that is
|
||||
-- half frame and half world (see BattleCam.steerable, which is where the
|
||||
-- reasoning lives and which the RIG answers to as well -- so a stored
|
||||
-- angle from before the setting was switched on stands down with it).
|
||||
local function battleLive()
|
||||
local ok, shot = pcall(function()
|
||||
return V.require("OverworldBattle").shot()
|
||||
end)
|
||||
return (ok and shot and BattleCam.steerable) and true or false
|
||||
end
|
||||
|
||||
CamControl.battleLive = battleLive
|
||||
|
||||
-- The free-roam overworld, with the 3D pass carrying it: the gate every
|
||||
-- zoom that is not a battle's answers to.
|
||||
local function roaming()
|
||||
return Voxel.active() and Voxel3D.available() and FirstPerson.onTop()
|
||||
end
|
||||
|
||||
-- Which camera a zoom is aimed at: "battle", "boom", "survey", or nil for
|
||||
-- nothing that zooms (1ST, or a screen with no camera of ours behind it).
|
||||
function CamControl.zoomTarget()
|
||||
if battleLive() then return "battle" end
|
||||
if not roaming() then return nil end
|
||||
if Voxel.isThirdPerson(Voxel.level) then return "boom" end
|
||||
if Voxel.isFirstPerson(Voxel.level) then return nil end
|
||||
return "survey"
|
||||
end
|
||||
|
||||
-- ------- zoom
|
||||
--
|
||||
-- `notches` is signed the way every zoom in this file is: POSITIVE pulls
|
||||
-- the camera OUT. The engine's own survey step runs the other way, and is
|
||||
-- negated at the one place it is called rather than everywhere else being
|
||||
-- bent to match it.
|
||||
--
|
||||
-- Returns true when the input was ours, which is what tells a wrap to stop
|
||||
-- rather than forward.
|
||||
|
||||
local function surveyStep(notches)
|
||||
local ok = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
Game:zoomStep(notches > 0 and -1 or 1)
|
||||
end)
|
||||
return ok
|
||||
end
|
||||
|
||||
function CamControl.zoomBy(notches)
|
||||
if not notches or notches == 0 then return false end
|
||||
local target = CamControl.zoomTarget()
|
||||
if target == "battle" then
|
||||
BattleCam.stepZoom(notches)
|
||||
return true
|
||||
elseif target == "boom" then
|
||||
ThirdPerson.stepZoom(notches)
|
||||
return true
|
||||
elseif target == "survey" then
|
||||
-- one call per notch: the engine's ladder is integer rungs, and a
|
||||
-- wheel spun hard should climb them all rather than one
|
||||
for _ = 1, math.min(8, math.abs(notches)) do surveyStep(notches) end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- A pinch's own scale: > 1 is fingers spreading, which means zoom IN
|
||||
-- (pull the world closer), which is a NEGATIVE notch count.
|
||||
function CamControl.pinchBy(factor)
|
||||
if not (factor and factor > 0) then return false end
|
||||
local target = CamControl.zoomTarget()
|
||||
if target == "boom" then
|
||||
return ThirdPerson.scaleZoom(1 / factor)
|
||||
elseif target == "battle" then
|
||||
-- battles take a pinch too: the wheel and the keys reach this camera
|
||||
-- and a phone has neither, so without it the lens would be the one
|
||||
-- control a touch screen could not work
|
||||
return BattleCam.stepZoom(math.log(1 / factor)
|
||||
/ math.log(BattleCam.ZOOM_STEP))
|
||||
elseif target == "survey" then
|
||||
CamControl.surveyAccum = (CamControl.surveyAccum or 0)
|
||||
+ math.log(factor) / math.log(2) * CamControl.SURVEY_PINCH
|
||||
local moved = false
|
||||
while CamControl.surveyAccum >= 1 do
|
||||
CamControl.surveyAccum = CamControl.surveyAccum - 1
|
||||
surveyStep(-1)
|
||||
moved = true
|
||||
end
|
||||
while CamControl.surveyAccum <= -1 do
|
||||
CamControl.surveyAccum = CamControl.surveyAccum + 1
|
||||
surveyStep(1)
|
||||
moved = true
|
||||
end
|
||||
return moved
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
CamControl.surveyAccum = 0
|
||||
|
||||
-- ------- the battle's orbit
|
||||
--
|
||||
-- Only ever the battle's: the free-roam rungs already steer their own look
|
||||
-- through FirstPerson, and these wraps sit outside it precisely so a fight
|
||||
-- can borrow the same devices without either of them growing a mode check.
|
||||
|
||||
-- The right stick, read as a rate off the axes FirstPerson's own wrap is
|
||||
-- already recording (it records whatever the rung, so a battle can read
|
||||
-- them without a second wrap on the same seam). Ticked from
|
||||
-- OverworldBattle.update, which runs whatever is on top of the stack.
|
||||
--
|
||||
-- X walks the shot round the arena, Y raises the seat. The Y is NEGATED:
|
||||
-- a stick pushed forward reads as negative on SDL's axis, and pushing
|
||||
-- forward should send the camera UP and over -- the same "push the camera
|
||||
-- where you want it" the drag and the mouse below use.
|
||||
function CamControl.tick(dt)
|
||||
if not battleLive() then return end
|
||||
local x, y = FirstPerson.stickX(), FirstPerson.stickY()
|
||||
if x ~= 0 then BattleCam.stickOrbit(x, dt) end
|
||||
if y ~= 0 then BattleCam.stickPitch(-y, dt) end
|
||||
end
|
||||
|
||||
-- ------- the wraps
|
||||
|
||||
local installed = false
|
||||
|
||||
function CamControl.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
|
||||
-- ------- the wheel
|
||||
--
|
||||
-- The engine's own handler is the survey zoom, so the wrap only has to
|
||||
-- take the notch away when some OTHER camera wants it; "survey" falls
|
||||
-- through to exactly the code that always ran.
|
||||
do
|
||||
local inner = Game.wheelmoved
|
||||
function Game:wheelmoved(dx, dy)
|
||||
local target = CamControl.zoomTarget()
|
||||
if (target == "battle" or target == "boom") and dy and dy ~= 0 then
|
||||
CamControl.zoomBy(dy > 0 and -1 or 1)
|
||||
return
|
||||
end
|
||||
return inner(self, dx, dy)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the stick clicks
|
||||
--
|
||||
-- Q and E, on the pad: the left stick's click pulls the camera out and the
|
||||
-- right stick's pulls it in. A controller has no wheel and no number row,
|
||||
-- and the two clicks are the only buttons a Gen 1 pad layout leaves free
|
||||
-- (SELECT already walks the angle ladder).
|
||||
--
|
||||
-- Claimed for the two cameras a pad player can actually be looking at
|
||||
-- while pressing them -- the third-person boom and a staged battle's lens
|
||||
-- -- and forwarded untouched everywhere else, so a player who has rebound
|
||||
-- either click keeps it on every other screen, a rebind capture included.
|
||||
-- Not on the orbit rungs: the survey zoom has the OPTIONS row and the
|
||||
-- wheel already, and taking a pad button for it would be taking one from
|
||||
-- a player who never asked.
|
||||
local CLICK_ZOOMS = { boom = true, battle = true }
|
||||
do
|
||||
local inner = Game.gamepadpressed
|
||||
function Game:gamepadpressed(joystick, button)
|
||||
if (button == "leftstick" or button == "rightstick")
|
||||
and CLICK_ZOOMS[CamControl.zoomTarget() or ""] then
|
||||
CamControl.zoomBy(button == "leftstick" and 1 or -1)
|
||||
return
|
||||
end
|
||||
return inner(self, joystick, button)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the mouse
|
||||
--
|
||||
-- Battle only. The free-roam look already owns relative motion through
|
||||
-- FirstPerson's own wrap (this one is outside it, so what is claimed here
|
||||
-- never reaches it) and a fight is exactly when that look is not driving.
|
||||
--
|
||||
-- Bare motion, no button held: moving the mouse moves the shot.
|
||||
--
|
||||
-- Each event's contribution is CLAMPED, though, because not every motion
|
||||
-- event is a hand moving. The pointer entering the window, a warp back to
|
||||
-- centre, an alt-tab -- each arrives as ONE event carrying the whole
|
||||
-- distance from wherever the cursor was last seen, and in testing that
|
||||
-- was a couple of hundred counts: enough to swing the shot a quarter of
|
||||
-- the way to side-on before the player had touched anything. A real hand
|
||||
-- delivers its travel as a stream of small events and is unaffected; a
|
||||
-- teleport delivers it as one and is cut down to the size of a flick.
|
||||
local MOUSE_STEP = 40
|
||||
local function clamp(v)
|
||||
return math.max(-MOUSE_STEP, math.min(MOUSE_STEP, v or 0))
|
||||
end
|
||||
do
|
||||
local inner = love.mousemoved
|
||||
love.mousemoved = function(x, y, dx, dy, istouch)
|
||||
if battleLive() and not istouch then
|
||||
-- dy is NEGATED for the same reason the stick's is: moving the
|
||||
-- mouse away from you sends the camera up and over
|
||||
if dx and dx ~= 0 then BattleCam.mouseOrbit(clamp(dx)) end
|
||||
if dy and dy ~= 0 then BattleCam.mousePitch(-clamp(dy)) end
|
||||
-- forwarded anyway: the cursor still has UI to point at, and the
|
||||
-- steer is a read of the motion rather than a claim on it
|
||||
end
|
||||
if inner then return inner(x, y, dx, dy, istouch) end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the touch screen
|
||||
--
|
||||
-- Two gestures, told apart by how many fingers are down on OPEN screen
|
||||
-- (the overlay's own d-pad and buttons are never either):
|
||||
--
|
||||
-- one finger, in a battle drags the shot around the arena
|
||||
-- two fingers pinch to zoom, wherever zooming means
|
||||
-- something -- and while they are down the
|
||||
-- free-roam look stands aside, so a pinch in
|
||||
-- 3RD does not also spin the view
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local free = {} -- id -> {x, y} for every finger on open screen
|
||||
local pinch = nil -- { a, b, gap } while two of them are pinching
|
||||
|
||||
local function freeCount()
|
||||
local n = 0
|
||||
for _ in pairs(free) do n = n + 1 end
|
||||
return n
|
||||
end
|
||||
|
||||
local function gapOf(a, b)
|
||||
local dx, dy = free[a].x - free[b].x, free[a].y - free[b].y
|
||||
return math.sqrt(dx * dx + dy * dy)
|
||||
end
|
||||
|
||||
-- Two free fingers and a camera that zooms: start measuring. The look
|
||||
-- drag is dropped for the duration -- FirstPerson never sees the moves
|
||||
-- below -- and re-seated on whichever finger survives, so the view does
|
||||
-- not jump by however far the pinch travelled.
|
||||
local function startPinch()
|
||||
if pinch or freeCount() < 2 then return end
|
||||
local ids = {}
|
||||
for id in pairs(free) do ids[#ids + 1] = id end
|
||||
local gap = gapOf(ids[1], ids[2])
|
||||
if gap < 16 then return end
|
||||
pinch = { a = ids[1], b = ids[2], gap = gap }
|
||||
CamControl.surveyAccum = 0
|
||||
pcall(FirstPerson.dropLook)
|
||||
end
|
||||
|
||||
local function endPinch(lifted)
|
||||
if not pinch then return end
|
||||
local survivor = nil
|
||||
for id in pairs(free) do
|
||||
if id ~= lifted then survivor = id break end
|
||||
end
|
||||
pinch = nil
|
||||
if survivor and free[survivor] then
|
||||
pcall(FirstPerson.reseatLook, survivor,
|
||||
free[survivor].x, free[survivor].y)
|
||||
end
|
||||
end
|
||||
|
||||
local function onControl(x, y)
|
||||
local hit = nil
|
||||
pcall(function() hit = TouchControls:hitTest(x, y) end)
|
||||
return hit
|
||||
end
|
||||
|
||||
-- Whether this module has any interest in touches at all this frame.
|
||||
-- Kept deliberately wide -- a battle, or anything that zooms -- because
|
||||
-- the wrap forwards everything it does not claim regardless.
|
||||
local function wantsTouch()
|
||||
return battleLive() or CamControl.zoomTarget() ~= nil
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchpressed
|
||||
function Game:touchpressed(id, x, y)
|
||||
if wantsTouch() and not onControl(x, y) then
|
||||
free[id] = { x = x, y = y }
|
||||
if CamControl.zoomTarget() then startPinch() end
|
||||
-- forwarded even so: a single free finger is the free-roam look's
|
||||
-- to claim (FirstPerson's wrap is inside this one), and in a
|
||||
-- battle it is nobody's until it MOVES
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchmoved
|
||||
function Game:touchmoved(id, x, y)
|
||||
local f = free[id]
|
||||
if f then
|
||||
local px, py = f.x, f.y
|
||||
f.x, f.y = x, y
|
||||
if pinch and (id == pinch.a or id == pinch.b) then
|
||||
local gap = gapOf(pinch.a, pinch.b)
|
||||
local factor = gap / math.max(1, pinch.gap)
|
||||
if math.abs(factor - 1) > CamControl.PINCH_SLACK then
|
||||
CamControl.pinchBy(factor)
|
||||
pinch.gap = gap
|
||||
end
|
||||
return -- claimed: never a look drag too
|
||||
end
|
||||
if battleLive() and not pinch then
|
||||
local w, h = 1280, 720
|
||||
pcall(function()
|
||||
w, h = love.graphics.getWidth(), love.graphics.getHeight()
|
||||
end)
|
||||
BattleCam.dragOrbit((x - px) / math.max(320, w))
|
||||
-- dragged UP sends the camera up and over, the same way the
|
||||
-- stick and the mouse do
|
||||
BattleCam.dragPitch(-(y - py) / math.max(240, h))
|
||||
return
|
||||
end
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local inner = Game.touchreleased
|
||||
function Game:touchreleased(id, x, y)
|
||||
if free[id] then
|
||||
if pinch and (id == pinch.a or id == pinch.b) then endPinch(id) end
|
||||
free[id] = nil
|
||||
end
|
||||
return inner(self, id, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
-- a reset that drops held input state drops ours with it, exactly as the
|
||||
-- free-roam look's does
|
||||
do
|
||||
local inner = Game.focus
|
||||
function Game:focus(f)
|
||||
free, pinch = {}, nil
|
||||
CamControl.surveyAccum = 0
|
||||
return inner(self, f)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return CamControl
|
||||
+230
-24
@@ -1,4 +1,4 @@
|
||||
-- Voxel world mode: the first-person camera -- the 1ST rung.
|
||||
-- Voxel world mode: the free-roam camera -- the 1ST and 3RD rungs.
|
||||
--
|
||||
-- Every other rung is the same camera at a different pitch: an orbit over
|
||||
-- the view centre, described by one number. 1ST is a different rig
|
||||
@@ -9,6 +9,13 @@
|
||||
-- uniforms, project(), the sky's vanishing line, the water's lean -- reads
|
||||
-- eye and focus the same way it always has.
|
||||
--
|
||||
-- 3RD is that same rig with the eye pulled back onto a boom behind the
|
||||
-- player's shoulder (lib/ThirdPerson.lua). Everything in this file is
|
||||
-- already general over where the eye stands -- the attitude, the look
|
||||
-- inputs, the move intent, the cards that turn to face the eye -- so the
|
||||
-- third-person rung is one number applied at the very end of frame(),
|
||||
-- rather than a second camera to keep in step with this one.
|
||||
--
|
||||
-- What this module owns:
|
||||
--
|
||||
-- the ATTITUDE yaw and pitch, fed by whichever look input speaks:
|
||||
@@ -47,6 +54,7 @@ local Mat4 = V.require("Mat4")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local WorldCurve = V.require("WorldCurve")
|
||||
local ThirdPerson = V.require("ThirdPerson")
|
||||
|
||||
local FirstPerson = {}
|
||||
|
||||
@@ -102,6 +110,15 @@ 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
|
||||
@@ -133,21 +150,62 @@ end
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- Whether the 1ST rung is selected and the 3D pass can carry it.
|
||||
-- Whether a free-roam rung -- 1ST or 3RD -- is selected and the 3D pass can
|
||||
-- carry it. Both stand the camera with the player, so both read the look
|
||||
-- inputs, both walk free, and both turn the cards; how far behind the head
|
||||
-- the eye ends up is ThirdPerson's business alone.
|
||||
function FirstPerson.engaged()
|
||||
return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available()
|
||||
return Voxel.isFreeCam(Voxel.level) and Voxel3D.available()
|
||||
end
|
||||
|
||||
-- Whether the overworld is what the player is looking at: nothing pushed
|
||||
-- over it, so the buttons are free-roam's. Shared with everything else in
|
||||
-- the mod that asks the same question of the same stack (CamControl's
|
||||
-- zooms above all), rather than each restating the pcall.
|
||||
function FirstPerson.onTop()
|
||||
local ok, top, ow = pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
return Game.stack and Game.stack:top(), Game.overworld
|
||||
end)
|
||||
return ok and top ~= nil and top == ow
|
||||
end
|
||||
|
||||
-- Whether first person should be READING the player's inputs right now:
|
||||
-- engaged, with the overworld on top of the stack (a menu, a dialog or a
|
||||
-- battle above it owns the buttons, exactly as it does for grid walking).
|
||||
function FirstPerson.driving()
|
||||
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
|
||||
return FirstPerson.engaged() and FirstPerson.onTop()
|
||||
end
|
||||
|
||||
-- The right stick's live X, for a camera that is not this one: while a
|
||||
-- battle is staged the free-roam look is not driving, but the axes are
|
||||
-- still arriving on the wrap below (which records whatever the rung), and
|
||||
-- the battle's orbit wants them. Reading them here rather than wrapping
|
||||
-- the same seam twice.
|
||||
function FirstPerson.stickX()
|
||||
return stick.x or 0
|
||||
end
|
||||
|
||||
function FirstPerson.stickY()
|
||||
return stick.y or 0
|
||||
end
|
||||
|
||||
-- ------- lending the look finger out
|
||||
--
|
||||
-- A pinch needs both fingers on the screen, and one of them is very likely
|
||||
-- the finger this module claimed as the look drag. Rather than have the
|
||||
-- pinch fight for it, CamControl asks for it: dropLook while the gesture
|
||||
-- runs, reseatLook on whichever finger survives it. Re-seating rather than
|
||||
-- simply releasing is what stops the view snapping by however far the
|
||||
-- pinch travelled -- the finger carries on as the look drag from where it
|
||||
-- now is, which is what it looks like it should do.
|
||||
function FirstPerson.dropLook()
|
||||
lookTouch = nil
|
||||
end
|
||||
|
||||
function FirstPerson.reseatLook(id, x, y)
|
||||
if id == nil then lookTouch = nil return end
|
||||
lookTouch = { id = id, x = x, y = y }
|
||||
end
|
||||
|
||||
-- The eased blend, 0 at the orbit and 1 in the head.
|
||||
@@ -164,11 +222,30 @@ function FirstPerson.cardBlend()
|
||||
return ease(FirstPerson.blend)
|
||||
end
|
||||
|
||||
-- A VR eye stepping into the rig's shoes: the VR pass builds its own
|
||||
-- placed cameras (one per eye) and hands each one here as it draws, so
|
||||
-- everything keyed to "the first-person rig is drawing" -- the billboard
|
||||
-- yaw, the frame remap, the hidden player card -- answers for that eye.
|
||||
-- In the diorama (blend 0) adoption is inert: cardBlend still reports
|
||||
-- zero and the cards keep their lean.
|
||||
function FirstPerson.adoptVReye(record)
|
||||
rig = record
|
||||
end
|
||||
|
||||
-- Whether the player's own card should be left out of the camera draw:
|
||||
-- deep enough into the blend that the card would fill the lens from
|
||||
-- inside. The sun pass keeps drawing it either way -- a first-person
|
||||
-- player still throws a shadow on the ground ahead.
|
||||
--
|
||||
-- Never while 3RD's boom is genuinely out, whatever the blend: the whole
|
||||
-- point of a boom is that the character it is booming away from is on
|
||||
-- screen. (Nor the silhouette that rides the same answer -- seeing your own
|
||||
-- outline through the building you just walked behind is what a
|
||||
-- third-person camera owes the player.) A boom SQUEEZED into the head by a
|
||||
-- wall answers false there and the card comes out again, because at that
|
||||
-- range it is the first-person problem word for word.
|
||||
function FirstPerson.hidePlayer()
|
||||
if ThirdPerson.showsPlayer() then return false end
|
||||
return FirstPerson.cardBlend() > 0.9
|
||||
end
|
||||
|
||||
@@ -183,17 +260,79 @@ function FirstPerson.lookBy(dyaw, dpitch)
|
||||
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)
|
||||
-- A bearing as one of the grid's four directions -- the 45-degree
|
||||
-- quantisation every facing in this file is made with, in one place so the
|
||||
-- compass, the body and the card frames can never disagree about where a
|
||||
-- boundary is.
|
||||
local function facingOf(a)
|
||||
local s, c = math.sin(a), math.cos(a)
|
||||
if math.abs(s) > math.abs(c) then
|
||||
return s > 0 and "right" or "left"
|
||||
end
|
||||
return c > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
-- The view direction's flat compass facing, for everything that still
|
||||
-- thinks in the grid's four directions: the cell A interacts with, the
|
||||
-- sprite the sun sees, the direction a blocked slide bonks in.
|
||||
function FirstPerson.compassFacing()
|
||||
return facingOf(FirstPerson.yaw)
|
||||
end
|
||||
|
||||
-- Which way the BODY points, as a continuous world bearing, given the
|
||||
-- world-space direction it is walking (0, 0 while standing). In the head,
|
||||
-- the body is the head: you face what you look at. On the boom you can see
|
||||
-- yourself, and a character sliding sideways while facing the lens reads as
|
||||
-- a bug rather than as a strafe -- so a walking body turns to face its own
|
||||
-- travel, and a standing one comes back round to the camera's bearing,
|
||||
-- which is the one A talks along.
|
||||
function FirstPerson.bodyBearing(wx, wz)
|
||||
if ThirdPerson.extended() and wx and wz and (wx ~= 0 or wz ~= 0) then
|
||||
return math.atan2(wx, wz)
|
||||
end
|
||||
return FirstPerson.yaw
|
||||
end
|
||||
|
||||
-- The same answer as one of the four facings, which is what the grid game
|
||||
-- (and the sprite sheet) reasons in.
|
||||
function FirstPerson.bodyFacing(wx, wz)
|
||||
return facingOf(FirstPerson.bodyBearing(wx, wz))
|
||||
end
|
||||
|
||||
-- ------- the body's live bearing
|
||||
--
|
||||
-- The bearing the player's own body is actually pointing along RIGHT NOW,
|
||||
-- or nil whenever the free walk is not the thing pointing it (a scripted
|
||||
-- move, a cutscene, the grid walk with the rung off). FreeMove maintains
|
||||
-- it; only the player's own card reads it.
|
||||
--
|
||||
-- It exists because the card's frame is chosen by the angle BETWEEN the
|
||||
-- body and the eye, and quantising the body to a compass direction first
|
||||
-- throws away exactly the precision that choice needs. A standing body is
|
||||
-- pointed along the camera's own yaw, so the true angle between them is a
|
||||
-- flat 180 degrees and the card should show its back and nothing else --
|
||||
-- but snap the body to one of four directions on the game tick, then
|
||||
-- measure it against an eye that has kept turning since, and the pair can
|
||||
-- read as 135 degrees and pick the PROFILE frame instead. Spin the camera
|
||||
-- fast and the character flicks to a mirrored side view for a frame or
|
||||
-- two. Keeping the bearing continuous gives the measurement a full 45
|
||||
-- degrees of slack before it can cross a boundary, which no frame's worth
|
||||
-- of turning comes close to spending.
|
||||
FirstPerson.bodyYaw = nil
|
||||
|
||||
-- Point the body along the direction it is walking (or, standing, along
|
||||
-- the camera): records the continuous bearing and hands back the compass
|
||||
-- facing the caller wants for p.facing.
|
||||
function FirstPerson.pointBody(wx, wz)
|
||||
FirstPerson.bodyYaw = FirstPerson.bodyBearing(wx, wz)
|
||||
return facingOf(FirstPerson.bodyYaw)
|
||||
end
|
||||
|
||||
-- Hand the body back to whatever else is turning it.
|
||||
function FirstPerson.releaseBody()
|
||||
FirstPerson.bodyYaw = nil
|
||||
end
|
||||
|
||||
-- The unit look direction, and its flat (ground-plane) part.
|
||||
local function lookDir()
|
||||
local cp = math.cos(FirstPerson.pitch)
|
||||
@@ -224,21 +363,43 @@ function FirstPerson.cardYaw(wx, wz)
|
||||
return math.atan2(dx, dz)
|
||||
end
|
||||
|
||||
-- Which of the four sprite frames a body at world bearing `phi` shows an
|
||||
-- eye looking at (wx, wz): the bearing rotated into the viewer's own frame,
|
||||
-- quantised. nil when there is no rig to be seen from.
|
||||
local function frameFor(phi, wx, wz)
|
||||
local eye = rig and rig.eye
|
||||
if not (eye and phi) then return nil end
|
||||
local dx, dz = eye[1] - wx, eye[3] - wz
|
||||
if dx * dx + dz * dz < 1e-9 then return nil end
|
||||
local rel = wrapPi(phi - math.atan2(dx, dz))
|
||||
local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4
|
||||
return FACING_ORDER[idx + 1]
|
||||
end
|
||||
|
||||
-- Which of the four sprite frames an entity shows THIS eye: its facing
|
||||
-- rotated into the viewer's own frame, quantised. The flat game's frames
|
||||
-- are "how this pose looks from the south", so the apparent facing is the
|
||||
-- pose rotated by where the viewer actually stands -- walk behind an NPC
|
||||
-- and you see their back, circle to their flank and you see the profile,
|
||||
-- exactly as the four frames Gen 1 drew intend.
|
||||
--
|
||||
-- An NPC's facing IS one of the four and nothing finer, so this is the
|
||||
-- whole story for everyone in the world except the one body the camera is
|
||||
-- attached to -- see playerFacing.
|
||||
function FirstPerson.apparentFacing(facing, wx, wz)
|
||||
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]
|
||||
return frameFor(FACING_ANGLE[facing], wx, wz) or facing
|
||||
end
|
||||
|
||||
-- The PLAYER's own card, which is the one case where the body's bearing is
|
||||
-- known to better than a compass point (bodyYaw, above) -- and the one case
|
||||
-- where it matters, because the eye is derived FROM that bearing rather
|
||||
-- than independent of it. Measured continuously, a standing body reads as
|
||||
-- a flat 180 degrees from its own camera and shows its back, steadily,
|
||||
-- however fast the camera is spun. Falls back to the four-direction answer
|
||||
-- whenever something other than the free walk is turning the body.
|
||||
function FirstPerson.playerFacing(facing, wx, wz)
|
||||
return frameFor(FirstPerson.bodyYaw, wx, wz)
|
||||
or FirstPerson.apparentFacing(facing, wx, wz)
|
||||
end
|
||||
|
||||
-- ------- the move intent
|
||||
@@ -334,6 +495,12 @@ function FirstPerson.update(dt)
|
||||
rig = nil
|
||||
end
|
||||
|
||||
-- the boom, on the same tick and for the same reason: it has to keep
|
||||
-- easing after 3RD is left, and it needs the blend to know whether a
|
||||
-- change of rung is a SLIDE (already inside the world, 1ST <-> 3RD) or
|
||||
-- part of the dive in from the orbit, which carries the eye anyway
|
||||
ThirdPerson.update(dt, FirstPerson.blend)
|
||||
|
||||
-- mouse capture follows engagement: captured whenever the rung is on and
|
||||
-- the window has focus, released the moment either ends. Checked against
|
||||
-- the live mode rather than toggled on edges, so a capture lost to the
|
||||
@@ -434,6 +601,12 @@ function FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
head[2] + ly * FirstPerson.FOCUS_DIST,
|
||||
head[3] + lz * FirstPerson.FOCUS_DIST }
|
||||
|
||||
-- 3RD: the eye walks back off the head along the very direction it looks,
|
||||
-- as far as the world allows. Fully in (1ST, and every frame of the
|
||||
-- diorama) this hands back the head and the focus untouched, so the two
|
||||
-- rungs are one rig with one number between them.
|
||||
local camEye, camFocus = ThirdPerson.place(head, lx, ly, lz, fpFocus)
|
||||
|
||||
local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh)
|
||||
local function mix(p, q)
|
||||
return { p[1] + (q[1] - p[1]) * e,
|
||||
@@ -452,8 +625,8 @@ function FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
local k = WorldCurve.k(vh) * (1 - e)
|
||||
|
||||
rig = {
|
||||
eye = mix(oEye, head),
|
||||
focus = mix(oFocus, fpFocus),
|
||||
eye = mix(oEye, camEye),
|
||||
focus = mix(oFocus, camFocus),
|
||||
fov = oFov + (FirstPerson.FOV - oFov) * e,
|
||||
up = up,
|
||||
curve = k,
|
||||
@@ -493,6 +666,9 @@ function FirstPerson.signature()
|
||||
math.floor(b * 64),
|
||||
math.floor(FirstPerson.yaw * 64),
|
||||
math.floor(FirstPerson.pitch * 64),
|
||||
-- and how far back the boom stands the eye: a wall shortening it moves
|
||||
-- the camera the sun's box is fitted around, standing still or not
|
||||
ThirdPerson.signature(),
|
||||
}, ",")
|
||||
end
|
||||
|
||||
@@ -585,11 +761,30 @@ function FirstPerson.install()
|
||||
-- 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
|
||||
@@ -602,6 +797,9 @@ function FirstPerson.install()
|
||||
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
|
||||
@@ -641,6 +839,14 @@ function FirstPerson.install()
|
||||
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
|
||||
|
||||
+67
-25
@@ -1,11 +1,16 @@
|
||||
-- Voxel world mode: free movement for the first-person rung.
|
||||
-- Voxel world mode: free movement for the free-roam rungs.
|
||||
--
|
||||
-- The engine walks a grid: sixteen frames per cell, four directions,
|
||||
-- input locked mid-step. Inside a 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.
|
||||
-- input locked mid-step. Inside a camera that stands with the player that
|
||||
-- gait reads as riding a rail, so while 1ST or 3RD drives, this module
|
||||
-- replaces the WALK and nothing else: the player's position becomes
|
||||
-- continuous, steered by the camera's own yaw -- push forward and you go
|
||||
-- where you look, at any angle, sliding along whatever you graze.
|
||||
--
|
||||
-- Both rungs walk identically: the boom behind the shoulder (3RD) changes
|
||||
-- where the eye stands, not which way it points, and the walk was always
|
||||
-- rotated by the YAW. The one thing it does change is which way the body
|
||||
-- POINTS while it moves -- see bodyFacing in the tick.
|
||||
--
|
||||
-- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact
|
||||
-- about cells -- what blocks, what warps, what rustles, what bites -- and
|
||||
@@ -73,6 +78,11 @@ end
|
||||
|
||||
function FreeMove.drop()
|
||||
pos = nil
|
||||
-- and the body with it: while something else is walking the player --
|
||||
-- a scripted move, a ledge hop, the grid walk off the rung -- the
|
||||
-- engine's own four-direction facing is the whole truth about which way
|
||||
-- they point, so the card must stop reading our finer one
|
||||
FirstPerson.releaseBody()
|
||||
end
|
||||
|
||||
-- named for the suite: the module's live position, nil while dropped
|
||||
@@ -177,12 +187,21 @@ end
|
||||
-- ------- the blocked push
|
||||
--
|
||||
-- The grid game's blocked step is where half its verbs live: the map-edge
|
||||
-- crossing, the ledge hop, the boulder shove, the route-gate warp fired
|
||||
-- by collision, and the honest bonk. Hand the engine the quantised
|
||||
-- direction and let its own handlers decide -- each one validates itself
|
||||
-- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds),
|
||||
-- so calling them on every firm push is safe. Returns true when one of
|
||||
-- them took the frame over.
|
||||
-- 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
|
||||
@@ -199,13 +218,14 @@ local function pushSpecials(state, dir, why)
|
||||
return true
|
||||
end
|
||||
end
|
||||
if why ~= "entity" then
|
||||
if (state.bumpCooldown or 0) <= 0 then
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").play(Game.data, "Collision")
|
||||
state.bumpCooldown = 16
|
||||
end
|
||||
end
|
||||
-- and NO bonk. The grid walk's collision sound marks a discrete event:
|
||||
-- you pressed a direction, the step was refused, nothing happened. A
|
||||
-- free walk has no such moment -- the body slides along every wall it
|
||||
-- grazes, continuously, and a corridor taken at a slight angle is a
|
||||
-- steady graze from end to end. Rate-limited or not, that came out as a
|
||||
-- machine-gun of bonks for walking normally down a hallway. The wall
|
||||
-- stopping you is the feedback; the sound only ever said so twice a
|
||||
-- second whether or not anything had changed.
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -231,14 +251,26 @@ function FreeMove.tick(state)
|
||||
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()
|
||||
-- which way a bonk points. (A body that is WALKING may turn along its
|
||||
-- travel instead -- see below, once there is a travel to turn along; a
|
||||
-- standing one always faces where the camera looks, which is what makes
|
||||
-- A predictable.) pointBody rather than compassFacing, so the card also
|
||||
-- gets the CONTINUOUS bearing behind that compass point.
|
||||
p.facing = FirstPerson.pointBody(0, 0)
|
||||
|
||||
if input:wasPressed("a") then
|
||||
-- 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 input:wasPressed("start") then
|
||||
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
|
||||
@@ -266,6 +298,16 @@ function FreeMove.tick(state)
|
||||
|
||||
if not moving then return end
|
||||
|
||||
-- and once there IS a direction of travel, the body may point along it
|
||||
-- rather than along the head: on the boom (3RD) you can see yourself, so
|
||||
-- a strafe has to look like walking sideways. In the head it is the head
|
||||
-- either way -- bodyBearing says so.
|
||||
p.facing = FirstPerson.pointBody(wx, wz)
|
||||
|
||||
-- the engine's own bonk clock, kept draining while the free walk has the
|
||||
-- wheel: nothing here rings it (see pushSpecials), but stepping back onto
|
||||
-- the grid must not inherit a cooldown frozen at whatever it held when
|
||||
-- the rung was picked
|
||||
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
|
||||
|
||||
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
|
||||
@@ -307,8 +349,8 @@ function FreeMove.tick(state)
|
||||
FreeMove.drop()
|
||||
return
|
||||
end
|
||||
-- the push handlers may have turned the facing; the head still rules
|
||||
p.facing = FirstPerson.compassFacing()
|
||||
-- the push handlers may have turned the facing; the walk still rules
|
||||
p.facing = FirstPerson.pointBody(wx, wz)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
+707
@@ -0,0 +1,707 @@
|
||||
-- HORDE MODE: the code, the dark, and the way back.
|
||||
--
|
||||
-- Up Up Down Down Left Right Left Right B A, standing in the overworld,
|
||||
-- and Kanto turns on you: the sky goes to a starless violet night, the
|
||||
-- Lavender Town theme comes up, the camera locks into the player's own
|
||||
-- head, a handgun appears in their right hand, and waves of people walk
|
||||
-- out of the dark to kill them. Score goes up per kill; when the health
|
||||
-- runs out a GAME OVER screen offers a score and PRESS A, and pressing it
|
||||
-- puts everything back exactly as it was.
|
||||
--
|
||||
-- WHAT THIS FILE OWNS: the code detector, the state machine, the snapshot
|
||||
-- and its restore, and every hook that holds the world still while the
|
||||
-- mode runs. The gun is lib/HordeGun, the crowd is lib/HordeMobs, the
|
||||
-- readout is lib/HordeHud, the sounds are lib/HordeSfx and the ending is
|
||||
-- lib/HordeGameOver.
|
||||
--
|
||||
-- IT IS NOT A STACK STATE, and that is the load-bearing decision. Pushing
|
||||
-- a state over the overworld stops StateStack ticking the overworld,
|
||||
-- which stops OverworldState:handleInput, which stops FreeMove -- the
|
||||
-- player would be unable to walk. So horde mode is a MODE FLAG driven
|
||||
-- from the voxel pipeline's update hook, exactly as lib/OverworldBattle
|
||||
-- rides it: the one tick that keeps running through menus, transitions
|
||||
-- and battles. The GAME OVER screen IS a pushed state, because by then
|
||||
-- the walking is over and freezing the world under it is the point.
|
||||
--
|
||||
-- THE CODE IS READ OFF GAME BOY BUTTONS, not off keys. Every input device
|
||||
-- the engine has -- keyboard, gamepad, raw joystick, the touch overlay,
|
||||
-- and the VR controllers (lib/VR.driveControls feeds Input:overlayPressed
|
||||
-- and the stick path) -- lands in src/core/Input as one of eight buttons.
|
||||
-- One detector on that abstraction is therefore a detector on ALL of
|
||||
-- them, which is why the code works on a headset with no keyboard in the
|
||||
-- room. It reads Input.pressQueue from the `input.step` hook, the fixed
|
||||
-- step's own boundary, so it sees every edge exactly once whatever the
|
||||
-- frame rate did.
|
||||
--
|
||||
-- THE DARK is not a new renderer. DayNight is pinned to NIGHT and then
|
||||
-- its two public colour functions are WRAPPED and multiplied down toward
|
||||
-- violet -- so the sky bands, the world tint, the flat 2D world (DayTint
|
||||
-- paints the same multiply), the water's reflection and the shadow rig
|
||||
-- all darken together, because every one of them already reads those two
|
||||
-- functions. Wrapped rather than edited in place because both memoise
|
||||
-- into file-local caches this module cannot reach.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel = V.require("VoxelState")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local DayNight = V.require("DayNight")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local HordeSfx = V.require("HordeSfx")
|
||||
|
||||
local Horde = {}
|
||||
|
||||
-- lib modules that require THIS one back (the mobs read the session, the
|
||||
-- gun reports kills). Loaded on first use rather than at the top, so the
|
||||
-- require cycle never closes.
|
||||
local Mobs, Gun, Hud
|
||||
local function parts()
|
||||
Mobs = Mobs or V.require("HordeMobs")
|
||||
Gun = Gun or V.require("HordeGun")
|
||||
Hud = Hud or V.require("HordeHud")
|
||||
return Mobs, Gun, Hud
|
||||
end
|
||||
|
||||
-- ------- tuning
|
||||
--
|
||||
-- Every number the mode is balanced on, in one place.
|
||||
|
||||
Horde.MAX_HP = 100
|
||||
Horde.CONTACT_DAMAGE = 9 -- one mob's touch
|
||||
Horde.INTRO_TIME = 3.6 -- the beat before the first wave
|
||||
Horde.DYING_TIME = 1.1 -- from the last hit to the GAME OVER card
|
||||
Horde.SONG = "Music_Lavender"
|
||||
|
||||
-- how far down NIGHT is dragged. The sky's bands and the world tint are
|
||||
-- multiplied by these; the third is how much of the colour is pulled out
|
||||
-- on the way (1 keeps it, 0 is greyscale) -- a little desaturation is
|
||||
-- what turns "dark" into "grim".
|
||||
Horde.GLOOM_SKY = { 0.34, 0.30, 0.46 }
|
||||
Horde.GLOOM_WORLD = { 0.42, 0.38, 0.56 }
|
||||
Horde.GLOOM_INDOOR = { 0.55, 0.50, 0.68 }
|
||||
Horde.GLOOM_SAT = 0.72
|
||||
Horde.SHADOW_BOOST = 1.45 -- the moon presses harder than it should
|
||||
|
||||
-- ------- state
|
||||
|
||||
Horde.active = false -- every hook in this file gates on it
|
||||
Horde.state = "idle" -- idle | intro | active | dying | gameover
|
||||
Horde.session = nil
|
||||
|
||||
-- Whether the combat is live: mobs move, the gun fires, damage lands.
|
||||
-- False during the intro beat, the death fade, the GAME OVER card -- and
|
||||
-- while ANYTHING is on the stack above the overworld, which is what
|
||||
-- stops the trigger from firing into a world the player has stopped
|
||||
-- looking at while the exit prompt asks them a question.
|
||||
function Horde.playing()
|
||||
if not (Horde.active and Horde.state == "active") then return false end
|
||||
local ok, live = pcall(function()
|
||||
local G = require("src.core.Game")
|
||||
local ow = G.overworld
|
||||
return G.stack and ow and G.stack:top() == ow and not ow.transitioning
|
||||
end)
|
||||
return ok and live == true
|
||||
end
|
||||
|
||||
-- Whether the mode owns the camera rung right now, which is the whole of
|
||||
-- what "locked to first person" means: main.lua's cycleVoxel refuses
|
||||
-- while this is true, and that one function is what the 3 key, the pad's
|
||||
-- SELECT and the VR stick click all call.
|
||||
function Horde.viewLocked()
|
||||
return Horde.active
|
||||
end
|
||||
|
||||
-- Whether the free walk should skip its A (talk) and START (menu)
|
||||
-- branches. Nobody stops to read a sign mid-firefight, and START has a
|
||||
-- different job here (see askExit).
|
||||
function Horde.suppressWorldInput()
|
||||
return Horde.active
|
||||
end
|
||||
|
||||
local function game()
|
||||
local ok, G = pcall(require, "src.core.Game")
|
||||
return ok and G or nil
|
||||
end
|
||||
|
||||
local function overworld(G)
|
||||
G = G or game()
|
||||
return G and G.overworld or nil
|
||||
end
|
||||
|
||||
-- The way out, on demand. START -- the pad's, the keyboard's ESCAPE, the
|
||||
-- touch overlay's -- and the VR left stick click all ask this, and it
|
||||
-- asks the player. Nothing here ends the mode; the prompt does that
|
||||
-- through Horde.finish if the answer is yes.
|
||||
--
|
||||
-- Refused while anything is already on top of the overworld, so the
|
||||
-- question cannot stack on itself or arrive over the GAME OVER card.
|
||||
--
|
||||
-- BELOW the two helpers above, deliberately: a Lua local is only in
|
||||
-- scope after its declaration, so a function written above them captures
|
||||
-- the GLOBAL of that name instead -- which is nil, and only says so when
|
||||
-- somebody presses the button.
|
||||
function Horde.askExit(G)
|
||||
G = G or game()
|
||||
if not (Horde.active and Horde.state ~= "gameover") then return false end
|
||||
local ow = overworld(G)
|
||||
if not (G and G.stack and ow and G.stack:top() == ow) then return false end
|
||||
if ow.transitioning then return false end
|
||||
local pushed = false
|
||||
pcall(function()
|
||||
require("src.ui.Screens").push(G, "HordeExitPrompt")
|
||||
pushed = true
|
||||
end)
|
||||
return pushed
|
||||
end
|
||||
|
||||
-- ------- the code
|
||||
--
|
||||
-- Advance on the expected button; on a wrong one, fall back to the
|
||||
-- longest run already entered that is still a valid start of the code,
|
||||
-- and try again from there. That fallback is why this is a table rather
|
||||
-- than a counter: the code STARTS with a repeat, so a player who presses
|
||||
-- Up three times has, on the third, still entered "Up Up" -- and a naive
|
||||
-- "wrong button, back to the beginning" rule would throw one of them
|
||||
-- away and refuse a code that was in fact typed correctly. (It is the
|
||||
-- prefix function from Knuth-Morris-Pratt, over ten buttons.)
|
||||
--
|
||||
-- The timeout is in fixed steps, 60 to the second: a code is a deliberate
|
||||
-- act, and a stray Up a minute ago should not be half of one.
|
||||
|
||||
local SEQUENCE = { "up", "up", "down", "down",
|
||||
"left", "right", "left", "right", "b", "a" }
|
||||
local IDLE_STEPS = 150 -- two and a half seconds between buttons
|
||||
|
||||
-- FALLBACK[n] = how much of the code is still entered after n matched
|
||||
-- buttons and then a wrong one
|
||||
local FALLBACK = { [0] = 0, [1] = 0 }
|
||||
do
|
||||
local k = 0
|
||||
for i = 2, #SEQUENCE do
|
||||
while k > 0 and SEQUENCE[k + 1] ~= SEQUENCE[i] do k = FALLBACK[k] end
|
||||
if SEQUENCE[k + 1] == SEQUENCE[i] then k = k + 1 end
|
||||
FALLBACK[i] = k
|
||||
end
|
||||
end
|
||||
|
||||
local progress = 0
|
||||
local sinceLast = 0
|
||||
|
||||
-- Named for the suite: how far into the code the detector has got.
|
||||
function Horde._progress()
|
||||
return progress
|
||||
end
|
||||
|
||||
local function resetCode()
|
||||
progress, sinceLast = 0, 0
|
||||
end
|
||||
|
||||
-- Can the mode start from where the player is standing? The overworld has
|
||||
-- to be the live state (not a menu, not a battle, not a transition wipe),
|
||||
-- the 3D pass has to exist to put a camera inside, and the world has to be
|
||||
-- free-roaming rather than mid-cutscene.
|
||||
--
|
||||
-- MID-STEP IS ALLOWED, and that is not an oversight. Six of the code's ten
|
||||
-- buttons are directions, so entering it on a d-pad walks the player four
|
||||
-- cells across the map -- and at the moment the closing A lands they are
|
||||
-- very often still animating the last of those steps. Refusing a code for
|
||||
-- being mid-step would refuse most of the codes anyone actually enters.
|
||||
-- The snapshot records the cell the step began from, which is where the
|
||||
-- restore puts them back.
|
||||
function Horde.canStart(G)
|
||||
G = G or game()
|
||||
if not G or Horde.active then return false end
|
||||
local ow = overworld(G)
|
||||
if not (ow and ow.map and ow.player) then return false end
|
||||
if not (G.stack and G.stack:top() == ow) then return false end
|
||||
if ow.transitioning or ow.scripted or ow.engaging then return false end
|
||||
if ow.player.inputLocked then return false end
|
||||
if not Voxel3D.available() then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- One fixed step of the detector, over the edges about to be promoted.
|
||||
-- Separated from the hook so the suite can drive it with a plain list.
|
||||
function Horde.feed(queue)
|
||||
if Horde.active then
|
||||
resetCode()
|
||||
return false
|
||||
end
|
||||
sinceLast = sinceLast + 1
|
||||
if progress > 0 and sinceLast > IDLE_STEPS then resetCode() end
|
||||
local fired = false
|
||||
for _, btn in ipairs(queue or {}) do
|
||||
sinceLast = 0
|
||||
while progress > 0 and SEQUENCE[progress + 1] ~= btn do
|
||||
progress = FALLBACK[progress]
|
||||
end
|
||||
if SEQUENCE[progress + 1] == btn then
|
||||
progress = progress + 1
|
||||
if progress >= #SEQUENCE then
|
||||
resetCode()
|
||||
fired = true
|
||||
end
|
||||
end
|
||||
end
|
||||
return fired
|
||||
end
|
||||
|
||||
-- ------- the snapshot
|
||||
--
|
||||
-- Everything the mode changes, read back before it changes any of it.
|
||||
-- Presentational settings included: the rung, the two engine FX levels the
|
||||
-- rung clearing would zero, and the clock -- a player who was watching a
|
||||
-- CYCLE sunset gets their sunset back.
|
||||
|
||||
local function snapshot(G)
|
||||
local ow = overworld(G)
|
||||
local p = ow.player
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local opts = G.save and G.save.options or {}
|
||||
local snap = {
|
||||
mapId = ow.map.id,
|
||||
cellX = p.cellX, cellY = p.cellY,
|
||||
px = p.px, py = p.py,
|
||||
facing = p.facing,
|
||||
viewLevel = Pipelines.level("voxel"),
|
||||
tilt = opts.tilt or 0,
|
||||
gbcfx = opts.gbcfx or 0,
|
||||
fpYaw = FirstPerson.yaw,
|
||||
fpPitch = FirstPerson.pitch,
|
||||
dayIndex = DayNight.setting:read(),
|
||||
dayClock = DayNight.clock,
|
||||
}
|
||||
return snap
|
||||
end
|
||||
|
||||
-- ------- the gloom
|
||||
--
|
||||
-- Installed once and inert while the mode is off: each wrapper calls
|
||||
-- through and returns the base answer untouched unless Horde.active.
|
||||
|
||||
local gloomInstalled = false
|
||||
|
||||
local function desaturate(r, g, b, keep)
|
||||
local lum = 0.30 * r + 0.59 * g + 0.11 * b
|
||||
return lum + (r - lum) * keep,
|
||||
lum + (g - lum) * keep,
|
||||
lum + (b - lum) * keep
|
||||
end
|
||||
|
||||
local function installGloom()
|
||||
if gloomInstalled then return end
|
||||
gloomInstalled = true
|
||||
|
||||
-- The sky's bands. Sky.bands caches BY COLOUR VALUE, so darkening what
|
||||
-- this returns rebuilds the band ramp on its own -- and puts it back the
|
||||
-- same way when the mode ends.
|
||||
do
|
||||
local base = DayNight.palette
|
||||
local cacheIn, cacheOut = nil, nil
|
||||
DayNight.palette = function(t)
|
||||
local pal = base(t)
|
||||
if not Horde.active then return pal end
|
||||
if cacheIn == pal then return cacheOut end
|
||||
local k = Horde.GLOOM_SKY
|
||||
local out = {}
|
||||
for i, c in ipairs(pal) do
|
||||
local r, g, b = c[1] * k[1], c[2] * k[2], c[3] * k[3]
|
||||
r, g, b = desaturate(r, g, b, Horde.GLOOM_SAT)
|
||||
out[i] = { math.floor(r), math.floor(g), math.floor(b) }
|
||||
end
|
||||
cacheIn, cacheOut = pal, out
|
||||
return out
|
||||
end
|
||||
end
|
||||
|
||||
-- The world multiply -- the voxel shader's tint uniform AND, through
|
||||
-- DayTint, the flat 2D world. Indoors normally returns neutral white;
|
||||
-- under the horde it does not, because a Pokemon Centre with the horde
|
||||
-- in it should not look like a Pokemon Centre.
|
||||
do
|
||||
local base = DayNight.tint
|
||||
local cacheIn, cacheOut, cacheOutdoor = nil, nil, nil
|
||||
DayNight.tint = function(outdoor, t)
|
||||
local c = base(outdoor, t)
|
||||
if not Horde.active then return c end
|
||||
if cacheIn == c and cacheOutdoor == outdoor then return cacheOut end
|
||||
local k = outdoor and Horde.GLOOM_WORLD or Horde.GLOOM_INDOOR
|
||||
local r, g, b = c[1] * k[1], c[2] * k[2], c[3] * k[3]
|
||||
r, g, b = desaturate(r, g, b, Horde.GLOOM_SAT)
|
||||
cacheIn, cacheOutdoor, cacheOut = c, outdoor, { r, g, b }
|
||||
return cacheOut
|
||||
end
|
||||
end
|
||||
|
||||
-- and the shadows press harder: applyRig writes SHADOW_ALPHA from the
|
||||
-- hour, so the boost goes on after it has had its say
|
||||
do
|
||||
local base = DayNight.applyRig
|
||||
DayNight.applyRig = function(outdoor)
|
||||
local t = base(outdoor)
|
||||
if Horde.active then
|
||||
Voxel3D.SHADOW_ALPHA = math.min(0.75,
|
||||
(Voxel3D.SHADOW_ALPHA or 0) * Horde.SHADOW_BOOST)
|
||||
end
|
||||
return t
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- starting
|
||||
|
||||
-- The banner over the world: text, and how long it holds before fading.
|
||||
function Horde.banner(text, hold)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
s.bannerText = text
|
||||
s.bannerT = 0
|
||||
s.bannerHold = hold or 2.2
|
||||
end
|
||||
|
||||
function Horde.begin(G)
|
||||
G = G or game()
|
||||
if not Horde.canStart(G) then return false end
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local mobs, gun = parts()
|
||||
|
||||
local snap = snapshot(G)
|
||||
Horde.session = {
|
||||
hp = Horde.MAX_HP, maxHp = Horde.MAX_HP,
|
||||
score = 0, wave = 0, kills = 0,
|
||||
t = 0, introT = Horde.INTRO_TIME, dyingT = 0,
|
||||
damageFlash = 0, hitMarker = 0, hurtCooldown = 0,
|
||||
bannerText = nil, bannerT = 0, bannerHold = 0,
|
||||
snapshot = snap,
|
||||
spawned = {}, -- mapId -> { [objIndex] = true }, for the scrub
|
||||
mobs = {},
|
||||
waveRemaining = 0, waveGap = 0, spawnGap = 0, followQueue = 0,
|
||||
startedAt = os and os.time and os.time() or 0,
|
||||
}
|
||||
Horde.active = true
|
||||
Horde.state = "intro"
|
||||
|
||||
-- the rung, forced and then held: FP_LEVEL is the one rung with a camera
|
||||
-- inside the world, and cycleVoxel refuses to leave it while active
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
Pipelines.syncOptions(G.save.options)
|
||||
G.save.options.tilt, G.save.options.gbcfx = 0, 0
|
||||
pcall(function() require("src.render.Tilt").setLevel(0) end)
|
||||
pcall(function() require("src.render.GBCFX").setLevel(0) end)
|
||||
pcall(G.writeOptions, G)
|
||||
|
||||
-- night, pinned; the gloom wrappers do the rest on top of it
|
||||
local nightIndex = 3 -- DayNight.setting values: sync/day/NIGHT/...
|
||||
for i, v in ipairs(DayNight.setting.values) do
|
||||
if v == "night" then nightIndex = i end
|
||||
end
|
||||
DayNight.setting:setIndex(nightIndex, G)
|
||||
|
||||
pcall(function()
|
||||
require("src.core.Music").play(G.data, Horde.SONG, true,
|
||||
{ reason = "horde" })
|
||||
end)
|
||||
|
||||
gun.reset()
|
||||
mobs.begin(G)
|
||||
Horde.banner("A DARKNESS APPROACHES", 2.6)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- damage and score
|
||||
|
||||
function Horde.addScore(n)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
s.score = s.score + (n or 0)
|
||||
end
|
||||
|
||||
-- A mob reached the player. Returns true when the hit landed (it is on a
|
||||
-- cooldown, so a crowd of six does not delete the player in one frame).
|
||||
function Horde.damage(n)
|
||||
local s = Horde.session
|
||||
if not (s and Horde.playing()) then return false end
|
||||
if s.hurtCooldown > 0 then return false end
|
||||
s.hurtCooldown = 0.55
|
||||
s.hp = math.max(0, s.hp - (n or Horde.CONTACT_DAMAGE))
|
||||
s.damageFlash = 1
|
||||
HordeSfx.play(HordeSfx.HURT)
|
||||
if s.hp <= 0 then
|
||||
Horde.state = "dying"
|
||||
s.dyingT = Horde.DYING_TIME
|
||||
pcall(function() require("src.core.Sound").stopLoop("Low_Health_Alarm") end)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the ending
|
||||
|
||||
local function pushGameOver(G)
|
||||
Horde.state = "gameover"
|
||||
local s = Horde.session
|
||||
local best = 0
|
||||
pcall(function() best = V.mod.save:get("hordeBest", 0) or 0 end)
|
||||
if s.score > best then
|
||||
best = s.score
|
||||
pcall(function() V.mod.save:set("hordeBest", best) end)
|
||||
end
|
||||
s.best = best
|
||||
pcall(function() require("src.core.Music").stop() end)
|
||||
pcall(function()
|
||||
require("src.ui.Screens").push(G, "HordeGameOver")
|
||||
end)
|
||||
end
|
||||
|
||||
-- Put everything back. Called from the GAME OVER card's A press.
|
||||
--
|
||||
-- Order matters: active goes false FIRST, so the music hook, the gloom
|
||||
-- wrappers and the mob spawner have all stood down before anything is
|
||||
-- restored under them. The warp home is taken even when the player never
|
||||
-- left the map they started on -- setMap rebuilds the cast from the map
|
||||
-- record, which is what puts every NPC the horde ate back on its feet.
|
||||
function Horde.finish(G)
|
||||
G = G or game()
|
||||
local s = Horde.session
|
||||
if not s then return false end
|
||||
local mobs = parts()
|
||||
local snap = s.snapshot or {}
|
||||
|
||||
Horde.active = false
|
||||
Horde.state = "idle"
|
||||
resetCode()
|
||||
|
||||
mobs.cleanup(G)
|
||||
pcall(function() require("src.core.Sound").stopLoop("Low_Health_Alarm") end)
|
||||
|
||||
-- the clock, back to the hour and the setting the player kept
|
||||
if snap.dayIndex then DayNight.setting:setIndex(snap.dayIndex, G) end
|
||||
if snap.dayClock then DayNight.clock = snap.dayClock end
|
||||
|
||||
-- the rung and the two FX levels the rung clearing zeroed
|
||||
pcall(function()
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
Pipelines.setLevel("voxel", snap.viewLevel or 0)
|
||||
Pipelines.syncOptions(G.save.options)
|
||||
G.save.options.tilt = snap.tilt or 0
|
||||
G.save.options.gbcfx = snap.gbcfx or 0
|
||||
require("src.render.Tilt").setLevel(snap.tilt or 0)
|
||||
require("src.render.GBCFX").setLevel(snap.gbcfx or 0)
|
||||
G:writeOptions()
|
||||
end)
|
||||
|
||||
if snap.fpYaw then FirstPerson.yaw = snap.fpYaw end
|
||||
if snap.fpPitch then FirstPerson.pitch = snap.fpPitch end
|
||||
|
||||
Horde.session = nil
|
||||
|
||||
-- home, through the engine's own warp: a fade, a setMap, and the map's
|
||||
-- own music coming back up on the other side (the hook that was forcing
|
||||
-- Lavender is inert now)
|
||||
local ow = overworld(G)
|
||||
if ow and snap.mapId then
|
||||
pcall(function()
|
||||
ow:startWarpTo(snap.mapId, snap.cellX, snap.cellY, snap.facing or "down",
|
||||
function()
|
||||
-- the pixel position and the facing, restated on
|
||||
-- the far side of the fade. setMap already placed
|
||||
-- both, but the free walk owns them while the rung
|
||||
-- is still easing out of the head, and the head was
|
||||
-- looking wherever the last shot was aimed
|
||||
local p = overworld(G) and overworld(G).player
|
||||
if not p then return end
|
||||
if snap.px then p.px, p.py = snap.px, snap.py end
|
||||
if snap.facing then p.facing = snap.facing end
|
||||
end,
|
||||
{ via = "warp" })
|
||||
end)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
--
|
||||
-- Rides the voxel pipeline's update hook, which Game:update calls every
|
||||
-- frame whatever the level and whatever is on the stack -- so the mode
|
||||
-- keeps thinking through a warp's transition wipe and under the GAME OVER
|
||||
-- card, which is exactly what a mode that owns the whole screen needs.
|
||||
|
||||
function Horde.update(dt)
|
||||
if not Horde.active then return end
|
||||
local s = Horde.session
|
||||
if not s then
|
||||
Horde.active = false
|
||||
return
|
||||
end
|
||||
dt = math.min(dt or 0, 0.1) -- a hitch must not teleport the wave
|
||||
local G = game()
|
||||
local mobs, gun, hud = parts()
|
||||
|
||||
s.t = s.t + dt
|
||||
s.damageFlash = math.max(0, s.damageFlash - dt * 2.2)
|
||||
s.hitMarker = math.max(0, s.hitMarker - dt * 4)
|
||||
s.hurtCooldown = math.max(0, s.hurtCooldown - dt)
|
||||
if s.bannerText then
|
||||
s.bannerT = s.bannerT + dt
|
||||
if s.bannerT > s.bannerHold + 1.1 then s.bannerText = nil end
|
||||
end
|
||||
hud.update(dt)
|
||||
|
||||
if Horde.state == "intro" then
|
||||
s.introT = s.introT - dt
|
||||
if s.introT <= 0 then
|
||||
Horde.state = "active"
|
||||
mobs.nextWave(G)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if Horde.state == "dying" then
|
||||
s.dyingT = s.dyingT - dt
|
||||
mobs.update(dt, G) -- the crowd keeps coming while you fall
|
||||
if s.dyingT <= 0 then pushGameOver(G) end
|
||||
return
|
||||
end
|
||||
|
||||
if Horde.state ~= "active" then return end
|
||||
|
||||
-- the world only ticks while the overworld is actually the live state:
|
||||
-- during a warp's wipe there is no map under the mobs to walk on
|
||||
local ow = overworld(G)
|
||||
local live = G and G.stack and ow and G.stack:top() == ow
|
||||
and not ow.transitioning
|
||||
gun.update(dt, live)
|
||||
if live then mobs.update(dt, G) end
|
||||
|
||||
-- the siren the game already owns, for the last third of the health bar
|
||||
local low = s.hp <= s.maxHp * 0.3
|
||||
if low ~= s.alarmOn then
|
||||
s.alarmOn = low
|
||||
pcall(function()
|
||||
local Sound = require("src.core.Sound")
|
||||
if low then Sound.startLoop(G.data, "Low_Health_Alarm")
|
||||
else Sound.stopLoop("Low_Health_Alarm") end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the seams
|
||||
--
|
||||
-- Every engine and mod hook the mode needs, installed once. main.lua
|
||||
-- calls this AFTER FreeMove.install and the SELECT wrap, so the
|
||||
-- handleInput wrap this adds sits outside both of theirs.
|
||||
|
||||
local installed = false
|
||||
|
||||
function Horde.install()
|
||||
if installed then return end
|
||||
installed = true
|
||||
local mod = V.mod
|
||||
|
||||
installGloom()
|
||||
|
||||
-- THE CODE. `input.step` runs once per fixed step, immediately before
|
||||
-- Input:step promotes the queue into this step's edges -- so pressQueue
|
||||
-- is exactly "the buttons that were pressed since last time", in order,
|
||||
-- from every device at once. Read, never consumed: the game still gets
|
||||
-- every one of them.
|
||||
mod.hooks:wrap("input.step", function(next, G, dt)
|
||||
local inp = G and G.input
|
||||
if inp and inp.pressQueue and Horde.feed(inp.pressQueue) then
|
||||
pcall(Horde.begin, G)
|
||||
elseif Horde.playing() and inp and inp.pressQueue then
|
||||
-- B is a trigger while the horde is up (the pad's B, the keyboard's,
|
||||
-- the touch overlay's). Read here rather than in the frame tick
|
||||
-- because THIS is the boundary that sees each press exactly once.
|
||||
for _, btn in ipairs(inp.pressQueue) do
|
||||
if btn == "b" then
|
||||
local _, gun = parts()
|
||||
gun.fire()
|
||||
end
|
||||
end
|
||||
end
|
||||
return next(G, dt)
|
||||
end)
|
||||
|
||||
-- Lavender, and it stays Lavender. Every song choice in the engine goes
|
||||
-- through this hook, so a door into a building cannot change the record.
|
||||
mod.hooks:wrap("music.select", function(next, chosen, ctx)
|
||||
if Horde.active and Horde.state ~= "gameover" then
|
||||
return next(Horde.SONG, ctx)
|
||||
end
|
||||
return next(chosen, ctx)
|
||||
end)
|
||||
|
||||
-- no wild encounters: returning nil from this hook suppresses the roll
|
||||
-- outright, which is the documented way to do it
|
||||
mod.hooks:wrap("encounter.roll", function(next, encDef, ctx)
|
||||
if Horde.active then return nil end
|
||||
return next(encDef, ctx)
|
||||
end)
|
||||
|
||||
-- and no trainer walking up to talk. Wrapped rather than set through
|
||||
-- self.engaging, which would also freeze the player's own input.
|
||||
do
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
if not OverworldState.dramaticShapeHordeSight then
|
||||
local inner = OverworldState.checkTrainerSight
|
||||
function OverworldState:checkTrainerSight(...)
|
||||
if Horde.active then return end
|
||||
return inner(self, ...)
|
||||
end
|
||||
OverworldState.dramaticShapeHordeSight = true
|
||||
end
|
||||
end
|
||||
|
||||
-- THE BUTTONS THE WORLD MAY NOT HAVE. A, START, SELECT and B are the
|
||||
-- mode's, and this wrap is where they are taken -- the OUTERMOST wrap on
|
||||
-- handleInput, installed after FreeMove's and after the SELECT hook, so
|
||||
-- the edges are gone before either of them looks.
|
||||
--
|
||||
-- It has to be here rather than inside the free walk, because the free
|
||||
-- walk is not always the one reading: the rung is forced to 1ST at the
|
||||
-- moment the code completes, but the camera takes a few frames to blend
|
||||
-- into the head, and until it does the GRID walk still owns the frame.
|
||||
-- That is not a corner case -- it is the very first frame of every run,
|
||||
-- and the code's own closing A was landing in it and opening a dialogue
|
||||
-- with whoever the player happened to be standing next to.
|
||||
--
|
||||
-- The EDGE is cleared, not the hold: pressed[] is rebuilt from scratch
|
||||
-- every fixed step, so this reaches exactly this step's presses and
|
||||
-- nothing downstream of it can revive one.
|
||||
do
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
if not OverworldState.dramaticShapeHordeInput then
|
||||
local inner = OverworldState.handleInput
|
||||
function OverworldState:handleInput(...)
|
||||
if Horde.active then
|
||||
local G = game()
|
||||
local inp = G and G.input
|
||||
if inp and inp.pressed then
|
||||
-- START is the way out, and it is asked rather than taken:
|
||||
-- read here, BEFORE the edge is cleared, so the engine's own
|
||||
-- START menu never sees it
|
||||
if inp.pressed.start then Horde.askExit(G) end
|
||||
inp.pressed.a = nil -- no talking
|
||||
inp.pressed.b = nil -- the trigger, already read
|
||||
inp.pressed.start = nil -- and no start menu
|
||||
inp.pressed.select = nil -- no changing the view
|
||||
end
|
||||
end
|
||||
return inner(self, ...)
|
||||
end
|
||||
OverworldState.dramaticShapeHordeInput = true
|
||||
end
|
||||
end
|
||||
|
||||
-- the crowd follows the player through the door: a warp lands a new map
|
||||
-- with none of the old one's actors on it, so the roster is re-seeded on
|
||||
-- the far side (lib/HordeMobs)
|
||||
mod.events:on("map.entered", function(payload)
|
||||
if not Horde.active then return end
|
||||
local mobs = parts()
|
||||
pcall(mobs.onMapEntered, payload)
|
||||
end)
|
||||
end
|
||||
|
||||
return Horde
|
||||
@@ -0,0 +1,94 @@
|
||||
-- HORDE MODE: the way out.
|
||||
--
|
||||
-- START (the pad's, the keyboard's ESCAPE, the touch overlay's) and the
|
||||
-- VR left stick click all land here: a plain yes/no over the frozen
|
||||
-- world, asking whether to leave. YES hands over to Horde.finish, which
|
||||
-- is the same restore the GAME OVER card runs -- the map, the cell, the
|
||||
-- facing, the camera rung, the hour, the music and every NPC put back
|
||||
-- exactly as they were. NO drops the player straight back into the
|
||||
-- firefight.
|
||||
--
|
||||
-- Pushing a state is what pauses the mode, and it is the only thing that
|
||||
-- can: horde mode rides the pipeline's update hook rather than the state
|
||||
-- stack precisely so that nothing on the stack stops it, but the combat
|
||||
-- inside Horde.update is gated on the overworld actually being the live
|
||||
-- state, so this prompt freezes the crowd and the gun for as long as it
|
||||
-- is up. That is the correct behaviour for a confirmation and it is why
|
||||
-- "no pausing" does not extend to this one.
|
||||
--
|
||||
-- Drawn the way the game draws a yes/no: a white bordered box with black
|
||||
-- text and the filled arrow beside the row (see Theme.choiceBox, which
|
||||
-- is where the original's own YES_NO_MENU sits). Black on white because
|
||||
-- that is what the font IS -- the sheets are black glyphs on transparent
|
||||
-- and no colour can lighten one.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
|
||||
local HordeExitPrompt = {}
|
||||
HordeExitPrompt.__index = HordeExitPrompt
|
||||
|
||||
-- the question's box, and the choice box under it, in 8px tiles
|
||||
local ASK = { tx = 1, ty = 6, tw = 18, th = 4 }
|
||||
local PICK = { tx = 13, ty = 10, tw = 6, th = 6 }
|
||||
|
||||
function HordeExitPrompt.new(game)
|
||||
local self = setmetatable({}, HordeExitPrompt)
|
||||
self.game = game
|
||||
-- NOT opaque: the horde is still standing out there behind this, which
|
||||
-- is most of what makes the question feel like a decision
|
||||
self.isOpaque = false
|
||||
self.index = 2 -- NO, the way every dangerous prompt starts
|
||||
self.done = false
|
||||
return self
|
||||
end
|
||||
|
||||
local function sfx(game, name)
|
||||
pcall(function()
|
||||
require("src.core.Sound").play(game.data, name)
|
||||
end)
|
||||
end
|
||||
|
||||
function HordeExitPrompt:update()
|
||||
if self.done then return end
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.index = self.index == 1 and 2 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.done = true
|
||||
sfx(self.game, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
if self.index == 1 then pcall(Horde.finish, self.game) end
|
||||
elseif input:wasPressed("b") or input:wasPressed("start") then
|
||||
-- B and START both mean "no": the button that opened this closes it,
|
||||
-- which is the one thing a player who opened it by accident will try
|
||||
self.done = true
|
||||
sfx(self.game, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
end
|
||||
end
|
||||
|
||||
function HordeExitPrompt:draw()
|
||||
local ok, Font = pcall(require, "src.render.Font")
|
||||
if not ok then return end
|
||||
local okT, Theme = pcall(require, "src.ui.Theme")
|
||||
|
||||
Font.drawBox(ASK.tx, ASK.ty, ASK.tw, ASK.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("EXIT MINI GAME?", (ASK.tx + 2) * 8, (ASK.ty + 2) * 8)
|
||||
|
||||
Font.drawBox(PICK.tx, PICK.ty, PICK.tw, PICK.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw("YES", (PICK.tx + 2) * 8, (PICK.ty + 2) * 8)
|
||||
Font.draw("NO", (PICK.tx + 2) * 8, (PICK.ty + 4) * 8)
|
||||
local cursor = okT and Theme.cursor or 0xED
|
||||
Font.drawCode(cursor, (PICK.tx + 1) * 8,
|
||||
(PICK.ty + 2 + (self.index - 1) * 2) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return HordeExitPrompt
|
||||
@@ -0,0 +1,107 @@
|
||||
-- HORDE MODE: the card at the end.
|
||||
--
|
||||
-- A stack state, unlike the mode itself -- and for the opposite reason.
|
||||
-- Horde mode cannot be a pushed state because pushing one stops the
|
||||
-- overworld ticking and the player could not walk; the GAME OVER card
|
||||
-- WANTS exactly that. Pushed, it freezes the world underneath, takes the
|
||||
-- buttons, and stands there until A.
|
||||
--
|
||||
-- It draws in the engine's own 160x144 UI canvas with the game's own
|
||||
-- font, which is what makes it work in VR for free: with a headset live
|
||||
-- and something other than the overworld on top of the stack, lib/VR
|
||||
-- already puts the flat screen on the floating panel (or on the Pokedex
|
||||
-- in the player's left hand). A card drawn the way the game draws cards
|
||||
-- arrives there with no VR code at all.
|
||||
--
|
||||
-- A pops it and hands over to Horde.finish, which is what puts the world
|
||||
-- back: the map, the cell, the facing, the camera rung, the hour, the
|
||||
-- music, and every NPC the horde had turned into a mob.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
|
||||
local W, H = 160, 144
|
||||
|
||||
local HordeGameOver = {}
|
||||
HordeGameOver.__index = HordeGameOver
|
||||
|
||||
function HordeGameOver.new(game)
|
||||
local self = setmetatable({}, HordeGameOver)
|
||||
self.game = game
|
||||
self.isOpaque = true
|
||||
self.t = 0
|
||||
-- the session is read ONCE, here: Horde.finish clears it, and this card
|
||||
-- outlives that by a frame or two while the warp home fades
|
||||
local s = Horde.session or {}
|
||||
self.score = math.floor(s.score or 0)
|
||||
self.best = math.floor(s.best or 0)
|
||||
self.wave = math.max(1, s.wave or 1)
|
||||
self.kills = s.kills or 0
|
||||
self.done = false
|
||||
return self
|
||||
end
|
||||
|
||||
function HordeGameOver:update(dt)
|
||||
self.t = self.t + (dt or 0)
|
||||
if self.done then return end
|
||||
-- a beat of dead air before the prompt takes input, so the button that
|
||||
-- was being mashed at the moment of death does not dismiss the card
|
||||
if self.t < 0.6 then return end
|
||||
local input = self.game and self.game.input
|
||||
if input and input:wasPressed("a") then
|
||||
self.done = true
|
||||
pcall(function()
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
end)
|
||||
self.game.stack:pop()
|
||||
pcall(Horde.finish, self.game)
|
||||
end
|
||||
end
|
||||
|
||||
-- THE CARD IS DRAWN THE WAY THE GAME DRAWS CARDS: a bordered white box
|
||||
-- with black text in it (Font.drawBox then setColor(0,0,0)), exactly as
|
||||
-- HallOfFame and every menu do. That is not decoration -- the UI canvas
|
||||
-- is a FOUR-SHADE Game Boy screen, and an arbitrary colour drawn into it
|
||||
-- has nowhere to land. A first cut of this card painted a dark red on
|
||||
-- near-black and composited as a rectangle of pure black, with the score
|
||||
-- in it and invisible.
|
||||
local function centred(Font, str, y, scale)
|
||||
scale = scale or 1
|
||||
local w = Font.width(str) * scale
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor((W - w) / 2), y)
|
||||
love.graphics.scale(scale, scale)
|
||||
Font.draw(str, 0, 0)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
function HordeGameOver:draw()
|
||||
local ok, Font = pcall(require, "src.render.Font")
|
||||
if not ok then return end
|
||||
|
||||
-- the whole screen as one box: isOpaque keeps the stack from drawing
|
||||
-- the world under it, but the canvas still holds whatever was there
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
centred(Font, "GAME OVER", 3 * 8, 2)
|
||||
|
||||
centred(Font, ("SCORE %d"):format(self.score), 8 * 8)
|
||||
centred(Font, ("WAVE %d"):format(self.wave), 10 * 8)
|
||||
centred(Font, ("KILLS %d"):format(self.kills), 11 * 8)
|
||||
|
||||
if self.best > 0 then
|
||||
centred(Font, (self.score >= self.best) and "NEW BEST!"
|
||||
or ("BEST %d"):format(self.best), 13 * 8)
|
||||
end
|
||||
|
||||
-- the prompt blinks the way every "press a button" in this game blinks
|
||||
if self.t > 0.6 and (self.t % 1.0) < 0.62 then
|
||||
centred(Font, "PRESS A", 15 * 8)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return HordeGameOver
|
||||
@@ -0,0 +1,511 @@
|
||||
-- HORDE MODE: the handgun.
|
||||
--
|
||||
-- A voxel model in the player's right hand, authored here in METRES the
|
||||
-- way lib/Pokedex authors the device in the left one -- because the VR
|
||||
-- mapping's scale is what turns metres into world pixels, a mesh built
|
||||
-- this way is the right size in the hand at every scale the mod has, and
|
||||
-- the same mesh serves the flat screen's view model.
|
||||
--
|
||||
-- IN VR the gun rides the tracked right hand through VRRig.propMatrix,
|
||||
-- pointed by the runtime's AIM pose where one exists (the pose a runtime
|
||||
-- defines as "where the user is pointing") and by the grip pose where it
|
||||
-- does not. You aim it by pointing it. The iron sights are real geometry,
|
||||
-- and lining them up is how you shoot accurately, because the shot is
|
||||
-- traced down the model's own barrel axis.
|
||||
--
|
||||
-- ON THE FLAT SCREEN there is no hand to track, so the gun is carried by
|
||||
-- the camera: a model matrix built from the first-person eye and its yaw
|
||||
-- and pitch, with the gun hanging at the hip until the player aims. AIM
|
||||
-- DOWN SIGHTS slides it to the centre of the screen with the sight line
|
||||
-- ON the eye axis -- the model is authored with its rear notch at the
|
||||
-- origin precisely so that offset is (0, 0, forward) -- and narrows the
|
||||
-- field of view, which is the whole of what aiming does here.
|
||||
--
|
||||
-- THE SHOT IS A RAY, traced the same way in both modes: march it in world
|
||||
-- pixels, let terrain height stop it (a wall is a tall cell, so a cell
|
||||
-- whose ground is above the ray's height is a wall the bullet hits), and
|
||||
-- test every live mob against it as a standing cylinder. Nearest wins,
|
||||
-- and a hit above the shoulder line counts double.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local VRRig = V.require("VRRig")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local Horde = V.require("Horde")
|
||||
local HordeSfx = V.require("HordeSfx")
|
||||
|
||||
local HordeGun = {}
|
||||
|
||||
-- ------- tuning
|
||||
|
||||
HordeGun.MAG = 8
|
||||
HordeGun.RELOAD_TIME = 1.5
|
||||
HordeGun.FIRE_COOLDOWN = 0.17 -- semi-auto, and it fits the reload clicks
|
||||
HordeGun.RANGE = 220 -- world pixels: about fourteen cells
|
||||
HordeGun.HIT_RADIUS = 6 -- a person is about twelve pixels wide
|
||||
HordeGun.ADS_TIME = 0.13
|
||||
HordeGun.ADS_FOV = math.rad(40)
|
||||
|
||||
-- Where the gun sits relative to the EYE, in metres, hip and aimed. The
|
||||
-- model's own origin is its rear sight notch, so the aimed offset is a
|
||||
-- pure push forward: nothing to line up, it is already lined up.
|
||||
HordeGun.HIP = { -0.115, -0.125, 0.30 }
|
||||
HordeGun.ADS = { 0, -0.002, 0.34 }
|
||||
|
||||
-- Where it sits relative to the tracked hand, in METRES and in the POSE's
|
||||
-- own axes -- so with the barrel pointed away from the player (see below)
|
||||
-- -Z is forward, and this nudges the gun a little down and forward of the
|
||||
-- pose origin so the hand is behind it rather than inside it.
|
||||
HordeGun.HAND_OFFSET = { 0, -0.012, -0.02 }
|
||||
|
||||
-- THE BARREL, AND WHICH WAY IS FORWARD.
|
||||
--
|
||||
-- OpenXR's AIM pose -- the one this rides where the runtime offers it --
|
||||
-- is defined with its **-Z axis pointing the way the user is aiming**.
|
||||
-- The model below is authored with its barrel along **+Z**, because that
|
||||
-- is what the flat screen's view model wants (Ry(yaw)*Rx(pitch) carries
|
||||
-- +Z onto the look direction). Half a turn about Y is what reconciles
|
||||
-- them, and it is the whole of the attachment.
|
||||
--
|
||||
-- Getting this wrong does not read as "slightly off": the first cut
|
||||
-- copied the Pokedex's quarter-turn about X, which lays a flat slab along
|
||||
-- the controller's body and is exactly right for a slab -- on a gun it
|
||||
-- pointed the muzzle at the player's own face.
|
||||
HordeGun.HAND_YAW = math.pi
|
||||
|
||||
-- AND A PITCH, because a hand is not a tripod. A controller held the way
|
||||
-- you hold a pistol -- fist closed, wrist cocked -- has its own aim axis
|
||||
-- running up and forward out of the top of your fist, well above the line
|
||||
-- your hand FEELS like it is pointing along. A model laid flat on that
|
||||
-- axis reads as a gun held by somebody with a broken wrist.
|
||||
--
|
||||
-- So the gun tips its muzzle down 45 degrees off the pose, which puts the
|
||||
-- barrel back on the line the grip implies. The shot follows: the ray is
|
||||
-- read off the finished matrix's own +Z column (see place), so it comes
|
||||
-- out of the barrel as drawn rather than off the pose it was hung on --
|
||||
-- point the gun, hit the thing.
|
||||
HordeGun.HAND_PITCH = math.rad(45)
|
||||
|
||||
-- ------- the model
|
||||
--
|
||||
-- One voxel is 8mm, so the pistol below comes out about 18cm long -- a
|
||||
-- compact service automatic. Authored around the REAR SIGHT NOTCH at the
|
||||
-- origin, barrel down +Z, up +Y. (+X is the viewer's LEFT: the world runs
|
||||
-- +X east and +Z south, so a body facing +Z has its right hand toward
|
||||
-- -X, which is why the hip offset's x is negative.)
|
||||
|
||||
local VOX = 0.008
|
||||
|
||||
local COLORS = {
|
||||
{ 60, 62, 72 }, -- 1 slide
|
||||
{ 30, 31, 38 }, -- 2 frame / shadowed
|
||||
{ 46, 40, 40 }, -- 3 grip
|
||||
{ 104, 108, 122 }, -- 4 highlight
|
||||
{ 248, 240, 176 }, -- 5 sight dot
|
||||
{ 18, 18, 22 }, -- 6 bore
|
||||
{ 132, 136, 148 }, -- 7 trigger
|
||||
{ 255, 246, 196 }, -- 8 flash core
|
||||
{ 255, 168, 56 }, -- 9 flash edge
|
||||
}
|
||||
|
||||
local paletteTex, bodyMesh, flashMesh = nil, nil, nil
|
||||
|
||||
local function palette()
|
||||
if paletteTex then return paletteTex end
|
||||
if not (love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage) then return nil end
|
||||
local ok, data = pcall(love.image.newImageData, #COLORS, 1)
|
||||
if not (ok and data) then return nil end
|
||||
for i, c in ipairs(COLORS) do
|
||||
pcall(data.setPixel, data, i - 1, 0,
|
||||
c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
end
|
||||
local built, img = pcall(love.graphics.newImage, data)
|
||||
if not built then return nil end
|
||||
pcall(img.setFilter, img, "nearest", "nearest")
|
||||
paletteTex = img
|
||||
return img
|
||||
end
|
||||
|
||||
-- one solid box, in voxels, straight into the shared vertex format
|
||||
local function box(verts, indices, x, y, z, w, h, d, color)
|
||||
local u = (color - 0.5) / #COLORS
|
||||
local ox, oy, oz = x * VOX, y * VOX, z * VOX
|
||||
local sx, sy, sz = w * VOX, h * VOX, d * VOX
|
||||
for face = 1, 6 do
|
||||
local corners = Voxel3D.FACE_CORNERS[face]
|
||||
local shade = Voxel3D.FACE_SHADE[face]
|
||||
local n = #verts / 4
|
||||
for _, c in ipairs(corners) do
|
||||
verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz,
|
||||
u, 0.5, shade }
|
||||
end
|
||||
Voxel3D.pushQuad(indices, n)
|
||||
end
|
||||
end
|
||||
|
||||
local function buildBody()
|
||||
if bodyMesh then return bodyMesh end
|
||||
local v, i = {}, {}
|
||||
-- slide, and the bore's dark eye at the end of it
|
||||
box(v, i, -2, -5, -1, 4, 4, 18, 1)
|
||||
box(v, i, -2, -2, -1, 4, 0.6, 18, 4) -- the light along the top edge
|
||||
box(v, i, -1, -4, 16.6, 2, 2, 0.6, 6)
|
||||
-- frame under the slide, and the dust cover forward of the guard
|
||||
box(v, i, -1.8, -8, 0.5, 3.6, 3.2, 12, 2)
|
||||
-- the grip, three blocks stepping back: a raked butt without a hull
|
||||
box(v, i, -1.8, -11, -1.2, 3.6, 3.2, 5, 3)
|
||||
box(v, i, -1.8, -14, -2.6, 3.6, 3.2, 5, 3)
|
||||
box(v, i, -1.8, -16.8, -3.8, 3.6, 3, 5, 2)
|
||||
-- trigger guard: the bar under, the post in front
|
||||
box(v, i, -1.4, -11.4, 3.6, 2.8, 1, 4.4, 2)
|
||||
box(v, i, -1.4, -11.4, 7.4, 2.8, 3.4, 1, 2)
|
||||
box(v, i, -0.9, -10.8, 4.6, 1.8, 2, 1, 7) -- the trigger itself
|
||||
-- IRON SIGHTS. Two rear posts with a notch between them at the origin,
|
||||
-- one front post at the muzzle: look through the gap, put the front
|
||||
-- post's dot in it, and the barrel is pointing where you are looking.
|
||||
box(v, i, -2, -1, -0.2, 0.9, 1.3, 1.4, 2)
|
||||
box(v, i, 1.1, -1, -0.2, 0.9, 1.3, 1.4, 2)
|
||||
box(v, i, -1.95, -0.2, 0.3, 0.5, 0.5, 0.5, 5)
|
||||
box(v, i, 1.45, -0.2, 0.3, 0.5, 0.5, 0.5, 5)
|
||||
box(v, i, -0.45, -1, 15.2, 0.9, 1.5, 1, 2)
|
||||
box(v, i, -0.3, 0.1, 15.4, 0.6, 0.6, 0.6, 5)
|
||||
bodyMesh = Voxel3D.newMesh(v, i)
|
||||
return bodyMesh
|
||||
end
|
||||
|
||||
-- the muzzle flash: a bright cross of boxes off the bore, drawn for two
|
||||
-- frames after a shot and never lit by anything
|
||||
local function buildFlash()
|
||||
if flashMesh then return flashMesh end
|
||||
local v, i = {}, {}
|
||||
box(v, i, -1.6, -4.6, 17.4, 3.2, 3.2, 2.6, 8)
|
||||
box(v, i, -3.4, -3.8, 17.6, 6.8, 1.6, 1.8, 9)
|
||||
box(v, i, -0.9, -6.4, 17.6, 1.8, 6.4, 1.8, 9)
|
||||
box(v, i, -1.1, -4.1, 19.6, 2.2, 2.2, 2.2, 9)
|
||||
flashMesh = Voxel3D.newMesh(v, i)
|
||||
return flashMesh
|
||||
end
|
||||
|
||||
-- ------- state
|
||||
|
||||
local gun = {
|
||||
ammo = HordeGun.MAG,
|
||||
reloading = false,
|
||||
reloadT = 0,
|
||||
reloadStage = 0,
|
||||
cooldown = 0,
|
||||
ads = false,
|
||||
adsBlend = 0,
|
||||
kick = 0,
|
||||
flash = 0,
|
||||
frame = nil, -- the VR hand's model matrix for this frame
|
||||
ray = nil, -- the VR aim ray in world space, if there is one
|
||||
}
|
||||
|
||||
HordeGun.state = gun
|
||||
|
||||
function HordeGun.reset()
|
||||
gun.ammo = HordeGun.MAG
|
||||
gun.reloading, gun.reloadT, gun.reloadStage = false, 0, 0
|
||||
gun.cooldown, gun.kick, gun.flash = 0, 0, 0
|
||||
gun.ads, gun.adsBlend = false, 0
|
||||
gun.frame, gun.ray = nil, nil
|
||||
end
|
||||
|
||||
-- how far into the aim the sights are, 0..1 -- read by the HUD (the
|
||||
-- crosshair goes away) and by the camera (the field of view narrows)
|
||||
function HordeGun.adsBlend()
|
||||
return gun.adsBlend
|
||||
end
|
||||
|
||||
function HordeGun.ammo()
|
||||
return gun.ammo, HordeGun.MAG, gun.reloading
|
||||
end
|
||||
|
||||
function HordeGun.setAds(on)
|
||||
gun.ads = on and true or false
|
||||
end
|
||||
|
||||
-- ------- the shot
|
||||
|
||||
-- The eye and the direction it is looking, in world pixels. In VR this is
|
||||
-- the gun's own barrel (set by the VR frame); on the flat screen it is
|
||||
-- the camera, because the gun follows the camera exactly.
|
||||
local function ray(G)
|
||||
if gun.ray then return gun.ray end
|
||||
local ow = G and G.overworld
|
||||
if not (ow and ow.player and ow.map) then return nil end
|
||||
local p = ow.player
|
||||
local gh = 0
|
||||
pcall(function()
|
||||
gh = V.require("VoxelScene").groundAt(ow.map, p.cellX, p.cellY) or 0
|
||||
end)
|
||||
local cp = math.cos(FirstPerson.pitch)
|
||||
return {
|
||||
p.px + 8, gh + FirstPerson.EYE_HEIGHT, p.py + 8,
|
||||
math.sin(FirstPerson.yaw) * cp,
|
||||
-math.sin(FirstPerson.pitch),
|
||||
math.cos(FirstPerson.yaw) * cp,
|
||||
}
|
||||
end
|
||||
|
||||
-- How far the ray travels before terrain stops it. A wall in this world
|
||||
-- is a cell whose ground stands taller than the ray does where it crosses
|
||||
-- it, which is the same test for a fence you can shoot over, a building
|
||||
-- you cannot, and a doorway you can shoot through.
|
||||
local function occlusion(map, r)
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local step = 3
|
||||
local t = step
|
||||
while t <= HordeGun.RANGE do
|
||||
local x = r[1] + r[4] * t
|
||||
local y = r[2] + r[5] * t
|
||||
local z = r[3] + r[6] * t
|
||||
local cx, cy = math.floor(x / 16), math.floor(z / 16)
|
||||
if not map:inBounds(cx, cy) then return t end
|
||||
local gh = 0
|
||||
local ok, got = pcall(VoxelScene.groundAt, map, cx, cy)
|
||||
if ok and got then gh = got end
|
||||
if y < gh - 0.5 then return t end
|
||||
if y < 0 then return t end
|
||||
t = t + step
|
||||
end
|
||||
return HordeGun.RANGE
|
||||
end
|
||||
|
||||
-- The nearest mob the ray reaches, and whether it caught the head.
|
||||
local function pick(G, r, maxT)
|
||||
local Mobs = V.require("HordeMobs")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local ow = G and G.overworld
|
||||
if not (ow and ow.map) then return nil end
|
||||
local flat = r[4] * r[4] + r[6] * r[6]
|
||||
if flat < 1e-6 then return nil end
|
||||
local best, bestT, bestHead = nil, maxT, false
|
||||
for _, e in ipairs(Mobs.list()) do
|
||||
local npc = e.npc
|
||||
if npc and not e.dead and e.mapId == ow.map.id then
|
||||
local mx, mz = npc.px + 8, npc.py + 8
|
||||
local t = ((mx - r[1]) * r[4] + (mz - r[3]) * r[6]) / flat
|
||||
if t > 0 and t < bestT then
|
||||
local hx = r[1] + r[4] * t - mx
|
||||
local hz = r[3] + r[6] * t - mz
|
||||
if hx * hx + hz * hz <= HordeGun.HIT_RADIUS * HordeGun.HIT_RADIUS then
|
||||
local gh = 0
|
||||
local ok, got = pcall(VoxelScene.groundAt, ow.map,
|
||||
npc.cellX, npc.cellY)
|
||||
if ok and got then gh = got end
|
||||
local y = r[2] + r[5] * t
|
||||
if y >= gh - 2 and y <= gh + 17 then
|
||||
best, bestT, bestHead = e, t, y >= gh + 11
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return best, bestHead
|
||||
end
|
||||
|
||||
-- Pull the trigger. Every input device funnels here (see Horde.install,
|
||||
-- FirstPerson's mouse and touch wraps, and VR.driveControls), so the
|
||||
-- cooldown below is also what keeps two devices reporting the same press
|
||||
-- from spending two rounds.
|
||||
function HordeGun.fire()
|
||||
if not Horde.playing() then return false end
|
||||
if gun.cooldown > 0 or gun.reloading then return false end
|
||||
if gun.ammo <= 0 then
|
||||
gun.cooldown = 0.35
|
||||
HordeSfx.play(HordeSfx.DRY)
|
||||
HordeGun.reload()
|
||||
return false
|
||||
end
|
||||
local G = require("src.core.Game")
|
||||
gun.ammo = gun.ammo - 1
|
||||
gun.cooldown = HordeGun.FIRE_COOLDOWN
|
||||
gun.kick = 1
|
||||
gun.flash = 0.05
|
||||
HordeSfx.shot()
|
||||
|
||||
local r = ray(G)
|
||||
if r then
|
||||
local ow = G.overworld
|
||||
local maxT = ow and ow.map and occlusion(ow.map, r) or HordeGun.RANGE
|
||||
local hit, head = pick(G, r, maxT)
|
||||
if hit then
|
||||
local Mobs = V.require("HordeMobs")
|
||||
local result = Mobs.hit(hit, head and 2 or 1)
|
||||
local s = Horde.session
|
||||
if s then
|
||||
s.hitMarker = 1
|
||||
if result == "kill" and head then Horde.addScore(50) end
|
||||
end
|
||||
end
|
||||
end
|
||||
if gun.ammo <= 0 then HordeGun.reload() end
|
||||
return true
|
||||
end
|
||||
|
||||
function HordeGun.reload()
|
||||
if gun.reloading or gun.ammo >= HordeGun.MAG then return false end
|
||||
gun.reloading = true
|
||||
gun.reloadT = 0
|
||||
gun.reloadStage = 0
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the frame
|
||||
|
||||
function HordeGun.update(dt, live)
|
||||
if not Horde.active then
|
||||
FirstPerson.fovScale = 1 -- give the lens back on the way out
|
||||
return
|
||||
end
|
||||
gun.cooldown = math.max(0, gun.cooldown - dt)
|
||||
gun.kick = math.max(0, gun.kick - dt * 7)
|
||||
gun.flash = math.max(0, gun.flash - dt)
|
||||
|
||||
local target = (gun.ads and live) and 1 or 0
|
||||
local astep = dt / HordeGun.ADS_TIME
|
||||
if gun.adsBlend < target then
|
||||
gun.adsBlend = math.min(target, gun.adsBlend + astep)
|
||||
else
|
||||
gun.adsBlend = math.max(target, gun.adsBlend - astep)
|
||||
end
|
||||
-- the lens narrows with the sights. Half of what aiming does here is
|
||||
-- the model coming to the centre of the screen; the other half is this
|
||||
local e = gun.adsBlend * gun.adsBlend * (3 - 2 * gun.adsBlend)
|
||||
FirstPerson.fovScale = 1 - (1 - HordeGun.ADS_FOV / FirstPerson.FOV) * e
|
||||
|
||||
if gun.reloading then
|
||||
local was = gun.reloadT
|
||||
gun.reloadT = gun.reloadT + dt
|
||||
-- three clicks on their own clock: the magazine out, the fresh one
|
||||
-- in, the slide home. Staged by time rather than animated frames so
|
||||
-- the sound and the dip below stay in step at any frame rate.
|
||||
local marks = { { 0.10, HordeSfx.MAG_OUT }, { 0.62, HordeSfx.MAG_IN },
|
||||
{ 1.15, HordeSfx.RACK } }
|
||||
for _, m in ipairs(marks) do
|
||||
if was < m[1] and gun.reloadT >= m[1] then HordeSfx.play(m[2]) end
|
||||
end
|
||||
if gun.reloadT >= HordeGun.RELOAD_TIME then
|
||||
gun.reloading = false
|
||||
gun.reloadT = 0
|
||||
gun.ammo = HordeGun.MAG
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- VR placement
|
||||
--
|
||||
-- Called from the VR frame with the same mapping the eyes got. `pose` is
|
||||
-- the tracked right hand -- the runtime's aim pose where it has one.
|
||||
|
||||
function HordeGun.place(pose, pivot, anchor, scale, yaw)
|
||||
if not (Horde.active and pose) then
|
||||
HordeGun.clear()
|
||||
return
|
||||
end
|
||||
local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||
m = Mat4.mul(m, Mat4.translate(HordeGun.HAND_OFFSET[1],
|
||||
HordeGun.HAND_OFFSET[2],
|
||||
HordeGun.HAND_OFFSET[3]))
|
||||
m = Mat4.mul(m, Mat4.rotateY(HordeGun.HAND_YAW))
|
||||
m = Mat4.mul(m, Mat4.rotateX(HordeGun.HAND_PITCH))
|
||||
-- the recoil, up and back along the gun's own axes
|
||||
local k = gun.kick
|
||||
if k > 0 then
|
||||
m = Mat4.mul(m, Mat4.translate(0, 0, -0.05 * k))
|
||||
m = Mat4.mul(m, Mat4.rotateX(-0.30 * k))
|
||||
end
|
||||
gun.frame = m
|
||||
|
||||
-- the barrel, in world pixels: the shot goes where the gun points, so
|
||||
-- lining the sights up with an eye is what aims it
|
||||
local o = { m[4], m[8], m[12] }
|
||||
local dx, dy, dz = m[3], m[7], m[11] -- the model's +Z column
|
||||
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if len > 1e-6 then
|
||||
gun.ray = { o[1], o[2], o[3], dx / len, dy / len, dz / len }
|
||||
else
|
||||
gun.ray = nil
|
||||
end
|
||||
end
|
||||
|
||||
function HordeGun.clear()
|
||||
gun.frame, gun.ray = nil, nil
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
--
|
||||
-- Runs inside VoxelScene's drawScene, once per eye in VR and once per
|
||||
-- frame flat, after the world -- so the gun composites with real depth
|
||||
-- and leaning it into a wall occludes honestly.
|
||||
|
||||
-- Should the gun be drawn at all this frame? Keyed on the first-person
|
||||
-- rig's own IDENTITY rather than on the rung's number, because a staged
|
||||
-- VR battle places a camera through the same seam and the gun has no
|
||||
-- business in it.
|
||||
function HordeGun.visible()
|
||||
if not Horde.active then return false end
|
||||
if gun.frame then return true end
|
||||
return FirstPerson.cardBlend() > 0.35
|
||||
end
|
||||
|
||||
-- The flat screen's view model matrix: carried by the camera, offset to
|
||||
-- the hip or the sight line, with the recoil on top.
|
||||
local function flatModel()
|
||||
local cam = Voxel3D.camera
|
||||
local eye = cam and cam.eye
|
||||
if not eye then return nil end
|
||||
local a = gun.adsBlend
|
||||
a = a * a * (3 - 2 * a)
|
||||
local hip, ads = HordeGun.HIP, HordeGun.ADS
|
||||
local ox = hip[1] + (ads[1] - hip[1]) * a
|
||||
local oy = hip[2] + (ads[2] - hip[2]) * a
|
||||
local oz = hip[3] + (ads[3] - hip[3]) * a
|
||||
-- the reload dip: the gun swings down and out of the shot while the
|
||||
-- hands are busy, easing back as the slide comes home
|
||||
if gun.reloading then
|
||||
local t = math.min(1, gun.reloadT / HordeGun.RELOAD_TIME)
|
||||
local dip = math.sin(math.min(1, t * 1.15) * math.pi)
|
||||
oy = oy - 0.09 * dip
|
||||
ox = ox - 0.03 * dip
|
||||
end
|
||||
local k = gun.kick
|
||||
oz = oz - 0.045 * k
|
||||
|
||||
local m = Mat4.translate(eye[1], eye[2], eye[3])
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.yaw))
|
||||
m = Mat4.mul(m, Mat4.rotateX(FirstPerson.pitch - 0.34 * k))
|
||||
m = Mat4.mul(m, Mat4.scale(VRRig.FP_SCALE, VRRig.FP_SCALE, VRRig.FP_SCALE))
|
||||
m = Mat4.mul(m, Mat4.translate(ox, oy, oz))
|
||||
if gun.reloading then
|
||||
local t = math.min(1, gun.reloadT / HordeGun.RELOAD_TIME)
|
||||
m = Mat4.mul(m, Mat4.rotateX(-0.55 * math.sin(math.min(1, t * 1.15)
|
||||
* math.pi)))
|
||||
end
|
||||
return m
|
||||
end
|
||||
|
||||
function HordeGun.draw()
|
||||
if not HordeGun.visible() then return end
|
||||
local model = gun.frame or flatModel()
|
||||
if not model then return end
|
||||
local body, pal = buildBody(), palette()
|
||||
if not (body and pal) then return end
|
||||
Voxel3D.draw(body, pal, model)
|
||||
if gun.flash > 0 then
|
||||
local flash = buildFlash()
|
||||
if flash then Voxel3D.draw(flash, pal, model) end
|
||||
end
|
||||
end
|
||||
|
||||
function HordeGun.invalidate()
|
||||
paletteTex, bodyMesh, flashMesh = nil, nil, nil
|
||||
end
|
||||
|
||||
return HordeGun
|
||||
@@ -0,0 +1,484 @@
|
||||
-- HORDE MODE: the readout.
|
||||
--
|
||||
-- Health, ammunition, score, wave, the crosshair, the hit marker, the red
|
||||
-- that closes in when something reaches you, and the banners -- "A
|
||||
-- DARKNESS APPROACHES", then "WAVE 1" and every wave after it.
|
||||
--
|
||||
-- IT IS DRAWN TWICE, INTO TWO DIFFERENT PLACES, and that is not
|
||||
-- duplication for its own sake. The flat screen's HUD goes into the SCENE
|
||||
-- canvas through Voxel3D.beginOverlay -- the same seam the overworld's FX
|
||||
-- bubbles use -- because that canvas is what the window composites. A
|
||||
-- headset never sees that canvas: with VR live the window's world pass
|
||||
-- short-circuits to the mirror, and the eyes are rendered on their own in
|
||||
-- lib/VR. So the eye canvases get their own pass, at the same instant the
|
||||
-- VR frame paints its fade over them, in the same 2D idiom.
|
||||
--
|
||||
-- Both call the same draw with a different scale and a different safe
|
||||
-- area: a headset wants everything well inside the lens rather than
|
||||
-- pinned to the corners, because the corners of a VR frame are off the
|
||||
-- edge of the visible world.
|
||||
--
|
||||
-- EVERY WORD IS ON A WHITE PLATE, and that is not a style choice -- it is
|
||||
-- what the font is. The Game Boy font sheets are BLACK glyphs on
|
||||
-- transparent, so setColor cannot make a letter pale: multiplying black
|
||||
-- by white is still black. That is why every box in the game is drawn
|
||||
-- white first and its text black on top (Font.drawBox, then
|
||||
-- setColor(0,0,0)), and it is why a first cut of this HUD -- pale text,
|
||||
-- straight onto the night -- composited as black letters on a black
|
||||
-- street and could not be read at all. Plates also happen to be the right
|
||||
-- answer aesthetically: the game already talks to the player in white
|
||||
-- boxes, and a horde mode that shouts in the same voice belongs to it.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
|
||||
local HordeHud = {}
|
||||
|
||||
local Font = nil
|
||||
local function font()
|
||||
if Font then return Font end
|
||||
local ok, F = pcall(require, "src.render.Font")
|
||||
if ok then Font = F end
|
||||
return Font
|
||||
end
|
||||
|
||||
-- the pulse under the low-health plate and the banner's own breathing
|
||||
local blink = 0
|
||||
|
||||
function HordeHud.update(dt)
|
||||
blink = (blink + (dt or 0)) % 1.0
|
||||
end
|
||||
|
||||
-- named for the suite: the banner's line breaking, which is the part with
|
||||
-- an answer worth pinning
|
||||
HordeHud._layout = nil -- assigned below, once `layout` exists
|
||||
|
||||
-- ------- pieces
|
||||
--
|
||||
-- Every helper takes a scale `s` and draws in GB pixels multiplied by it,
|
||||
-- so one layout serves a 4x window and a headset's eye buffer alike.
|
||||
|
||||
local PAD = 3 -- plate padding, in GB pixels
|
||||
|
||||
-- A white plate with a dark edge: the surface a black glyph can be read
|
||||
-- on. Returns the interior origin, so a caller lays text out from there.
|
||||
local function plate(x, y, w, h, s, alpha)
|
||||
love.graphics.setColor(0.06, 0.05, 0.09, (alpha or 1) * 0.92)
|
||||
love.graphics.rectangle("fill", x - s, y - s, w + 2 * s, h + 2 * s)
|
||||
love.graphics.setColor(0.93, 0.94, 0.90, alpha or 1)
|
||||
love.graphics.rectangle("fill", x, y, w, h)
|
||||
return x + PAD * s, y + PAD * s
|
||||
end
|
||||
|
||||
local function textWidth(str, s)
|
||||
local F = font()
|
||||
if not F then return 0 end
|
||||
return F.width(str) * s
|
||||
end
|
||||
|
||||
-- Black glyphs at `s` times their size. Black because that is the only
|
||||
-- colour the font has (see the header).
|
||||
local function text(str, x, y, s)
|
||||
local F = font()
|
||||
if not F then return 0 end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor(x), math.floor(y))
|
||||
love.graphics.scale(s, s)
|
||||
F.draw(str, 0, 0)
|
||||
love.graphics.pop()
|
||||
end
|
||||
|
||||
-- One line of text on its own plate, anchored left or right.
|
||||
local function label(str, x, y, s, align)
|
||||
local tw = textWidth(str, s)
|
||||
local pw, ph = tw + PAD * 2 * s, 8 * s + PAD * 2 * s
|
||||
local px = (align == "right") and (x - pw) or x
|
||||
local ix, iy = plate(px, y, pw, ph, s)
|
||||
text(str, ix, iy, s)
|
||||
return pw, ph
|
||||
end
|
||||
|
||||
-- The health bar: a plate with a red bar inside it, so the red reads
|
||||
-- against white rather than against a night street.
|
||||
local function healthBar(x, y, w, h, s, fill, flash)
|
||||
local ix, iy = plate(x, y, w, h, s)
|
||||
local iw, ih = w - PAD * 2 * s, h - PAD * 2 * s
|
||||
love.graphics.setColor(0.80, 0.80, 0.78, 1)
|
||||
love.graphics.rectangle("fill", ix, iy, iw, ih)
|
||||
local r, g, b = 0.78, 0.12, 0.16
|
||||
if flash then r, g, b = 1, 0.45, 0.35 end
|
||||
love.graphics.setColor(r, g, b, 1)
|
||||
love.graphics.rectangle("fill", ix, iy, math.max(0, iw * fill), ih)
|
||||
end
|
||||
|
||||
-- The crosshair: four ticks around a gap that opens as the gun kicks, and
|
||||
-- gone entirely down the sights, where the iron sights ARE the crosshair.
|
||||
-- Drawn as a dark pair under a light pair so it survives both a white
|
||||
-- wall and a black doorway.
|
||||
local function crosshair(cx, cy, s, spread, alpha)
|
||||
local gap = (3 + spread * 4) * s
|
||||
local len = 4 * s
|
||||
local t = math.max(1, s)
|
||||
local function ticks(o, thick, r, g, b, a)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
love.graphics.rectangle("fill", cx - gap - len - o, cy - thick / 2 - o,
|
||||
len + 2 * o, thick + 2 * o)
|
||||
love.graphics.rectangle("fill", cx + gap - o, cy - thick / 2 - o,
|
||||
len + 2 * o, thick + 2 * o)
|
||||
love.graphics.rectangle("fill", cx - thick / 2 - o, cy - gap - len - o,
|
||||
thick + 2 * o, len + 2 * o)
|
||||
love.graphics.rectangle("fill", cx - thick / 2 - o, cy + gap - o,
|
||||
thick + 2 * o, len + 2 * o)
|
||||
end
|
||||
ticks(math.max(1, s * 0.5), t, 0, 0, 0, alpha * 0.85)
|
||||
ticks(0, t, 0.98, 0.98, 1, alpha)
|
||||
end
|
||||
|
||||
local function hitMarker(cx, cy, s, amount)
|
||||
if amount <= 0 then return end
|
||||
love.graphics.setColor(1, 0.30, 0.26, amount)
|
||||
local o = 5 * s
|
||||
local len = 5 * s
|
||||
local t = math.max(1, s)
|
||||
for _, d in ipairs({ { -1, -1 }, { 1, -1 }, { -1, 1 }, { 1, 1 } }) do
|
||||
love.graphics.push()
|
||||
love.graphics.translate(cx + d[1] * o, cy + d[2] * o)
|
||||
love.graphics.rotate(math.pi / 4 * (d[1] * d[2] > 0 and 1 or -1))
|
||||
love.graphics.rectangle("fill", -t / 2, -len / 2, t, len)
|
||||
love.graphics.pop()
|
||||
end
|
||||
end
|
||||
|
||||
-- How wide a run of glyphs comes out at `bs` pixels per font pixel, with
|
||||
-- `track` of air after each one.
|
||||
local function runWidth(F, codes, bs, track)
|
||||
local total = 0
|
||||
for _, code in ipairs(codes) do
|
||||
total = total + F.advanceOf(code) * bs + track
|
||||
end
|
||||
return total - track
|
||||
end
|
||||
|
||||
-- The banner's lines, and the size to draw them at.
|
||||
--
|
||||
-- THE SCALE IS NEGOTIATED, not assumed. The caller's scale comes from the
|
||||
-- window's own zoom, so a player zoomed well in gets a large `s` -- and
|
||||
-- "A DARKNESS APPROACHES" at twice a large scale is wider than the
|
||||
-- screen, which is how the words ran off both edges. So: shrink until the
|
||||
-- longest single WORD fits, then wrap the words into as many lines as
|
||||
-- that leaves. Wrapping first and shrinking only when a word alone cannot
|
||||
-- fit keeps the announcement as big as the frame can carry it.
|
||||
local function layout(F, str, scale, maxW)
|
||||
local words = {}
|
||||
for word in tostring(str):gmatch("%S+") do words[#words + 1] = word end
|
||||
if #words == 0 then return nil end
|
||||
|
||||
local bs = math.max(1, scale * 2)
|
||||
local function track(size) return math.max(1, math.floor(size / 2)) end
|
||||
while bs > 1 do
|
||||
local widest = 0
|
||||
for _, word in ipairs(words) do
|
||||
local ww = runWidth(F, F.encode(word), bs, track(bs))
|
||||
if ww > widest then widest = ww end
|
||||
end
|
||||
if widest <= maxW then break end
|
||||
bs = bs - 1
|
||||
end
|
||||
|
||||
local tr = track(bs)
|
||||
local spaceW = runWidth(F, F.encode(" "), bs, tr) + tr
|
||||
local lines, line, lineW = {}, nil, 0
|
||||
for _, word in ipairs(words) do
|
||||
local ww = runWidth(F, F.encode(word), bs, tr)
|
||||
if not line then
|
||||
line, lineW = word, ww
|
||||
elseif lineW + spaceW + ww <= maxW then
|
||||
line, lineW = line .. " " .. word, lineW + spaceW + ww
|
||||
else
|
||||
lines[#lines + 1] = { text = line, width = lineW }
|
||||
line, lineW = word, ww
|
||||
end
|
||||
end
|
||||
lines[#lines + 1] = { text = line, width = lineW }
|
||||
return lines, bs, tr
|
||||
end
|
||||
|
||||
HordeHud._layout = layout
|
||||
|
||||
-- The banner: a plate across the middle of the frame with the words on
|
||||
-- it, as big as the frame can carry. It fades in and out rather than
|
||||
-- cutting -- an announcement, not a notification -- and the plate fades
|
||||
-- with it.
|
||||
local function banner(w, h, scale)
|
||||
local sess = Horde.session
|
||||
if not (sess and sess.bannerText) then return end
|
||||
local t, hold = sess.bannerT, sess.bannerHold
|
||||
local alpha
|
||||
if t < 0.4 then alpha = t / 0.4
|
||||
elseif t < hold then alpha = 1
|
||||
else alpha = math.max(0, 1 - (t - hold) / 1.1) end
|
||||
if alpha <= 0 then return end
|
||||
|
||||
local F = font()
|
||||
if not F then return end
|
||||
local margin = 6 * scale
|
||||
local lines, bs, tr = layout(F, sess.bannerText, scale, w - margin * 2)
|
||||
if not lines then return end
|
||||
|
||||
local lineH = 8 * bs
|
||||
local gap = math.max(1, math.floor(bs * 0.4))
|
||||
local pad = PAD * 2 * scale
|
||||
local ph = #lines * lineH + (#lines - 1) * gap + pad * 2
|
||||
local y = math.floor(h * 0.30 - ph / 2)
|
||||
-- the plate runs the full width: a band across the world, which reads
|
||||
-- as the game interrupting itself rather than as a label on it
|
||||
plate(0, y, w, ph, scale, alpha)
|
||||
local iy = y + pad
|
||||
|
||||
love.graphics.setColor(0, 0, 0, alpha)
|
||||
for i, line in ipairs(lines) do
|
||||
local pen = math.floor((w - line.width) / 2)
|
||||
local ly = iy + (i - 1) * (lineH + gap)
|
||||
for _, code in ipairs(F.encode(line.text)) do
|
||||
love.graphics.push()
|
||||
love.graphics.translate(pen, ly)
|
||||
love.graphics.scale(bs, bs)
|
||||
F.drawCode(code, 0, 0)
|
||||
love.graphics.pop()
|
||||
pen = pen + F.advanceOf(code) * bs + tr
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the whole thing
|
||||
--
|
||||
-- `inset` is how far off the edges the corners sit, which is the one real
|
||||
-- difference between a window and a headset.
|
||||
|
||||
local function draw(w, h, s, inset)
|
||||
local sess = Horde.session
|
||||
if not sess then return end
|
||||
local Gun = V.require("HordeGun")
|
||||
local ammo, mag, reloading = Gun.ammo()
|
||||
local ads = Gun.adsBlend()
|
||||
|
||||
love.graphics.push("all")
|
||||
love.graphics.setBlendMode("alpha")
|
||||
|
||||
-- The red. A VIGNETTE rather than a wash over everything: a full-screen
|
||||
-- fill strong enough to register at a glance also hides the thing that
|
||||
-- just hit you, which in a mode about being surrounded is the one thing
|
||||
-- it must not do. Bands closing in from the edges instead, so the
|
||||
-- middle of the frame stays readable and the alarm arrives in the
|
||||
-- corner of the eye.
|
||||
local hurt = sess.damageFlash
|
||||
local low = 1 - math.min(1, sess.hp / (sess.maxHp * 0.35))
|
||||
local wash = math.max(hurt * 0.9, low * 0.6
|
||||
* (0.7 + 0.3 * math.sin(blink * math.pi * 2)))
|
||||
if wash > 0 then
|
||||
local band = math.min(w, h) * 0.38
|
||||
local steps = 8
|
||||
for i = 1, steps do
|
||||
local t = i / steps
|
||||
local d = band * t
|
||||
love.graphics.setColor(0.60, 0.02, 0.06, wash * 0.13)
|
||||
love.graphics.rectangle("fill", 0, 0, w, d)
|
||||
love.graphics.rectangle("fill", 0, h - d, w, d)
|
||||
love.graphics.rectangle("fill", 0, 0, d, h)
|
||||
love.graphics.rectangle("fill", w - d, 0, d, h)
|
||||
end
|
||||
end
|
||||
|
||||
-- health, top left
|
||||
local barW, barH = 60 * s, 8 * s + PAD * 2 * s
|
||||
healthBar(inset, inset, barW, barH, s, sess.hp / sess.maxHp, hurt > 0.3)
|
||||
label(("%d"):format(math.ceil(sess.hp)), inset, inset + barH + 3 * s, s)
|
||||
|
||||
-- score and wave, top right
|
||||
label(("SCORE %d"):format(math.floor(sess.score)), w - inset, inset, s,
|
||||
"right")
|
||||
label(("WAVE %d"):format(math.max(1, sess.wave)),
|
||||
w - inset, inset + (8 * s + PAD * 2 * s) + 3 * s, s, "right")
|
||||
|
||||
-- ammunition, bottom right: the rounds as pips over the count, which
|
||||
-- reads at a glance in a firefight where a number does not
|
||||
local ammoStr = reloading and "RELOADING" or ("%d / %d"):format(ammo, mag)
|
||||
local _, ah = label(ammoStr, w - inset, h - inset - (8 * s + PAD * 2 * s), s,
|
||||
"right")
|
||||
local pipW, pipH, pipGap = 3 * s, 7 * s, 2 * s
|
||||
local pipsW = mag * (pipW + pipGap) - pipGap
|
||||
local px = w - inset - pipsW
|
||||
local py = h - inset - ah - pipH - 5 * s
|
||||
love.graphics.setColor(0.06, 0.05, 0.09, 0.85)
|
||||
love.graphics.rectangle("fill", px - 2 * s, py - 2 * s,
|
||||
pipsW + 4 * s, pipH + 4 * s)
|
||||
for i = 1, mag do
|
||||
if i <= ammo and not reloading then
|
||||
love.graphics.setColor(0.98, 0.86, 0.36, 1)
|
||||
else
|
||||
love.graphics.setColor(0.32, 0.30, 0.36, 1)
|
||||
end
|
||||
love.graphics.rectangle("fill", px + (i - 1) * (pipW + pipGap), py,
|
||||
pipW, pipH)
|
||||
end
|
||||
if reloading then
|
||||
local t = math.min(1, Gun.state.reloadT / Gun.RELOAD_TIME)
|
||||
love.graphics.setColor(0.55, 0.78, 0.98, 1)
|
||||
love.graphics.rectangle("fill", px, py + pipH + 1 * s, pipsW * t, 2 * s)
|
||||
end
|
||||
|
||||
-- the sight picture
|
||||
local cx, cy = w / 2, h / 2
|
||||
if ads < 0.6 then
|
||||
crosshair(cx, cy, s, Gun.state.kick, (1 - ads / 0.6) * 0.9)
|
||||
end
|
||||
hitMarker(cx, cy, s, sess.hitMarker)
|
||||
|
||||
banner(w, h, s)
|
||||
|
||||
love.graphics.pop()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- ------- the two callers
|
||||
|
||||
-- The flat window. Called from the voxel pipeline's overlay block, into
|
||||
-- the scene canvas -- which is at the window's PIXEL size and may be
|
||||
-- supersampled on top of that, so the caller's scale carries both.
|
||||
--
|
||||
-- The caller's scale is CAPPED against the canvas rather than taken as
|
||||
-- given, because that scale is the world's zoom: zoom in far enough and
|
||||
-- the health bar was a metre wide and half of it off the top of the
|
||||
-- screen. A readout is not part of the world and should not zoom with
|
||||
-- it -- so it sizes off the frame it is drawn in, which keeps its
|
||||
-- apparent size the same at every zoom and grows it honestly on a bigger
|
||||
-- display (and with supersampling, which is in both numbers).
|
||||
function HordeHud.drawFlat(w, h, scale)
|
||||
if not Horde.active then return end
|
||||
local cap = math.max(1, math.floor(h / 260))
|
||||
local s = math.max(1, math.min(math.floor((scale or 1) + 0.5), cap))
|
||||
draw(w, h, s, 8 * s)
|
||||
end
|
||||
|
||||
-- ------- and the headset's, which is not a screen overlay at all
|
||||
--
|
||||
-- A VR eye gets NO 2D overlay. An earlier cut drew this same HUD into
|
||||
-- both eye canvases and it came out torn down the middle: the eye frusta
|
||||
-- are ASYMMETRIC, so the same canvas pixel is a different ANGLE in each
|
||||
-- eye, and the two images never fuse. Nor is there a crosshair to draw --
|
||||
-- the gun is a real object with real sights and the shot goes down its
|
||||
-- barrel, so a dot painted at the centre of the frame would be pointing
|
||||
-- at something else entirely.
|
||||
--
|
||||
-- What the headset gets instead is this: the readout as a TEXTURE, which
|
||||
-- lib/VR puts on the POKEDEX in the player's left hand -- already
|
||||
-- tracked, already lit, and already the surface this mod shows
|
||||
-- information on. Geometry in the world, so both eyes see it from their
|
||||
-- own position and the stereo is correct by construction. (It rode the
|
||||
-- gun for one revision and that was worse: a screen on the slide sits
|
||||
-- exactly where the iron sights have to be looked through.)
|
||||
--
|
||||
-- Sized to the device's own screen, which is the GB frame's 10:9.
|
||||
|
||||
local panelCanvas = nil
|
||||
local PANEL_W, PANEL_H = 160, 144
|
||||
|
||||
function HordeHud.panelTexture()
|
||||
if not Horde.active then return nil end
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local sess = Horde.session
|
||||
if not sess then return nil end
|
||||
local F = font()
|
||||
if not F then return nil end
|
||||
|
||||
if not panelCanvas then
|
||||
local ok, c = pcall(love.graphics.newCanvas, PANEL_W, PANEL_H)
|
||||
if not ok then return nil end
|
||||
panelCanvas = c
|
||||
pcall(panelCanvas.setFilter, panelCanvas, "nearest", "nearest")
|
||||
end
|
||||
|
||||
local Gun = V.require("HordeGun")
|
||||
local ammo, mag, reloading = Gun.ammo()
|
||||
|
||||
local ok = pcall(function()
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(panelCanvas)
|
||||
love.graphics.setBlendMode("alpha")
|
||||
love.graphics.clear(0.93, 0.94, 0.90, 1)
|
||||
|
||||
-- the health bar, framed, across the top
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 8, 8, PANEL_W - 16, 20)
|
||||
love.graphics.setColor(0.80, 0.80, 0.78, 1)
|
||||
love.graphics.rectangle("fill", 11, 11, PANEL_W - 22, 14)
|
||||
love.graphics.setColor(0.78, 0.12, 0.16, 1)
|
||||
love.graphics.rectangle("fill", 11, 11,
|
||||
(PANEL_W - 22) * math.max(0, sess.hp / sess.maxHp),
|
||||
14)
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
F.draw(("HP %d"):format(math.ceil(sess.hp)), 8, 34)
|
||||
F.draw(reloading and "RELOADING" or ("AMMO %d/%d"):format(ammo, mag),
|
||||
8, 50)
|
||||
|
||||
-- the round pips, so ammunition reads without counting digits
|
||||
local pipW, gap = 9, 5
|
||||
for i = 1, mag do
|
||||
if i <= ammo and not reloading then
|
||||
love.graphics.setColor(0.85, 0.65, 0.10, 1)
|
||||
else
|
||||
love.graphics.setColor(0.72, 0.73, 0.70, 1)
|
||||
end
|
||||
love.graphics.rectangle("fill", 8 + (i - 1) * (pipW + gap), 66, pipW, 12)
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
F.draw(("WAVE %d"):format(math.max(1, sess.wave)), 8, 86)
|
||||
F.draw(("%d"):format(math.floor(sess.score)), 8, 102)
|
||||
|
||||
-- and the banner, wrapped to the panel rather than to the frame.
|
||||
-- BLACK on the panel's own white, like everything else here: the font
|
||||
-- sheets are black glyphs on transparent, so a pale letter is not a
|
||||
-- thing that can be drawn (see the header).
|
||||
if sess.bannerText then
|
||||
local lines, bs, tr = layout(F, sess.bannerText, 1, PANEL_W - 8)
|
||||
if lines then
|
||||
local top = PANEL_H - 8 * bs * #lines - 5
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, top - 2, PANEL_W, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
local pen = math.floor((PANEL_W - line.width) / 2)
|
||||
local ly = PANEL_H - 8 * bs * (#lines - i + 1) - 3
|
||||
for _, code in ipairs(F.encode(line.text)) do
|
||||
love.graphics.push()
|
||||
love.graphics.translate(pen, ly)
|
||||
love.graphics.scale(bs, bs)
|
||||
F.drawCode(code, 0, 0)
|
||||
love.graphics.pop()
|
||||
pen = pen + F.advanceOf(code) * bs + tr
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.pop()
|
||||
end)
|
||||
pcall(love.graphics.setCanvas)
|
||||
if not ok then return nil end
|
||||
return panelCanvas
|
||||
end
|
||||
|
||||
-- window resize / hot reload
|
||||
function HordeHud.invalidate()
|
||||
if panelCanvas and panelCanvas.release then
|
||||
pcall(panelCanvas.release, panelCanvas)
|
||||
end
|
||||
panelCanvas = nil
|
||||
end
|
||||
|
||||
return HordeHud
|
||||
@@ -0,0 +1,553 @@
|
||||
-- HORDE MODE: the crowd.
|
||||
--
|
||||
-- Waves of people who want to touch you, walking the same grid the game
|
||||
-- walks, wearing the overworld's own character sheets. Every mob IS a
|
||||
-- real engine NPC (OverworldState:addRuntimeObject), which is what buys
|
||||
-- the whole feature for nothing: the engine interpolates their steps,
|
||||
-- the collision system lets them jostle, the voxel pass billboards them
|
||||
-- with the right frame for the angle you see them from, and the flat 2D
|
||||
-- path draws them too. Nothing here draws a character.
|
||||
--
|
||||
-- THEY ARE DRIVEN, NOT SCRIPTED. OverworldState:scriptMove would be the
|
||||
-- obvious way to walk one, and it is a trap: a queued script move sets
|
||||
-- `scripted` on the state, which blocks the PLAYER's input for as long as
|
||||
-- it runs. So mobs are spawned with movement = "STAY" (which leaves
|
||||
-- NPC:update's wander branch inert) and this file writes facing / target /
|
||||
-- moving / progress directly, once per step. NPC:update then does the
|
||||
-- pixel interpolation and the cell commit exactly as it does for a
|
||||
-- wandering shopkeeper.
|
||||
--
|
||||
-- PATHING IS A FLOW FIELD, not A* per mob. One breadth-first sweep out
|
||||
-- from the player's cell, over the map's walkable cells, gives EVERY mob
|
||||
-- its next step at once -- and gives it correctly through doorways and
|
||||
-- around buildings, which is what "gang up on the player" actually
|
||||
-- requires. Rebuilt a few times a second rather than per frame; between
|
||||
-- rebuilds a mob just walks downhill on the numbers. It also answers two
|
||||
-- other questions for free: how far a cell is from the player (so a spawn
|
||||
-- point can be picked at a fair distance and be guaranteed REACHABLE),
|
||||
-- and whether a mob is adjacent enough to swing.
|
||||
--
|
||||
-- The sweep ignores entity occupancy on purpose. Mobs are solid to each
|
||||
-- other, so a pack funnelling down a corridor will jam -- and the fix for
|
||||
-- that is not a cleverer path, it is that a mob whose downhill step is
|
||||
-- occupied tries its second choice and otherwise waits. That is what
|
||||
-- makes them pool around the player instead of forming a queue.
|
||||
--
|
||||
-- FOLLOWING THROUGH DOORS. A warp tears down every NPC on the old map, so
|
||||
-- the roster cannot survive one. What survives is the COUNT: the number
|
||||
-- still alive when the player ran, re-spawned on the far side over the
|
||||
-- next few seconds, from the cells nearest the door they came in by. From
|
||||
-- the player's chair that is the horde coming through the door after them.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Horde = V.require("Horde")
|
||||
local HordeSfx = V.require("HordeSfx")
|
||||
|
||||
local HordeMobs = {}
|
||||
|
||||
-- ------- tuning
|
||||
|
||||
-- Wave n throws this many at you, and no more than CAP stand at once.
|
||||
local function waveSize(n) return 4 + 3 * n end
|
||||
local CAP = 14
|
||||
local SPAWN_INTERVAL = 0.75 -- seconds between arrivals inside a wave
|
||||
local WAVE_GAP = 4.0 -- the breather, and the banner's window
|
||||
local FOLLOW_INTERVAL = 0.55 -- how fast they pour through a door
|
||||
|
||||
-- Frames per cell. The engine's own walk is 16; the horde is quicker than
|
||||
-- a shopkeeper and gets quicker as the waves stack, floored so it never
|
||||
-- outruns the player's own free walk.
|
||||
local function stepFrames(n)
|
||||
return math.max(9, 15 - math.floor(n / 2))
|
||||
end
|
||||
|
||||
local function mobHp(n) return math.min(4, 1 + math.floor(n / 3)) end
|
||||
local function killScore(n) return 100 + 25 * (n - 1) end
|
||||
local function waveBonus(n) return 250 * n end
|
||||
|
||||
-- How close a mob comes before it stops walking and starts swinging, in
|
||||
-- CELLS, and how close it has to be to land the hit, in world pixels.
|
||||
--
|
||||
-- The standoff is the difference between a horde and a wall. Nothing
|
||||
-- stops a mob taking the cell next to the player -- and when it does, a
|
||||
-- sixteen-pixel figure a cell away fills a sixty-five-degree lens edge to
|
||||
-- edge, so being surrounded looks like a texture rather than like people.
|
||||
-- Two cells back they read as figures closing in, the ring holds a dozen
|
||||
-- of them, and the player can still see what they are shooting at.
|
||||
local STANDOFF = 2
|
||||
local REACH = 40
|
||||
|
||||
-- The cast. Overworld sprite sheets that read as a threat coming out of
|
||||
-- the dark; anything missing from the loaded game is dropped at spawn.
|
||||
local CAST = {
|
||||
"SPRITE_ROCKET", "SPRITE_CHANNELER", "SPRITE_SCIENTIST", "SPRITE_BIKER",
|
||||
"SPRITE_GUARD", "SPRITE_SUPER_NERD", "SPRITE_HIKER", "SPRITE_SWIMMER",
|
||||
"SPRITE_GYM_GUIDE", "SPRITE_BLACK_HAIR_BOY_1", "SPRITE_GIRL",
|
||||
"SPRITE_MIDDLE_AGED_MAN", "SPRITE_FISHER", "SPRITE_GAMBLER",
|
||||
}
|
||||
|
||||
local OWNER = "DRAMATIC_SHAPE"
|
||||
|
||||
-- ------- the flow field
|
||||
--
|
||||
-- dist[cy * w + cx] = steps from the player, over walkable cells only.
|
||||
-- Nil where the sweep never reached, which is the same answer as "no way
|
||||
-- there from here" -- an island across water, a room behind a locked door.
|
||||
|
||||
local field = { mapId = nil, w = 0, h = 0, dist = nil, at = nil, age = 0 }
|
||||
|
||||
local REBUILD_EVERY = 0.28
|
||||
|
||||
local function passable(map, cx, cy)
|
||||
if not map:inBounds(cx, cy) then return false end
|
||||
if not map:isWalkableCell(cx, cy) then return false end
|
||||
-- a warp cell is walkable but standing on one takes the warp; mobs may
|
||||
-- cross them (that IS the door they follow you through) so they stay in
|
||||
return true
|
||||
end
|
||||
|
||||
-- fixed order, so a tie between two equally good steps always breaks the
|
||||
-- same way -- a mob that dithers between two cells reads as broken, and a
|
||||
-- pairs() walk over a hash would give a different answer every run
|
||||
local DIRS = { "right", "left", "down", "up" }
|
||||
local DX = { right = 1, left = -1, down = 0, up = 0 }
|
||||
local DY = { right = 0, left = 0, down = 1, up = -1 }
|
||||
|
||||
local function rebuildField(map, px, py)
|
||||
local w, h = map.widthCells, map.heightCells
|
||||
local dist = {}
|
||||
-- a plain array queue: BFS on a grid never revisits a cell, so no heap
|
||||
-- and no priority is needed and the whole sweep is one pass
|
||||
local qx, qy = { px }, { py }
|
||||
local head = 1
|
||||
dist[py * w + px] = 0
|
||||
while head <= #qx do
|
||||
local cx, cy = qx[head], qy[head]
|
||||
head = head + 1
|
||||
local d = dist[cy * w + cx] + 1
|
||||
for i = 1, 4 do
|
||||
local dir = DIRS[i]
|
||||
local nx, ny = cx + DX[dir], cy + DY[dir]
|
||||
local key = ny * w + nx
|
||||
if dist[key] == nil and passable(map, nx, ny) then
|
||||
dist[key] = d
|
||||
qx[#qx + 1], qy[#qy + 1] = nx, ny
|
||||
end
|
||||
end
|
||||
end
|
||||
field.mapId, field.w, field.h, field.dist = map.id, w, h, dist
|
||||
field.at = { px, py }
|
||||
field.age = 0
|
||||
end
|
||||
|
||||
local function distAt(cx, cy)
|
||||
if not field.dist then return nil end
|
||||
if cx < 0 or cy < 0 or cx >= field.w or cy >= field.h then return nil end
|
||||
return field.dist[cy * field.w + cx]
|
||||
end
|
||||
|
||||
-- named for the suite: the sweep, and the distance it wrote to a cell
|
||||
HordeMobs._dist = distAt
|
||||
HordeMobs._rebuild = rebuildField
|
||||
|
||||
-- ------- spawning
|
||||
|
||||
local function liveSprites(G)
|
||||
local out = {}
|
||||
local sprites = G and G.data and G.data.sprites
|
||||
for _, key in ipairs(CAST) do
|
||||
if sprites and sprites[key] then out[#out + 1] = key end
|
||||
end
|
||||
if #out == 0 and sprites then
|
||||
-- a total conversion with none of the vanilla sheets: take whatever
|
||||
-- walker it does have rather than spawning nothing at all
|
||||
local keys = {}
|
||||
for key, def in pairs(sprites) do
|
||||
if def and def.walker then keys[#keys + 1] = key end
|
||||
end
|
||||
table.sort(keys)
|
||||
for i = 1, math.min(6, #keys) do out[i] = keys[i] end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Cells at a fair distance from the player that the flow field says are
|
||||
-- actually reachable, preferring the far end of the band so the horde
|
||||
-- arrives from off in the dark rather than on top of you.
|
||||
local function spawnCells(map, near, far, want)
|
||||
local out = {}
|
||||
if not field.dist then return out end
|
||||
for key, d in pairs(field.dist) do
|
||||
if d >= near and d <= far then
|
||||
local cy = math.floor(key / field.w)
|
||||
local cx = key - cy * field.w
|
||||
out[#out + 1] = { cx, cy, d }
|
||||
end
|
||||
end
|
||||
-- shuffle, then bias toward distance: sorting outright would file every
|
||||
-- mob in from the same corner
|
||||
for i = #out, 2, -1 do
|
||||
local j = love.math.random(i)
|
||||
out[i], out[j] = out[j], out[i]
|
||||
end
|
||||
table.sort(out, function(a, b) return a[3] > b[3] end)
|
||||
while #out > (want or 16) do table.remove(out) end
|
||||
return out
|
||||
end
|
||||
|
||||
local function occupiedCell(state, cx, cy)
|
||||
local Collision = require("src.world.Collision")
|
||||
return Collision.occupied(state.entities, cx, cy, nil) ~= nil
|
||||
end
|
||||
|
||||
-- One mob, on a cell, on the live map. Returns the roster entry or nil.
|
||||
local function spawnAt(G, state, cx, cy, wave)
|
||||
local s = Horde.session
|
||||
if not s then return nil end
|
||||
local sprites = liveSprites(G)
|
||||
if #sprites == 0 then return nil end
|
||||
local def = {
|
||||
x = cx, y = cy,
|
||||
sprite = sprites[love.math.random(#sprites)],
|
||||
movement = "STAY",
|
||||
range = "DOWN",
|
||||
name = "HORDE",
|
||||
hordeMob = true,
|
||||
}
|
||||
local mapId = state.map.id
|
||||
local okAdd, npcId = pcall(state.addRuntimeObject, state, mapId, def, OWNER)
|
||||
if not (okAdd and npcId) then return nil end
|
||||
s.spawned[mapId] = s.spawned[mapId] or {}
|
||||
s.spawned[mapId][def.index] = true
|
||||
|
||||
local npc = nil
|
||||
for _, e in ipairs(state.npcs) do
|
||||
if e.id == npcId then npc = e break end
|
||||
end
|
||||
if not npc then return nil end
|
||||
npc.wanders = false
|
||||
npc.stepFrames = stepFrames(wave)
|
||||
local entry = {
|
||||
npc = npc, id = npcId, mapId = mapId,
|
||||
hp = mobHp(wave), attackT = 0,
|
||||
}
|
||||
s.mobs[#s.mobs + 1] = entry
|
||||
return entry
|
||||
end
|
||||
|
||||
-- ------- removal
|
||||
--
|
||||
-- Targeted, because the engine's own removeRuntimeObject walks every map
|
||||
-- in the game to find one object and a firefight calls this several times
|
||||
-- a second.
|
||||
|
||||
local function dropNpc(state, npcId)
|
||||
for _, list in ipairs({ state.npcs or {}, state.entities or {} }) do
|
||||
for i = #list, 1, -1 do
|
||||
if list[i].id == npcId then table.remove(list, i) end
|
||||
end
|
||||
end
|
||||
if state.npcPool then state.npcPool[npcId] = nil end
|
||||
end
|
||||
|
||||
-- Take this mode's objects back out of a map record. Runtime objects live
|
||||
-- in Game.data.maps[id].objects until removed, and setMap respawns from
|
||||
-- that list -- so a def left behind is a mob waiting on the far side of a
|
||||
-- door long after the mode ended.
|
||||
local function scrubMap(G, mapId, indices)
|
||||
local def = G and G.data and G.data.maps and G.data.maps[mapId]
|
||||
if not def or not def.objects then return end
|
||||
for i = #def.objects, 1, -1 do
|
||||
local obj = def.objects[i]
|
||||
if obj and obj.hordeMob and (not indices or indices[obj.index]) then
|
||||
table.remove(def.objects, i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the roster's own step
|
||||
|
||||
local function faceToward(npc, cx, cy)
|
||||
local dx, dy = cx - npc.cellX, cy - npc.cellY
|
||||
if math.abs(dx) > math.abs(dy) then
|
||||
return dx > 0 and "right" or "left"
|
||||
end
|
||||
return dy > 0 and "down" or "up"
|
||||
end
|
||||
|
||||
-- Walk one mob downhill on the flow field. The best neighbour is the one
|
||||
-- with the lowest distance; when it is taken, the second best is tried,
|
||||
-- and when both are taken the mob waits a beat -- which is what makes a
|
||||
-- pack pool around the player instead of queueing behind one another.
|
||||
local function stepMob(state, entry)
|
||||
local npc = entry.npc
|
||||
if npc.moving then return end
|
||||
local here = distAt(npc.cellX, npc.cellY)
|
||||
-- close enough: stand and swing rather than crowding into the lens
|
||||
if here and here <= STANDOFF then
|
||||
local p = state.player
|
||||
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||
return
|
||||
end
|
||||
local best, bestD, second, secondD = nil, nil, nil, nil
|
||||
for i = 1, 4 do
|
||||
local dir = DIRS[i]
|
||||
local tx, ty = npc.cellX + DX[dir], npc.cellY + DY[dir]
|
||||
local d = distAt(tx, ty)
|
||||
-- the standoff is enforced on the cell being ENTERED, not the one
|
||||
-- being stood on: a mob that checked only where it was would still
|
||||
-- finish the step it was already taking and end up in the lens
|
||||
if d and d < STANDOFF then d = nil end
|
||||
if d and (not here or d < here) then
|
||||
if not bestD or d < bestD then
|
||||
second, secondD = best, bestD
|
||||
best, bestD = { dir, tx, ty }, d
|
||||
elseif not secondD or d < secondD then
|
||||
second, secondD = { dir, tx, ty }, d
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, pick in ipairs({ best, second }) do
|
||||
if pick then
|
||||
local dir, tx, ty = pick[1], pick[2], pick[3]
|
||||
if not occupiedCell(state, tx, ty) then
|
||||
npc.facing = dir
|
||||
npc.targetX, npc.targetY = tx, ty
|
||||
npc.moving = true
|
||||
npc.progress = 0
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
-- boxed in: keep facing the player so the pack still reads as a threat
|
||||
local p = state.player
|
||||
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
-- ------- the public surface
|
||||
|
||||
function HordeMobs.begin(G)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
local state = G and G.overworld
|
||||
if not (state and state.map) then return end
|
||||
field.mapId = nil
|
||||
s.wave, s.waveRemaining, s.waveGap, s.spawnGap = 0, 0, 0, 0
|
||||
HordeMobs.convertLocals(state)
|
||||
end
|
||||
|
||||
-- Everyone already standing on the map joins in. Their sprite, their
|
||||
-- position, their business -- now walking at the player. Nothing is
|
||||
-- stored to undo it, because the restore warps through setMap, which
|
||||
-- rebuilds every one of them from the map record (see Horde.finish).
|
||||
function HordeMobs.convertLocals(state)
|
||||
local s = Horde.session
|
||||
if not (s and state and state.npcs) then return end
|
||||
local known = {}
|
||||
for _, e in ipairs(s.mobs) do known[e.npc] = true end
|
||||
for _, npc in ipairs(state.npcs) do
|
||||
if not known[npc] and not npc.passable then
|
||||
npc.wanders = false
|
||||
npc.frozen = false
|
||||
npc.stepFrames = stepFrames(math.max(1, s.wave))
|
||||
s.mobs[#s.mobs + 1] = {
|
||||
npc = npc, id = npc.id, mapId = state.map.id,
|
||||
hp = mobHp(math.max(1, s.wave)), attackT = 0, local_ = true,
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function HordeMobs.nextWave(G)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
s.wave = s.wave + 1
|
||||
s.waveRemaining = waveSize(s.wave)
|
||||
s.spawnGap = 0
|
||||
Horde.banner(("WAVE %d"):format(s.wave), 1.6)
|
||||
HordeSfx.play(HordeSfx.WAVE)
|
||||
for _, e in ipairs(s.mobs) do
|
||||
e.npc.stepFrames = stepFrames(s.wave)
|
||||
end
|
||||
end
|
||||
|
||||
-- A mob took a bullet. Returns "kill", "hit", or nil.
|
||||
function HordeMobs.hit(entry, damage)
|
||||
local s = Horde.session
|
||||
if not (s and entry) then return nil end
|
||||
entry.hp = entry.hp - (damage or 1)
|
||||
if entry.hp > 0 then
|
||||
HordeSfx.play(HordeSfx.HIT)
|
||||
return "hit"
|
||||
end
|
||||
entry.dead = true
|
||||
s.kills = s.kills + 1
|
||||
Horde.addScore(killScore(math.max(1, s.wave)))
|
||||
HordeSfx.randomCry()
|
||||
return "kill"
|
||||
end
|
||||
|
||||
-- Every live mob, for the gun's ray to test against.
|
||||
function HordeMobs.list()
|
||||
local s = Horde.session
|
||||
return s and s.mobs or {}
|
||||
end
|
||||
|
||||
function HordeMobs.update(dt, G)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
local state = G and G.overworld
|
||||
if not (state and state.map and state.player) then return end
|
||||
local p = state.player
|
||||
|
||||
-- the flow field, rebuilt on a clock and whenever the player changes
|
||||
-- cell far enough that the old numbers point at where they used to be
|
||||
field.age = field.age + dt
|
||||
local moved = field.at
|
||||
and (math.abs(field.at[1] - p.cellX) + math.abs(field.at[2] - p.cellY)) or 99
|
||||
if field.mapId ~= state.map.id or field.age >= REBUILD_EVERY or moved >= 2 then
|
||||
rebuildField(state.map, p.cellX, p.cellY)
|
||||
end
|
||||
|
||||
-- the dead, collected before anything walks
|
||||
for i = #s.mobs, 1, -1 do
|
||||
local e = s.mobs[i]
|
||||
if e.dead or not e.npc then
|
||||
if e.npc then dropNpc(state, e.id) end
|
||||
table.remove(s.mobs, i)
|
||||
end
|
||||
end
|
||||
|
||||
-- the living
|
||||
local pcx, pcy = p.px + 8, p.py + 8
|
||||
for _, e in ipairs(s.mobs) do
|
||||
local npc = e.npc
|
||||
e.attackT = math.max(0, e.attackT - dt)
|
||||
stepMob(state, e)
|
||||
local dx, dz = (npc.px + 8) - pcx, (npc.py + 8) - pcy
|
||||
if dx * dx + dz * dz <= REACH * REACH then
|
||||
if e.attackT <= 0 then
|
||||
e.attackT = 0.8
|
||||
npc.facing = faceToward(npc, p.cellX, p.cellY)
|
||||
Horde.damage()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not Horde.playing() then return end
|
||||
|
||||
-- the crowd that followed the player through a door, arriving
|
||||
if s.followQueue > 0 then
|
||||
s.spawnGap = s.spawnGap - dt
|
||||
if s.spawnGap <= 0 and #s.mobs < CAP then
|
||||
s.spawnGap = FOLLOW_INTERVAL
|
||||
local cells = spawnCells(state.map, 2, 9, 8)
|
||||
local cell = cells[1]
|
||||
if cell and spawnAt(G, state, cell[1], cell[2], s.wave) then
|
||||
s.followQueue = s.followQueue - 1
|
||||
else
|
||||
s.followQueue = s.followQueue - 1 -- nowhere to put them; let it go
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- the wave itself
|
||||
if s.waveRemaining > 0 then
|
||||
s.spawnGap = s.spawnGap - dt
|
||||
if s.spawnGap <= 0 and #s.mobs < CAP then
|
||||
s.spawnGap = SPAWN_INTERVAL
|
||||
local cells = spawnCells(state.map, 7, 18, 10)
|
||||
if #cells == 0 then cells = spawnCells(state.map, 3, 30, 10) end
|
||||
local cell = cells[1]
|
||||
if cell and spawnAt(G, state, cell[1], cell[2], s.wave) then
|
||||
s.waveRemaining = s.waveRemaining - 1
|
||||
else
|
||||
s.spawnGap = 1.5 -- no room right now; try again shortly
|
||||
end
|
||||
end
|
||||
elseif #s.mobs == 0 then
|
||||
s.waveGap = s.waveGap + dt
|
||||
if s.waveGap == dt then
|
||||
Horde.addScore(waveBonus(s.wave))
|
||||
Horde.banner(("WAVE %d CLEAR"):format(s.wave), 1.8)
|
||||
end
|
||||
if s.waveGap >= WAVE_GAP then
|
||||
s.waveGap = 0
|
||||
HordeMobs.nextWave(G)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the door
|
||||
--
|
||||
-- map.entered fires after setMap has rebuilt the world, which means every
|
||||
-- mob instance from the old map is already gone. What is left to do is
|
||||
-- take our defs off the old map (or they respawn if the player ever comes
|
||||
-- back), remember how many were chasing, and let update() walk them in.
|
||||
|
||||
function HordeMobs.onMapEntered(payload)
|
||||
local s = Horde.session
|
||||
if not s then return end
|
||||
local G = require("src.core.Game")
|
||||
local state = G.overworld
|
||||
if not (state and state.map) then return end
|
||||
local newId = state.map.id
|
||||
|
||||
local following = 0
|
||||
for _, e in ipairs(s.mobs) do
|
||||
if e.mapId ~= newId and not e.local_ then following = following + 1 end
|
||||
end
|
||||
-- the old map's records, and any instance the pool kept
|
||||
for mapId, indices in pairs(s.spawned) do
|
||||
if mapId ~= newId then
|
||||
scrubMap(G, mapId, indices)
|
||||
s.spawned[mapId] = nil
|
||||
end
|
||||
end
|
||||
for i = #s.mobs, 1, -1 do
|
||||
if s.mobs[i].mapId ~= newId then table.remove(s.mobs, i) end
|
||||
end
|
||||
|
||||
field.mapId = nil
|
||||
s.followQueue = math.max(s.followQueue, following)
|
||||
s.spawnGap = math.min(s.spawnGap, 0.4)
|
||||
HordeMobs.convertLocals(state)
|
||||
end
|
||||
|
||||
-- ------- the end
|
||||
--
|
||||
-- Every def this mode wrote, off every map it wrote one to. The live
|
||||
-- instances go too, though the restore's own warp would have taken them:
|
||||
-- cleanup has to leave a consistent world even when it is called from a
|
||||
-- path that never warps.
|
||||
|
||||
function HordeMobs.cleanup(G)
|
||||
G = G or require("src.core.Game")
|
||||
local s = Horde.session
|
||||
local state = G.overworld
|
||||
if s then
|
||||
for _, e in ipairs(s.mobs) do
|
||||
if state and not e.local_ then dropNpc(state, e.id) end
|
||||
end
|
||||
for mapId, indices in pairs(s.spawned) do
|
||||
scrubMap(G, mapId, indices)
|
||||
end
|
||||
s.mobs, s.spawned = {}, {}
|
||||
s.followQueue, s.waveRemaining = 0, 0
|
||||
else
|
||||
-- a session that vanished under us (a reload mid-mode): sweep every
|
||||
-- map for this mode's marker rather than leaving actors behind
|
||||
for mapId in pairs((G.data and G.data.maps) or {}) do
|
||||
scrubMap(G, mapId, nil)
|
||||
end
|
||||
end
|
||||
field.mapId, field.dist, field.at = nil, nil, nil
|
||||
end
|
||||
|
||||
-- named for the suite, down here because the walk is defined above it
|
||||
HordeMobs._stepMob = stepMob
|
||||
|
||||
return HordeMobs
|
||||
@@ -0,0 +1,262 @@
|
||||
-- HORDE MODE: the gun, in Game Boy hardware.
|
||||
--
|
||||
-- Every sound this mode makes is SYNTHESIZED on the same emulated APU the
|
||||
-- rest of the game speaks through -- no sample files ship with the mod.
|
||||
-- That is a deliberate aesthetic choice as much as a legal one: Lavender
|
||||
-- Town is playing, the cries are the real cries, and a 44kHz foley
|
||||
-- gunshot dropped on top would read as a different program running in the
|
||||
-- same window. Authored here with ChipAsm (src/audio/ChipAsm.lua), which
|
||||
-- assembles note tables into the channel bytecode ChipAudio interprets.
|
||||
--
|
||||
-- WHAT A GUNSHOT IS, on this hardware. Channel 4 is a noise generator
|
||||
-- whose `parameter` byte is NR43: the high nibble is the shift clock (LOW
|
||||
-- values are BRIGHT, high values are low rumble), bit 3 picks the short
|
||||
-- 7-bit LFSR (metallic and pitched) over the long 15-bit one (white
|
||||
-- hiss), and the low three bits divide. A real gunshot is a bright crack
|
||||
-- collapsing into a body and then a room tail, so each sound here is a
|
||||
-- STAGED program: three or four noise notes marching down the parameter
|
||||
-- byte, each shorter-lived than the last. `len` is in frames of 1/60s,
|
||||
-- `volume` is 0-15, and `fade` is the envelope period -- 1 decays fastest,
|
||||
-- 7 slowest, 0 holds for the note's whole length.
|
||||
--
|
||||
-- The shot also gets two frames of channel 1 underneath it: a square note
|
||||
-- swept hard downward, which is the only way to put a low thump on this
|
||||
-- chip. It costs the music its lead channel for 1/30s per shot, which is
|
||||
-- inaudible as interference and is most of what makes the shot feel like
|
||||
-- it has weight.
|
||||
--
|
||||
-- THREE SHOT VARIANTS, round-robined. Sound.play caches ONE Source per
|
||||
-- registered name and restarts it (stop then play), so firing twice on
|
||||
-- one name cuts the first shot's tail off. Three names means three
|
||||
-- Sources, so a fast trigger finger overlaps its own echoes the way a
|
||||
-- real one does -- and the variants differ slightly in their tails, which
|
||||
-- takes the machine-gun sameness off a repeated sound.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local HordeSfx = {}
|
||||
|
||||
-- the registered names, in the shape the rest of the mode asks for them
|
||||
HordeSfx.SHOTS = { "DS_HORDE_SHOT_1", "DS_HORDE_SHOT_2", "DS_HORDE_SHOT_3" }
|
||||
HordeSfx.DRY = "DS_HORDE_DRY"
|
||||
HordeSfx.MAG_OUT = "DS_HORDE_MAG_OUT"
|
||||
HordeSfx.MAG_IN = "DS_HORDE_MAG_IN"
|
||||
HordeSfx.RACK = "DS_HORDE_RACK"
|
||||
HordeSfx.HIT = "DS_HORDE_HIT"
|
||||
HordeSfx.HURT = "DS_HORDE_HURT"
|
||||
HordeSfx.WAVE = "DS_HORDE_WAVE"
|
||||
|
||||
-- ------- the programs
|
||||
|
||||
-- The shot's noise stage list: bright crack, body, tail, room. `tail`
|
||||
-- lets the three variants differ in how the last stage rings out without
|
||||
-- restating the whole program.
|
||||
local function shotNoise(tail)
|
||||
return {
|
||||
-- the crack: one frame, full volume, brightest parameter the chip has
|
||||
{ noiseNote = { len = 1, volume = 15, fade = 1, parameter = 0x00 } },
|
||||
-- the body: the shift clock drops, the 7-bit LFSR gives it a metallic
|
||||
-- edge -- this is the part that reads as "a mechanism did that"
|
||||
{ noiseNote = { len = 2, volume = 13, fade = 2, parameter = 0x2C } },
|
||||
-- the tail: lower, softer, longer
|
||||
{ noiseNote = { len = 3, volume = 8, fade = 3, parameter = tail[1] } },
|
||||
-- the room: a low breath of noise fading under everything
|
||||
{ noiseNote = { len = tail[2], volume = 4, fade = 4, parameter = tail[3] } },
|
||||
}
|
||||
end
|
||||
|
||||
-- The thump under the crack: channel 1's frequency register swept down
|
||||
-- hard. 0x600 is around 250Hz; the sweep drags it into the floor over the
|
||||
-- two frames it lives, which is a kick drum by another name.
|
||||
local THUMP = {
|
||||
{ pitchSweep = { pace = 2, subtract = true, shift = 3 } },
|
||||
{ squareNote = { len = 2, volume = 12, fade = 2, frequency = 0x600 } },
|
||||
}
|
||||
|
||||
local function shot(tail)
|
||||
return {
|
||||
channels = {
|
||||
{ hw = 1, program = THUMP },
|
||||
{ hw = 4, program = shotNoise(tail) },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
-- The reload, in three separate sounds the gun fires on its own clock:
|
||||
-- the magazine dropping out, the fresh one seating, and the slide coming
|
||||
-- back and going home. Noise only -- these are mechanical clicks, and
|
||||
-- keeping them off the tone channels leaves the music alone.
|
||||
local PROGRAMS = {
|
||||
[HordeSfx.SHOTS[1]] = shot({ 0x55, 5, 0x76 }),
|
||||
[HordeSfx.SHOTS[2]] = shot({ 0x54, 6, 0x77 }),
|
||||
[HordeSfx.SHOTS[3]] = shot({ 0x65, 4, 0x86 }),
|
||||
|
||||
-- the hammer falling on nothing: one dull tick, no tail
|
||||
[HordeSfx.DRY] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 7, fade = 1, parameter = 0x38 } },
|
||||
{ noiseNote = { len = 1, volume = 3, fade = 1, parameter = 0x54 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- the magazine leaving: a click and a soft drop away from it
|
||||
[HordeSfx.MAG_OUT] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 10, fade = 1, parameter = 0x1A } },
|
||||
{ noiseNote = { len = 2, volume = 5, fade = 2, parameter = 0x58 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- the fresh magazine seating: a firmer, lower clack with a bit of body
|
||||
[HordeSfx.MAG_IN] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 13, fade = 1, parameter = 0x18 } },
|
||||
{ noiseNote = { len = 2, volume = 8, fade = 2, parameter = 0x46 } },
|
||||
{ noiseNote = { len = 2, volume = 3, fade = 3, parameter = 0x67 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- the slide: back (bright scrape), a frame of nothing, then home (hard)
|
||||
[HordeSfx.RACK] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 2, volume = 9, fade = 2, parameter = 0x25 } },
|
||||
{ rest = 1 },
|
||||
{ noiseNote = { len = 1, volume = 14, fade = 1, parameter = 0x11 } },
|
||||
{ noiseNote = { len = 2, volume = 6, fade = 2, parameter = 0x44 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- a bullet arriving: short, bright, gone -- the hit marker's own sound
|
||||
[HordeSfx.HIT] = {
|
||||
channels = {
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 1, volume = 11, fade = 1, parameter = 0x14 } },
|
||||
{ noiseNote = { len = 1, volume = 5, fade = 2, parameter = 0x42 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- being hit: a low ugly thud on the noise channel with a square groan
|
||||
-- under it, sweeping DOWN -- the sound of losing something
|
||||
[HordeSfx.HURT] = {
|
||||
channels = {
|
||||
{ hw = 1, program = {
|
||||
{ pitchSweep = { pace = 3, subtract = true, shift = 4 } },
|
||||
{ squareNote = { len = 6, volume = 11, fade = 3, frequency = 0x480 } },
|
||||
} },
|
||||
{ hw = 4, program = {
|
||||
{ noiseNote = { len = 2, volume = 12, fade = 2, parameter = 0x66 } },
|
||||
{ noiseNote = { len = 4, volume = 6, fade = 3, parameter = 0x78 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
|
||||
-- a wave arriving: two rising square stabs, deliberately not a fanfare
|
||||
[HordeSfx.WAVE] = {
|
||||
channels = {
|
||||
{ hw = 1, program = {
|
||||
{ squareNote = { len = 3, volume = 10, fade = 2, frequency = 0x5C0 } },
|
||||
{ rest = 1 },
|
||||
{ squareNote = { len = 6, volume = 12, fade = 3, frequency = 0x680 } },
|
||||
} },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- ------- registration
|
||||
|
||||
-- Assemble every program and put it in the sfx registry. Called once from
|
||||
-- main.lua at load. A malformed note table raises inside ChipAsm; each is
|
||||
-- assembled under pcall so one bad program is one missing sound rather
|
||||
-- than a mod that fails to load.
|
||||
function HordeSfx.register(mod)
|
||||
local ok, ChipAsm = pcall(require, "src.audio.ChipAsm")
|
||||
if not (ok and ChipAsm) then return false end
|
||||
local n = 0
|
||||
for name, spec in pairs(PROGRAMS) do
|
||||
local built, out = pcall(ChipAsm.sfx, spec)
|
||||
if built and out and out.chip then
|
||||
local reg = pcall(function()
|
||||
mod.content.sfx:register(name, { chip = out.chip })
|
||||
end)
|
||||
if reg then n = n + 1 end
|
||||
elseif mod.log then
|
||||
mod.log:error("horde: sfx %s did not assemble: %s", name, tostring(out))
|
||||
end
|
||||
end
|
||||
return n > 0
|
||||
end
|
||||
|
||||
-- ------- playback
|
||||
--
|
||||
-- One indirection so callers never touch Sound directly and a headless
|
||||
-- run (no love.audio) costs a pcall rather than an error.
|
||||
|
||||
local function play(name)
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").play(Game.data, name)
|
||||
end)
|
||||
end
|
||||
|
||||
HordeSfx.play = play
|
||||
|
||||
local shotIndex = 0
|
||||
|
||||
-- The next shot in the round-robin, so consecutive rounds overlap rather
|
||||
-- than cutting each other off (see the header).
|
||||
function HordeSfx.shot()
|
||||
shotIndex = shotIndex % #HordeSfx.SHOTS + 1
|
||||
play(HordeSfx.SHOTS[shotIndex])
|
||||
end
|
||||
|
||||
-- ------- the cries
|
||||
--
|
||||
-- Every mob that dies screams as something from the national dex. The
|
||||
-- list is built once from the live cry registry -- whatever the game and
|
||||
-- whatever mods are loaded have between them -- so this needs no data of
|
||||
-- its own and picks up a total conversion's roster for free.
|
||||
|
||||
local cryList = nil
|
||||
|
||||
local function cries()
|
||||
if cryList then return cryList end
|
||||
local out = {}
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
local table_ = Game.data and Game.data.audio and Game.data.audio.cries
|
||||
for species in pairs(table_ or {}) do out[#out + 1] = species end
|
||||
end)
|
||||
table.sort(out) -- love.math.random over a stable order, not hash order
|
||||
cryList = out
|
||||
return out
|
||||
end
|
||||
|
||||
-- A random cry, at a random-ish pitch. Nothing is more Pokemon than the
|
||||
-- wrong animal noise coming out of a man in a suit.
|
||||
function HordeSfx.randomCry()
|
||||
local list = cries()
|
||||
if #list == 0 then return nil end
|
||||
local species = list[love.math.random(#list)]
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
require("src.core.Sound").playCry(Game.data, species)
|
||||
end)
|
||||
return species
|
||||
end
|
||||
|
||||
-- a fresh boot (or a hot reload) rebuilds the species list
|
||||
function HordeSfx.invalidate()
|
||||
cryList = nil
|
||||
end
|
||||
|
||||
return HordeSfx
|
||||
+44
-3
@@ -6,9 +6,11 @@
|
||||
-- here -- translation in the fourth column, m[4]/m[8]/m[12].
|
||||
--
|
||||
-- Only what the renderer actually needs: a perspective projection (the
|
||||
-- camera), an orthographic one (the sun's shadow pass), a look-based view,
|
||||
-- and the translate/rotateY/scale a model matrix is built from. No general
|
||||
-- inverse, no quaternions.
|
||||
-- camera), an orthographic one (the sun's shadow pass), an asymmetric one
|
||||
-- (a headset's per-eye frustum), a look-based view, a quaternion rotation
|
||||
-- (a headset's pose), and the translate/rotateY/scale a model matrix is
|
||||
-- built from. No general inverse -- the VR view inverts its rigid pieces
|
||||
-- one at a time.
|
||||
|
||||
local Mat4 = {}
|
||||
|
||||
@@ -62,6 +64,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)
|
||||
|
||||
+223
-7
@@ -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,9 +109,12 @@ 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
|
||||
|
||||
@@ -235,6 +249,31 @@ OverworldBattle.TEXT_RECT = {
|
||||
mimic = { 0, 56, 128, 40 },
|
||||
}
|
||||
|
||||
-- How far apart the two anchors are: the spacing every move animation was
|
||||
-- authored against, and so the yardstick the live pair is measured with.
|
||||
OverworldBattle.ANCHOR_SPAN = math.sqrt(
|
||||
(OverworldBattle.ANCHOR.enemy[1] - OverworldBattle.ANCHOR.player[1]) ^ 2
|
||||
+ (OverworldBattle.ANCHOR.enemy[2] - OverworldBattle.ANCHOR.player[2]) ^ 2)
|
||||
|
||||
-- The effects layer's scale for this shot: how far apart the two mons
|
||||
-- actually are on screen, over how far apart the slots they were authored
|
||||
-- for were. Clamped hard at both ends -- an effect is pixel art and a wild
|
||||
-- factor is worse than a slightly wrong one -- and held at exactly 1 when
|
||||
-- the marks coincide, which is a projection about to degenerate rather
|
||||
-- than a pair that has genuinely closed up.
|
||||
OverworldBattle.ANIM_SCALE_MIN = 0.5
|
||||
OverworldBattle.ANIM_SCALE_MAX = 2.0
|
||||
|
||||
function OverworldBattle.animScale(shot, px, py)
|
||||
if not (shot and shot.enemy and px and py) then return 1 end
|
||||
local dx, dy = shot.enemy[1] - px, shot.enemy[2] - py
|
||||
local span = math.sqrt(dx * dx + dy * dy)
|
||||
if not (span > 1) then return 1 end
|
||||
local k = span / OverworldBattle.ANCHOR_SPAN
|
||||
return math.max(OverworldBattle.ANIM_SCALE_MIN,
|
||||
math.min(OverworldBattle.ANIM_SCALE_MAX, k))
|
||||
end
|
||||
|
||||
function OverworldBattle.textRects(battle)
|
||||
if not battle or battle.blankForAskName then return {} end
|
||||
local r = OverworldBattle.TEXT_RECT
|
||||
@@ -312,6 +351,10 @@ end
|
||||
-- not nest.
|
||||
local session = nil
|
||||
|
||||
local function isIOS()
|
||||
return love.system and love.system.getOS and love.system.getOS() == "iOS"
|
||||
end
|
||||
|
||||
local function game()
|
||||
return require("src.core.Game")
|
||||
end
|
||||
@@ -460,6 +503,19 @@ function OverworldBattle.update(dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- Whether the shot is the player's to steer at all. BACK SPRITES pins
|
||||
-- their own mon to the GB's slot on the menu while the foe stands out on
|
||||
-- the map, and there is no angle that half-framed, half-solid
|
||||
-- composition survives -- so under it the camera holds the shot the rig
|
||||
-- was solved for (the slow drift aside, which was always there). Polled
|
||||
-- per frame rather than latched at battle start: the row is reachable
|
||||
-- from the mod manager's page mid-session.
|
||||
BattleCam.steerable = not OverworldBattle.backPinned()
|
||||
-- the right stick, read as a rate before the rig is built from it: the
|
||||
-- wheel, the keys, the mouse and a drag all arrive as events and have
|
||||
-- already landed, but a stick is a HELD position and only a tick can
|
||||
-- turn it into travel (CamControl, which owns every one of those inputs)
|
||||
pcall(V.require("CamControl").tick, dt)
|
||||
BattleCam.update(dt)
|
||||
-- the battle only exists once it has been pushed; a session opened at
|
||||
-- pushBattle time has it, one opened from battle.started was handed it
|
||||
@@ -473,6 +529,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)
|
||||
@@ -509,11 +582,15 @@ function OverworldBattle.update(dt)
|
||||
-- reason the scene is: it binds a canvas of its own. After the frost, so
|
||||
-- the glass is frosted from the world alone and never from the glyphs
|
||||
-- about to sit on it.
|
||||
local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
|
||||
local ios = isIOS()
|
||||
local okHud, up = false, false
|
||||
if not ios then
|
||||
okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot)
|
||||
end
|
||||
session.snapped = (okHud and up) and true or false
|
||||
-- once per battle, not once per frame: a driver that cannot do this cannot
|
||||
-- do it sixty times a second either, and the fallback is silent and fine
|
||||
if not okHud and not session.hudWarned then
|
||||
if not ios and not okHud and not session.hudWarned then
|
||||
session.hudWarned = true
|
||||
V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw "
|
||||
.. "in the battle frame this battle", tostring(up))
|
||||
@@ -531,6 +608,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()
|
||||
@@ -693,6 +868,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]
|
||||
@@ -959,6 +1136,7 @@ function OverworldBattle.install()
|
||||
local innerText = BattleState.drawTextArea
|
||||
function BattleState:drawTextArea()
|
||||
if not self.dramaticShapeShot then return innerText(self) end
|
||||
if isIOS() then return innerText(self) end
|
||||
local battle = self
|
||||
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
|
||||
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
|
||||
@@ -971,7 +1149,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
|
||||
@@ -980,16 +1158,36 @@ function OverworldBattle.install()
|
||||
-- give them. They ride to where the PAIR went: the midpoint of the two
|
||||
-- mons' projected positions, less the midpoint of the slots they used to
|
||||
-- sit in. A hit still lands on the mon it is aimed at.
|
||||
--
|
||||
-- And they ride the pair's SEPARATION as well, because the mons
|
||||
-- themselves do. Both are geometry standing on the map, so the camera
|
||||
-- sizes them: zoom in and they grow, swing round to side-on and the two
|
||||
-- marks close up as the axis foreshortens. A layer that only slid would
|
||||
-- have held the authored 106-pixel spacing through all of it -- a beam
|
||||
-- fired between two mons that are no longer that far apart, ending in
|
||||
-- the air beside the one it was aimed at. Scaling about the same
|
||||
-- midpoint keeps every authored offset the same fraction of the gap it
|
||||
-- was authored as.
|
||||
local a = OverworldBattle.ANCHOR
|
||||
-- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side
|
||||
-- contributes no movement at all and the pair's centre has gone half as
|
||||
-- far as the foe's mark did.
|
||||
local px, py = shot.player[1], shot.player[2]
|
||||
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
|
||||
local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2
|
||||
local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2
|
||||
local cx, cy = (shot.enemy[1] + px) / 2, (shot.enemy[2] + py) / 2
|
||||
local ax = (a.enemy[1] + a.player[1]) / 2
|
||||
local ay = (a.enemy[2] + a.player[2]) / 2
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
||||
love.graphics.translate(cx - ax, cy - ay)
|
||||
-- Clamped, and skipped outright if the marks ever coincide: a
|
||||
-- degenerate projection must leave the effects the size they were
|
||||
-- rather than collapse them to nothing or blow them across the screen.
|
||||
local k = OverworldBattle.animScale(shot, px, py)
|
||||
if k ~= 1 then
|
||||
love.graphics.translate(ax, ay)
|
||||
love.graphics.scale(k, k)
|
||||
love.graphics.translate(-ax, -ay)
|
||||
end
|
||||
local ok, err = pcall(innerAnim, self, colorized)
|
||||
love.graphics.pop()
|
||||
if not ok then error(err, 0) end
|
||||
@@ -1120,6 +1318,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)
|
||||
@@ -1177,6 +1382,17 @@ function OverworldBattle.drawHudPanels(battle)
|
||||
local shot = battle.dramaticShapeShot
|
||||
battle.dramaticShapeDark = nil
|
||||
if not shot then return end
|
||||
if isIOS() then
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||
local rect = OverworldBattle.HUD_RECT
|
||||
love.graphics.setColor(1, 1, 1, 0.84)
|
||||
if enemy then love.graphics.rectangle("fill", rect.enemy[1], rect.enemy[2], rect.enemy[3], rect.enemy[4]) end
|
||||
if player then love.graphics.rectangle("fill", rect.player[1], rect.player[2], rect.player[3], rect.player[4]) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
battle.dramaticShapeDark = nil
|
||||
return
|
||||
end
|
||||
if snapped() then
|
||||
battle.dramaticShapeDark = session and session.dark or nil
|
||||
return
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
local V = ...
|
||||
|
||||
local PixelCanvas = {}
|
||||
|
||||
function PixelCanvas.new(w, h)
|
||||
return pcall(love.graphics.newCanvas, w, h, { dpiscale = 1 })
|
||||
end
|
||||
|
||||
return PixelCanvas
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
-- VR: the POKEDEX in the player's left hand -- a voxel model of the
|
||||
-- series' own field guide, strapped to the tracked grip pose, whose
|
||||
-- screen is a real texture the mod can put a picture on.
|
||||
--
|
||||
-- Why it exists: a staged VR battle needs the 2D battle screen SOMEWHERE
|
||||
-- -- the text, the menus, the HP bars are the game -- but a flat panel
|
||||
-- floating square in front of the fight hides the fight. A trainer in
|
||||
-- the world already has the right prop for "a handheld device with a
|
||||
-- screen": look down at the Pokedex in your hand to read the battle,
|
||||
-- look up to watch it happen on the map.
|
||||
--
|
||||
-- THE MODEL is authored here in voxels, in METRES (VOX metres a voxel),
|
||||
-- around its own centre, front face +Z -- a red slab with the lens, the
|
||||
-- LEDs, the hinge and a d-pad, and a dark bezel the screen sits proud
|
||||
-- of. It rides VRRig.propMatrix, the same XR-to-world mapping the eyes
|
||||
-- use, so it sits exactly where the hand is and keeps its real size in
|
||||
-- every mode: a hand-sized device over the diorama, the same hand-sized
|
||||
-- device at life scale in first person and in battle.
|
||||
--
|
||||
-- THE SCREEN is a separate one-quad mesh drawn with its own texture --
|
||||
-- whatever canvas the caller hands `Pokedex.screen` (the VR frame hands
|
||||
-- it the front buffer during a battle, cropped by UV to the battle's own
|
||||
-- letterbox). No texture leaves the screen dark: a device that is off.
|
||||
--
|
||||
-- Everything here is passive state plus a draw call; VR.lua decides when
|
||||
-- the frame exists (hand tracked, session live) and VoxelScene's eye
|
||||
-- pass draws it after the world, so it composites with real depth
|
||||
-- against everything else.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
local Voxel3D = V.require("Voxel3D")
|
||||
local VRRig = V.require("VRRig")
|
||||
|
||||
local Pokedex = {}
|
||||
|
||||
-- one voxel, in metres: a centimetre-ish grid gives the classic chunky
|
||||
-- read at a device you can read a battle off (the body below comes out
|
||||
-- about 12 x 19 x 3 cm -- a quarter up from the first, believable size,
|
||||
-- because the screen carries every menu and was squint-small in hand)
|
||||
Pokedex.VOX = 0.011 * 1.25
|
||||
|
||||
-- Where the device sits relative to the GRIP pose, in metres, and how it
|
||||
-- is tipped. A full quarter turn forward lays the slab exactly along the
|
||||
-- controller's own body -- verified in the headset -- so holding the
|
||||
-- controller IS holding the device: raise your fist and the screen faces
|
||||
-- you. These two are the whole of the attachment.
|
||||
Pokedex.OFFSET = { 0, 0.04, -0.02 }
|
||||
Pokedex.TILT = -math.pi / 2 -- radians about X: 90 degrees forward,
|
||||
-- flush with the controller
|
||||
|
||||
-- body proportions, in voxels
|
||||
local W, H, D = 9, 14, 2
|
||||
|
||||
-- the palette the body's faces point their UVs at, one texel per colour
|
||||
local COLORS = {
|
||||
{ 200, 40, 48 }, -- 1 body red
|
||||
{ 140, 24, 32 }, -- 2 hinge / shaded red
|
||||
{ 64, 132, 244 }, -- 3 the lens blue
|
||||
{ 208, 228, 255 }, -- 4 lens glint
|
||||
{ 232, 60, 48 }, -- 5 LED red
|
||||
{ 248, 216, 64 }, -- 6 LED yellow
|
||||
{ 72, 200, 96 }, -- 7 LED green
|
||||
{ 46, 46, 54 }, -- 8 bezel / d-pad dark
|
||||
{ 24, 24, 30 }, -- 9 the dark screen (the "off" state's face)
|
||||
}
|
||||
|
||||
local paletteTex = nil -- one texel per COLORS entry
|
||||
local bodyMesh = nil
|
||||
local screenMesh = nil
|
||||
local screenKey = nil -- the UV rect screenMesh was built for
|
||||
|
||||
local function palette()
|
||||
if paletteTex then return paletteTex end
|
||||
if not (love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage) then return nil end
|
||||
local ok, data = pcall(love.image.newImageData, #COLORS, 1)
|
||||
if not (ok and data) then return nil end
|
||||
for i, c in ipairs(COLORS) do
|
||||
pcall(data.setPixel, data, i - 1, 0,
|
||||
c[1] / 255, c[2] / 255, c[3] / 255, 1)
|
||||
end
|
||||
local built, img = pcall(love.graphics.newImage, data)
|
||||
if not built then return nil end
|
||||
pcall(img.setFilter, img, "nearest", "nearest")
|
||||
paletteTex = img
|
||||
return img
|
||||
end
|
||||
|
||||
-- Append one solid box's six faces to `verts`/`indices`: position in
|
||||
-- voxels (relative to the device centre), size in voxels, colour by
|
||||
-- palette index. Faces carry the mod's own directional shade, so the
|
||||
-- slab reads as a solid the way every extruded block here does.
|
||||
local function box(verts, indices, x, y, z, w, h, d, color)
|
||||
local u = (color - 0.5) / #COLORS
|
||||
local vox = Pokedex.VOX
|
||||
local ox, oy, oz = (x - W / 2) * vox, (y - H / 2) * vox, (z - D / 2) * vox
|
||||
local sx, sy, sz = w * vox, h * vox, d * vox
|
||||
for face = 1, 6 do
|
||||
local corners = Voxel3D.FACE_CORNERS[face]
|
||||
local shade = Voxel3D.FACE_SHADE[face]
|
||||
local n = #verts / 4
|
||||
for _, c in ipairs(corners) do
|
||||
verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz,
|
||||
u, 0.5, shade }
|
||||
end
|
||||
Voxel3D.pushQuad(indices, n)
|
||||
end
|
||||
end
|
||||
|
||||
-- the screen's face on the front, in voxels (10:9, the GB frame's shape),
|
||||
-- shared by the dark "off" face in the body and the live quad
|
||||
local SCREEN = { x = 1.2, y = 4.6, w = 6.6, h = 5.94 }
|
||||
|
||||
local function buildBody()
|
||||
if bodyMesh then return bodyMesh end
|
||||
local verts, indices = {}, {}
|
||||
-- the slab, the hinge along the right edge, the lens, the LEDs, the
|
||||
-- d-pad and two chunky buttons -- the classic cover furniture, one box
|
||||
-- each on the front face (z = D..)
|
||||
box(verts, indices, 0, 0, 0, W, H, D, 1) -- body
|
||||
box(verts, indices, W - 0.7, 0, 0, 0.7, H, D + 0.15, 2) -- hinge
|
||||
box(verts, indices, 0.6, H - 2.6, D, 2, 2, 0.5, 3) -- lens
|
||||
box(verts, indices, 0.9, H - 1.3, D + 0.5, 0.6, 0.5, 0.12, 4) -- glint
|
||||
box(verts, indices, 3.2, H - 1.6, D, 0.8, 0.8, 0.35, 5) -- LEDs
|
||||
box(verts, indices, 4.5, H - 1.6, D, 0.8, 0.8, 0.35, 6)
|
||||
box(verts, indices, 5.8, H - 1.6, D, 0.8, 0.8, 0.35, 7)
|
||||
-- the bezel plate the screen sits in, and the dark screen face itself
|
||||
-- (what shows when nothing is on: a device that is off, not a hole)
|
||||
box(verts, indices, SCREEN.x - 0.4, SCREEN.y - 0.4, D,
|
||||
SCREEN.w + 0.8, SCREEN.h + 0.8, 0.4, 8)
|
||||
box(verts, indices, SCREEN.x, SCREEN.y, D + 0.4,
|
||||
SCREEN.w, SCREEN.h, 0.1, 9)
|
||||
-- d-pad below the screen, two crossed bars, and the A/B buttons
|
||||
box(verts, indices, 5.6, 1.1, D, 2.1, 0.7, 0.45, 8)
|
||||
box(verts, indices, 6.3, 0.4, D, 0.7, 2.1, 0.45, 8)
|
||||
box(verts, indices, 1.2, 0.8, D, 1.1, 1.1, 0.45, 5)
|
||||
box(verts, indices, 2.9, 0.8, D, 1.1, 1.1, 0.45, 8)
|
||||
bodyMesh = Voxel3D.newMesh(verts, indices)
|
||||
return bodyMesh
|
||||
end
|
||||
|
||||
-- The live screen: one quad a hair proud of the dark face, UV-mapped to
|
||||
-- `uv` = { u0, v0, u1, v1 } of whatever texture is on it. Rebuilt only
|
||||
-- when the UV rect moves (a window resize moving the battle letterbox).
|
||||
local function buildScreen(uv)
|
||||
local key = table.concat({ uv[1], uv[2], uv[3], uv[4] }, ":")
|
||||
if screenMesh and screenKey == key then return screenMesh end
|
||||
local vox = Pokedex.VOX
|
||||
local x0 = (SCREEN.x - W / 2) * vox
|
||||
local y0 = (SCREEN.y - H / 2) * vox
|
||||
local x1 = x0 + SCREEN.w * vox
|
||||
local y1 = y0 + SCREEN.h * vox
|
||||
local z = (D / 2 + 0.55) * vox
|
||||
local u0, v0, u1, v1 = uv[1], uv[2], uv[3], uv[4]
|
||||
local verts = {
|
||||
{ x0, y0, z, u0, v1, 1 }, { x1, y0, z, u1, v1, 1 },
|
||||
{ x1, y1, z, u1, v0, 1 }, { x0, y1, z, u0, v0, 1 },
|
||||
}
|
||||
local indices = {}
|
||||
Voxel3D.pushQuad(indices, 0)
|
||||
local mesh = Voxel3D.newMesh(verts, indices)
|
||||
if mesh then
|
||||
screenMesh, screenKey = mesh, key
|
||||
end
|
||||
return mesh
|
||||
end
|
||||
|
||||
-- ------- the frame's state, set by VR.lua
|
||||
--
|
||||
-- nil = no pokedex this frame (no session, no tracked left hand).
|
||||
Pokedex.frame = nil
|
||||
|
||||
-- Stand the device on a tracked LEFT-HAND pose under the current
|
||||
-- XR-to-world mapping (the same pivot/anchor/scale/yaw the eyes got).
|
||||
function Pokedex.place(pose, pivot, anchor, scale, yaw)
|
||||
local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||
m = Mat4.mul(m, Mat4.translate(Pokedex.OFFSET[1], Pokedex.OFFSET[2],
|
||||
Pokedex.OFFSET[3]))
|
||||
m = Mat4.mul(m, Mat4.rotateX(Pokedex.TILT))
|
||||
Pokedex.frame = { model = m }
|
||||
end
|
||||
|
||||
-- What the screen shows: a texture and the UV rect of it to fill the
|
||||
-- screen with. nil for a dark screen. Only meaningful after place().
|
||||
function Pokedex.screen(tex, u0, v0, u1, v1)
|
||||
if Pokedex.frame and tex then
|
||||
Pokedex.frame.tex = tex
|
||||
Pokedex.frame.uv = { u0 or 0, v0 or 0, u1 or 1, v1 or 1 }
|
||||
end
|
||||
end
|
||||
|
||||
function Pokedex.clear()
|
||||
Pokedex.frame = nil
|
||||
end
|
||||
|
||||
-- Draw the device with the scene's own pass (model matrix in world px).
|
||||
-- Runs inside VoxelScene's drawScene, per eye; no shadow-caster half --
|
||||
-- a UI prop should receive the world's light, not throw shade on it.
|
||||
function Pokedex.draw()
|
||||
local f = Pokedex.frame
|
||||
if not f then return end
|
||||
local body = buildBody()
|
||||
local pal = palette()
|
||||
if body and pal then
|
||||
Voxel3D.draw(body, pal, f.model)
|
||||
end
|
||||
if f.tex and f.uv then
|
||||
local screen = buildScreen(f.uv)
|
||||
if screen then
|
||||
Voxel3D.draw(screen, f.tex, f.model)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- window resize / hot reload: every GPU object here is derived and cheap
|
||||
function Pokedex.invalidate()
|
||||
paletteTex, bodyMesh, screenMesh, screenKey = nil, nil, nil, nil
|
||||
end
|
||||
|
||||
return Pokedex
|
||||
+4
-1
@@ -186,7 +186,7 @@ end
|
||||
local function getCanvas(res)
|
||||
if canvas == false then return nil end
|
||||
if canvas and canvasRes == res then return canvas end
|
||||
local ok, c = pcall(love.graphics.newCanvas, res, res)
|
||||
local ok, c = V.require("PixelCanvas").new(res, res)
|
||||
if not (ok and c) then
|
||||
canvas = false
|
||||
return nil
|
||||
@@ -221,6 +221,9 @@ end
|
||||
-- where the canvas cannot be made -- VoxelScene then keeps the flat decal
|
||||
-- shadows, which need nothing but a quad.
|
||||
function ShadowMap.available()
|
||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" then
|
||||
return false
|
||||
end
|
||||
if not (love.graphics and love.graphics.newCanvas
|
||||
and love.graphics.setDepthMode) then
|
||||
return false
|
||||
|
||||
+275
-46
@@ -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
|
||||
|
||||
+133
-8
@@ -3289,6 +3289,83 @@ end
|
||||
|
||||
-- ---- tall grass ----
|
||||
|
||||
-- ---- closing a standee's sides ----
|
||||
--
|
||||
-- The grass tufts and the flowers are both built the same way: each row of
|
||||
-- the 8x8 drawing becomes a horizontal RUN of lit pixels, stood up as a
|
||||
-- front face and a back face one voxel apart, with a lid on top. What that
|
||||
-- leaves open is the two ENDS of every run -- so the slab was a pair of
|
||||
-- billboards rather than a solid, and from any angle off square you looked
|
||||
-- in through the edge and straight out the other side. At the low cameras
|
||||
-- this mod has grown (1ST, 3RD, the battle's floor-level seat) that is
|
||||
-- most of the time.
|
||||
--
|
||||
-- A wall goes on an end only where the pixel beyond it is actually clear,
|
||||
-- which for a run's end it is by construction -- except where two runs on
|
||||
-- the same row meet across a gap of nothing, which cannot happen, and at
|
||||
-- the tile's border, where the neighbouring tile's own standee may or may
|
||||
-- not continue the shape. The border is closed anyway: tufts sit on their
|
||||
-- own half-cells with a gap between them, so an open border edge is a hole
|
||||
-- in the open, not a seam with anything.
|
||||
--
|
||||
-- Each wall samples ONE texel at its centre -- the end pixel it is closing
|
||||
-- off -- so it wears that pixel's own colour, which is the nearest coloured
|
||||
-- pixel to the surface being filled. Sampling a single texel is also what
|
||||
-- carries the animation: when a frame keys that pixel out, the wall's own
|
||||
-- fragments discard with the faces either side of it, so a swaying tuft
|
||||
-- never leaves a wall standing where its blade no longer is.
|
||||
-- `everyPixel` is for a standee whose silhouette ANIMATES. The mesh is
|
||||
-- built once, over the UNION of every frame's mask, and each frame is cut
|
||||
-- out again in texture space -- so a run that is six pixels wide in the
|
||||
-- union may be two pixels wide in the frame on screen, and the four pixels
|
||||
-- that dropped out took the union's end walls with them. What is left
|
||||
-- exposed is an interior boundary, which had no wall because in the union
|
||||
-- it was not a boundary at all. That is the gap that survived closing the
|
||||
-- run ends: the first frame looked solid and every other frame did not.
|
||||
--
|
||||
-- So an animated standee gets a wall on BOTH sides of EVERY pixel. A wall
|
||||
-- between two lit pixels is enclosed by the front and back faces and never
|
||||
-- seen; the moment its neighbour is keyed out it becomes the edge, already
|
||||
-- in place and already wearing the right colour. Each is inset a hair into
|
||||
-- its own pixel so the two that meet at a boundary are not coplanar -- the
|
||||
-- voxel pass draws with culling off, and two quads in the same plane would
|
||||
-- z-fight rather than politely take turns.
|
||||
local SIDE_INSET = 0.03
|
||||
|
||||
local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
||||
ax0, ay0, atlasW, atlasH, py, lit, everyPixel)
|
||||
local function texel(px)
|
||||
return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
|
||||
end
|
||||
local function left(px, at)
|
||||
local u, v = texel(px)
|
||||
quads[#quads + 1] = { -- facing -X
|
||||
{ at, yBot, zB }, { at, yBot, zF },
|
||||
{ at, yTop, zF }, { at, yTop, zB },
|
||||
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
|
||||
shade = OBJ_SHADE.side,
|
||||
}
|
||||
end
|
||||
local function right(px, at)
|
||||
local u, v = texel(px)
|
||||
quads[#quads + 1] = { -- facing +X
|
||||
{ at, yBot, zF }, { at, yBot, zB },
|
||||
{ at, yTop, zB }, { at, yTop, zF },
|
||||
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
|
||||
shade = OBJ_SHADE.side,
|
||||
}
|
||||
end
|
||||
if everyPixel then
|
||||
for px = ix, ix2 do
|
||||
left(px, px + SIDE_INSET)
|
||||
right(px, px + 1 - SIDE_INSET)
|
||||
end
|
||||
return
|
||||
end
|
||||
if not lit(ix - 1, py) then left(ix, ix) end
|
||||
if not lit(ix2 + 1, py) then right(ix2, ix2 + 1) end
|
||||
end
|
||||
|
||||
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
|
||||
-- whole clump of grass. Each tile stands as its own thin per-pixel slab
|
||||
-- at ITS OWN depth -- the cell's north tile row in the north half of the
|
||||
@@ -3359,6 +3436,20 @@ local function grassTemplate(map, data, tileId)
|
||||
shade = 1,
|
||||
}
|
||||
end
|
||||
-- and underneath, where a blade ends in mid-air over the ground
|
||||
if not opaque(ix, iy + 1) then
|
||||
quads[#quads + 1] = {
|
||||
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
|
||||
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
|
||||
uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
|
||||
shade = OBJ_SHADE.bottom,
|
||||
}
|
||||
end
|
||||
-- and the run's two end walls, which is what makes a blade a solid
|
||||
-- thing rather than two billboards you can see between (sideQuads
|
||||
-- above argues it, and why each wall wears its end pixel's colour)
|
||||
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
||||
ax0, ay0, atlasW, atlasH, iy, opaque)
|
||||
ix = ix2 + 1
|
||||
else
|
||||
ix = ix + 1
|
||||
@@ -3526,17 +3617,51 @@ local function flowerTemplate(map, data, tileId)
|
||||
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
|
||||
shade = OBJ_SHADE.back,
|
||||
}
|
||||
-- petal tips: a top strip where the row above is clear. The
|
||||
-- strip samples its own row's texel, so a tip that is not in
|
||||
-- the current frame discards with the face beneath it
|
||||
if not on(ix, py - 1) then
|
||||
quads[#quads + 1] = {
|
||||
{ ix, yTop, zB }, { ix2 + 1, yTop, zB },
|
||||
{ ix2 + 1, yTop, zF }, { ix, yTop, zF },
|
||||
uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } },
|
||||
-- ------- the shell, closed on all four remaining faces
|
||||
--
|
||||
-- A flower SWAYS: the geometry spans the union of every animation
|
||||
-- frame's mask and each frame is cut back out of it in texture
|
||||
-- space (see the header). So "is there a pixel next door" has two
|
||||
-- different answers -- one in the union this mesh was built from,
|
||||
-- and one in the frame actually on screen -- and only the second
|
||||
-- decides what is exposed.
|
||||
--
|
||||
-- Closing the union's own edges is therefore not enough, and was
|
||||
-- the bug the first cut of this shipped: the base frame looked
|
||||
-- solid and every other frame still had gaps, because a pixel that
|
||||
-- drops out of a frame takes the union's wall with it and leaves an
|
||||
-- interior boundary that never had one.
|
||||
--
|
||||
-- So every pixel gets a cap on all four of its remaining faces,
|
||||
-- whatever its neighbours do. A cap between two lit pixels sits
|
||||
-- inside the slab, enclosed by the front and back faces, and is
|
||||
-- never seen; the moment its neighbour is keyed out it IS the edge,
|
||||
-- already there and already wearing the right colour. Each samples
|
||||
-- its own pixel's texel, so it appears and vanishes with the pixel
|
||||
-- it belongs to rather than with the one it is closing off.
|
||||
--
|
||||
-- Inset a hair into its own pixel, because the voxel pass draws
|
||||
-- with culling off: the two caps that meet at a boundary would be
|
||||
-- coplanar and z-fight rather than politely take turns.
|
||||
for px = ix, ix2 do
|
||||
local tu = (ax0 + px + 0.5) / atlasW
|
||||
local tv = (ay0 + py + 0.5) / atlasH
|
||||
local xa, xb = px, px + 1
|
||||
local yT = yTop - SIDE_INSET
|
||||
local yB = yBot + SIDE_INSET
|
||||
quads[#quads + 1] = { -- the pixel's own lid
|
||||
{ xa, yT, zB }, { xb, yT, zB }, { xb, yT, zF }, { xa, yT, zF },
|
||||
uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
|
||||
shade = OBJ_SHADE.top,
|
||||
}
|
||||
quads[#quads + 1] = { -- and its floor
|
||||
{ xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB },
|
||||
uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
|
||||
shade = OBJ_SHADE.bottom,
|
||||
}
|
||||
end
|
||||
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
||||
ax0, ay0, atlasW, atlasH, py, on, true)
|
||||
ix = ix2 + 1
|
||||
else
|
||||
ix = ix + 1
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
-- Voxel world mode: the third-person camera -- the 3RD rung.
|
||||
--
|
||||
-- 3RD is 1ST with the eye pulled off the back of the head. Everything that
|
||||
-- makes the first-person rung work -- the steered attitude, the placed
|
||||
-- camera on Voxel3D's seam, the cards that turn to face the eye, the
|
||||
-- continuous camera-relative walk -- is already general over WHERE the eye
|
||||
-- stands, so this module adds exactly one thing to it: a BOOM.
|
||||
--
|
||||
-- What the boom owns:
|
||||
--
|
||||
-- the LENGTH how far behind the pivot the eye sits, eased in and out
|
||||
-- so stepping between 1ST and 3RD slides rather than cuts,
|
||||
-- and clamped every frame by what the world will allow.
|
||||
--
|
||||
-- the COLLISION a march back along the boom line through the terrain
|
||||
-- height field and the map's own walkability, so backing
|
||||
-- into a wall walks the camera in toward the player's
|
||||
-- shoulders instead of through the wall into the void.
|
||||
-- The recovery is deliberately slower than the intrusion:
|
||||
-- a camera must never be a frame late leaving geometry,
|
||||
-- and must never snap back out the instant a corner clears.
|
||||
--
|
||||
-- the SHOULDER the small lateral rail offset that keeps the character
|
||||
-- off dead centre, faded out with the boom so a camera
|
||||
-- jammed against a wall does not also slide sideways into
|
||||
-- it.
|
||||
--
|
||||
-- Deliberately NOT here: the attitude, the look inputs, the blend, the
|
||||
-- move intent (all lib/FirstPerson.lua, which drives this module and reads
|
||||
-- its answer while building the frame's rig), and movement itself
|
||||
-- (lib/FreeMove.lua, unchanged -- the walk is camera-relative either way,
|
||||
-- and the camera's yaw is the same number on both rungs).
|
||||
--
|
||||
-- Nothing here is required for the rung to draw: with no overworld to ask
|
||||
-- (a headless run, the test suite) every query answers "clear" and the boom
|
||||
-- extends to its full length over an empty world.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
local ThirdPerson = {}
|
||||
|
||||
-- ------- the boom's numbers
|
||||
--
|
||||
-- BOOM is world pixels behind the pivot at full extension. A cell is 16 and
|
||||
-- a character card is 16 tall, so 48 stands the camera three cells back:
|
||||
-- with the first-person lens (65 degrees vertical) that frames the player
|
||||
-- at roughly a quarter of the frame height -- the modern action-game
|
||||
-- middle ground, close enough to read the four-frame sprite and far enough
|
||||
-- to see the cell you are about to walk into.
|
||||
--
|
||||
-- PIVOT_LIFT raises the orbit point above the first-person eye, so the
|
||||
-- boom looks slightly DOWN across the player's shoulder rather than
|
||||
-- straight through the back of their head.
|
||||
--
|
||||
-- SHOULDER is the lateral rail offset, in world pixels, positive to the
|
||||
-- camera's right -- which puts the player left of centre, leaving the
|
||||
-- larger half of the frame in front of them.
|
||||
ThirdPerson.BOOM = 48
|
||||
ThirdPerson.PIVOT_LIFT = 4
|
||||
ThirdPerson.SHOULDER = 4
|
||||
|
||||
-- how long the eye takes to slide out to the boom (and back into the head
|
||||
-- when 1ST is picked), in seconds -- the same order as FirstPerson's own
|
||||
-- dive so stepping 75 -> 1ST -> 3RD reads as one continuous camera
|
||||
ThirdPerson.BOOM_TIME = 0.35
|
||||
|
||||
-- ------- the player's own zoom
|
||||
--
|
||||
-- A multiplier on BOOM, stepped by the wheel, Q/E or a pinch (see
|
||||
-- CamControl, which owns every one of those and decides which camera a
|
||||
-- given input is aimed at). The range is deliberately wider IN than OUT:
|
||||
-- close is the shot people reach for, and far enough out the character is
|
||||
-- a few pixels and the rung may as well be an orbit rung.
|
||||
--
|
||||
-- Stepped in fractions rather than world pixels so a notch feels the same
|
||||
-- at both ends -- the near end of a linear step would crawl and the far
|
||||
-- end would leap.
|
||||
ThirdPerson.ZOOM_MIN = 0.45 -- ~22px: over the shoulder, close
|
||||
ThirdPerson.ZOOM_MAX = 2.4 -- ~115px: the character in a landscape
|
||||
ThirdPerson.ZOOM_STEP = 1.18 -- one wheel notch / key press
|
||||
ThirdPerson.ZOOM_TIME = 0.18 -- how fast the eye eases to a new one
|
||||
|
||||
ThirdPerson.zoom = 1 -- eased, what place() actually uses
|
||||
ThirdPerson.zoomGoal = 1 -- what the input asked for
|
||||
|
||||
-- Step the zoom by `notches` (positive pulls the camera OUT). Returns true
|
||||
-- when the goal actually moved, so a caller can tell "zoomed" from "already
|
||||
-- at the stop" and let the input fall through.
|
||||
function ThirdPerson.stepZoom(notches)
|
||||
local was = ThirdPerson.zoomGoal
|
||||
local goal = was * (ThirdPerson.ZOOM_STEP ^ (notches or 0))
|
||||
ThirdPerson.zoomGoal = math.max(ThirdPerson.ZOOM_MIN,
|
||||
math.min(ThirdPerson.ZOOM_MAX, goal))
|
||||
return ThirdPerson.zoomGoal ~= was
|
||||
end
|
||||
|
||||
-- Scale the zoom by a continuous factor -- what a pinch hands over, where
|
||||
-- the gesture's own scale IS the answer and there are no notches.
|
||||
function ThirdPerson.scaleZoom(factor)
|
||||
if not (factor and factor > 0) then return false end
|
||||
return ThirdPerson.stepZoom(math.log(factor) / math.log(ThirdPerson.ZOOM_STEP))
|
||||
end
|
||||
|
||||
-- ------- the collision's numbers
|
||||
--
|
||||
-- STEP is how far apart the samples along the boom line are, in world
|
||||
-- pixels, and REFINE how many bisections narrow the first blocked one --
|
||||
-- four halvings of a 4px step lands the eye within a quarter pixel of the
|
||||
-- face, which is finer than the boom ever needs to be.
|
||||
--
|
||||
-- PAD is the clearance kept between the eye and whatever stopped it. It
|
||||
-- has to beat the placed camera's near plane (|eye - focus| * 0.05, which
|
||||
-- at full extension is about 3.6 world pixels -- see Voxel3D) or the near
|
||||
-- plane clips a hole in the very wall the boom stopped at.
|
||||
--
|
||||
-- CLEAR is how high above a cell's ground the eye must be to pass OVER
|
||||
-- something unwalkable rather than being stopped by it: a fence, a kerb or
|
||||
-- a plant pot should not shove the camera in, a building should. Roughly
|
||||
-- head height, so the eye clears the props and never the walls.
|
||||
ThirdPerson.STEP = 4
|
||||
ThirdPerson.REFINE = 4
|
||||
ThirdPerson.PAD = 5
|
||||
ThirdPerson.CLEAR = 20
|
||||
|
||||
-- How fast the boom is allowed to grow BACK once whatever shortened it is
|
||||
-- out of the way, in world pixels per second. Shortening is instant (a
|
||||
-- camera inside a wall is a hole in the frame); lengthening is rationed,
|
||||
-- so rounding a corner eases the eye back out instead of snapping it.
|
||||
ThirdPerson.RETURN = 150
|
||||
|
||||
-- ------- state
|
||||
--
|
||||
-- `out` is the eased extension, 0 in the head and 1 fully boomed -- the
|
||||
-- number that carries 1ST into 3RD. `len` is the boom's actual length in
|
||||
-- world pixels after the world has had its say, which is what place()
|
||||
-- stands the eye at and update() eases back toward `want`.
|
||||
ThirdPerson.out = 0
|
||||
ThirdPerson.len = 0
|
||||
ThirdPerson.want = 0
|
||||
|
||||
local function ease(t)
|
||||
return t * t * (3 - 2 * t)
|
||||
end
|
||||
|
||||
-- ------- gates
|
||||
|
||||
-- Whether the 3RD rung is the one selected. Not "is the boom out" -- that
|
||||
-- is extended() below, which stays true through the ease after the rung is
|
||||
-- left, the same way FirstPerson.blend outlives its own rung.
|
||||
--
|
||||
-- A live headset declines the boom outright: VR builds its own eye cameras
|
||||
-- from the tracked pose and never asks place() where to stand, and a
|
||||
-- headset that seats its wearer three cells behind their own body is a
|
||||
-- well-known way to make people ill. Answering false here is what keeps
|
||||
-- everything ELSE the extension decides -- the player's own card, the body
|
||||
-- that turns as it walks -- honest about the head VR actually puts you in.
|
||||
-- Required lazily and guarded: VR reaches this module through FirstPerson,
|
||||
-- and a headless run has no VR module worth loading at all.
|
||||
local function headset()
|
||||
local ok, on = pcall(function() return V.require("VR").active() end)
|
||||
return ok and on or false
|
||||
end
|
||||
|
||||
function ThirdPerson.selected()
|
||||
return Voxel.isThirdPerson(Voxel.level) and not headset()
|
||||
end
|
||||
|
||||
-- The eased extension, 0 at the head and 1 at the full boom.
|
||||
function ThirdPerson.extension()
|
||||
return ease(ThirdPerson.out)
|
||||
end
|
||||
|
||||
-- Whether the boom is out far enough to be a third-person camera at all --
|
||||
-- read off the TARGET extension rather than the live length, so it is
|
||||
-- steady while the world shoves the eye about. What the body reads to
|
||||
-- decide whether it turns along its own travel.
|
||||
function ThirdPerson.extended()
|
||||
return ThirdPerson.extension() > 0.5
|
||||
end
|
||||
|
||||
-- How far back the eye must ACTUALLY be, in world pixels, for the player's
|
||||
-- own card to be worth drawing: a shade under a cell, which is the point
|
||||
-- where a 16-pixel card stops being a character and starts being a wall of
|
||||
-- pixels across the lens.
|
||||
ThirdPerson.SHOW_AT = 14
|
||||
|
||||
-- Whether the player's own card belongs in the frame. Not the same
|
||||
-- question as extended(): back into a fence and the boom collapses into
|
||||
-- the head whatever the rung says, and a card drawn there fills the lens
|
||||
-- from inside exactly as it would in first person -- so it comes out, and
|
||||
-- the rung reads as first person for as long as the world insists on it.
|
||||
function ThirdPerson.showsPlayer()
|
||||
return ThirdPerson.extension() > 0 and ThirdPerson.len >= ThirdPerson.SHOW_AT
|
||||
end
|
||||
|
||||
-- ------- the world the boom has to fit through
|
||||
--
|
||||
-- Everything below asks the live overworld and pcall-guards the asking:
|
||||
-- with no map (headless, the suite, a frame mid-warp) the boom simply
|
||||
-- extends to its full length, which is the right answer for a world with
|
||||
-- nothing in it.
|
||||
|
||||
local function overworld()
|
||||
local ok, ow = pcall(function()
|
||||
return require("src.core.Game").overworld
|
||||
end)
|
||||
if not ok or not ow or not ow.map then return nil end
|
||||
return ow
|
||||
end
|
||||
|
||||
-- Which map, and which of its cells, covers a world point. The player's own
|
||||
-- map first, then the neighbours the scene streams in around it (same ox/oy
|
||||
-- offsets VoxelScene draws them at) -- without that pass the boom would
|
||||
-- shorten against "off the map" every time the player walked within three
|
||||
-- cells of a route connection, which is most of the time.
|
||||
--
|
||||
-- nil means no map covers it: genuinely off the world, where the border
|
||||
-- ring is drawn and the camera has no business going.
|
||||
local function cellAt(ow, wx, wz)
|
||||
local map = ow.map
|
||||
local cx, cy = math.floor(wx / 16), math.floor(wz / 16)
|
||||
if map:inBounds(cx, cy) then return map, cx, cy end
|
||||
for _, nb in ipairs(ow.neighbors or {}) do
|
||||
if nb.map then
|
||||
local nx = math.floor((wx - (nb.ox or 0)) / 16)
|
||||
local ny = math.floor((wz - (nb.oy or 0)) / 16)
|
||||
if nb.map:inBounds(nx, ny) then return nb.map, nx, ny end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether the eye may not stand at this world point. Two refusals, and
|
||||
-- they are different questions:
|
||||
--
|
||||
-- the GROUND is the terrain height field the mesh is actually built from
|
||||
-- (VoxelScene.groundAt -- the same answer a character stands on), so a
|
||||
-- ledge, a raised bank or a cliff stops the boom exactly where it stops
|
||||
-- the geometry, at any pitch.
|
||||
--
|
||||
-- the WALKABILITY is the map's own, and stands in for everything built
|
||||
-- ON the ground that the height field does not describe: house walls,
|
||||
-- trees, signs, counters. Held to CLEAR above that cell's ground so the
|
||||
-- short furniture of the world is passed over rather than bumped into.
|
||||
local function occupied(ow, wx, y, wz)
|
||||
local map, cx, cy = cellAt(ow, wx, wz)
|
||||
if not map then return true end
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local okG, gh = pcall(VoxelScene.groundAt, map, cx, cy)
|
||||
gh = (okG and gh) or 0
|
||||
if y < gh + ThirdPerson.PAD then return true end
|
||||
local okW, walkable = pcall(function() return map:isWalkableCell(cx, cy) end)
|
||||
if okW and not walkable and y < gh + ThirdPerson.CLEAR then return true end
|
||||
return false
|
||||
end
|
||||
|
||||
ThirdPerson._occupied = occupied -- named for the suite
|
||||
|
||||
-- How far back along (bx, by, bz) from `pivot` the eye can stand, up to
|
||||
-- `want`. March at STEP, and when a sample refuses, bisect back into the
|
||||
-- gap between it and the last clear one -- so the answer is the face's own
|
||||
-- position rather than the sampling grid's, and walking toward a wall
|
||||
-- draws the camera in smoothly instead of in four-pixel jerks. PAD comes
|
||||
-- off whatever survives.
|
||||
function ThirdPerson.reach(ow, pivot, bx, by, bz, want)
|
||||
if not ow or want <= 0 then return math.max(0, want) end
|
||||
local function clear(t)
|
||||
return not occupied(ow, pivot[1] + bx * t, pivot[2] + by * t,
|
||||
pivot[3] + bz * t)
|
||||
end
|
||||
local lo = 0
|
||||
local steps = math.ceil(want / ThirdPerson.STEP)
|
||||
local hi = nil
|
||||
for i = 1, steps do
|
||||
local t = math.min(want, i * ThirdPerson.STEP)
|
||||
if clear(t) then
|
||||
lo = t
|
||||
else
|
||||
hi = t
|
||||
break
|
||||
end
|
||||
end
|
||||
if not hi then return want end
|
||||
for _ = 1, ThirdPerson.REFINE do
|
||||
local mid = (lo + hi) / 2
|
||||
if clear(mid) then lo = mid else hi = mid end
|
||||
end
|
||||
return math.max(0, lo - ThirdPerson.PAD)
|
||||
end
|
||||
|
||||
-- ------- the tick
|
||||
--
|
||||
-- Rides FirstPerson.update, which is itself on the pipeline's own update
|
||||
-- hook, so this runs every frame whatever the rung -- the extension has to
|
||||
-- keep easing back in after 3RD is left. `blend` is FirstPerson's dive into
|
||||
-- the head: while it is fully out (the diorama), the extension SNAPS to its
|
||||
-- target rather than easing, so picking 3RD from an orbit rung is one
|
||||
-- motion (the dive) rather than two (a dive, then a slide backwards).
|
||||
function ThirdPerson.update(dt, blend)
|
||||
-- the player's own zoom FIRST, so everything below measures itself
|
||||
-- against the boom length this frame actually wants. A step is a request
|
||||
-- rather than a jump: three notches of wheel should read as one glide.
|
||||
local zg = ThirdPerson.zoomGoal
|
||||
if ThirdPerson.zoom ~= zg then
|
||||
local k = math.min(1, dt / ThirdPerson.ZOOM_TIME)
|
||||
local z = ThirdPerson.zoom + (zg - ThirdPerson.zoom) * k
|
||||
ThirdPerson.zoom = (math.abs(zg - z) < 1e-4) and zg or z
|
||||
end
|
||||
|
||||
local target = ThirdPerson.selected() and 1 or 0
|
||||
if (blend or 0) <= 0 then
|
||||
ThirdPerson.out = target
|
||||
ThirdPerson.len = ThirdPerson.reachFor() * target
|
||||
-- and the wanted length with it: place() is what normally maintains it
|
||||
-- and it does not run at all while the rig is out of the frame, so a
|
||||
-- stale want left here would have the recovery below creeping the boom
|
||||
-- back out over a camera that is not on screen
|
||||
ThirdPerson.want = ThirdPerson.len
|
||||
else
|
||||
local step = dt / ThirdPerson.BOOM_TIME
|
||||
if ThirdPerson.out < target then
|
||||
ThirdPerson.out = math.min(target, ThirdPerson.out + step)
|
||||
elseif ThirdPerson.out > target then
|
||||
ThirdPerson.out = math.max(target, ThirdPerson.out - step)
|
||||
end
|
||||
end
|
||||
|
||||
-- the rationed recovery: place() already pulled `len` in to whatever the
|
||||
-- world allowed this frame, and this is the only thing that lets it back
|
||||
-- out again
|
||||
if ThirdPerson.len < ThirdPerson.want then
|
||||
ThirdPerson.len = math.min(ThirdPerson.want,
|
||||
ThirdPerson.len + ThirdPerson.RETURN * dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- The boom's full length right now, before the world has its say: BOOM at
|
||||
-- the player's own zoom. Named so the collision march and the shoulder
|
||||
-- fade measure themselves against the same number.
|
||||
function ThirdPerson.reachFor()
|
||||
return ThirdPerson.BOOM * ThirdPerson.zoom
|
||||
end
|
||||
|
||||
-- ------- the eye
|
||||
--
|
||||
-- Where the camera stands, given the pivot the first-person rig would have
|
||||
-- put the eye at and the unit look direction it would have looked along.
|
||||
-- Returns the eye and the focus: both slide by the shoulder offset, so the
|
||||
-- view direction is untouched and only the frame's contents shift.
|
||||
--
|
||||
-- With the boom fully in this is exactly the first-person answer, to the
|
||||
-- pixel -- which is what makes 1ST and 3RD one rig with a number between
|
||||
-- them rather than two cameras to keep in sync.
|
||||
function ThirdPerson.place(pivot, lx, ly, lz, focus)
|
||||
local e = ThirdPerson.extension()
|
||||
if e <= 0 then
|
||||
ThirdPerson.want, ThirdPerson.len = 0, 0
|
||||
return pivot, focus
|
||||
end
|
||||
|
||||
local up = ThirdPerson.PIVOT_LIFT * e
|
||||
local orbit = { pivot[1], pivot[2] + up, pivot[3] }
|
||||
|
||||
local want = ThirdPerson.reachFor() * e
|
||||
ThirdPerson.want = want
|
||||
local room = ThirdPerson.reach(overworld(), orbit, -lx, -ly, -lz, want)
|
||||
-- in instantly, out only as fast as update() allows
|
||||
ThirdPerson.len = math.min(ThirdPerson.len, room)
|
||||
local len = ThirdPerson.len
|
||||
|
||||
-- the rail offset, faded with how much boom actually survived: a camera
|
||||
-- squeezed against a wall gives up its shoulder before it gives up its
|
||||
-- distance. Right of the look, flat: cross(look, worldUp) normalized,
|
||||
-- which for a look of (sin y, *, cos y) is (-cos y, 0, sin y) -- the same
|
||||
-- right hand FirstPerson.moveWorld strafes along.
|
||||
-- The rail rides the ZOOM as well, so it stays the same fraction of the
|
||||
-- frame at every distance: a fixed four pixels would swamp the close shot
|
||||
-- and vanish from the wide one.
|
||||
local flat = math.sqrt(lx * lx + lz * lz)
|
||||
local sx, sz = 0, 0
|
||||
if flat > 1e-6 then
|
||||
local s = ThirdPerson.SHOULDER * ThirdPerson.zoom * e
|
||||
* (len / math.max(want, 1e-6))
|
||||
sx, sz = -lz / flat * s, lx / flat * s
|
||||
end
|
||||
|
||||
local eye = { orbit[1] - lx * len + sx,
|
||||
orbit[2] - ly * len,
|
||||
orbit[3] - lz * len + sz }
|
||||
local aim = focus and { focus[1] + sx, focus[2] + up, focus[3] + sz }
|
||||
or nil
|
||||
return eye, aim
|
||||
end
|
||||
|
||||
-- What a shadow signature has to include about the boom: the sun's box is
|
||||
-- fitted around this camera, so sliding the eye back (or having a wall
|
||||
-- shove it in) re-fits it even standing still.
|
||||
function ThirdPerson.signature()
|
||||
if ThirdPerson.extension() <= 0 then return "" end
|
||||
return math.floor(ThirdPerson.len) .. "/" ..
|
||||
math.floor(ThirdPerson.extension() * 64)
|
||||
end
|
||||
|
||||
return ThirdPerson
|
||||
+4
-2
@@ -25,6 +25,7 @@
|
||||
-- failure -- headless, no shader support) apply() hands the canvas back
|
||||
-- untouched, so every other path is byte-for-byte what it always was.
|
||||
|
||||
local V = ...
|
||||
local TiltShift = {}
|
||||
|
||||
TiltShift.level = 0
|
||||
@@ -85,9 +86,10 @@ end
|
||||
|
||||
local function getCanvases(w, h)
|
||||
if not ping or cw ~= w or ch ~= h then
|
||||
local ok, a = pcall(love.graphics.newCanvas, w, h)
|
||||
local PixelCanvas = V.require("PixelCanvas")
|
||||
local ok, a = PixelCanvas.new(w, h)
|
||||
if not ok then return nil end
|
||||
local okB, b = pcall(love.graphics.newCanvas, w, h)
|
||||
local okB, b = PixelCanvas.new(w, h)
|
||||
if not okB then return nil end
|
||||
-- the gaussian's fractional tap offsets need linear filtering
|
||||
a:setFilter("linear", "linear")
|
||||
|
||||
+786
@@ -0,0 +1,786 @@
|
||||
-- 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
|
||||
-- Either free-roam rung puts the headset in the player's head: 3RD's boom
|
||||
-- is a FLAT-SCREEN framing device, and a headset that stands its wearer
|
||||
-- three cells behind their own body is a well-known way to make people
|
||||
-- ill. The rung still changes the walk and the cards the same way; only
|
||||
-- the eye stays where a head belongs.
|
||||
local fp = FirstPerson.engaged()
|
||||
local battle, battleFloor
|
||||
if camMode == "battle" then battle, battleFloor = battleStage() end
|
||||
if battle then
|
||||
-- the over-the-shoulder seat the flat battle shot stands in, pulled
|
||||
-- close enough for a headset's own lens (see VRRig.battleMount), at
|
||||
-- life scale, turned to face the arena
|
||||
local rec = BattleCam.rig(battle, battleFloor)
|
||||
pivot, mountYaw = VRRig.battleMount(rec.eye, rec.focus)
|
||||
anchor = { 0, 0, 0 }
|
||||
scale = VRRig.FP_SCALE
|
||||
elseif fp then
|
||||
local p = ow.player
|
||||
local gh = 0
|
||||
pcall(function() gh = VoxelScene.groundAt(ow.map, p.cellX, p.cellY) end)
|
||||
pivot = VRRig.fpPivot(p.px, p.py, gh, FirstPerson.EYE_HEIGHT)
|
||||
anchor = { 0, 0, 0 }
|
||||
scale = VRRig.FP_SCALE
|
||||
-- the snap turn is a yaw on the MAPPING, same seam the battle mount
|
||||
-- turns through
|
||||
if fpYawOff ~= 0 then mountYaw = fpYawOff end
|
||||
-- the HMD is the head: its yaw and pitch (plus the snaps) become
|
||||
-- FirstPerson's, so FreeMove walks where you look and A talks to
|
||||
-- what you face
|
||||
local yaw, pitch = VRRig.headYawPitch(views[1].pose.quat)
|
||||
FirstPerson.yaw = wrapPi(yaw + fpYawOff)
|
||||
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
|
||||
math.min(FirstPerson.PITCH_DOWN, pitch))
|
||||
else
|
||||
-- The table presents the world exactly as the flat screen does at
|
||||
-- rest: the pivot sits VIEW_DIST away along the RUNG'S own angle
|
||||
-- (stepping rungs re-tilts the model, easing with the rung tween),
|
||||
-- at the scale that reproduces the flat framing -- then the player's
|
||||
-- own adjustments go on top: the stick's zoom, the grip's height.
|
||||
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
|
||||
anchor = VRRig.dioramaAnchor(Voxel.angle, heightOff)
|
||||
scale = VRRig.dioramaScale(vh, Voxel.FOCAL) / zoom
|
||||
end
|
||||
|
||||
-- The pokedex, on the tracked left hand, under this very mapping --
|
||||
-- but only where it earns its keep: FIRST PERSON, where its screen is
|
||||
-- every menu, dialog and wipe the flat screen shows (and the floating
|
||||
-- billboard is retired outright -- see updateQuad), and the BATTLE
|
||||
-- seat, where its screen is the fight's own 2D scene. The diorama
|
||||
-- does without: a hand-sized device hovering over a tabletop town is
|
||||
-- clutter, and the panel serves there. No hand tracked, no device.
|
||||
local hand = ctl and ctl.handl or nil
|
||||
if hand and (battle or fp) then
|
||||
Pokedex.place(hand, pivot, anchor, scale, mountYaw)
|
||||
if uiShowing() then
|
||||
local scr = dexScreen()
|
||||
if scr then
|
||||
Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5])
|
||||
end
|
||||
elseif V.require("Horde").active then
|
||||
-- HORDE MODE's readout, on the device already in the player's left
|
||||
-- hand. It cannot be a flat overlay: the eye buffers have
|
||||
-- ASYMMETRIC frusta, so the same canvas pixel is a different ANGLE
|
||||
-- in each eye and a 2D HUD drawn into both tears down the middle.
|
||||
-- The Pokedex is real geometry both eyes see from their own
|
||||
-- position, so the stereo is correct by construction -- and it is
|
||||
-- already tracked, already lit, and already the thing this mod
|
||||
-- puts information on. (The gun wore it briefly and that was
|
||||
-- worse: a screen on the slide sits exactly where the iron sights
|
||||
-- need to be looked through.)
|
||||
--
|
||||
-- The UV rect goes over the usual way up: v = 0 at the TOP, which
|
||||
-- is how the device's screen quad reads every other texture it
|
||||
-- wears. An inverted rect was tried first, on the theory that a
|
||||
-- self-drawn canvas samples from the bottom -- it does not here,
|
||||
-- and it stood the readout on its head.
|
||||
local tex = V.require("HordeHud").panelTexture()
|
||||
if tex then Pokedex.screen(tex, 0, 0, 1, 1) end
|
||||
end
|
||||
else
|
||||
Pokedex.clear()
|
||||
end
|
||||
|
||||
-- and the horde's gun on the tracked RIGHT hand, under the same
|
||||
-- mapping. The AIM pose where the runtime offers one -- the barrel
|
||||
-- should point where the player is pointing, not along their wrist --
|
||||
-- and the grip pose as the fallback. Placed here rather than in the
|
||||
-- draw because the shot is traced down the model's own axis, so the
|
||||
-- matrix has to exist before anything can be hit with it.
|
||||
do
|
||||
local HordeGun = V.require("HordeGun")
|
||||
local right = ctl and (ctl.aimr or ctl.handr) or nil
|
||||
if right and fp and not battle and V.require("Horde").active then
|
||||
HordeGun.place(right, pivot, anchor, scale, mountYaw)
|
||||
else
|
||||
HordeGun.clear()
|
||||
end
|
||||
end
|
||||
|
||||
local eyes = {}
|
||||
for i = 1, 2 do
|
||||
local v = views[i]
|
||||
eyes[i] = {
|
||||
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw),
|
||||
w = v.w, h = v.h,
|
||||
slot = i == 1 and "vrL" or "vrR",
|
||||
-- the battle seat is a placed shot, not the first-person rig: the
|
||||
-- cards keep their stage lean rather than yawing at this eye, and
|
||||
-- the player's own card stays visible in it
|
||||
adopt = not battle,
|
||||
}
|
||||
end
|
||||
eyes.cx, eyes.cy = pivot[1], pivot[3]
|
||||
|
||||
local okR, canvases = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
|
||||
VR.paletteFor, eyes)
|
||||
if not (okR and type(canvases) == "table" and canvases[1] and canvases[2])
|
||||
then
|
||||
return false
|
||||
end
|
||||
|
||||
-- the snap's fade, over the finished eyes: plain black at this moment's
|
||||
-- strength, drawn before the blit so the headset never sees the swap.
|
||||
-- A full-frame fill is the ONE 2D thing that is safe to draw into an
|
||||
-- eye buffer -- it covers everything, so it does not matter that the
|
||||
-- two frusta disagree about where any given pixel points.
|
||||
if fadeAlpha > 0 then
|
||||
pcall(function()
|
||||
for i = 1, 2 do
|
||||
local c = canvases[i]
|
||||
love.graphics.setCanvas(c)
|
||||
love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha))
|
||||
love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight())
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end)
|
||||
end
|
||||
|
||||
for i = 1, 2 do
|
||||
local canvas = canvases[i]
|
||||
local tex, tw, th = VRXR.acquireEye(i)
|
||||
if tex then
|
||||
local fbo = fboCache[canvas]
|
||||
if not fbo then
|
||||
fbo = VRGL.canvasFBO(canvas)
|
||||
fboCache[canvas] = fbo
|
||||
end
|
||||
if fbo then
|
||||
VRGL.blitToTexture(fbo, canvas:getWidth(), canvas:getHeight(),
|
||||
tex, tw, th)
|
||||
end
|
||||
end
|
||||
VRXR.releaseEye(i)
|
||||
end
|
||||
mirrorSrc = canvases[1]
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- the UI panel
|
||||
|
||||
-- Whether the flat screen is showing something the world pass cannot: a
|
||||
-- menu, a dialog, a battle, a transition wipe -- or everything, when the
|
||||
-- world pass is off entirely.
|
||||
local function wantQuad(worldUp)
|
||||
if not worldUp then return true end
|
||||
return uiShowing()
|
||||
end
|
||||
|
||||
local function updateQuad(worldUp, fp)
|
||||
if not wantQuad(worldUp) then return nil end
|
||||
-- Wherever the pokedex is up and lit -- first person's menus, the
|
||||
-- battle seat's 2D fight -- it IS the screen, and no floating
|
||||
-- billboard is submitted at all. (No tracked left hand still gets
|
||||
-- the panel: the UI must be readable somewhere.)
|
||||
if Pokedex.frame and Pokedex.frame.tex then return nil end
|
||||
local tex, qw, qh = VRXR.acquireQuad()
|
||||
if not tex then return nil end
|
||||
local ww, wh = qw, qh
|
||||
pcall(function() ww, wh = love.graphics.getPixelDimensions() end)
|
||||
-- The panel wears the GB FRAME, not the window: everything the flat
|
||||
-- screen has to say lives in the 160x144 letterbox (the world around
|
||||
-- it is just the mirror's picture). The frame region is blitted OUT
|
||||
-- of the window and SCALED into the swapchain image -- never copied
|
||||
-- pixel-for-pixel, because the swapchain's size is fixed at session
|
||||
-- start and a fullscreened window outgrows it, running the frame (and
|
||||
-- the START menu flush with its right edge) off the copy. Scaled, the
|
||||
-- panel shows the identical picture at the identical ratio whatever
|
||||
-- size the window is. Source coordinates are GL's, origin bottom-left.
|
||||
local crop = nil
|
||||
local copied = false
|
||||
pcall(function()
|
||||
local BattleScene = V.require("BattleScene")
|
||||
local lx, ly, s = BattleScene.letterbox()
|
||||
local wpx = math.ceil(BattleScene.GB_W * s)
|
||||
local hpx = math.ceil(BattleScene.GB_H * s)
|
||||
local sx = math.max(0, math.floor(lx))
|
||||
local sy = math.max(0, math.floor(wh - ly - hpx))
|
||||
wpx = math.min(wpx, ww - sx)
|
||||
hpx = math.min(hpx, wh - sy)
|
||||
if wpx < 1 or hpx < 1 then return end
|
||||
-- fitted to the swapchain image at the REGION's own aspect: the
|
||||
-- crop then presents exactly that rect, so the panel's shape is the
|
||||
-- GB frame's at any window and any swapchain size
|
||||
local fit = math.min(qw / wpx, qh / hpx)
|
||||
local dw = math.max(1, math.floor(wpx * fit))
|
||||
local dh = math.max(1, math.floor(hpx * fit))
|
||||
if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then
|
||||
copied = true
|
||||
crop = { 0, 0, dw, dh }
|
||||
end
|
||||
end)
|
||||
if not copied then
|
||||
-- no letterbox to cut (or the blit refused): the old whole-window
|
||||
-- copy, clamped, is still a readable panel
|
||||
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
|
||||
end
|
||||
VRXR.releaseQuad()
|
||||
local base = fp and QUAD_FP or QUAD_DIORAMA
|
||||
if not crop then return base end
|
||||
return { pos = base.pos, width = base.width, crop = crop }
|
||||
end
|
||||
|
||||
-- ------- the controllers
|
||||
--
|
||||
-- The mapping the mod ships (rebindable in the runtime's own UI):
|
||||
--
|
||||
-- both modes left stick moves (through the engine's own stick path,
|
||||
-- so it grid-walks the diorama and free-walks 1ST);
|
||||
-- A/B are A/B; either trigger is START; clicking the
|
||||
-- LEFT stick steps the VOXEL angle ladder exactly as
|
||||
-- the "3" key (and the pad's SELECT) does.
|
||||
-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick.
|
||||
-- diorama only right stick up/down zooms the model; squeezing a grip
|
||||
-- and moving that hand up or down drags the whole table
|
||||
-- with it.
|
||||
--
|
||||
-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
|
||||
-- no controller button does it. VR.leave below stays as the API for it.
|
||||
|
||||
-- The left stick click makes EXACTLY the step the "3" key makes: one
|
||||
-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing
|
||||
-- TILT and GBC FX in the save -- by calling the very function the key
|
||||
-- and the pad's SELECT button already share. main.lua installs it below
|
||||
-- (cycleVoxel is a local of that file); the free-roam gate is the
|
||||
-- registry's own, inside it, so a click over a menu or mid-warp is a
|
||||
-- no-op exactly like the key.
|
||||
VR.cycleVoxel = nil -- cycleVoxel(game), set by main.lua
|
||||
|
||||
function VR.stepView()
|
||||
pcall(function()
|
||||
if not VR.cycleVoxel then return end
|
||||
VR.cycleVoxel(require("src.core.Game"))
|
||||
end)
|
||||
end
|
||||
|
||||
-- Leave VR: the VR row toggled back off and persisted, exactly as if
|
||||
-- stepped on the OPTIONS menu, so the next update tears the session down
|
||||
-- and the flat screen takes the picture back. Deliberately bound to NO
|
||||
-- controller button (a click that ejects you from the headset is a trap
|
||||
-- mid-fight); kept as the one programmatic door out.
|
||||
function VR.leave()
|
||||
pcall(function()
|
||||
local Game = require("src.core.Game")
|
||||
VR.setting:setIndex(VR.setting:read() + 1, Game)
|
||||
end)
|
||||
end
|
||||
|
||||
local function setGB(inp, btn, down)
|
||||
if down and not held[btn] then
|
||||
held[btn] = true
|
||||
inp:overlayPressed(btn)
|
||||
elseif not down and held[btn] then
|
||||
held[btn] = nil
|
||||
inp:overlayReleased(btn)
|
||||
end
|
||||
end
|
||||
|
||||
local function driveControls(ctl, dt, fp)
|
||||
if not ctl then
|
||||
releaseInputs()
|
||||
return
|
||||
end
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
if not (ok and Game.input) then return end
|
||||
local inp = Game.input
|
||||
|
||||
-- HORDE MODE re-reads the right hand as a weapon: the trigger fires
|
||||
-- (its own OpenXR action, suggested alongside START on the same input
|
||||
-- -- see VRXR.setupInput), and B reloads. START is dropped rather than
|
||||
-- forwarded, because the mode does not pause. Everything else -- the
|
||||
-- stick's walk, the snap turn, A -- keeps working, so the player can
|
||||
-- still move and look while they are being chased.
|
||||
local Horde = V.require("Horde")
|
||||
if Horde.playing() then
|
||||
local Gun = V.require("HordeGun")
|
||||
if ctl.fireChanged and ctl.fire then Gun.fire() end
|
||||
if ctl.bChanged and ctl.b then Gun.reload() end
|
||||
setGB(inp, "a", ctl.a)
|
||||
setGB(inp, "b", false)
|
||||
setGB(inp, "start", false)
|
||||
else
|
||||
setGB(inp, "a", ctl.a)
|
||||
setGB(inp, "b", ctl.b)
|
||||
setGB(inp, "start", ctl.start)
|
||||
end
|
||||
|
||||
-- the left stick, through the engine's OWN stick handler: it quantises
|
||||
-- to the grid d-pad for the diorama, and FirstPerson.moveVector reads
|
||||
-- the same raw pair for the free walk. OpenXR's +Y is up; the engine's
|
||||
-- lefty is +down.
|
||||
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
|
||||
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
|
||||
|
||||
-- the left stick click: the VOXEL ladder ordinarily, and the way out of
|
||||
-- horde mode while it runs (the rung is locked there, so the click has
|
||||
-- nothing else to do, and a headset has no ESCAPE key)
|
||||
if ctl.toggleChanged and ctl.toggle then
|
||||
if Horde.active then Horde.askExit() else VR.stepView() end
|
||||
end
|
||||
|
||||
-- first person's turn on the right stick. SMOOTH TURN ON makes it a
|
||||
-- rate -- hold and the world rotates under you -- and OFF (the
|
||||
-- default) makes it a 45-degree snap per flick: see the row's own
|
||||
-- reasoning where it is declared. Either way the offset turns the
|
||||
-- MAPPING, so the eyes, the walk direction, the pokedex and the gun
|
||||
-- all agree about which way the world now faces.
|
||||
if fp and camMode ~= "battle" and VR.smoothTurn:get() == true then
|
||||
local sx = ctl.lookX or 0
|
||||
local a = math.abs(sx)
|
||||
if a > 0.2 then
|
||||
a = (a - 0.2) / 0.8
|
||||
-- increasing yaw turns LEFT in this mod's compass, so a stick
|
||||
-- pushed right subtracts -- the same sign the snap below uses
|
||||
fpYawOff = wrapPi(fpYawOff
|
||||
- (sx > 0 and 1 or -1) * a * a
|
||||
* VR.SMOOTH_TURN_RATE * (dt or 0))
|
||||
end
|
||||
snapArmed = true -- so a switch back to snap mid-flick re-arms
|
||||
elseif fp and camMode ~= "battle" then
|
||||
local sx = ctl.lookX or 0
|
||||
if math.abs(sx) > 0.65 then
|
||||
if snapArmed then
|
||||
snapArmed = false
|
||||
-- increasing yaw turns LEFT in this mod's compass, so a stick
|
||||
-- pushed right subtracts
|
||||
fpYawOff = wrapPi(fpYawOff + (sx > 0 and -SNAP_TURN or SNAP_TURN))
|
||||
end
|
||||
elseif math.abs(sx) < 0.35 then
|
||||
snapArmed = true
|
||||
end
|
||||
end
|
||||
|
||||
if not fp and camMode ~= "battle" then
|
||||
local zy = ctl.lookY or 0
|
||||
if math.abs(zy) > 0.15 then
|
||||
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
|
||||
end
|
||||
-- the grab-drag: while a grip is squeezed, the table follows that
|
||||
-- hand's height, metre for metre
|
||||
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
|
||||
local y = (gr >= gl) and ctl.handrY or ctl.handlY
|
||||
if math.max(gl, gr) > 0.6 and y then
|
||||
if lastHandY then
|
||||
heightOff = math.max(-1.5, math.min(1.5, heightOff + (y - lastHandY)))
|
||||
end
|
||||
lastHandY = y
|
||||
else
|
||||
lastHandY = nil
|
||||
end
|
||||
else
|
||||
lastHandY = nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the per-frame drive
|
||||
|
||||
function VR.update(dt)
|
||||
local on = VR.enabled()
|
||||
if not on then
|
||||
if wasOn then
|
||||
shutdown("off")
|
||||
failed = nil
|
||||
end
|
||||
wasOn = false
|
||||
return
|
||||
end
|
||||
|
||||
if not wasOn then failed = nil end -- a fresh toggle earns a fresh try
|
||||
wasOn = true
|
||||
if failed then return end
|
||||
|
||||
if not started then
|
||||
local qw, qh = 1024, 768
|
||||
pcall(function() qw, qh = love.graphics.getPixelDimensions() end)
|
||||
if VRXR.start(qw, qh) then
|
||||
started = true
|
||||
status = "session created"
|
||||
print("[DRAMATIC_SHAPE] VR: " .. VRXR.status())
|
||||
else
|
||||
failed = VRXR.status()
|
||||
print("[DRAMATIC_SHAPE] VR unavailable: " .. failed
|
||||
.. " -- fix that, then toggle the VR row to retry")
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
if not VRXR.poll() then
|
||||
-- the runtime took the session away (headset off, runtime shut down)
|
||||
shutdown("session lost")
|
||||
failed = "session lost -- toggle VR off and on to retry"
|
||||
return
|
||||
end
|
||||
if not VRXR.isRunning() then return end
|
||||
|
||||
-- the headset paces the app now; vsync would fight it
|
||||
if savedVsync == nil then
|
||||
savedVsync = 1
|
||||
pcall(function() savedVsync = love.window.getVSync() end)
|
||||
pcall(love.window.setVSync, 0)
|
||||
end
|
||||
|
||||
-- the battle camera holds still for as long as a headset is watching:
|
||||
-- its drift is a flat screen's depth cue, and a swaying picture inside
|
||||
-- VR reads as the world lurching
|
||||
BattleCam.still = true
|
||||
|
||||
-- The battle snap's fade: while the camera the frame WANTS is not the
|
||||
-- one it is showing, black rises; at full black the mount swaps; then
|
||||
-- black lifts. Driven here, on game time, so a fight that ends during
|
||||
-- the fade just turns it around.
|
||||
local want = battleStage() and "battle" or "explore"
|
||||
if want ~= camMode then
|
||||
fadeAlpha = math.min(1, fadeAlpha + (dt or 0) / FADE_TIME)
|
||||
if fadeAlpha >= 1 then camMode = want end
|
||||
else
|
||||
fadeAlpha = math.max(0, fadeAlpha - (dt or 0) / FADE_TIME)
|
||||
end
|
||||
|
||||
local time, should = VRXR.waitFrame()
|
||||
if not time then return end
|
||||
|
||||
-- the controllers, before the world renders: the frame the toggle
|
||||
-- flips rungs on should be the frame that renders the new rig. The
|
||||
-- state is kept in hand for renderWorld too -- the pokedex stands on
|
||||
-- the same frame's left-hand pose.
|
||||
local ctl = VRXR.input(time)
|
||||
driveControls(ctl, dt, FirstPerson.engaged())
|
||||
|
||||
local worldUp = false
|
||||
if should then
|
||||
local views = VRXR.locateViews(time)
|
||||
if views then
|
||||
worldUp = renderWorld(views, ctl)
|
||||
end
|
||||
end
|
||||
local quadPose = updateQuad(worldUp, FirstPerson.engaged())
|
||||
VRXR.endFrame(time, worldUp or nil, quadPose)
|
||||
end
|
||||
|
||||
-- ------- the window while a headset owns the picture
|
||||
|
||||
-- The flat window becomes the mirror: the left eye, fitted to the window.
|
||||
-- Returns nil when there is nothing to mirror (the caller draws the flat
|
||||
-- path as ever).
|
||||
function VR.mirror(sw, sh)
|
||||
if not (VR.active() and mirrorSrc) then return nil end
|
||||
if not (mirrorCanvas and mirrorCanvas:getWidth() == sw
|
||||
and mirrorCanvas:getHeight() == sh) then
|
||||
local ok, c = pcall(love.graphics.newCanvas, sw, sh)
|
||||
if not ok then return nil end
|
||||
mirrorCanvas = c
|
||||
end
|
||||
local ok = pcall(function()
|
||||
love.graphics.setCanvas(mirrorCanvas)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
local mw, mh = mirrorSrc:getDimensions()
|
||||
local s = math.min(sw / mw, sh / mh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(mirrorSrc, (sw - mw * s) / 2, (sh - mh * s) / 2, 0, s, s)
|
||||
love.graphics.setCanvas()
|
||||
end)
|
||||
pcall(love.graphics.setCanvas)
|
||||
return ok and mirrorCanvas or nil
|
||||
end
|
||||
|
||||
-- window resize, hot reload: the eye canvases are Voxel3D's and go with
|
||||
-- its invalidate; ours is the mirror and the FBO ids learned from dead
|
||||
-- canvases
|
||||
function VR.invalidate()
|
||||
if mirrorCanvas and mirrorCanvas.release then
|
||||
pcall(mirrorCanvas.release, mirrorCanvas)
|
||||
end
|
||||
mirrorCanvas, mirrorSrc = nil, nil
|
||||
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
|
||||
dexCanvas = nil
|
||||
Pokedex.invalidate()
|
||||
V.require("HordeGun").invalidate()
|
||||
V.require("HordeHud").invalidate()
|
||||
for k in pairs(fboCache) do fboCache[k] = nil end
|
||||
end
|
||||
|
||||
return VR
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
-- VR: the raw OpenGL this mod is otherwise proud to never need.
|
||||
--
|
||||
-- OpenXR hands over its swapchain images as GL TEXTURE IDS, and LOVE never
|
||||
-- exposes the GL names behind its own canvases -- so getting a rendered
|
||||
-- eye from a love Canvas into a headset means dropping below LOVE for a
|
||||
-- few calls a frame: discover the canvas's framebuffer, blit it into the
|
||||
-- swapchain texture, and put the pipeline back exactly as LOVE believes it
|
||||
-- to be. Everything here is that, and only that.
|
||||
--
|
||||
-- Three rules keep this safe:
|
||||
--
|
||||
-- discovery over spelunking. The canvas's FBO id is read from the
|
||||
-- driver with documented queries (bind the canvas THROUGH LOVE, ask
|
||||
-- GL_DRAW_FRAMEBUFFER_BINDING) rather than from LOVE's internals, so a
|
||||
-- LOVE patch cannot move it out from under us.
|
||||
--
|
||||
-- restore what LOVE caches. LOVE tracks the bound framebuffer and skips
|
||||
-- redundant binds, so raw binds must end back at the exact binding LOVE
|
||||
-- thinks is current -- the default framebuffer, 0, since every call
|
||||
-- here runs between LOVE passes -- or LOVE's next draw lands in ours.
|
||||
--
|
||||
-- pcall at the rim, ffi inside. The FFI setup can fail (headless, a GL
|
||||
-- context without FBO entry points); it fails ONCE, at load(), and
|
||||
-- callers see `nil, reason` rather than an error mid-frame.
|
||||
|
||||
local VRGL = {}
|
||||
|
||||
local ffi = nil
|
||||
local gl = nil -- opengl32 exports (GL 1.1 + wgl)
|
||||
local ext = {} -- post-1.1 entry points via wglGetProcAddress
|
||||
local ready = false
|
||||
local reason = nil
|
||||
local fbo = nil -- our scratch framebuffer, made once
|
||||
|
||||
local GL = {
|
||||
FRAMEBUFFER = 0x8D40,
|
||||
READ_FRAMEBUFFER = 0x8CA8,
|
||||
DRAW_FRAMEBUFFER = 0x8CA9,
|
||||
DRAW_FRAMEBUFFER_BINDING = 0x8CA6,
|
||||
COLOR_ATTACHMENT0 = 0x8CE0,
|
||||
COLOR_BUFFER_BIT = 0x4000,
|
||||
NEAREST = 0x2600,
|
||||
LINEAR = 0x2601,
|
||||
TEXTURE_2D = 0x0DE1,
|
||||
FRONT = 0x0404,
|
||||
BACK = 0x0405,
|
||||
}
|
||||
VRGL.GL = GL
|
||||
|
||||
local CDEF = [[
|
||||
typedef void (__stdcall *PROC)();
|
||||
void* wglGetCurrentDC(void);
|
||||
void* wglGetCurrentContext(void);
|
||||
PROC wglGetProcAddress(const char*);
|
||||
unsigned int glGetError(void);
|
||||
void glGetIntegerv(unsigned int pname, int* params);
|
||||
void glReadBuffer(unsigned int mode);
|
||||
void glFlush(void);
|
||||
void glCopyTexSubImage2D(unsigned int target, int level, int xoffset,
|
||||
int yoffset, int x, int y, int width, int height);
|
||||
void glBindTexture(unsigned int target, unsigned int texture);
|
||||
typedef void (__stdcall *pfn_glBindFramebuffer)(unsigned int, unsigned int);
|
||||
typedef void (__stdcall *pfn_glGenFramebuffers)(int, unsigned int*);
|
||||
typedef void (__stdcall *pfn_glDeleteFramebuffers)(int, const unsigned int*);
|
||||
typedef void (__stdcall *pfn_glFramebufferTexture2D)(unsigned int,
|
||||
unsigned int, unsigned int, unsigned int, int);
|
||||
typedef void (__stdcall *pfn_glBlitFramebuffer)(int, int, int, int,
|
||||
int, int, int, int, unsigned int, unsigned int);
|
||||
]]
|
||||
|
||||
-- One-time FFI setup. Idempotent, and every path out records why it
|
||||
-- stopped, so VR's status line can say something better than "no".
|
||||
function VRGL.load()
|
||||
if ready then return true end
|
||||
if reason then return false, reason end
|
||||
local ok, err = pcall(function()
|
||||
ffi = require("ffi")
|
||||
-- cdef survives a reload; redefinition is the only error worth eating
|
||||
pcall(ffi.cdef, CDEF)
|
||||
gl = ffi.load("opengl32")
|
||||
local function proc(name, typ)
|
||||
local p = gl.wglGetProcAddress(name)
|
||||
if p == nil then error(name .. " not exposed by this GL context", 0) end
|
||||
return ffi.cast(typ, p)
|
||||
end
|
||||
ext.glBindFramebuffer = proc("glBindFramebuffer", "pfn_glBindFramebuffer")
|
||||
ext.glGenFramebuffers = proc("glGenFramebuffers", "pfn_glGenFramebuffers")
|
||||
ext.glDeleteFramebuffers =
|
||||
proc("glDeleteFramebuffers", "pfn_glDeleteFramebuffers")
|
||||
ext.glFramebufferTexture2D =
|
||||
proc("glFramebufferTexture2D", "pfn_glFramebufferTexture2D")
|
||||
ext.glBlitFramebuffer = proc("glBlitFramebuffer", "pfn_glBlitFramebuffer")
|
||||
end)
|
||||
if not ok then
|
||||
reason = "GL interop unavailable: " .. tostring(err)
|
||||
return false, reason
|
||||
end
|
||||
ready = true
|
||||
return true
|
||||
end
|
||||
|
||||
-- The window's device and GL contexts, which the OpenXR session binds to.
|
||||
function VRGL.contexts()
|
||||
if not VRGL.load() then return nil, nil end
|
||||
return gl.wglGetCurrentDC(), gl.wglGetCurrentContext()
|
||||
end
|
||||
|
||||
-- The GL framebuffer behind a LOVE canvas. Bound through LOVE (so LOVE's
|
||||
-- own cache stays truthful), read from the driver, then released.
|
||||
function VRGL.canvasFBO(canvas)
|
||||
if not VRGL.load() then return nil end
|
||||
local id = nil
|
||||
local ok = pcall(function()
|
||||
love.graphics.setCanvas(canvas)
|
||||
local out = ffi.new("int[1]")
|
||||
gl.glGetIntegerv(GL.DRAW_FRAMEBUFFER_BINDING, out)
|
||||
id = out[0]
|
||||
love.graphics.setCanvas()
|
||||
end)
|
||||
pcall(love.graphics.setCanvas)
|
||||
return ok and id or nil
|
||||
end
|
||||
|
||||
-- Blit a LOVE canvas's pixels into a GL texture (an XR swapchain image),
|
||||
-- flipped vertically on the way: LOVE renders its canvases y-down, GL
|
||||
-- textures composite y-up, and the blit is the one place the two meet.
|
||||
--
|
||||
-- `srcFBO` comes from canvasFBO (cache it -- it is stable for the
|
||||
-- canvas's lifetime). Ends with framebuffer 0 bound, which is the binding
|
||||
-- LOVE believes in between its passes.
|
||||
function VRGL.blitToTexture(srcFBO, sw, sh, tex, tw, th)
|
||||
if not ready then return false end
|
||||
local ok = pcall(function()
|
||||
if not fbo then
|
||||
local out = ffi.new("unsigned int[1]")
|
||||
ext.glGenFramebuffers(1, out)
|
||||
fbo = out[0]
|
||||
end
|
||||
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
|
||||
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
|
||||
GL.TEXTURE_2D, tex, 0)
|
||||
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, srcFBO)
|
||||
ext.glBlitFramebuffer(0, sh, sw, 0, 0, 0, tw, th,
|
||||
GL.COLOR_BUFFER_BIT, GL.LINEAR)
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
end)
|
||||
if not ok then pcall(function() ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) end) end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Copy the WINDOW's currently displayed image (the front buffer -- the
|
||||
-- back buffer's contents are undefined after a swap) into a GL texture:
|
||||
-- the UI quad the headset floats in front of the world. Restores the read
|
||||
-- buffer to BACK, the default LOVE never changes.
|
||||
function VRGL.copyFrontBuffer(tex, w, h)
|
||||
if not ready then return false end
|
||||
local ok = pcall(function()
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.FRONT)
|
||||
gl.glBindTexture(GL.TEXTURE_2D, tex)
|
||||
gl.glCopyTexSubImage2D(GL.TEXTURE_2D, 0, 0, 0, 0, 0, w, h)
|
||||
gl.glBindTexture(GL.TEXTURE_2D, 0)
|
||||
gl.glReadBuffer(GL.BACK)
|
||||
end)
|
||||
if not ok then pcall(function() gl.glReadBuffer(GL.BACK) end) end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Blit a REGION of the window's front buffer into a GL texture (an XR
|
||||
-- swapchain image), SCALED to (dw, dh) at the texture's origin. This is
|
||||
-- the panel's route: the whole-window copy above is pixel-for-pixel, so
|
||||
-- a window larger than the swapchain image simply ran off its edges --
|
||||
-- fullscreen cut the GB frame's own menu off the panel. A scaled blit
|
||||
-- has no such cliff: the letterbox region lands whole at the texture's
|
||||
-- own resolution whatever size the window is. Source coordinates are GL
|
||||
-- window space, origin bottom-left; LINEAR, because the region rarely
|
||||
-- matches the target size exactly and dropped rows read worse than a
|
||||
-- soft one. Restores the read buffer and framebuffer LOVE believes in.
|
||||
function VRGL.copyFrontRegionToTexture(tex, sx, sy, sw, sh, dw, dh)
|
||||
if not ready then return false end
|
||||
local ok = pcall(function()
|
||||
if not fbo then
|
||||
local out = ffi.new("unsigned int[1]")
|
||||
ext.glGenFramebuffers(1, out)
|
||||
fbo = out[0]
|
||||
end
|
||||
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.FRONT)
|
||||
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
|
||||
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
|
||||
GL.TEXTURE_2D, tex, 0)
|
||||
ext.glBlitFramebuffer(sx, sy, sx + sw, sy + sh, 0, 0, dw, dh,
|
||||
GL.COLOR_BUFFER_BIT, GL.LINEAR)
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.BACK)
|
||||
end)
|
||||
if not ok then
|
||||
pcall(function()
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.BACK)
|
||||
end)
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Copy the window's front buffer into a LOVE CANVAS (by its FBO id, from
|
||||
-- canvasFBO), flipped so the canvas reads top-down exactly like the
|
||||
-- window: what LOVE then draws from that canvas at (0,0) is the screen,
|
||||
-- row for row. The battle's VR quad is the caller: it needs the screen as
|
||||
-- something LOVE can CUT UP (scissored cutouts of the UI), not just as a
|
||||
-- finished texture -- copyFrontBuffer above is for the finished case.
|
||||
function VRGL.copyFrontToCanvas(dstFBO, w, h)
|
||||
if not ready then return false end
|
||||
local ok = pcall(function()
|
||||
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.FRONT)
|
||||
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, dstFBO)
|
||||
ext.glBlitFramebuffer(0, 0, w, h, 0, h, w, 0,
|
||||
GL.COLOR_BUFFER_BIT, GL.NEAREST)
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.BACK)
|
||||
end)
|
||||
if not ok then
|
||||
pcall(function()
|
||||
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
|
||||
gl.glReadBuffer(GL.BACK)
|
||||
end)
|
||||
end
|
||||
return ok
|
||||
end
|
||||
|
||||
function VRGL.status()
|
||||
if ready then return "ok" end
|
||||
return reason or "not loaded"
|
||||
end
|
||||
|
||||
return VRGL
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
-- VR: the pose arithmetic -- how a headset eye becomes one of this mod's
|
||||
-- cameras. Pure math on purpose: no FFI, no OpenXR types, nothing a
|
||||
-- headless test cannot hold still. Everything device-shaped stays in
|
||||
-- VRXR/VRGL; everything world-shaped is here.
|
||||
--
|
||||
-- Two ways the world can sit around a headset, and they mirror the VOXEL
|
||||
-- ladder exactly:
|
||||
--
|
||||
-- DIORAMA every orbit rung. The map is a tabletop miniature: a point
|
||||
-- of the world (the view centre) is pinned VIEW_DIST away
|
||||
-- along the rung's own viewing angle (dioramaAnchor), at the
|
||||
-- scale that reproduces the flat screen's framing
|
||||
-- (dioramaScale) -- so at rest the model presents exactly as
|
||||
-- the standard view does, and the head moves freely around it
|
||||
-- -- lean in and the town grows, walk around the table and
|
||||
-- see the far side of the buildings honest occlusion has been
|
||||
-- hiding.
|
||||
--
|
||||
-- FIRST_PERSON the 1ST rung. The player's head is pinned to where the
|
||||
-- headset started, at FP_SCALE, so a 16-pixel person stands
|
||||
-- about 1.6 m tall and a cell is a stride. The HMD's own
|
||||
-- orientation becomes FirstPerson's yaw and pitch, so movement
|
||||
-- stays "push forward, go where you look" through the same
|
||||
-- FreeMove the flat screen uses.
|
||||
--
|
||||
-- SPACES AND UNITS. OpenXR LOCAL space is metres, +Y up, -Z the way the
|
||||
-- head faced at session start. World space is world PIXELS, +Y up, +Z
|
||||
-- south. The two are aligned axis-for-axis -- "away from you" is north --
|
||||
-- so the whole mapping is one translate-and-scale:
|
||||
--
|
||||
-- worldFromXr(p) = pivot + s * (p - anchor)
|
||||
--
|
||||
-- with `pivot` a world point, `anchor` the LOCAL-space point pinned to it,
|
||||
-- and `s` the scale in px/m. An eye's camera is then
|
||||
--
|
||||
-- worldFromEye = T(pivot) * S(s) * T(-anchor) * T(pose.pos) * R(pose.q)
|
||||
-- view = the same chain inverted piece by rigid piece
|
||||
--
|
||||
-- and the VIEW deliberately ends in METRES: it un-scales the world, so eye
|
||||
-- space -- where the projection's near and far live -- is real-world
|
||||
-- metres whatever the mode's scale. Depth precision and clip planes stay
|
||||
-- sane at both 10 px/m and 128 px/m.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Mat4 = V.require("Mat4")
|
||||
|
||||
local VRRig = {}
|
||||
|
||||
-- first person's life size: 10 px/m makes a 16 px tile a 1.6 m stride
|
||||
VRRig.FP_SCALE = 10
|
||||
|
||||
-- How far the diorama's pivot sits from the resting head, in metres --
|
||||
-- the arm's-length viewing distance the anchor and the scale below are
|
||||
-- both built around.
|
||||
VRRig.VIEW_DIST = 0.95
|
||||
|
||||
-- Where, in LOCAL metres, the diorama's pivot sits: VIEW_DIST away along
|
||||
-- the RUNG'S OWN viewing angle. The flat screen's camera looks at the
|
||||
-- world `a` radians off vertical; putting the pivot at (-d cos a) below
|
||||
-- and (-d sin a) ahead of the resting head reproduces exactly that line
|
||||
-- of sight -- step onto the 35 rung and the table presents at 35 degrees,
|
||||
-- onto 75 and it rises toward eye level, easing between them as the rung
|
||||
-- tween runs. `heightOff` is the grab-drag adjustment, in metres of world
|
||||
-- travel (positive drags the world up).
|
||||
function VRRig.dioramaAnchor(angleRad, heightOff)
|
||||
local d = VRRig.VIEW_DIST
|
||||
return { 0,
|
||||
-d * math.cos(angleRad or 0) + (heightOff or 0),
|
||||
-d * math.sin(angleRad or 0) }
|
||||
end
|
||||
|
||||
-- The diorama's scale, in world px per metre: the one that makes the
|
||||
-- table subtend the same field the flat screen frames. The flat camera
|
||||
-- fits `vh` world pixels in a lens of focal `focal` (Voxel.FOCAL); at
|
||||
-- VIEW_DIST the same framing needs vh * focal / d pixels to the metre --
|
||||
-- so the resting head sees the standard view's angle AND its apparent
|
||||
-- size, and the zoom rows (which change vh) keep working in VR.
|
||||
function VRRig.dioramaScale(vh, focal)
|
||||
return math.max(16, (vh or 288) * (focal or 1) / VRRig.VIEW_DIST)
|
||||
end
|
||||
|
||||
-- kept as the test suite's fixed example anchor, and as the fallback for
|
||||
-- an angle nobody supplied
|
||||
VRRig.TABLE = { 0, -0.45, -0.75 }
|
||||
|
||||
-- ------- the battle mount
|
||||
--
|
||||
-- A staged fight snaps the headset to an OVER-THE-SHOULDER seat: the same
|
||||
-- line the flat battle camera stands on (eye through focus, so the player's
|
||||
-- mon is near-left and the foe far-right exactly as the flat shot frames
|
||||
-- them), but pulled in to BATTLE_DIST -- the flat rig is a long lens from
|
||||
-- fifteen metres back, and a headset's lens is its own eyes, so keeping the
|
||||
-- distance would shrink the fight to a stage seen from the back row. 66 px
|
||||
-- is the wide rig's own standing distance: six and a half metres at life
|
||||
-- scale, close enough to fill the view, far enough to hold both mons in it
|
||||
-- -- and short enough to stay inside the small rooms the wide rig exists
|
||||
-- for.
|
||||
VRRig.BATTLE_DIST = 66
|
||||
|
||||
-- Where the head sits for a staged fight, and which way the mapping must
|
||||
-- turn so that seat FACES it. Returns the pivot (world px -- pin the XR
|
||||
-- origin here at FP_SCALE) and the yaw for eyeCamera: the flat camera
|
||||
-- looks along focus - eye, the resting headset looks along XR -Z (world
|
||||
-- north), and the yaw is what closes that gap.
|
||||
function VRRig.battleMount(eye, focus)
|
||||
local dx = eye[1] - focus[1]
|
||||
local dy = eye[2] - focus[2]
|
||||
local dz = eye[3] - focus[3]
|
||||
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
if len < 1e-6 then return { eye[1], eye[2], eye[3] }, 0 end
|
||||
local k = VRRig.BATTLE_DIST / len
|
||||
-- Ry(yaw) sends XR forward (0,0,-1) to (-sin yaw, 0, -cos yaw); aiming
|
||||
-- that along the horizontal of focus - eye solves to atan2 of eye - focus
|
||||
return { focus[1] + dx * k, focus[2] + dy * k, focus[3] + dz * k },
|
||||
math.atan2(dx, dz)
|
||||
end
|
||||
|
||||
-- eye-space clip planes, in metres (see the unit note above)
|
||||
VRRig.NEAR = 0.05
|
||||
VRRig.FAR = 400
|
||||
|
||||
-- ------- one eye's camera
|
||||
|
||||
-- Build the placed-camera record for one eye.
|
||||
--
|
||||
-- pose { pos = {x,y,z} metres, quat = {x,y,z,w} } (OpenXR LOCAL)
|
||||
-- fov { angleLeft, angleRight, angleUp, angleDown } signed radians
|
||||
-- pivot {x,y,z} world px pinned to `anchor`
|
||||
-- anchor {x,y,z} LOCAL metres (VRRig.TABLE, or 0,0,0 for first person)
|
||||
-- scale world px per metre
|
||||
-- yaw optional turn of the whole mapping about +Y, radians: the
|
||||
-- battle mount faces the resting head at the arena with it.
|
||||
-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor).
|
||||
--
|
||||
-- Returns a table shaped for Voxel3D.camera: raw view + proj, the world
|
||||
-- eye and focus (for setLook, the water's lean, the sky), fov as a
|
||||
-- vertical span, and the curve declined -- a bent tabletop reads as a
|
||||
-- broken model, and first person already declines it on the flat screen.
|
||||
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw)
|
||||
local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3]
|
||||
local q = pose.quat
|
||||
local R = Mat4.fromQuat(q[1], q[2], q[3], q[4])
|
||||
|
||||
-- view = R^T * T(-pos) * T(anchor) * Ry(-yaw) * S(1/s) * T(-pivot)
|
||||
local view = Mat4.mul(Mat4.transpose(R), Mat4.translate(-px, -py, -pz))
|
||||
view = Mat4.mul(view, Mat4.translate(anchor[1], anchor[2], anchor[3]))
|
||||
if yaw and yaw ~= 0 then
|
||||
view = Mat4.mul(view, Mat4.rotateY(-yaw))
|
||||
end
|
||||
view = Mat4.mul(view, Mat4.scale(1 / scale, 1 / scale, 1 / scale))
|
||||
view = Mat4.mul(view, Mat4.translate(-pivot[1], -pivot[2], -pivot[3]))
|
||||
|
||||
local proj = Mat4.fovProjection(fov.angleLeft, fov.angleRight,
|
||||
fov.angleUp, fov.angleDown,
|
||||
VRRig.NEAR, VRRig.FAR)
|
||||
|
||||
-- The eye's RAY FAN, in world axes: the direction a canvas point
|
||||
-- (u, v in 0..1, left-to-right and top-to-bottom) looks along is
|
||||
-- base + u * du + v * dv. The sky reads its per-pixel TRUE elevation
|
||||
-- off this (a real skybox cannot be painted from any per-frame row
|
||||
-- mapping -- that is exact only at the view's own azimuth and swims
|
||||
-- everywhere else). Directions only, so the mapping's scale drops out;
|
||||
-- the yaw must not (the battle mount and the snap turn swing the world).
|
||||
local Rw = R
|
||||
if yaw and yaw ~= 0 then Rw = Mat4.mul(Mat4.rotateY(yaw), R) end
|
||||
local tl, tr = math.tan(fov.angleLeft), math.tan(fov.angleRight)
|
||||
local tu, td = math.tan(fov.angleUp), math.tan(fov.angleDown)
|
||||
-- world columns of the head's rotation: right (X), up (Y), forward (-Z)
|
||||
local rxc, ryc, rzc = Rw[1], Rw[5], Rw[9]
|
||||
local uxc, uyc, uzc = Rw[2], Rw[6], Rw[10]
|
||||
local fxc, fyc, fzc = -Rw[3], -Rw[7], -Rw[11]
|
||||
local skyRay = {
|
||||
base = { fxc + rxc * tl + uxc * tu,
|
||||
fyc + ryc * tl + uyc * tu,
|
||||
fzc + rzc * tl + uzc * tu },
|
||||
du = { rxc * (tr - tl), ryc * (tr - tl), rzc * (tr - tl) },
|
||||
dv = { uxc * (td - tu), uyc * (td - tu), uzc * (td - tu) },
|
||||
}
|
||||
|
||||
-- the eye and its forward, in world pixels: worldFromEye applied to the
|
||||
-- origin and to -Z
|
||||
local ax, ay, az = px - anchor[1], py - anchor[2], pz - anchor[3]
|
||||
-- R's third column is the eye's +Z axis; forward is its negation
|
||||
local fx, fy, fz = -R[3], -R[7], -R[11]
|
||||
if yaw and yaw ~= 0 then
|
||||
local c, s = math.cos(yaw), math.sin(yaw)
|
||||
ax, az = c * ax + s * az, -s * ax + c * az
|
||||
fx, fz = c * fx + s * fz, -s * fx + c * fz
|
||||
end
|
||||
local ex = pivot[1] + scale * ax
|
||||
local ey = pivot[2] + scale * ay
|
||||
local ez = pivot[3] + scale * az
|
||||
|
||||
return {
|
||||
view = view,
|
||||
proj = proj,
|
||||
eye = { ex, ey, ez },
|
||||
focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale },
|
||||
fov = fov.angleUp - fov.angleDown,
|
||||
curve = 0,
|
||||
skyRay = skyRay,
|
||||
}
|
||||
end
|
||||
|
||||
-- The WORLD model matrix a hand-held prop stands on: worldFromXr (the
|
||||
-- same mapping the eyes use -- so the prop is exactly where the hand is,
|
||||
-- whatever mode the mapping is in) composed with the hand's own tracked
|
||||
-- pose. A mesh authored in METRES rides it straight: the mapping's scale
|
||||
-- is what turns metres into world pixels, so the prop keeps its real
|
||||
-- size in the hand at the diorama's scale and at life scale alike.
|
||||
--
|
||||
-- model = T(pivot) * S(s) * Ry(yaw) * T(-anchor) * T(hand.pos) * R(hand.quat)
|
||||
function VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
|
||||
local m = Mat4.translate(pivot[1], pivot[2], pivot[3])
|
||||
m = Mat4.mul(m, Mat4.scale(scale, scale, scale))
|
||||
if yaw and yaw ~= 0 then m = Mat4.mul(m, Mat4.rotateY(yaw)) end
|
||||
m = Mat4.mul(m, Mat4.translate(-anchor[1], -anchor[2], -anchor[3]))
|
||||
m = Mat4.mul(m, Mat4.translate(pose.pos[1], pose.pos[2], pose.pos[3]))
|
||||
local q = pose.quat
|
||||
return Mat4.mul(m, Mat4.fromQuat(q[1], q[2], q[3], q[4]))
|
||||
end
|
||||
|
||||
-- The flat compass numbers a head orientation implies, for driving
|
||||
-- FirstPerson (and through it FreeMove) from the HMD: yaw in this mod's
|
||||
-- convention (0 south, pi/2 east) and pitch positive-down.
|
||||
function VRRig.headYawPitch(quat)
|
||||
local R = Mat4.fromQuat(quat[1], quat[2], quat[3], quat[4])
|
||||
local fx, fy, fz = -R[3], -R[7], -R[11]
|
||||
local flat = math.sqrt(fx * fx + fz * fz)
|
||||
local yaw = flat > 1e-6 and math.atan2(fx, fz) or 0
|
||||
local pitch = -math.asin(math.max(-1, math.min(1, fy)))
|
||||
return yaw, pitch
|
||||
end
|
||||
|
||||
-- The two pivots. First person pins the player's head; the diorama pins
|
||||
-- the view centre at the ground plane. `gh` is the ground height under
|
||||
-- the player (VoxelScene.groundAt), `eyeH` FirstPerson.EYE_HEIGHT.
|
||||
function VRRig.fpPivot(pxTopLeft, pyTopLeft, gh, eyeH)
|
||||
return { pxTopLeft + 8, (gh or 0) + (eyeH or 13), pyTopLeft + 8 }
|
||||
end
|
||||
|
||||
function VRRig.dioramaPivot(cx, cy)
|
||||
return { cx, 0, cy }
|
||||
end
|
||||
|
||||
return VRRig
|
||||
+1073
File diff suppressed because it is too large
Load Diff
+202
-6
@@ -33,6 +33,7 @@ local WorldCurve = V.require("WorldCurve")
|
||||
local Sky = V.require("Sky")
|
||||
local DayNight = V.require("DayNight")
|
||||
local GlassMask = V.require("GlassMask")
|
||||
local PixelCanvas = V.require("PixelCanvas")
|
||||
|
||||
local Voxel3D = {}
|
||||
|
||||
@@ -424,6 +425,12 @@ end
|
||||
-- { 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
|
||||
-- their own map ever needs. A staged shot -- the overworld battle's
|
||||
@@ -438,6 +445,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
|
||||
@@ -484,6 +498,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]
|
||||
@@ -497,6 +520,35 @@ 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)
|
||||
-- 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
|
||||
@@ -505,6 +557,10 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
|
||||
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
|
||||
@@ -572,6 +628,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
|
||||
@@ -616,12 +722,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
|
||||
@@ -644,7 +821,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
|
||||
local name = slot or "world"
|
||||
local slotHeld = slots[name]
|
||||
if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then
|
||||
local ok, c = pcall(love.graphics.newCanvas, w, h)
|
||||
local ok, c = PixelCanvas.new(w, h)
|
||||
if not ok then return false end
|
||||
c:setFilter("nearest", "nearest")
|
||||
if slotHeld then releaseSlot(slotHeld) end
|
||||
@@ -676,10 +853,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
|
||||
@@ -692,8 +879,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
|
||||
@@ -1166,6 +1359,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()
|
||||
|
||||
+175
-18
@@ -25,6 +25,8 @@ 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")
|
||||
|
||||
@@ -224,8 +226,16 @@ end
|
||||
-- reads its own shadowing with must describe the same frame, or the
|
||||
-- mirror-flip half of the pair asks the map about texels the sun filed
|
||||
-- under the other cheek.
|
||||
-- The player's own card asks a different function for the same answer:
|
||||
-- their body's bearing is what the camera is derived FROM, so it is known
|
||||
-- continuously rather than as one of four directions, and measuring
|
||||
-- against the compass point instead flicks the card to a profile for a
|
||||
-- frame or two when the camera is spun fast (see playerFacing).
|
||||
local function viewFacing(p)
|
||||
if FirstPerson.cardBlend() > 0.5 then
|
||||
if p.isPlayer then
|
||||
return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
|
||||
end
|
||||
return p.facing
|
||||
@@ -261,21 +271,32 @@ end
|
||||
-- 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 b = FirstPerson.cardBlend()
|
||||
local m = Mat4.translate(px + 8, y, py + 8)
|
||||
if b > 0 then
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
|
||||
end
|
||||
m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
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
|
||||
@@ -294,7 +315,6 @@ end
|
||||
-- his edge would swing him off his seat. The width rode in on the record
|
||||
-- for exactly this (ChunkMesher.buildFigureMeshes).
|
||||
local function figureMatrix(f, offX, offZ)
|
||||
local Voxel = V.require("VoxelState")
|
||||
local b = FirstPerson.cardBlend()
|
||||
local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0)
|
||||
local m = Mat4.translate(wx, f.y, wz)
|
||||
@@ -304,7 +324,7 @@ local function figureMatrix(f, offX, offZ)
|
||||
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b))
|
||||
m = Mat4.mul(m, Mat4.translate(-half, 0, 0))
|
||||
end
|
||||
return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b)))
|
||||
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
|
||||
@@ -772,9 +792,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
|
||||
|
||||
@@ -835,12 +858,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
|
||||
@@ -890,8 +925,30 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- exactly what it always was. The scene centre it returns walks from
|
||||
-- the orbit's view centre into the head, so the curve's focus and the
|
||||
-- depth reference follow the camera actually in charge.
|
||||
local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh)
|
||||
if fpRig then cx, cy = fpCx, fpCy end
|
||||
--
|
||||
-- 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
|
||||
@@ -899,11 +956,13 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
|
||||
-- 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)
|
||||
water, nbWater, battleCards, battleToken)
|
||||
|
||||
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
||||
return nil
|
||||
end
|
||||
-- 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
|
||||
@@ -991,14 +1050,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),
|
||||
@@ -1014,7 +1113,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,
|
||||
@@ -1025,7 +1124,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
|
||||
|
||||
+32
-14
@@ -33,17 +33,18 @@ local Voxel = {}
|
||||
-- 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.
|
||||
--
|
||||
-- 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 }
|
||||
-- 1ST and 3RD are the other rungs that are more than an angle: the camera
|
||||
-- steps off its orbit entirely and stands with the player -- in their eyes
|
||||
-- (lib/FirstPerson.lua), or on a boom behind their shoulder
|
||||
-- (lib/ThirdPerson.lua) -- with free look and free movement on both. Their
|
||||
-- ANGLE entries are 75 -- the orbit rung they hand over from -- because the
|
||||
-- tween in and out starts from whatever the orbit shows, and the lowest rung
|
||||
-- is the one a dive into a head should start from. Everything angle-derived
|
||||
-- (the sky's fade, the billboard lean the blend eases away) reads that 75
|
||||
-- while the free-roam rig owns the actual camera.
|
||||
Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75, 75 }
|
||||
Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75",
|
||||
"1ST (EXPERIMENTAL)" }
|
||||
"1ST (EXPERIMENTAL)", "3RD (EXPERIMENTAL)" }
|
||||
Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1
|
||||
|
||||
-- the rung FULL sits on, so nothing has to hunt for it by label
|
||||
@@ -60,6 +61,23 @@ function Voxel.isFirstPerson(level)
|
||||
return (level or Voxel.level) == Voxel.FP_LEVEL
|
||||
end
|
||||
|
||||
-- and the third-person one, which is the same rig with the eye boomed off
|
||||
-- the back of the head (lib/ThirdPerson.lua)
|
||||
Voxel.TP_LEVEL = 7
|
||||
|
||||
function Voxel.isThirdPerson(level)
|
||||
return (level or Voxel.level) == Voxel.TP_LEVEL
|
||||
end
|
||||
|
||||
-- The two of them together: the rungs where the camera stands WITH the
|
||||
-- player rather than orbiting the view centre, which is what decides that
|
||||
-- the look inputs are read, the walk goes free and the cards turn to face
|
||||
-- the eye. Everything that used to ask isFirstPerson for those asks this.
|
||||
function Voxel.isFreeCam(level)
|
||||
level = level or Voxel.level
|
||||
return Voxel.isFirstPerson(level) or Voxel.isThirdPerson(level)
|
||||
end
|
||||
|
||||
-- ------- what the hotkey walks
|
||||
--
|
||||
-- The ANGLE rungs only, with FULL left out. The key is a display-mode
|
||||
@@ -69,11 +87,11 @@ end
|
||||
-- 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.
|
||||
--
|
||||
-- 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
|
||||
-- 1ST and 3RD are on the path: they change the camera and only the camera,
|
||||
-- which is exactly what the key promises -- and the key is also the way back
|
||||
-- OUT of them on a keyboard, where the mouse is captured and the OPTIONS
|
||||
-- menu is a trip.
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST
|
||||
Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6, 7 } -- OFF,15,35,50,75,1ST,3RD
|
||||
|
||||
-- The rung a press moves to from `level`.
|
||||
--
|
||||
|
||||
@@ -24,10 +24,11 @@
|
||||
-- below. This file declares; lib/ draws.
|
||||
--
|
||||
-- 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.
|
||||
-- nothing about what it IS. TWO rungs are the deliberate exception. 1ST
|
||||
-- (the camera in the player's own eyes) and 3RD (the same rig, boomed back
|
||||
-- behind their shoulder) replace the grid WALK with a free,
|
||||
-- camera-relative one while either is selected (lib/FreeMove.lua), because
|
||||
-- a camera 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
|
||||
@@ -92,6 +93,15 @@ local Water = V.require("Water")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local FreeMove = V.require("FreeMove")
|
||||
local CamControl = V.require("CamControl")
|
||||
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.
|
||||
@@ -190,6 +200,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
|
||||
@@ -200,6 +216,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
|
||||
@@ -211,6 +234,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
|
||||
@@ -235,6 +271,12 @@ mod.content.render_pipelines:register("voxel", {
|
||||
-- pixels, so only the scale needs saying.
|
||||
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
|
||||
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
|
||||
-- and back to the window's own size, which is what the engine composites
|
||||
@@ -247,6 +289,7 @@ mod.content.render_pipelines:register("voxel", {
|
||||
OverworldBattle.invalidate()
|
||||
AntiAlias.invalidate()
|
||||
ChunkMesher.invalidate() -- no map id = every cached mesh
|
||||
VR.invalidate() -- the mirror, and FBO ids of dead canvases
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -373,10 +416,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.
|
||||
@@ -384,7 +431,8 @@ 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 "
|
||||
@@ -405,11 +453,40 @@ local SETTINGS = {
|
||||
.. "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)
|
||||
|
||||
@@ -456,14 +533,68 @@ 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()
|
||||
-- Q and E work whichever camera is in front of the player -- the
|
||||
-- battle's lens, the third-person boom, or the engine's own survey
|
||||
-- zoom on an orbit rung. CamControl answers which, and answers "none"
|
||||
-- for 1ST and for every screen with no camera of ours behind it, in
|
||||
-- which case the key falls through untouched. Ahead of the hotkey
|
||||
-- table because unlike those it is NOT free-roam only: a staged battle
|
||||
-- is exactly where the zoom is most wanted.
|
||||
if (key == "q" or key == "e")
|
||||
and not (top and top.onKeyPressed) then
|
||||
if CamControl.zoomBy(key == "q" and 1 or -1) then return end
|
||||
end
|
||||
-- A screen with its own key handler gets the key first, exactly as the
|
||||
-- engine's first branch does: typing a nickname must not toggle a
|
||||
-- render mode. Only free-roam presses are ours to take.
|
||||
@@ -472,32 +603,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
|
||||
@@ -580,6 +691,25 @@ end
|
||||
--
|
||||
-- Everything they did is still reachable: uninstall the mod and both rows are
|
||||
-- back, at whatever they were last set to.
|
||||
-- BATTLE BG rides the same reasoning, and comes off for a reason of its own.
|
||||
-- The row picks what fills the screen AROUND the battle's 160x144 field --
|
||||
-- WHITE paper, BLACK bars, or the frozen overworld dimmed behind it -- and
|
||||
-- all three were answers to the same question: what to do with the voids,
|
||||
-- given the battle is a small picture in the middle of a big window.
|
||||
--
|
||||
-- This mod answers that question differently and permanently. A staged fight
|
||||
-- fills the whole window with the map the fight is standing on, and the
|
||||
-- flat battle screen it composites over it is drawn on the mode's own
|
||||
-- surface; there are no voids left for the row to fill. WORLD is the worst
|
||||
-- of the three under it -- it makes the battle non-opaque so the engine
|
||||
-- draws the overworld underneath, which is a SECOND copy of the world drawn
|
||||
-- under the one the arena pass already put there, dimmed and at a different
|
||||
-- camera. BLACK bars over a diorama read as a letterboxed screenshot.
|
||||
--
|
||||
-- So the value is pinned at WHITE, which is the one the mode was composed
|
||||
-- against, and the row comes off the menu on the same reasoning as TILT and
|
||||
-- GBC FX: a row that no longer decides anything is worse than no row.
|
||||
-- Uninstall the mod and it is back, at whatever it was last set to.
|
||||
local function pinEngineFx(game)
|
||||
game = game or require("src.core.Game")
|
||||
local opts = game and game.save and game.save.options
|
||||
@@ -588,7 +718,9 @@ local function pinEngineFx(game)
|
||||
local changed = false
|
||||
if opts then
|
||||
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
|
||||
or (opts.battleBg or "white") ~= "white"
|
||||
opts.tilt, opts.gbcfx = 0, 0
|
||||
opts.battleBg = "white"
|
||||
end
|
||||
pcall(Tilt.setLevel, 0)
|
||||
pcall(GBCFX.setLevel, 0)
|
||||
@@ -606,6 +738,10 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||
pinEngineFx(game)
|
||||
dropRow(out, "tilt")
|
||||
dropRow(out, "gbcfx")
|
||||
-- and BATTLE BG with them: this mode fills the window with the map, so
|
||||
-- the row's whole question -- what to put in the voids around the battle
|
||||
-- -- no longer has voids to be about (see pinEngineFx)
|
||||
dropRow(out, "battleBg")
|
||||
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
|
||||
-- one away. While a fight can be staged on the map, OG is the only layout it
|
||||
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
|
||||
@@ -772,12 +908,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:
|
||||
@@ -803,9 +943,11 @@ end
|
||||
-- so this file keeps naming every engine seam the mod touches.
|
||||
OverworldBattle.install()
|
||||
|
||||
-- ------- the first-person rung's inputs and its walk
|
||||
-- ------- the free-roam rungs' inputs and their walk
|
||||
--
|
||||
-- 1ST needs two things no other rung does, and each is a named seam:
|
||||
-- 1ST and 3RD need two things no other rung does, and each is a named seam.
|
||||
-- Both rungs are one rig -- the boom behind the shoulder is a number inside
|
||||
-- it (lib/ThirdPerson.lua) -- so both are installed by the same two calls:
|
||||
--
|
||||
-- FirstPerson.install claims the LOOK inputs the engine ignores: the right
|
||||
-- stick's axes (Game:gamepadaxis passes them to Input, which returns early
|
||||
@@ -817,11 +959,11 @@ OverworldBattle.install()
|
||||
-- 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.
|
||||
-- only while one of the two rungs 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
|
||||
-- Cycling Road pull lives behind. While either 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
|
||||
@@ -829,6 +971,83 @@ OverworldBattle.install()
|
||||
FirstPerson.install()
|
||||
FreeMove.install()
|
||||
|
||||
-- ------- the zooms, and the battle camera the player can steer
|
||||
--
|
||||
-- CamControl claims the wheel, Q/E, the mouse and the touch screen for
|
||||
-- whichever camera is actually in front of the player -- the staged
|
||||
-- battle's, the third-person boom, or the engine's own survey zoom -- and
|
||||
-- forwards everything else. Installed AFTER the two above deliberately: a
|
||||
-- wrap installed later is the OUTER one, so a fight gets first refusal on
|
||||
-- the mouse and the fingers, which is right, because while one is staged
|
||||
-- the free-roam look is not driving.
|
||||
CamControl.install()
|
||||
|
||||
-- ------- 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
|
||||
@@ -930,7 +1149,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
||||
return DayNight.tod()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.5.0"
|
||||
mod.exports.version = "1.5.5"
|
||||
-- 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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.5",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
@@ -16,20 +16,32 @@ 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",
|
||||
"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",
|
||||
"SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched",
|
||||
"on the 1ST and 3RD rungs ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
|
||||
"on the 1ST and 3RD rungs the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
|
||||
"on the 3RD rung the character turns to face where they are walking rather than where the camera looks, so a strafe reads as one; standing still they come back round to the camera's bearing, which is the one A talks along",
|
||||
"on 1ST and 3RD the wall-collision sound is gone: a free walk slides along every wall it grazes rather than refusing a discrete step, so the bonk rang twice a second for walking down a corridor",
|
||||
"the BATTLE BG options row is taken OFF the menu and pinned to WHITE for as long as this mod is installed -- the mode fills the window with the map, so the row's own question (what to put in the voids around the battle) has no voids left to be about, and its WORLD setting drew a second dimmed copy of the overworld under the arena; uninstalling puts the row back",
|
||||
"a staged battle's camera can be steered by the player -- right stick, touch drag or mouse to swing it around the arena and raise it, wheel / Q / E / pinch / stick click for the lens -- between the shot the rig was solved for and a side-on view of the arena, and it opens the lens as it goes so both Pokemon stay framed; the angle and lens carry into the next battle",
|
||||
"with BACK SPRITES on the battle camera is held at the solved shot, because that setting pins your own Pokemon to the menu's slot while the foe stands on the map and no angle holds a half-framed, half-solid composition",
|
||||
},
|
||||
added = {
|
||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)",
|
||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST / 3RD -- a first-person camera and a third-person one, both with free look and free movement)",
|
||||
"T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur",
|
||||
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
|
||||
"Q and E zoom whichever camera is in front of you -- the third-person boom, a staged battle's lens, or the engine's own survey zoom on an orbit rung -- alongside the mouse wheel, a two-finger pinch, and the pad's left and right stick clicks (out and in). 1ST claims none of them: the eye is in the player's head and there is no distance to change",
|
||||
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
|
||||
"3D-BTL on hotkey 8 (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",
|
||||
@@ -41,13 +53,25 @@ 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",
|
||||
"1ST and 3RD need the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
|
||||
"in 1ST and 3RD, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
|
||||
"3RD's boom shortens against whatever stands behind the player, so backing into a wall walks the camera in to their shoulders; squeezed all the way in it draws as 1ST until they step clear",
|
||||
"3RD in VR is 1ST in VR: a headset that seats its wearer three cells behind their own body is a well-known way to make people ill, so the boom is declined while a headset is live",
|
||||
"rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it",
|
||||
"VR is Windows x64 only (the shipped loader and the Win32 GL binding): on any other platform -- mobile above all -- the VR row is absent from the OPTIONS menu and the manager's page both, and a stored vr=true carried over in a save is ignored. On Windows it renders the scene once per eye (heavy with WATER FULL or AA up); pad/keyboard/mouse keep working alongside the XR controllers. The loader DLL is found wherever the mod was put -- the dev tree, an installed release's save directory, or an imported archive, from which it is copied once into the save directory so the FFI has a real disk path",
|
||||
"VR on and off are both the VR row's job (options menu or manager); no controller button does either",
|
||||
"the Pokedex needs a TRACKED left controller; without one there is no device in hand and the floating panel carries the UI as before",
|
||||
"while the VR row is ON, 3D-BTL is held ON and BACK SPRITES held OFF (the headset's battle staging assumes both), and both rows leave the OPTIONS menu until VR goes off -- their stored values come back with them",
|
||||
"while a headset is live the battle HUDs keep their classic in-frame slots instead of snapping to the window's edges, and the engine's edge-anchored menus (the START menu above all) are held inside the frame the same way, on the flat mirror too -- both VR screens crop to the GB frame, and a block at the window's edge would be cropped away with it",
|
||||
"VR swapchains prefer plain RGBA8; a runtime that only offers sRGB shows slightly lifted colours",
|
||||
"HORDE MODE needs the 3D pass, like every rung that has a camera in it: without one the code is refused and nothing happens",
|
||||
"the horde's crowd are the overworld's own sprite billboards, so at close range they are flat cards the size of a person -- they hold two cells off the player for that reason, close enough to swing and far enough to be seen past",
|
||||
"a run is not saved: the score and the best score ride the save slot, but the mode itself has no state to resume, and quitting mid-run simply ends it",
|
||||
},
|
||||
},
|
||||
credits = {
|
||||
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
|
||||
{ who = "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 },
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>OpenXR.Loader</id>
|
||||
<version>1.0.10.2</version>
|
||||
<authors>Khronos Group</authors>
|
||||
<owners>Khronos Group</owners>
|
||||
<requireLicenseAcceptance>false</requireLicenseAcceptance>
|
||||
<license type="expression">Apache-2.0</license>
|
||||
<licenseUrl>https://licenses.nuget.org/Apache-2.0</licenseUrl>
|
||||
<projectUrl>https://github.com/KhronosGroup/OpenXR-SDK</projectUrl>
|
||||
<description>Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application</description>
|
||||
<tags>native khronos openxr loader headers</tags>
|
||||
<dependencies>
|
||||
<dependency id="OpenXR.Headers" version="1.0.10.2" />
|
||||
</dependencies>
|
||||
</metadata>
|
||||
</package>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
|
||||
<Default Extension="psmdcp" ContentType="application/vnd.openxmlformats-package.core-properties+xml" />
|
||||
<Default Extension="props" ContentType="application/octet" />
|
||||
<Default Extension="targets" ContentType="application/octet" />
|
||||
<Default Extension="dll" ContentType="application/octet" />
|
||||
<Default Extension="lib" ContentType="application/octet" />
|
||||
<Default Extension="nuspec" ContentType="application/octet" />
|
||||
</Types>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Type="http://schemas.microsoft.com/packaging/2010/07/manifest" Target="/OpenXR.Loader.nuspec" Id="R0D169365D22F5E6F" />
|
||||
<Relationship Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="/package/services/metadata/core-properties/948f85fa57f345119150f5525085c62c.psmdcp" Id="R10D7CDDCA5670667" />
|
||||
</Relationships>
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<OpenXRLoaderPackageRoot>$(MSBuildThisFileDirectory)..\..\</OpenXRLoaderPackageRoot>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
|
||||
<Choose>
|
||||
<When Condition="'$(ApplicationType)|$(ApplicationTypeRevision)' == 'Windows Store|10.0'">
|
||||
<PropertyGroup>
|
||||
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)_uwp\release</OpenXRLoaderBinaryRoot>
|
||||
</PropertyGroup>
|
||||
</When>
|
||||
<Otherwise>
|
||||
<PropertyGroup>
|
||||
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)\release</OpenXRLoaderBinaryRoot>
|
||||
</PropertyGroup>
|
||||
</Otherwise>
|
||||
</Choose>
|
||||
|
||||
<ItemDefinitionGroup>
|
||||
<Link>
|
||||
<AdditionalDependencies>%(AdditionalDependencies);$(OpenXRLoaderBinaryRoot)\lib\openxr_loader.lib</AdditionalDependencies>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
<!-- Copy the OpenXR loader DLL to the output directory and include in packaging -->
|
||||
<ItemGroup Condition="'$(OpenXRSkipLoaderCopy)'!='true'">
|
||||
<None Include="$(OpenXRLoaderBinaryRoot)\bin\openxr_loader.dll">
|
||||
<Link>%(Filename)%(Extension)</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<DeploymentContent>true</DeploymentContent>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="EnsurePropsImported" BeforeTargets="PrepareForBuild">
|
||||
<Error Condition="'$(OpenXRLoaderPackageRoot)'==''" Text="OpenXRLoaderPackageRoot property missing. Project is malformed. Try removing and re-adding the NuGet reference." />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<coreProperties xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.openxmlformats.org/package/2006/metadata/core-properties">
|
||||
<dc:creator>Khronos Group</dc:creator>
|
||||
<dc:description>Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application</dc:description>
|
||||
<dc:identifier>OpenXR.Loader</dc:identifier>
|
||||
<version>1.0.10.2</version>
|
||||
<keywords>native khronos openxr loader headers</keywords>
|
||||
<lastModifiedBy>NuGet, Version=5.4.0.3, Culture=neutral, PublicKeyToken=31bf3856ad364e35;Microsoft Windows NT 6.2.9200.0;.NET Framework 4.7.2</lastModifiedBy>
|
||||
</coreProperties>
|
||||
@@ -0,0 +1,211 @@
|
||||
-- Scratch driver: the cameras the player steers -- the third-person boom's
|
||||
-- zoom, and the battle shot's orbit, climb and lens, including the far
|
||||
-- stops (side-on, 45 degrees up) and what BACK SPRITES takes away.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cam_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/camshots 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/cam")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[cam] DRAMATIC_SHAPE mod not loaded")
|
||||
return love.event.quit()
|
||||
end
|
||||
local V = handle.lib
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local ThirdPerson = V.require("ThirdPerson")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local OverworldBattle = V.require("OverworldBattle")
|
||||
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
|
||||
-- The flower's animation frame, pinned per shot rather than left to the
|
||||
-- clock: the mesh spans the union of every frame and each one is cut back
|
||||
-- out in texture space, so a gap that only opens on frame 2 is invisible
|
||||
-- to a driver that always photographs frame 0 -- which is exactly how the
|
||||
-- first cut of the closed sides shipped looking correct.
|
||||
local ANIM = { frame = 0 }
|
||||
TileRenderer.animFrame = function() return ANIM.frame end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
local shots = 0
|
||||
local function shot(name)
|
||||
if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then
|
||||
shots = shots + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the boom's zoom
|
||||
U.teleport(game, "PALLET_TOWN", 13, 14, "down")
|
||||
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
for _ = 1, 200 do
|
||||
if FirstPerson.blend >= 1 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
FirstPerson.yaw = math.pi
|
||||
U.wait(20)
|
||||
for _, z in ipairs({ "min", "default", "max" }) do
|
||||
ThirdPerson.zoomGoal = (z == "min" and ThirdPerson.ZOOM_MIN)
|
||||
or (z == "max" and ThirdPerson.ZOOM_MAX) or 1
|
||||
for _ = 1, 90 do U.wait(1) end
|
||||
print(("[cam] boom %-8s zoom %.2f len %.1f"):format(z, ThirdPerson.zoom,
|
||||
ThirdPerson.len))
|
||||
shot("boom_" .. z)
|
||||
end
|
||||
ThirdPerson.zoomGoal = 1
|
||||
U.wait(60)
|
||||
|
||||
-- ------- the standees' closed sides
|
||||
--
|
||||
-- Pallet's flower beds and Route 1's tall grass, from a low camera close
|
||||
-- in -- the angle that showed the slabs were open at the ends of every
|
||||
-- run and let you see straight through them.
|
||||
ThirdPerson.zoomGoal = ThirdPerson.ZOOM_MIN
|
||||
for _, s in ipairs({
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
|
||||
pitch = math.rad(6), label = "flowers_low" },
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = 3 * math.pi / 4,
|
||||
pitch = math.rad(30), label = "flowers_angled" },
|
||||
{ map = "ROUTE_1", x = 10, y = 28, yaw = math.pi,
|
||||
pitch = math.rad(8), label = "grass_low" },
|
||||
}) do
|
||||
U.teleport(game, s.map, s.x, s.y, "down")
|
||||
settle()
|
||||
FirstPerson.yaw, FirstPerson.pitch = s.yaw, s.pitch
|
||||
U.wait(60)
|
||||
-- EVERY animation frame, because a gap the union closed and a frame
|
||||
-- reopens is only visible on that frame
|
||||
for f = 0, 3 do
|
||||
ANIM.frame = f
|
||||
U.wait(6)
|
||||
shot(("%s_f%d"):format(s.label, f))
|
||||
end
|
||||
ANIM.frame = 0
|
||||
end
|
||||
ThirdPerson.zoomGoal = 1
|
||||
U.wait(60)
|
||||
|
||||
-- ------- the battle shot
|
||||
--
|
||||
-- Staged the way the game stages one, then steered to each stop with the
|
||||
-- module's own entry points -- the same ones the wheel, the stick and a
|
||||
-- drag reach.
|
||||
Pipelines.setLevel("voxel", 4)
|
||||
U.wait(60)
|
||||
do
|
||||
-- Somebody to fight WITH: a fresh driver save has an empty party, and
|
||||
-- a trainer battle with nobody to send out ends on the frame it starts
|
||||
-- -- which is what made every steered shot below sample a dead session.
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 45),
|
||||
Pokemon.new(game.data, "PIKACHU", 40),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local class = next(game.data.trainers)
|
||||
local battle = BattleState.newTrainer(game, class, 1)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
end
|
||||
-- The wipe, then just enough of the send-out chatter to get both mons
|
||||
-- standing on the arena -- and NOT one tap more. A is also FIGHT and then
|
||||
-- the first move, so tapping past the intro starts an exchange and the
|
||||
-- fight can be over before the camera has been steered anywhere.
|
||||
U.wait(70)
|
||||
for _ = 1, 40 do
|
||||
if OverworldBattle.shot() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
U.wait(60)
|
||||
print(("[cam] staged: arena %s shot %s top %s")
|
||||
:format(tostring(OverworldBattle.arena() ~= nil),
|
||||
tostring(OverworldBattle.shot() ~= nil),
|
||||
tostring(game.stack:top() ~= game.overworld)))
|
||||
if not OverworldBattle.shot() then
|
||||
print("[cam] no staged battle -- skipping the battle shots")
|
||||
print(("[cam] %d shots into %s"):format(shots, ROOT))
|
||||
return love.event.quit()
|
||||
end
|
||||
|
||||
local function settleCam(label)
|
||||
for _ = 1, 120 do U.wait(1) end
|
||||
print(("[cam] battle %-20s orbit %.2f/%.2f pitch %.2f/%.2f "
|
||||
.. "zoom %.2f/%.2f steerable %s live %s")
|
||||
:format(label, BattleCam.orbit, BattleCam.orbitGoal,
|
||||
BattleCam.pitch, BattleCam.pitchGoal,
|
||||
BattleCam.zoom, BattleCam.zoomGoal,
|
||||
tostring(BattleCam.steerable),
|
||||
tostring(OverworldBattle.shot() ~= nil)))
|
||||
shot("battle_" .. label)
|
||||
end
|
||||
|
||||
BattleCam.recentre()
|
||||
settleCam("home")
|
||||
|
||||
-- right to the stop: this must land SQUARE to the arena's axis
|
||||
BattleCam.dragOrbit(10)
|
||||
settleCam("side_on")
|
||||
|
||||
-- and there is nothing to the left of home
|
||||
BattleCam.recentre()
|
||||
BattleCam.dragOrbit(-10)
|
||||
settleCam("left_stop")
|
||||
|
||||
-- up to the stop, and refusing to go below home
|
||||
BattleCam.recentre()
|
||||
BattleCam.dragPitch(10)
|
||||
settleCam("high")
|
||||
BattleCam.recentre()
|
||||
BattleCam.dragPitch(-10)
|
||||
settleCam("low_stop")
|
||||
|
||||
-- the lens at both ends
|
||||
BattleCam.recentre()
|
||||
for _ = 1, 40 do BattleCam.stepZoom(-1) end
|
||||
settleCam("zoom_in")
|
||||
BattleCam.recentre()
|
||||
for _ = 1, 40 do BattleCam.stepZoom(1) end
|
||||
settleCam("zoom_out")
|
||||
|
||||
-- everything at once, which is the shot a player would actually build
|
||||
BattleCam.recentre()
|
||||
BattleCam.dragOrbit(0.55)
|
||||
BattleCam.dragPitch(0.5)
|
||||
for _ = 1, 4 do BattleCam.stepZoom(-1) end
|
||||
settleCam("steered")
|
||||
|
||||
-- What BACK SPRITES takes away is NOT shot here: the flag it works
|
||||
-- through is re-derived from the row every frame
|
||||
-- (OverworldBattle.update), so a driver cannot hold it down for the
|
||||
-- hundred-odd frames a settled shot needs, and a picture that claimed to
|
||||
-- show a locked camera while the camera was in fact free would be worse
|
||||
-- than no picture. The suite asserts it instead, on both axes and the
|
||||
-- lens, and on the RIG as well as on the inputs.
|
||||
BattleCam.recentre()
|
||||
|
||||
print(("[cam] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
+1295
-9
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,329 @@
|
||||
-- Scratch driver: HORDE MODE, from the code to the card.
|
||||
--
|
||||
-- Enters the konami code the way a player does -- as Game Boy button
|
||||
-- edges on the fixed step, which is the same path the pad, the touch
|
||||
-- overlay and the VR controllers take -- then photographs the intro
|
||||
-- banner, the darkened sky, the gun at the hip and down the sights, a
|
||||
-- wave mid-chase, the muzzle flash, the reload, a run through a door with
|
||||
-- the crowd behind, and the GAME OVER card. Finishes by pressing A and
|
||||
-- checking that everything the mode changed came back.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/horde_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/horde lovec.exe .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/horde")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[horde] DRAMATIC_SHAPE mod not loaded")
|
||||
return love.event.quit()
|
||||
end
|
||||
local V = handle.lib
|
||||
local Horde = V.require("Horde")
|
||||
local Mobs = V.require("HordeMobs")
|
||||
local Gun = V.require("HordeGun")
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
|
||||
local shots = 0
|
||||
local function shot(name)
|
||||
if U.shot(game, ("%s/%s.png"):format(ROOT, name)) then
|
||||
shots = shots + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function settle(frames)
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(frames or 30)
|
||||
end
|
||||
|
||||
-- ------- the code, entered as edges
|
||||
--
|
||||
-- One button per fixed step, exactly as a device delivers them: the
|
||||
-- driver writes the queue Input:step is about to drain, which is the
|
||||
-- same array a keypress lands in.
|
||||
--
|
||||
-- Each one is RELEASED after. A synthetic inject has no source behind
|
||||
-- it, so Input:step latches it held (see the branch there) and nothing
|
||||
-- ever lets go -- which leaves all four directions down for the rest of
|
||||
-- the run and the player walking into a wall forever. U.tap does the
|
||||
-- same clear for the same reason.
|
||||
local function pressCode()
|
||||
local KONAMI = { "up", "up", "down", "down",
|
||||
"left", "right", "left", "right", "b", "a" }
|
||||
for _, btn in ipairs(KONAMI) do
|
||||
table.insert(game.input.pressQueue, btn)
|
||||
U.wait(2)
|
||||
game.input.state[btn] = false
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- before
|
||||
U.teleport(game, "PALLET_TOWN", 13, 14, "down")
|
||||
Pipelines.setLevel("voxel", 3) -- the 35-degree diorama
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle(40)
|
||||
local ow = game.stack:top()
|
||||
local p = ow.player
|
||||
local was = {
|
||||
map = ow.map.id, cellX = p.cellX, cellY = p.cellY, facing = p.facing,
|
||||
level = Pipelines.level("voxel"),
|
||||
npcs = #ow.npcs,
|
||||
}
|
||||
shot("00_before")
|
||||
print(("[horde] before: %s (%d,%d) rung %d, %d npcs")
|
||||
:format(was.map, was.cellX, was.cellY, was.level, was.npcs))
|
||||
|
||||
-- ------- the code lands
|
||||
pressCode()
|
||||
U.wait(4)
|
||||
print("[horde] active: " .. tostring(Horde.active)
|
||||
.. " state: " .. tostring(Horde.state))
|
||||
if not Horde.active then
|
||||
print("[horde] the code did not take -- nothing else here can run")
|
||||
return love.event.quit()
|
||||
end
|
||||
U.wait(20)
|
||||
shot("01_darkness_approaches") -- the banner, over the darkening
|
||||
|
||||
-- and the same announcement at the zoom that used to run it off both
|
||||
-- edges of the screen: it has to wrap (or shrink) rather than overflow
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 3
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
Horde.banner("A DARKNESS APPROACHES", 2.6)
|
||||
U.wait(30)
|
||||
shot("01b_banner_zoomed")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 0
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
U.wait(10)
|
||||
print(("[horde] rung is now %d (%s)"):format(Pipelines.level("voxel"),
|
||||
Pipelines.levelLabel("voxel")))
|
||||
|
||||
-- the rung must be locked: the key, SELECT and the VR click all call the
|
||||
-- one function this refuses through
|
||||
local Game = require("src.core.Game")
|
||||
Game.keypressed(game, "3")
|
||||
print(("[horde] after pressing 3 the rung is %d -- locked: %s")
|
||||
:format(Pipelines.level("voxel"),
|
||||
tostring(Pipelines.level("voxel") == Voxel.FP_LEVEL)))
|
||||
|
||||
-- ------- the first wave
|
||||
for _ = 1, 900 do
|
||||
if Horde.state == "active" and #Horde.session.mobs >= 5 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
FirstPerson.yaw = math.pi -- look north up the street
|
||||
U.wait(30)
|
||||
shot("02_wave_hip")
|
||||
print(("[horde] wave %d, %d mobs standing")
|
||||
:format(Horde.session.wave, #Horde.session.mobs))
|
||||
|
||||
-- ------- START asks the way out
|
||||
--
|
||||
-- The same GB button the pad's START, the keyboard's ESCAPE and the
|
||||
-- touch overlay all press, so this one tap covers every device except
|
||||
-- the VR stick click (which calls the same Horde.askExit).
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local prompt = game.stack:top()
|
||||
print(("[horde] START opened a prompt: %s (still active: %s)")
|
||||
:format(tostring(prompt ~= game.overworld), tostring(Horde.active)))
|
||||
shot("02b_exit_prompt")
|
||||
-- NO, and back to the fight
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
print(("[horde] answered NO: back on the overworld %s, active %s")
|
||||
:format(tostring(game.stack:top() == game.overworld),
|
||||
tostring(Horde.active)))
|
||||
|
||||
-- ------- the sights
|
||||
Gun.setAds(true)
|
||||
U.wait(20)
|
||||
shot("03_ads")
|
||||
Gun.setAds(false)
|
||||
U.wait(14)
|
||||
|
||||
-- ------- the shot
|
||||
--
|
||||
-- Aimed deliberately at the nearest mob rather than fired into the
|
||||
-- street: what is being checked is that the ray finds a body, that the
|
||||
-- body dies, and that the kill is worth something.
|
||||
local function aimAtNearest()
|
||||
local best, bestD
|
||||
for _, e in ipairs(Horde.session.mobs) do
|
||||
local dx = (e.npc.px + 8) - (p.px + 8)
|
||||
local dz = (e.npc.py + 8) - (p.py + 8)
|
||||
local d = dx * dx + dz * dz
|
||||
if not bestD or d < bestD then best, bestD = e, d end
|
||||
end
|
||||
if not best then return nil end
|
||||
local dx = (best.npc.px + 8) - (p.px + 8)
|
||||
local dz = (best.npc.py + 8) - (p.py + 8)
|
||||
FirstPerson.yaw = math.atan2(dx, dz)
|
||||
FirstPerson.pitch = 0
|
||||
return best, math.sqrt(bestD)
|
||||
end
|
||||
|
||||
local scoreWas, killsWas = Horde.session.score, Horde.session.kills
|
||||
local target, range = aimAtNearest()
|
||||
print(("[horde] aiming at a mob %s px away, yaw %.2f")
|
||||
:format(range and ("%.0f"):format(range) or "?", FirstPerson.yaw))
|
||||
U.wait(6)
|
||||
Gun.fire()
|
||||
U.wait(1)
|
||||
shot("04_muzzle_flash")
|
||||
-- a mob takes more than one round as the waves stack; keep firing at
|
||||
-- whatever is nearest until something dies or the magazine is out
|
||||
for _ = 1, 7 do
|
||||
if Horde.session.kills > killsWas then break end
|
||||
U.wait(16)
|
||||
aimAtNearest()
|
||||
Gun.fire()
|
||||
end
|
||||
U.wait(20)
|
||||
print(("[horde] fired: ammo %d, score %d -> %d, kills %d -> %d")
|
||||
:format(select(1, Gun.ammo()), scoreWas, Horde.session.score,
|
||||
killsWas, Horde.session.kills))
|
||||
|
||||
-- ------- the reload, caught mid-dip
|
||||
for _ = 1, 8 do
|
||||
Gun.fire()
|
||||
U.wait(12)
|
||||
end
|
||||
Gun.reload()
|
||||
U.wait(45)
|
||||
shot("05_reloading")
|
||||
print("[horde] reloading: " .. tostring(select(3, Gun.ammo())))
|
||||
for _ = 1, 200 do
|
||||
if not select(3, Gun.ammo()) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
print(("[horde] reloaded to %d"):format(select(1, Gun.ammo())))
|
||||
|
||||
-- ------- through a door, with the crowd behind
|
||||
local before = #Horde.session.mobs
|
||||
U.teleport(game, "REDS_HOUSE_1F", 3, 6, "down")
|
||||
settle(60)
|
||||
for _ = 1, 400 do
|
||||
if #Horde.session.mobs > 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(60)
|
||||
shot("06_indoors_followed")
|
||||
print(("[horde] indoors: %d mobs followed (was %d outside)")
|
||||
:format(#Horde.session.mobs, before))
|
||||
|
||||
-- ------- the end
|
||||
--
|
||||
-- Drained rather than played out, so the driver finishes in seconds and
|
||||
-- the card is photographed from the same path a real death takes.
|
||||
Horde.session.hp = 1
|
||||
Horde.session.hurtCooldown = 0
|
||||
Horde.damage(50)
|
||||
for _ = 1, 400 do
|
||||
if Horde.state == "gameover" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
shot("07_game_over")
|
||||
print(("[horde] game over at score %d, wave %d, %d kills")
|
||||
:format(Horde.session and Horde.session.score or -1,
|
||||
Horde.session and Horde.session.wave or -1,
|
||||
Horde.session and Horde.session.kills or -1))
|
||||
|
||||
-- ------- and back
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 600 do
|
||||
if not Horde.active and not game.stack:top().transitioning then break end
|
||||
U.wait(1)
|
||||
end
|
||||
settle(60)
|
||||
local now = game.stack:top()
|
||||
local np = now.player
|
||||
print(("[horde] restored: %s (%d,%d) facing %s, rung %d, %d npcs")
|
||||
:format(now.map.id, np.cellX, np.cellY, np.facing,
|
||||
Pipelines.level("voxel"), #now.npcs))
|
||||
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
|
||||
:format(tostring(now.map.id == was.map),
|
||||
tostring(np.cellX == was.cellX and np.cellY == was.cellY),
|
||||
tostring(np.facing == was.facing),
|
||||
tostring(Pipelines.level("voxel") == was.level),
|
||||
tostring(#now.npcs == was.npcs)))
|
||||
-- nothing of the mode may be left on any map
|
||||
local left = 0
|
||||
for _, def in pairs(game.data.maps) do
|
||||
for _, obj in ipairs(def.objects or {}) do
|
||||
if obj.hordeMob then left = left + 1 end
|
||||
end
|
||||
end
|
||||
print(("[horde] horde objects left behind: %d"):format(left))
|
||||
shot("08_after")
|
||||
|
||||
-- ------- and out the other door
|
||||
--
|
||||
-- The same restore, reached the other way: enter the code again and
|
||||
-- leave through START -> YES rather than by dying. This is the path a
|
||||
-- player who just wants their game back actually takes.
|
||||
pressCode()
|
||||
U.wait(6)
|
||||
if not Horde.active then
|
||||
print("[horde] second activation refused -- the exit path is untested")
|
||||
print(("[horde] %d shots into %s"):format(shots, ROOT))
|
||||
return love.event.quit()
|
||||
end
|
||||
for _ = 1, 400 do
|
||||
if Horde.state == "active" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
U.tap(game, "up") -- NO -> YES
|
||||
U.wait(6)
|
||||
shot("09_exit_yes")
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 600 do
|
||||
if not Horde.active and not game.stack:top().transitioning then break end
|
||||
U.wait(1)
|
||||
end
|
||||
settle(60)
|
||||
local out = game.stack:top()
|
||||
local op = out.player
|
||||
print(("[horde] exited via START/YES: active %s, %s (%d,%d) facing %s, rung %d")
|
||||
:format(tostring(Horde.active), out.map.id, op.cellX, op.cellY,
|
||||
op.facing, Pipelines.level("voxel")))
|
||||
print(("[horde] matches start: map %s cell %s facing %s rung %s npcs %s")
|
||||
:format(tostring(out.map.id == was.map),
|
||||
tostring(op.cellX == was.cellX and op.cellY == was.cellY),
|
||||
tostring(op.facing == was.facing),
|
||||
tostring(Pipelines.level("voxel") == was.level),
|
||||
tostring(#out.npcs == was.npcs)))
|
||||
local left2 = 0
|
||||
for _, def in pairs(game.data.maps) do
|
||||
for _, obj in ipairs(def.objects or {}) do
|
||||
if obj.hordeMob then left2 = left2 + 1 end
|
||||
end
|
||||
end
|
||||
print(("[horde] horde objects left behind: %d"):format(left2))
|
||||
|
||||
print(("[horde] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,198 @@
|
||||
-- Scratch driver: shots of the 3RD (third-person) rung -- the eye boomed
|
||||
-- off the back of the player's head, the player's own card drawn and turned
|
||||
-- to face it, the boom shortening against walls indoors, and the body
|
||||
-- turning to face where it walks.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tp_shots.lua \
|
||||
-- SHOT_DIR=.scratchpad/tpshots 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/tp")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[tp] DRAMATIC_SHAPE mod not loaded")
|
||||
return love.event.quit()
|
||||
end
|
||||
local V = handle.lib
|
||||
local FirstPerson = V.require("FirstPerson")
|
||||
local ThirdPerson = V.require("ThirdPerson")
|
||||
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
|
||||
|
||||
-- the nearest WALKABLE cell (fp_shots' helper): a guessed coordinate
|
||||
-- inside a building footprint buries the pivot in the geometry
|
||||
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(("[tp] (%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: the player's own card seen from behind, in
|
||||
-- each compass direction plus a diagonal (the off-grid case, where a
|
||||
-- south-facing card would be edge-on to any orbit camera)
|
||||
{ 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: the boom over water, reflecting the player
|
||||
{ map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" },
|
||||
-- pitched down, the classic third-person framing; and up, where the
|
||||
-- boom has to shorten rather than bury the eye in the ground
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
|
||||
pitch = math.rad(35), label = "pallet_down" },
|
||||
{ map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi,
|
||||
pitch = -math.rad(40), label = "pallet_skyward" },
|
||||
-- Route 1: grass and ledges, with the character in frame for scale
|
||||
{ map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" },
|
||||
-- interiors: the one place a 48px boom cannot fit, so the collision
|
||||
-- march is the whole shot
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi,
|
||||
label = "center_north" },
|
||||
{ map = "REDS_HOUSE_1F", x = 3, y = 4, yaw = math.pi,
|
||||
label = "reds_house" },
|
||||
}
|
||||
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
place(s.map, s.x, s.y)
|
||||
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
FirstPerson.yaw = s.yaw
|
||||
FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT
|
||||
U.wait(20)
|
||||
print(("[tp] %-16s boom %.1f of %.1f"):format(s.label, ThirdPerson.len,
|
||||
ThirdPerson.BOOM))
|
||||
if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then
|
||||
shots = shots + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- the slide from 1ST to 3RD: the eye walks out of the head rather than
|
||||
-- cutting, so the halfway frame is a real camera position
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
|
||||
settle()
|
||||
if U.shot(game, ROOT .. "/from_1st.png") then shots = shots + 1 end
|
||||
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
|
||||
for _ = 1, 300 do
|
||||
if ThirdPerson.out >= 0.5 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if U.shot(game, ROOT .. "/boom_mid.png") then shots = shots + 1 end
|
||||
U.wait(60)
|
||||
if U.shot(game, ROOT .. "/boom_out.png") then shots = shots + 1 end
|
||||
|
||||
-- ------- the free walk, with the body turning
|
||||
--
|
||||
-- Hold forward with the head yawed off-grid: the player must GLIDE (off
|
||||
-- the 16px grid, which no grid step can do) and the body must end up
|
||||
-- facing its own travel rather than the lens.
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
Pipelines.setLevel("voxel", Voxel.TP_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 = p.px, p.py
|
||||
U.hold(game, "up", 90)
|
||||
local moved = math.abs(p.px - x0) + math.abs(p.py - y0)
|
||||
print(("[tp] walk moved %.1f px; off-grid: %s; body faces %s (camera %s)")
|
||||
:format(moved,
|
||||
tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0),
|
||||
tostring(p.facing), tostring(FirstPerson.compassFacing())))
|
||||
if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end
|
||||
|
||||
-- strafing: the one case the body facing exists for. Hold RIGHT with the
|
||||
-- head due north and the character must walk east showing its flank.
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
settle()
|
||||
FirstPerson.yaw = math.pi
|
||||
U.hold(game, "right", 40)
|
||||
print(("[tp] strafe: body faces %s, camera looks %s")
|
||||
:format(tostring(p.facing), tostring(FirstPerson.compassFacing())))
|
||||
if U.shot(game, ROOT .. "/strafe.png") then shots = shots + 1 end
|
||||
|
||||
-- ------- the spin, sampled
|
||||
--
|
||||
-- Stand still and turn the camera fast, sampling the frame the player's
|
||||
-- own card actually draws every rendered frame. A standing body is
|
||||
-- pointed along the camera's own yaw, so it must show its back the whole
|
||||
-- way round; anything else is the sideways flick. Sampled through the
|
||||
-- live rig, so this measures the real thing rather than the arithmetic.
|
||||
place("PALLET_TOWN", 13, 14)
|
||||
Pipelines.setLevel("voxel", Voxel.TP_LEVEL)
|
||||
settle()
|
||||
ow = game.stack:top()
|
||||
p = ow.player
|
||||
local seen, frames = {}, 0
|
||||
for _ = 1, 240 do
|
||||
FirstPerson.lookBy(math.rad(7), 0) -- ~420 deg/s, a hard flick
|
||||
U.wait(1)
|
||||
local f = FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
|
||||
seen[f] = (seen[f] or 0) + 1
|
||||
frames = frames + 1
|
||||
end
|
||||
local report = {}
|
||||
for f, n in pairs(seen) do
|
||||
report[#report + 1] = ("%s x%d"):format(f, n)
|
||||
end
|
||||
table.sort(report)
|
||||
print(("[tp] spin: %d frames, card frames seen: %s")
|
||||
:format(frames, table.concat(report, ", ")))
|
||||
|
||||
print(("[tp] %d shots into %s"):format(shots, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user