diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80e88b1..3f00542 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..67720a0
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
index be3241e..0efee17 100644
--- a/README.md
+++ b/README.md
@@ -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 ↑ ↑ ↓
-↓ ← → ← →
-B A.
-
-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 B's key | right trigger, or B | tap the screen | right trigger |
-| aim down sights | right click | left trigger | — | point the gun and look down it |
-| reload | R | X | automatic when empty | B 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 B 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.,
diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua
index 2e673cb..712b583 100644
--- a/lib/FreeMove.lua
+++ b/lib/FreeMove.lua
@@ -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
diff --git a/lib/Horde.lua b/lib/Horde.lua
index b2ae13e..a9dff23 100644
--- a/lib/Horde.lua
+++ b/lib/Horde.lua
@@ -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
diff --git a/lib/HordeExitPrompt.lua b/lib/HordeExitPrompt.lua
new file mode 100644
index 0000000..9b4274c
--- /dev/null
+++ b/lib/HordeExitPrompt.lua
@@ -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
diff --git a/lib/HordeGun.lua b/lib/HordeGun.lua
index bfb4b51..b3782f2 100644
--- a/lib/HordeGun.lua
+++ b/lib/HordeGun.lua
@@ -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
diff --git a/lib/HordeHud.lua b/lib/HordeHud.lua
index 3273791..facfd64 100644
--- a/lib/HordeHud.lua
+++ b/lib/HordeHud.lua
@@ -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
diff --git a/lib/VR.lua b/lib/VR.lua
index 6c228e7..5ea7f80 100644
--- a/lib/VR.lua
+++ b/lib/VR.lua
@@ -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
diff --git a/main.lua b/main.lua
index c064c60..d4abea1 100644
--- a/main.lua
+++ b/main.lua
@@ -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
diff --git a/manifest.json b/manifest.json
index fdbaac1..ca73c80 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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",
diff --git a/mod.card b/mod.card
index 4a82fa1..74fdc89 100644
--- a/mod.card
+++ b/mod.card
@@ -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)",
},
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index ed370c4..2b7cf7d 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -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()
diff --git a/tests/horde_shots.lua b/tests/horde_shots.lua
index 6fceac9..8953539 100644
--- a/tests/horde_shots.lua
+++ b/tests/horde_shots.lua
@@ -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