add smooth turning, removing collision sound effect

This commit is contained in:
DramaticShape
2026-08-02 22:42:46 -04:00
parent c0c180fd01
commit e3c13edda7
14 changed files with 730 additions and 111 deletions
+17 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## 1.6.0
## 1.5.4
### Added
@@ -38,6 +38,22 @@
### Changed
- **SMOOTH TURN**, a new options row under VR (OFF by default), turns the
right stick into a continuous turn instead of the 45-degree snap. The
snap stays the default deliberately -- a software turn moves the world
past a head that did not move, which is the most reliable way to make
somebody ill in a headset -- but it costs continuity, so the choice is
the player's. The row only exists while VR is ON: a comfort setting for
a device that is not plugged in decides nothing.
- **The wall bump is silent in first person**, on the flat screen and in
VR alike. On the grid a blocked step is a discrete event -- you pressed
a direction, the game refused, and the bump answers you once. The free
walk has no such moment: the body slides along whatever it grazes, so
walking a fence line or rounding a doorframe is blocked on one axis
continuously and the sound came out as a rattle rather than as an
answer. The grid walk keeps its own bump untouched.
- **`FirstPerson.fovScale`** is a new seam on the first-person rig: a
plain multiplier on the field of view, for anything that wants to
narrow the lens without owning the camera. Horde mode's iron sights
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DramaticShape
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -32
View File
@@ -23,40 +23,18 @@ 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 |
## Horde mode
Standing in the overworld, enter <kbd>↑</kbd> <kbd>↑</kbd> <kbd>↓</kbd>
<kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> <kbd>←</kbd> <kbd>→</kbd>
<kbd>B</kbd> <kbd>A</kbd>.
The sky drops to a starless violet night, the Lavender Town theme comes
up, the camera locks into your own head, and a handgun appears in your
right hand. Waves of people — the map's own NPCs among them — walk out
of the dark to kill you, and follow you through doors. Every one that
falls screams as a random Pokémon. Score per kill and per wave cleared;
no pausing, and no leaving the mode until your health is gone. The GAME
OVER card offers your score and PRESS A, and pressing it puts the map,
the cell, the facing, the camera, the hour, the music and every NPC back
exactly as they were.
The code is read off Game Boy *buttons*, not keys, so it works the same
on a keyboard, a pad, a phone and a pair of VR controllers.
| control | keyboard / mouse | pad | touch | VR |
| --- | --- | --- | --- | --- |
| fire | left click, or <kbd>B</kbd>'s key | right trigger, or <kbd>B</kbd> | tap the screen | right trigger |
| aim down sights | right click | left trigger | — | point the gun and look down it |
| reload | <kbd>R</kbd> | <kbd>X</kbd> | automatic when empty | <kbd>B</kbd> on the right hand |
| move / look | as first person, unchanged | | | |
The iron sights are real geometry: in VR you line the front post up in
the rear notch and the shot goes where the barrel points.
## VR
The **VR** options row (OFF / ON, off by default) drives a PCVR headset
through OpenXR on Windows — SteamVR, Oculus or WMR.
The **SMOOTH TURN** row appears under it while VR is ON (OFF by
default): ON turns the right stick into a continuous turn instead of the
45° snap. The snap is the default deliberately — a software turn moves
the world past a head that did not move, which is the most reliable way
to make somebody ill in a headset — but it costs continuity, so the
choice is yours.
### VR controls
Suggested onto Touch, Index and WMR controllers (rebindable in the
@@ -70,15 +48,16 @@ alongside.
| 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° |
| 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 |
| right hand | *horde mode only* — the handgun; the right trigger fires and <kbd>B</kbd> reloads (the trigger is START as ever outside the mode) |
## Licenses
This mod redistributes one third-party binary:
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.,
+15 -15
View File
@@ -177,12 +177,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 +208,6 @@ 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
return false
end
@@ -274,8 +276,6 @@ function FreeMove.tick(state)
if not moving then return end
state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1)
local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE
or FreeMove.WALK
local dx, dz = wx * speed, wz * speed
+44 -4
View File
@@ -90,9 +90,18 @@ 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 and the GAME OVER card.
-- 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()
return Horde.active and Horde.state == "active"
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
@@ -104,7 +113,8 @@ function Horde.viewLocked()
end
-- Whether the free walk should skip its A (talk) and START (menu)
-- branches. No pausing, and nobody stops to read a sign mid-firefight.
-- 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
@@ -119,6 +129,32 @@ local function overworld(G)
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
@@ -642,9 +678,13 @@ function Horde.install()
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 -- no pausing
inp.pressed.start = nil -- and no start menu
inp.pressed.select = nil -- no changing the view
end
end
+94
View File
@@ -0,0 +1,94 @@
-- HORDE MODE: the way out.
--
-- START (the pad's, the keyboard's ESCAPE, the touch overlay's) and the
-- VR left stick click all land here: a plain yes/no over the frozen
-- world, asking whether to leave. YES hands over to Horde.finish, which
-- is the same restore the GAME OVER card runs -- the map, the cell, the
-- facing, the camera rung, the hour, the music and every NPC put back
-- exactly as they were. NO drops the player straight back into the
-- firefight.
--
-- Pushing a state is what pauses the mode, and it is the only thing that
-- can: horde mode rides the pipeline's update hook rather than the state
-- stack precisely so that nothing on the stack stops it, but the combat
-- inside Horde.update is gated on the overworld actually being the live
-- state, so this prompt freezes the crowd and the gun for as long as it
-- is up. That is the correct behaviour for a confirmation and it is why
-- "no pausing" does not extend to this one.
--
-- Drawn the way the game draws a yes/no: a white bordered box with black
-- text and the filled arrow beside the row (see Theme.choiceBox, which
-- is where the original's own YES_NO_MENU sits). Black on white because
-- that is what the font IS -- the sheets are black glyphs on transparent
-- and no colour can lighten one.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Horde = V.require("Horde")
local HordeExitPrompt = {}
HordeExitPrompt.__index = HordeExitPrompt
-- the question's box, and the choice box under it, in 8px tiles
local ASK = { tx = 1, ty = 6, tw = 18, th = 4 }
local PICK = { tx = 13, ty = 10, tw = 6, th = 6 }
function HordeExitPrompt.new(game)
local self = setmetatable({}, HordeExitPrompt)
self.game = game
-- NOT opaque: the horde is still standing out there behind this, which
-- is most of what makes the question feel like a decision
self.isOpaque = false
self.index = 2 -- NO, the way every dangerous prompt starts
self.done = false
return self
end
local function sfx(game, name)
pcall(function()
require("src.core.Sound").play(game.data, name)
end)
end
function HordeExitPrompt:update()
if self.done then return end
local input = self.game and self.game.input
if not input then return end
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("a") then
self.done = true
sfx(self.game, "Press_AB")
self.game.stack:pop()
if self.index == 1 then pcall(Horde.finish, self.game) end
elseif input:wasPressed("b") or input:wasPressed("start") then
-- B and START both mean "no": the button that opened this closes it,
-- which is the one thing a player who opened it by accident will try
self.done = true
sfx(self.game, "Press_AB")
self.game.stack:pop()
end
end
function HordeExitPrompt:draw()
local ok, Font = pcall(require, "src.render.Font")
if not ok then return end
local okT, Theme = pcall(require, "src.ui.Theme")
Font.drawBox(ASK.tx, ASK.ty, ASK.tw, ASK.th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("EXIT MINI GAME?", (ASK.tx + 2) * 8, (ASK.ty + 2) * 8)
Font.drawBox(PICK.tx, PICK.ty, PICK.tw, PICK.th)
love.graphics.setColor(0, 0, 0, 1)
Font.draw("YES", (PICK.tx + 2) * 8, (PICK.ty + 2) * 8)
Font.draw("NO", (PICK.tx + 2) * 8, (PICK.ty + 4) * 8)
local cursor = okT and Theme.cursor or 0xED
Font.drawCode(cursor, (PICK.tx + 1) * 8,
(PICK.ty + 2 + (self.index - 1) * 2) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return HordeExitPrompt
+35 -4
View File
@@ -55,9 +55,39 @@ HordeGun.ADS_FOV = math.rad(40)
HordeGun.HIP = { -0.115, -0.125, 0.30 }
HordeGun.ADS = { 0, -0.002, 0.34 }
-- and where it sits relative to the tracked hand, in metres
HordeGun.HAND_OFFSET = { 0, 0.02, 0.03 }
HordeGun.HAND_TILT = -math.pi / 2 -- lay the barrel along the controller
-- 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
--
@@ -383,7 +413,8 @@ function HordeGun.place(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.rotateX(HordeGun.HAND_TILT))
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
+212 -31
View File
@@ -51,6 +51,10 @@ 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,
@@ -148,9 +152,66 @@ local function hitMarker(cx, cy, s, amount)
end
end
-- The banner: one wide plate across the middle of the frame with the
-- words on it, big. It fades in and out rather than cutting -- an
-- announcement, not a notification -- and the plate fades with it.
-- 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
@@ -163,30 +224,32 @@ local function banner(w, h, scale)
local F = font()
if not F then return end
local str = sess.bannerText
local bs = scale * 2
local track = 2 * scale -- extra pixels between glyphs
local codes = F.encode(str)
local total = 0
for _, code in ipairs(codes) do
total = total + F.advanceOf(code) * bs + track
end
total = total - track
local margin = 6 * scale
local lines, bs, tr = layout(F, sess.bannerText, scale, w - margin * 2)
if not lines then return end
local ph = 8 * bs + PAD * 2 * scale * 2
local y = math.floor(h * 0.28)
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
local _, iy = plate(0, y, w, ph, scale, alpha)
local pen = math.floor((w - total) / 2)
plate(0, y, w, ph, scale, alpha)
local iy = y + pad
love.graphics.setColor(0, 0, 0, alpha)
for _, code in ipairs(codes) do
love.graphics.push()
love.graphics.translate(pen, iy)
love.graphics.scale(bs, bs)
F.drawCode(code, 0, 0)
love.graphics.pop()
pen = pen + F.advanceOf(code) * bs + track
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
@@ -285,19 +348,137 @@ end
-- 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 s = math.max(1, math.floor((scale or 1) + 0.5))
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
-- One VR eye, at the moment lib/VR paints its own fade over the finished
-- eye canvas. The inset is generous: the corners of an eye buffer are
-- outside the lens, and a health bar nobody can see is not a health bar.
function HordeHud.drawEye(w, h)
if not Horde.active then return end
local s = math.max(1, math.floor(h / 260 + 0.5))
draw(w, h, s, math.floor(math.min(w, h) * 0.17))
-- ------- 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
+75 -20
View File
@@ -59,6 +59,25 @@ local VR = {}
-- 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
@@ -181,6 +200,11 @@ local function shutdown(reason)
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
@@ -311,6 +335,25 @@ local function renderWorld(views, ctl)
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()
@@ -356,25 +399,16 @@ local function renderWorld(views, ctl)
-- 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.
-- The horde's HUD goes on in the same pass and for the same reason:
-- this is the one place the mod draws 2D over a finished eye. It cannot
-- ride the window's overlay -- with a headset live that pass returns
-- the mirror and never runs.
local hordeUp = V.require("Horde").active
if fadeAlpha > 0 or hordeUp then
-- 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()
local HordeHud = hordeUp and V.require("HordeHud") or nil
for i = 1, 2 do
local c = canvases[i]
love.graphics.setCanvas(c)
if fadeAlpha > 0 then
love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha))
love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight())
end
if HordeHud then
love.graphics.setColor(1, 1, 1, 1)
HordeHud.drawEye(c:getWidth(), c:getHeight())
end
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)
@@ -556,12 +590,32 @@ local function driveControls(ctl, dt, fp)
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
if ctl.toggleChanged and ctl.toggle then VR.stepView() end
-- 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 snap turn: a flick of the right stick steps the view
-- 45 degrees, once per flick -- it re-arms only when the stick comes
-- back toward centre, so holding it turns exactly once
if fp and camMode ~= "battle" then
-- 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
@@ -720,6 +774,7 @@ function VR.invalidate()
dexCanvas = nil
Pokedex.invalidate()
V.require("HordeGun").invalidate()
V.require("HordeHud").invalidate()
for k in pairs(fboCache) do fboCache[k] = nil end
end
+18 -3
View File
@@ -464,14 +464,25 @@ local SETTINGS = {
-- 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 _, entry in ipairs(SETTINGS) do
-- the VR row is absent from the mod manager's page too where the
-- 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
if entry[1] ~= VR.setting or VR.supported() then
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
@@ -937,6 +948,10 @@ mod.content.screens:register("HordeGameOver", {
new = function(game) return V.require("HordeGameOver").new(game) end,
})
mod.content.screens:register("HordeExitPrompt", {
new = function(game) return V.require("HordeExitPrompt").new(game) end,
})
-- The gamepad's triggers, which nothing else in the engine or this mod
-- claims: the RIGHT one fires and the LEFT one aims. Read as axes because
-- that is what SDL calls them; the 0.5 crossing is the press. Installed
@@ -1140,7 +1155,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod()
end)
mod.exports.version = "1.6.0"
mod.exports.version = "1.5.4"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.6.0",
"version": "1.5.4",
"api": 2,
"entry": "main.lua",
"profile": "content",
+2
View File
@@ -19,6 +19,7 @@ return {
"SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched",
"on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched",
"on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view",
"on the 1ST rung the wall-bump sound is silent -- the free walk slides along whatever it grazes rather than making a discrete blocked step, so the grid game's bonk came out as a rattle; the grid walk keeps its own",
"while HORDE MODE runs, the world is held still around it: no wild encounters, no trainers walking up, no talking to anyone, no START menu and no changing the camera rung -- doors and warps keep working, because the crowd follows the player through them",
},
added = {
@@ -34,6 +35,7 @@ return {
"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)",
},
+105
View File
@@ -4217,6 +4217,8 @@ end)()
T.check(type(Data.screens) == "table" and Data.screens.HordeGameOver ~= nil,
"the GAME OVER card is in the screens registry")
T.check(type(Data.screens) == "table" and Data.screens.HordeExitPrompt ~= nil,
"and so is the exit prompt START opens")
local sfx = Data.audio and Data.audio.sfx or {}
local HordeSfx = lib.require("HordeSfx")
for _, name in ipairs(HordeSfx.SHOTS) do
@@ -4374,6 +4376,109 @@ end)()
T.eq(stranded.facing, "left",
"it turns toward the player instead, so the pack still reads as a threat")
-- ------- the way out
--
-- START is asked, not taken: the mode does not pause, but it does have
-- to be leavable, and every device's START (the pad's, the keyboard's
-- ESCAPE, the touch overlay's) lands on the same GB button.
T.eq(Horde.askExit(keyGame), false,
"there is nothing to exit with the mode off")
-- and asking while the mode IS on must not throw. Worth its own check:
-- askExit reaches for this module's own overworld helper, and a Lua
-- local declared BELOW the function that uses it is a nil global --
-- which says nothing at load, nothing in a headless suite that never
-- turns the mode on, and blows up the first time somebody presses
-- ESCAPE. That is exactly how it shipped the first time.
Horde.active, Horde.state = true, "active"
local askedOk, askErr = pcall(Horde.askExit, keyGame)
Horde.active, Horde.state = false, "idle"
T.eq(askedOk, true, "asking the way out never throws: " .. tostring(askErr))
-- ------- the banner wraps rather than running off the screen
--
-- The HUD's scale follows the window's zoom, so a player zoomed well in
-- gets glyphs twice a large scale -- and the announcement was wider
-- than the frame it was announcing over.
local Hud = lib.require("HordeHud")
local wrap = Hud._layout
T.check(type(wrap) == "function", "the banner exposes its layout")
-- a stand-in font of fixed-width glyphs: the real sheets are not in the
-- fixture set, and pinning the line breaks against exact advances is a
-- claim about the WRAPPING rather than about whatever art is loaded
local stub = {
encode = function(s)
local out = {}
for i = 1, #s do out[i] = s:byte(i) end
return out
end,
advanceOf = function() return 8 end,
}
local SHOUT = "A DARKNESS APPROACHES"
local wide = wrap(stub, SHOUT, 2, 4000)
T.eq(#wide, 1, "given room, the announcement is one line")
local narrow, smallBs = wrap(stub, SHOUT, 2, 300)
T.check(#narrow > 1, "and wraps onto more lines when the frame is narrow")
for _, line in ipairs(narrow) do
T.check(line.width <= 300,
("every wrapped line fits the frame (%q is %d wide)")
:format(line.text, line.width))
end
-- A scale far past what the frame can carry shrinks the glyphs rather
-- than letting a word hang off the edge -- there is no wrap point
-- inside a word, so wrapping alone cannot save this case. This is the
-- one the player hit: the HUD's scale follows the window's zoom.
local tiny, tinyBs = wrap(stub, SHOUT, 40, 200)
for _, line in ipairs(tiny) do
T.check(line.width <= 200,
("a scale the frame cannot carry shrinks to fit (%q is %d wide)")
:format(line.text, line.width))
end
T.check(tinyBs < smallBs,
"and that shrink really is a smaller glyph than the narrow case's")
-- the degenerate end: one unbreakable word and almost no room. The
-- glyph cannot go below one pixel, so this is allowed to overflow --
-- what it may NOT do is fail to lay out
local one = wrap(stub, "APPROACHES", 8, 4)
T.check(one and #one == 1, "a single word always lays out on one line")
T.eq(wrap(stub, " ", 2, 100), nil, "and nothing but spaces lays out to nothing")
-- ------- SMOOTH TURN belongs to the headset
--
-- A comfort setting for a device that is not plugged in decides
-- nothing, so the row exists only while VR is ON -- and it is OFF by
-- default, because a software turn moves the world past a head that
-- did not move and that is how you make somebody ill in a headset.
local VRMod = lib.require("VR")
T.eq(VRMod.smoothTurn:get(), false, "SMOOTH TURN is off out of the box")
local function optionRows()
local out = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "tilt" } })
local ids = {}
for _, row in ipairs(out) do ids[row.id] = row end
return ids
end
Pipelines.setLevel("voxel", 3) -- off FULL, which owns other rows
VRMod.setting:sync(false)
T.check(not optionRows()["DRAMATIC_SHAPE:smoothturn"],
"with VR off the row is not on the OPTIONS menu")
VRMod.setting:sync(true)
local smoothRow = optionRows()["DRAMATIC_SHAPE:smoothturn"]
T.check(smoothRow ~= nil, "and with VR on it is")
if smoothRow then
T.eq(smoothRow.label, "SMOOTH TURN", "under its own name")
T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise")
end
VRMod.setting:sync(false)
-- ------- the gun's own bookkeeping
Gun.reset()
+80
View File
@@ -99,6 +99,22 @@ return function(game)
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")))
@@ -121,6 +137,24 @@ return function(game)
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)
@@ -244,6 +278,52 @@ return function(game)
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