mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
@@ -174,6 +174,32 @@ It runs immediately before queued button edges are promoted, so input added by
|
||||
the wrapper is visible during that same fixed step. The callback receives
|
||||
`(next, game, dt)` and must call `next(game, dt)`.
|
||||
|
||||
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
||||
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
||||
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
|
||||
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
||||
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
||||
are LOVE window units, the same space `render.hud`'s viewport and the touch
|
||||
overlay lay out in. The on-screen touch controls keep first refusal: a
|
||||
pointer that begins on a virtual control belongs to the pad for its whole
|
||||
lifecycle and never reaches the hook, while one that begins outside stays
|
||||
visible even if it later crosses a control. A real mouse reaches the hook
|
||||
without `POKEPORT_TOUCH` (synthesized `istouch` mouse twins are dropped, so
|
||||
a mobile touch fires once), and focus or visibility loss and input recovery
|
||||
deliver a `"cancelled"` for every pointer the hook saw pressed but not yet
|
||||
released. Return `true` without calling `next` to consume the event.
|
||||
|
||||
`mod.input` presses GB buttons source-safely. `mod.input:tap(game, btn)`
|
||||
queues exactly one `wasPressed` edge for the next fixed step and holds
|
||||
nothing; `local token = mod.input:press(game, btn)` holds the button until
|
||||
`mod.input:release(token)`. Buttons are `up`, `down`, `left`, `right`, `a`,
|
||||
`b`, `start` and `select`. Every press is its own input source inside the
|
||||
engine's multi-source bookkeeping, so releasing a token never clears a hold
|
||||
the keyboard, a controller, the touch overlay or another mod still owns;
|
||||
`release` is idempotent and refuses tokens taken by another mod.
|
||||
Outstanding tokens are released automatically on entry-chunk rollback, hot
|
||||
reload and input recovery.
|
||||
|
||||
`ui.title_menu.items` receives `(next, game, items)` and follows the same
|
||||
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
|
||||
for a tool to offer a fresh-session action before gameplay begins.
|
||||
|
||||
@@ -609,7 +609,7 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
||||
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchpressed(id, x, y)
|
||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
@@ -621,7 +621,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
||||
if Importer then
|
||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchmoved(id, x, y)
|
||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
@@ -633,7 +633,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
||||
if Importer then
|
||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
Game:touchreleased(id, x, y)
|
||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function love.wheelmoved(x, y)
|
||||
@@ -670,12 +670,19 @@ function love.mousepressed(x, y, button, istouch)
|
||||
if istouch and love.system.getOS() == "Android" then return end
|
||||
return EditorApp.mousepressed(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchpressed("mouse", x, y)
|
||||
if mouseTouch then
|
||||
-- the mouse is standing in for a finger: the touch path owns it, and
|
||||
-- feeding the same press back in as a mouse pointer would double it
|
||||
if Game and button == 1 then Game:touchpressed("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
-- #807: a real mouse reaches gameplay as a pointer event for mods; Game
|
||||
-- drops synthesized istouch twins so a mobile touch that already arrived
|
||||
-- through love.touchpressed cannot fire twice
|
||||
if Game then Game:mousepressed(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousereleased(x, y, button)
|
||||
function love.mousereleased(x, y, button, istouch)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousereleased(x, y, button)
|
||||
@@ -684,20 +691,24 @@ function love.mousereleased(x, y, button)
|
||||
if editorMode and EditorApp.mousereleased then
|
||||
return EditorApp.mousereleased(x, y, button)
|
||||
end
|
||||
if mouseTouch and Game and button == 1 then
|
||||
Game:touchreleased("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousereleased(x, y, button, istouch) end
|
||||
end
|
||||
|
||||
function love.mousemoved(x, y)
|
||||
function love.mousemoved(x, y, dx, dy, istouch)
|
||||
if TouchEditor then
|
||||
if love.system.getOS() == "Android" then return end
|
||||
return TouchEditor.mousemoved(x, y)
|
||||
end
|
||||
if editorMode or Importer then return end
|
||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
||||
Game:touchmoved("mouse", x, y)
|
||||
if mouseTouch then
|
||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||
return
|
||||
end
|
||||
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
|
||||
end
|
||||
|
||||
function love.textinput(text)
|
||||
@@ -708,10 +719,31 @@ function love.textinput(text)
|
||||
end
|
||||
end
|
||||
|
||||
-- #785: set once love.quit has routed a window close into HostShell.restart,
|
||||
-- so the follow-up quit event the restart itself raises (quit("restart") on
|
||||
-- desktop; AppImage and Android relaunch the process instead, #575) falls
|
||||
-- through to the normal shutdown below instead of restarting forever.
|
||||
local quitToLauncher = false
|
||||
|
||||
function love.quit()
|
||||
if editorMode and EditorApp.quit then
|
||||
return EditorApp.quit() -- return true to abort quit
|
||||
end
|
||||
-- Closing the window of a running game returns to the launcher instead of
|
||||
-- exiting the app, so testing a mod does not need a relaunch every time
|
||||
-- (#785). Game is only non-nil once bootGame ran; Importer non-nil means
|
||||
-- the launcher (or its import) owns the window and its close still quits.
|
||||
-- Scripted and headless runs (autopilot, frame driver, import-only, ROM
|
||||
-- path import) keep the plain exit so they terminate as before. Nothing
|
||||
-- is saved here on purpose: a window close never wrote the save, and the
|
||||
-- restart path must be no worse than that, not quietly better.
|
||||
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
|
||||
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM")
|
||||
if Game and not Importer and not quitToLauncher and not scripted then
|
||||
quitToLauncher = true
|
||||
require("src.core.HostShell").restart()
|
||||
return true -- abort this quit; the restart lands back in the launcher
|
||||
end
|
||||
pcall(function()
|
||||
require("src.core.DiscordPresence").shutdown()
|
||||
end)
|
||||
|
||||
@@ -418,8 +418,11 @@ function StatBox:draw()
|
||||
Font.drawBox(9, 2, 11, 10)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local s = self.mon.stats
|
||||
local rows = { { "ATTACK", s.attack }, { "DEFENSE", s.defense },
|
||||
{ "SPEED", s.speed }, { "SPECIAL", s.special } }
|
||||
-- labels through Strings so a mod catalog translates them (#811)
|
||||
local rows = { { Strings("ATTACK"), s.attack },
|
||||
{ Strings("DEFENSE"), s.defense },
|
||||
{ Strings("SPEED"), s.speed },
|
||||
{ Strings("SPECIAL"), s.special } }
|
||||
for i, r in ipairs(rows) do
|
||||
Font.draw(r[1], 88, 24 + (i - 1) * 16)
|
||||
Font.draw(("%3d"):format(r[2]), 128, 32 + (i - 1) * 16)
|
||||
@@ -793,7 +796,7 @@ end
|
||||
-- tutorial passes failThrow -- it stands in for that event (#636).
|
||||
function BattleState:makeOldManDemo(name, failThrow)
|
||||
self.demo = true
|
||||
self.demoName = name or "OLD MAN"
|
||||
self.demoName = name or Strings("OLD MAN")
|
||||
self.demoFails = failThrow and true or false
|
||||
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
||||
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
|
||||
@@ -1566,7 +1569,7 @@ function BattleState:enter()
|
||||
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||
-- intro that LinkBattle previously inherited from newWild).
|
||||
self.enemySendingOut = true
|
||||
self:say(Strings("%s sent\nout %s!", self.opponentName or "FOE",
|
||||
self:say(Strings("%s sent\nout %s!", self.opponentName or Strings("FOE"),
|
||||
self.enemy.name))
|
||||
self:act(function()
|
||||
self.enemySendingOut = false
|
||||
@@ -2147,7 +2150,7 @@ function BattleState:oldManThrow()
|
||||
self.phase = "messages"
|
||||
self.afterQueue = "finish"
|
||||
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
||||
self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
|
||||
self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or Strings("OLD MAN")))
|
||||
self:act(function()
|
||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||
-- ItemUseBall's beat before the toss chain (like throwBall)
|
||||
@@ -5509,8 +5512,13 @@ function BattleState:drawTextArea()
|
||||
local def = self.data.moves[mv.id]
|
||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||
end
|
||||
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
||||
40, 96 + self.moveIndex * 8)
|
||||
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
|
||||
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
|
||||
-- filled arrow into the tilemap over it whenever the cursor sits there
|
||||
-- (home/window.asm:184-185), so the current row is always filled. Only
|
||||
-- one glyph may land per cell -- drawCode blits black-on-transparent, so
|
||||
-- stacking 0xED over 0xEC would merge the two arrows (#814).
|
||||
Font.drawCode(0xED, 40, 96 + self.moveIndex * 8)
|
||||
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
|
||||
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
|
||||
end
|
||||
|
||||
@@ -26,9 +26,14 @@ local function displayName(b)
|
||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||
end
|
||||
|
||||
-- Stat names as printed (data/battle/stat_mod_names.asm
|
||||
-- StatModTextStrings). Strings.source, not Strings: this table is built
|
||||
-- at require time, before Strings.load has a catalog, so changeStage
|
||||
-- looks each label up at use time (#811).
|
||||
local STAT_LABEL = {
|
||||
attack = "ATTACK", defense = "DEFENSE", speed = "SPEED",
|
||||
special = "SPECIAL", accuracy = "ACCURACY", evasion = "EVADE",
|
||||
attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
|
||||
speed = Strings.source("SPEED"), special = Strings.source("SPECIAL"),
|
||||
accuracy = Strings.source("ACCURACY"), evasion = Strings.source("EVADE"),
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -54,14 +59,15 @@ local function changeStage(battle, who, stat, delta, fromEnemy)
|
||||
who.hazeStatReset = nil
|
||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||
-- two-stage variants scroll "greatly" onto a third line
|
||||
local label = Strings(STAT_LABEL[stat]) -- looked up here, not at require (#811)
|
||||
if delta >= 2 then
|
||||
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s\ngreatly rose!", displayName(who), label) }
|
||||
elseif delta == 1 then
|
||||
return { Strings("%s's\n%s rose!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s rose!", displayName(who), label) }
|
||||
elseif delta == -1 then
|
||||
return { Strings("%s's\n%s fell!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s fell!", displayName(who), label) }
|
||||
end
|
||||
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), STAT_LABEL[stat]) }
|
||||
return { Strings("%s's\n%s\ngreatly fell!", displayName(who), label) }
|
||||
end
|
||||
MoveEffects.changeStage = changeStage
|
||||
|
||||
@@ -494,7 +500,9 @@ MoveEffects.full = {
|
||||
chooseDamage = function(ctx)
|
||||
-- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616)
|
||||
local dmg = fixedDamageFor(ctx)
|
||||
if not dmg then return nil, "But, it failed!" end
|
||||
if not dmg then
|
||||
return nil, romText(ctx.battle.data, "_ButItFailedText", "But, it failed!")
|
||||
end
|
||||
return dmg, plainInfo()
|
||||
end,
|
||||
},
|
||||
@@ -510,7 +518,7 @@ MoveEffects.full = {
|
||||
local blocked = immuneMsg(ctx)
|
||||
if blocked then return false, blocked end
|
||||
if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then
|
||||
return false, "But, it failed!"
|
||||
return false, romText(ctx.battle.data, "_ButItFailedText", "But, it failed!")
|
||||
end
|
||||
return true
|
||||
end,
|
||||
@@ -535,7 +543,9 @@ MoveEffects.full = {
|
||||
DREAM_EATER_EFFECT = {
|
||||
-- only works on sleeping targets (checked before damage)
|
||||
gate = function(ctx)
|
||||
if ctx.target.mon.status ~= "SLP" then return false, "But, it failed!" end
|
||||
if ctx.target.mon.status ~= "SLP" then
|
||||
return false, romText(ctx.battle.data, "_ButItFailedText", "But, it failed!")
|
||||
end
|
||||
return true
|
||||
end,
|
||||
afterDamage = drainHalf("_DreamWasEatenText", Strings.source("%s's\ndream was eaten!")),
|
||||
|
||||
@@ -244,7 +244,10 @@ end
|
||||
|
||||
local function drawMoveMenu(battle)
|
||||
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
|
||||
if battle.moveSwapIndex then
|
||||
-- The filled cursor replaces the hollow swap marker when they share a row
|
||||
-- (PlaceMenuCursor's tilemap write, home/window.asm:184-185); drawCode blits
|
||||
-- black-on-transparent, so skip the 0xEC instead of stacking glyphs (#814).
|
||||
if battle.moveSwapIndex and battle.moveSwapIndex ~= battle.moveIndex then
|
||||
local col = (battle.moveSwapIndex - 1) % 2
|
||||
local row = math.floor((battle.moveSwapIndex - 1) / 2)
|
||||
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
|
||||
|
||||
+114
-4
@@ -799,6 +799,7 @@ end
|
||||
function Game:focus(f)
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
self:cancelPointers()
|
||||
end
|
||||
|
||||
function Game:visible(v)
|
||||
@@ -807,12 +808,14 @@ function Game:visible(v)
|
||||
else
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
self:cancelPointers()
|
||||
end
|
||||
end
|
||||
|
||||
function Game:onResume()
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
self:cancelPointers()
|
||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
||||
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||
@@ -828,6 +831,11 @@ end
|
||||
function Game:recoverInput(event, joystick)
|
||||
Input:reset()
|
||||
TouchControls:reset()
|
||||
-- reset just dropped every source, mod holds included: retire the mods'
|
||||
-- outstanding press tokens so nothing stale can be released later, and
|
||||
-- tell subscribers their live pointers died (#807)
|
||||
if self.mods and self.mods.releaseModInput then self.mods:releaseModInput() end
|
||||
self:cancelPointers()
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
if SwitchDiagnostics.isEnabled() then
|
||||
if joystick then
|
||||
@@ -850,16 +858,118 @@ function Game:joystickremoved(joystick)
|
||||
TouchControls:joystickremoved()
|
||||
end
|
||||
|
||||
function Game:touchpressed(id, x, y)
|
||||
TouchControls:touchpressed(id, x, y)
|
||||
-- Gameplay pointer seam (#807). TouchControls keeps first refusal: a
|
||||
-- pointer that begins on a virtual control belongs to the pad for its
|
||||
-- whole lifecycle and never reaches mods, while one that begins outside
|
||||
-- stays mod-visible even if it later wanders across a control
|
||||
-- (TouchControls only tracks ids it captured at press). Everything a
|
||||
-- subscriber costs -- the per-pointer records in self.modPointers, the
|
||||
-- payload tables -- sits behind wantsHook, so a mod-free boot allocates
|
||||
-- nothing here.
|
||||
|
||||
-- vanilla for input.pointer: nobody consumed the event
|
||||
local function pointerUnclaimed() return false end
|
||||
|
||||
-- coordinates are LOVE window units, the same space render.hud's viewport
|
||||
-- and the touch overlay lay out in
|
||||
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
||||
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
||||
phase = phase, source = source, id = id, x = x, y = y,
|
||||
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
||||
})
|
||||
end
|
||||
|
||||
function Game:touchmoved(id, x, y)
|
||||
function Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||
if TouchControls:touchpressed(id, x, y) then return end
|
||||
if not ModRuntime.wantsHook("input.pointer") then return end
|
||||
-- POKEPORT_TOUCH routes the mouse through here as a stand-in finger
|
||||
-- under the id "mouse" (see main.lua); mods still see its true source
|
||||
local source = id == "mouse" and "mouse" or "touch"
|
||||
self.modPointers = self.modPointers or {}
|
||||
self.modPointers[id] = { source = source, x = x, y = y,
|
||||
pressure = pressure }
|
||||
self:pointerEvent("pressed", source, id, x, y, dx, dy, pressure)
|
||||
end
|
||||
|
||||
function Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||
TouchControls:touchmoved(id, x, y)
|
||||
local p = self.modPointers and self.modPointers[id]
|
||||
if not p then return end
|
||||
-- the POKEPORT_TOUCH mouse path carries no deltas; derive them from the
|
||||
-- pointer's last seen position so drags read the same either way
|
||||
if dx == nil then dx, dy = x - p.x, y - p.y end
|
||||
p.x, p.y = x, y
|
||||
if pressure ~= nil then p.pressure = pressure end
|
||||
if ModRuntime.wantsHook("input.pointer") then
|
||||
self:pointerEvent("moved", p.source, id, x, y, dx, dy, pressure)
|
||||
end
|
||||
end
|
||||
|
||||
function Game:touchreleased(id, x, y)
|
||||
function Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||
TouchControls:touchreleased(id, x, y)
|
||||
local p = self.modPointers and self.modPointers[id]
|
||||
if not p then return end
|
||||
self.modPointers[id] = nil
|
||||
if ModRuntime.wantsHook("input.pointer") then
|
||||
self:pointerEvent("released", p.source, id, x, y, dx, dy, pressure)
|
||||
end
|
||||
end
|
||||
|
||||
-- A real mouse without POKEPORT_TOUCH (#807). Gameplay itself has no
|
||||
-- mouse verbs, so the pointer hook is the only consumer and everything is
|
||||
-- behind the wantsHook gate. A synthesized istouch twin is dropped
|
||||
-- unconditionally: the same contact already arrived through
|
||||
-- Game:touchpressed, and forwarding both would fire a mobile touch twice.
|
||||
function Game:mousepressed(x, y, button, istouch)
|
||||
if istouch then return end
|
||||
if not ModRuntime.wantsHook("input.pointer") then return end
|
||||
self.modPointers = self.modPointers or {}
|
||||
local p = self.modPointers.mouse
|
||||
if p then
|
||||
p.held, p.x, p.y = (p.held or 1) + 1, x, y
|
||||
else
|
||||
self.modPointers.mouse = { source = "mouse", x = x, y = y,
|
||||
held = 1, button = button }
|
||||
end
|
||||
self:pointerEvent("pressed", "mouse", "mouse", x, y, 0, 0, nil, button)
|
||||
end
|
||||
|
||||
-- hover moves are delivered too (button = nil); only pressed pointers are
|
||||
-- tracked, because only they owe a released/cancelled later
|
||||
function Game:mousemoved(x, y, dx, dy, istouch)
|
||||
if istouch then return end
|
||||
local p = self.modPointers and self.modPointers.mouse
|
||||
if p then p.x, p.y = x, y end
|
||||
if not ModRuntime.wantsHook("input.pointer") then return end
|
||||
self:pointerEvent("moved", "mouse", "mouse", x, y, dx, dy, nil, nil)
|
||||
end
|
||||
|
||||
function Game:mousereleased(x, y, button, istouch)
|
||||
if istouch then return end
|
||||
local p = self.modPointers and self.modPointers.mouse
|
||||
if not p then return end
|
||||
p.held = (p.held or 1) - 1
|
||||
if p.held <= 0 then self.modPointers.mouse = nil end
|
||||
if ModRuntime.wantsHook("input.pointer") then
|
||||
self:pointerEvent("released", "mouse", "mouse", x, y, 0, 0, nil, button)
|
||||
end
|
||||
end
|
||||
|
||||
-- Focus/visibility loss and input recovery swallow pointer releases the
|
||||
-- same way they swallow key-ups (the hazard Input:reset exists for):
|
||||
-- every mod-visible pointer gets a "cancelled" instead of leaving
|
||||
-- subscribers waiting on a "released" that can never arrive (#807).
|
||||
-- Cleared even when the subscriber is already gone, so no stale record
|
||||
-- outlives its mod.
|
||||
function Game:cancelPointers()
|
||||
local pointers = self.modPointers
|
||||
if not pointers then return end
|
||||
self.modPointers = nil
|
||||
if not ModRuntime.wantsHook("input.pointer") then return end
|
||||
for id, p in pairs(pointers) do
|
||||
self:pointerEvent("cancelled", p.source, id, p.x, p.y, 0, 0,
|
||||
p.pressure, p.button)
|
||||
end
|
||||
end
|
||||
|
||||
-- Point the loader's mod.save backing at this save's modData so per-mod
|
||||
|
||||
@@ -186,6 +186,21 @@ function Input:overlayReleased(btn)
|
||||
release(self, btn, "touch:" .. btn)
|
||||
end
|
||||
|
||||
-- Programmatic mod input (#807). mod.input taps and holds land here under
|
||||
-- loader-issued "mod:<id>:<n>" source names, riding the same per-source
|
||||
-- bookkeeping as every physical path above, so releasing one can never
|
||||
-- clear a hold a key, stick, hat, the overlay, or another mod still owns.
|
||||
-- A tap is a sourcePress immediately followed by its sourceRelease: the
|
||||
-- queued edge survives into the next step, and the emptied source map
|
||||
-- keeps the hold from being revived (see Input:step's sources == {} rule).
|
||||
function Input:sourcePress(btn, source)
|
||||
press(self, btn, source)
|
||||
end
|
||||
|
||||
function Input:sourceRelease(btn, source)
|
||||
release(self, btn, source)
|
||||
end
|
||||
|
||||
function Input:gamepadpressed(joystick, button)
|
||||
local btn = self.padBindings[button]
|
||||
if btn then
|
||||
|
||||
@@ -423,11 +423,17 @@ local function setDpad(self, touch, dir)
|
||||
if dir then pressBtn(self, dir) end
|
||||
end
|
||||
|
||||
-- Returns true when this touch was captured by a virtual control -- the
|
||||
-- pad's first refusal on the gameplay pointer seam (#807). Capture is
|
||||
-- decided here, at press, and rides self.touches[id] for the touch's
|
||||
-- whole lifecycle; an uncaptured touch is never tracked, so wandering
|
||||
-- across a control later neither presses it nor hides the touch from mods.
|
||||
function TouchControls:touchpressed(id, x, y)
|
||||
-- preview mode is layout-edit only: never press GB buttons
|
||||
if self.preview then return end
|
||||
if not (self.active and self.enabled ~= false and self.img) then return end
|
||||
-- a controller hid the overlay; the first touch only brings it back
|
||||
-- (uncaptured: it began on no control, so mods may still see it)
|
||||
if self.controllerHidden then
|
||||
self.controllerHidden = false
|
||||
return
|
||||
@@ -437,7 +443,7 @@ function TouchControls:touchpressed(id, x, y)
|
||||
if inCircle(L[btn], x, y, SLOP[btn]) then
|
||||
self.touches[id] = { control = btn }
|
||||
pressBtn(self, btn)
|
||||
return
|
||||
return true
|
||||
end
|
||||
end
|
||||
-- square hit zone a bit past the cross art; one owning finger at a time
|
||||
@@ -449,6 +455,7 @@ function TouchControls:touchpressed(id, x, y)
|
||||
local touch = { control = "dpad", dir = nil }
|
||||
self.touches[id] = touch
|
||||
setDpad(self, touch, dpadDir(dz, x, y))
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ function HotReload.run(game, opts)
|
||||
local Logger = require("src.core.Logger")
|
||||
local data = game.data
|
||||
if data and data.reloadGenerated then data:reloadGenerated() end
|
||||
-- outstanding mod.input holds and mod-visible pointers belong to the
|
||||
-- loader being torn down; retire them while its subscribers still
|
||||
-- exist, or the fresh loader inherits phantom "mod:*" sources and
|
||||
-- pointers nobody left alive can release (#807)
|
||||
if game.mods and game.mods.releaseModInput then game.mods:releaseModInput() end
|
||||
if game.cancelPointers then game:cancelPointers() end
|
||||
local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil)
|
||||
loader.game = game
|
||||
-- mod.save keeps pointing at the live slot across the reload
|
||||
|
||||
@@ -138,6 +138,7 @@ function Loader.new(opts)
|
||||
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||
exports = {}, migrations = {}, order = {},
|
||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||
modInput = {},
|
||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||
dev = dev,
|
||||
}, Loader)
|
||||
@@ -527,6 +528,44 @@ function Loader:_registerCommand(modId, verb, fn)
|
||||
return self.content.commands:register(verb, fn, modId)
|
||||
end
|
||||
|
||||
-- the GB buttons mod.input may drive (#807)
|
||||
local GB_BUTTONS = {
|
||||
up = true, down = true, left = true, right = true,
|
||||
a = true, b = true, start = true, select = true,
|
||||
}
|
||||
|
||||
-- per-mod mod.input ledger (#807): seq numbers this mod's Input sources,
|
||||
-- tokens maps each opaque press token to what release must undo. Living
|
||||
-- on the loader (not the api closure) is what lets rollback, hot reload
|
||||
-- and input recovery retire a mod's holds from outside the mod's own code.
|
||||
function Loader:_modInput(modId)
|
||||
local bucket = self.modInput[modId]
|
||||
if not bucket then
|
||||
bucket = { seq = 0, tokens = {} }
|
||||
self.modInput[modId] = bucket
|
||||
end
|
||||
return bucket
|
||||
end
|
||||
|
||||
-- Release every outstanding mod.input hold: one mod's on entry-chunk
|
||||
-- rollback, everyone's (no argument) on hot reload and input recovery
|
||||
-- (#807). When Input:reset already dropped the sources these releases
|
||||
-- are no-ops; the point is the stale tokens die with the code that took
|
||||
-- them, so a later mod.input:release on one is refused instead of
|
||||
-- touching a button someone else now holds.
|
||||
function Loader:releaseModInput(modId)
|
||||
if modId == nil then
|
||||
for id in pairs(self.modInput) do self:releaseModInput(id) end
|
||||
return
|
||||
end
|
||||
local bucket = self.modInput[modId]
|
||||
if not bucket then return end
|
||||
self.modInput[modId] = nil
|
||||
for _, rec in pairs(bucket.tokens) do
|
||||
rec.input:sourceRelease(rec.btn, rec.source)
|
||||
end
|
||||
end
|
||||
|
||||
function Loader:_api(mod)
|
||||
local loader = self
|
||||
local modId = mod.manifest.id
|
||||
@@ -559,6 +598,45 @@ function Loader:_api(mod)
|
||||
hooks = { wrap = function(_, name, callback, priority)
|
||||
return loader.hooks:wrap(name, callback, priority, modId)
|
||||
end },
|
||||
-- source-safe scripted GB input (#807): tap queues exactly one
|
||||
-- wasPressed edge for the next fixed step with no held state; press
|
||||
-- holds until release. Every call is its own "mod:<id>:<n>" source in
|
||||
-- game.input, so releasing a token can never drop a button the
|
||||
-- keyboard, a pad, the touch overlay, or another mod still holds.
|
||||
input = {
|
||||
tap = function(_, game, btn)
|
||||
local input = game and game.input
|
||||
assert(input, "mod.input needs the live game (see game.ready)")
|
||||
assert(GB_BUTTONS[btn], "unknown GB button: " .. tostring(btn))
|
||||
local bucket = loader:_modInput(modId)
|
||||
bucket.seq = bucket.seq + 1
|
||||
local source = "mod:" .. modId .. ":" .. bucket.seq
|
||||
input:sourcePress(btn, source)
|
||||
input:sourceRelease(btn, source)
|
||||
end,
|
||||
press = function(_, game, btn)
|
||||
local input = game and game.input
|
||||
assert(input, "mod.input needs the live game (see game.ready)")
|
||||
assert(GB_BUTTONS[btn], "unknown GB button: " .. tostring(btn))
|
||||
local bucket = loader:_modInput(modId)
|
||||
bucket.seq = bucket.seq + 1
|
||||
local source = "mod:" .. modId .. ":" .. bucket.seq
|
||||
input:sourcePress(btn, source)
|
||||
local token = {}
|
||||
bucket.tokens[token] = { input = input, btn = btn, source = source }
|
||||
return token
|
||||
end,
|
||||
-- idempotent, and a token another mod took is simply not in this
|
||||
-- ledger, so cross-mod release is refused by construction
|
||||
release = function(_, token)
|
||||
local bucket = loader.modInput[modId]
|
||||
local rec = bucket and bucket.tokens[token]
|
||||
if not rec then return false end
|
||||
bucket.tokens[token] = nil
|
||||
rec.input:sourceRelease(rec.btn, rec.source)
|
||||
return true
|
||||
end,
|
||||
},
|
||||
-- the widget toolkit facade (12 4.5) is one shared surface, not
|
||||
-- per-mod state; each widget inside it loads on first touch
|
||||
ui = ModUI,
|
||||
@@ -714,6 +792,7 @@ function Loader:_rollback(modId)
|
||||
end
|
||||
self.events:removeOwner(modId)
|
||||
self.hooks:removeOwner(modId)
|
||||
self:releaseModInput(modId)
|
||||
self.exports[modId] = nil
|
||||
self.optionSchemas[modId] = nil
|
||||
self.migrations[modId] = nil
|
||||
|
||||
+5
-2
@@ -237,8 +237,11 @@ function ListMenu:draw()
|
||||
if i == self.index then
|
||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
|
||||
-- auto A-press, home/list_menu.asm:89-91)
|
||||
Font.drawCode((self.swapIndex == i or self.hollowIndex == i)
|
||||
-- auto A-press, home/list_menu.asm:89-91). A swap-marked row does
|
||||
-- NOT stay hollow under the cursor: PlaceMenuCursor writes '▶'
|
||||
-- into the tilemap over the '▷' whenever the cursor sits there
|
||||
-- (home/window.asm:184-185) and restores it on the way out (#814)
|
||||
Font.drawCode(self.hollowIndex == i
|
||||
and Theme.cursorHollow or Theme.cursor, 8, y)
|
||||
end
|
||||
if self.swapIndex == i and i ~= self.index then
|
||||
|
||||
@@ -776,8 +776,10 @@ function PartyMenu:draw()
|
||||
if i == self.index then
|
||||
Font.drawCode(Theme.cursor, 0, cursorY)
|
||||
end
|
||||
if i == self.swapFrom or i == self.softboiledFrom then
|
||||
Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow
|
||||
-- the unfilled swap arrow; the filled cursor replaces it in the tilemap
|
||||
-- when they share a row (PlaceMenuCursor, home/window.asm:184-185) (#814)
|
||||
if (i == self.swapFrom or i == self.softboiledFrom) and i ~= self.index then
|
||||
Font.drawCode(Theme.cursorHollow, 0, cursorY)
|
||||
end
|
||||
end
|
||||
if self.swapFrom then
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- Driver: the FIGHT/PKMN/ITEM/RUN cursor after a voluntary switch (#770).
|
||||
-- SendOutMon (pokered engine/battle/core.asm:1733-1735) zeroes the saved
|
||||
-- battle-menu byte AND the move-list byte behind it on every player
|
||||
-- send-out, so the reopened menu starts on FIGHT; the #737 sendOutMonCursors
|
||||
-- reset already covers this and the driver is the proof. Judge WITHOUT
|
||||
-- POKEPORT_SPEED: fast-forward makes the reopened menu impossible to read.
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_menu_default_bug770_test.lua POKEPORT_IDENTITY=bug770 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- :L60 pair so the foe's free move after the switch cannot faint anyone
|
||||
-- and force the ChooseNextMon path instead of the voluntary one
|
||||
check("CHARIZARD resolves in the species table",
|
||||
game.data.pokemon.CHARIZARD ~= nil)
|
||||
check("SNORLAX resolves in the species table",
|
||||
game.data.pokemon.SNORLAX ~= nil)
|
||||
game.save.player.name = "bryan"
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 60),
|
||||
Pokemon.new(game.data, "SNORLAX", 60),
|
||||
}
|
||||
check("party has two healthy mons",
|
||||
#game.save.party == 2
|
||||
and game.save.party[1].hp > 0 and game.save.party[2].hp > 0)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
check("overworld is up to push the battle from", game.overworld ~= nil)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
local function mashUntil(cond, max)
|
||||
for _ = 1, max or 120 do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
U.wait(220) -- the send-out intro plays before the menu is reachable
|
||||
check("the wild battle reached its action menu",
|
||||
mashUntil(function() return battle.phase == "menu" end))
|
||||
check("cursor starts on FIGHT", battle.menuIndex == 1)
|
||||
|
||||
-- park the cursor on PKMN first: with it off FIGHT, "still 1 afterwards"
|
||||
-- proves a reset happened rather than nothing ever moving
|
||||
U.tap(game, "right")
|
||||
U.wait(4)
|
||||
check("cursor moved to PKMN", battle.menuIndex == 2)
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
local menu = game.stack:top()
|
||||
check("A on PKMN opened the party menu", getmetatable(menu) == PartyMenu)
|
||||
|
||||
-- steer onto slot 2; #768 seeds the cursor from partyMenuSavedIndex so
|
||||
-- the start slot is not fixed, but down wraps and must land on 2
|
||||
for _ = 1, 6 do
|
||||
if getmetatable(menu) ~= PartyMenu or menu.index == 2 then break end
|
||||
U.tap(game, "down")
|
||||
U.wait(4)
|
||||
end
|
||||
check("party cursor sits on slot 2",
|
||||
getmetatable(menu) == PartyMenu and menu.index == 2)
|
||||
U.tap(game, "a") -- SWITCH / STATS / CANCEL, SWITCH preselected
|
||||
U.wait(6)
|
||||
check("the SWITCH submenu is open",
|
||||
getmetatable(menu) == PartyMenu and menu.submenu ~= nil
|
||||
and menu.subIndex == 1)
|
||||
U.tap(game, "a") -- SWITCH -> resolveSwitch; the foe takes a free move
|
||||
U.wait(10)
|
||||
|
||||
check("the turn resolved back to the action menu",
|
||||
mashUntil(function() return battle.phase == "menu" end, 300))
|
||||
check("the second mon is the one out now",
|
||||
battle.player.mon == game.save.party[2])
|
||||
check("menuIndex reset to FIGHT after the send-out (#770)",
|
||||
battle.menuIndex == 1)
|
||||
check("moveIndex reset to the first slot too", battle.moveIndex == 1)
|
||||
U.shot(game, DIR .. "/bug770_menu_after_switch.png")
|
||||
U.log("captured", DIR .. "/bug770_menu_after_switch.png")
|
||||
|
||||
U.log("The menu on screen just reopened after a PKMN switch; the cursor")
|
||||
U.log("should be back on FIGHT. Switch again yourself: it must land on")
|
||||
U.log("FIGHT every time -- reopening on PKMN is the old #770 bug.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
-- Driver: the swap cursor in the FIGHT move list (#814). pokered
|
||||
-- SelectMenuItem parks the hollow arrow 0xEC on the marked row (engine/battle/
|
||||
-- core.asm:2600-2607) and HandleMenuInput's PlaceMenuCursor writes the filled
|
||||
-- arrow 0xED into the tilemap over it whenever the cursor sits there
|
||||
-- (home/window.asm:184-185), so the row under the cursor is always filled.
|
||||
-- Font.drawCode blits black-on-transparent, so drawing both glyphs on one cell
|
||||
-- merged the two arrows. Eye check, no SPEED.
|
||||
-- POKEPORT_DRIVER=tests/drivers/move_swap_arrow_bug814_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug814 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
-- CHARIZARD at 50 knows four moves, so marking row 1 and parking the cursor
|
||||
-- on row 2 leaves both arrows on screen at once
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
local lead = game.save.party[1]
|
||||
check("the lead knows at least 3 moves (needs rows to scroll between)",
|
||||
#lead.moves >= 3)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("overworld is up to push the battle from", ow ~= nil)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function tapUntil(cond, taps, gap)
|
||||
for _ = 1, (taps or 60) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, (gap or 6) do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
U.wait(220) -- the send-out intro plays before the menu is reachable
|
||||
check("reached the FIGHT/PKMN/ITEM/RUN menu",
|
||||
tapUntil(function() return battle.phase == "menu" end, 120))
|
||||
check("cursor starts on FIGHT", battle.menuIndex == 1)
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
check("the FIGHT move list is open (#814 lives on this screen)",
|
||||
battle.phase == "moveSelect")
|
||||
check("cursor starts on move 1", battle.moveIndex == 1)
|
||||
|
||||
-- SELECT marks the move under the cursor for swapping
|
||||
U.tap(game, "select")
|
||||
U.wait(10)
|
||||
check("SELECT marked move 1 (moveSwapIndex == 1)",
|
||||
battle.moveSwapIndex == 1)
|
||||
|
||||
-- cursor down to row 2: hollow arrow stays on row 1, filled follows to row 2
|
||||
U.tap(game, "down")
|
||||
U.wait(10)
|
||||
check("cursor moved to move 2", battle.moveIndex == 2)
|
||||
check("the mark stayed on move 1", battle.moveSwapIndex == 1)
|
||||
check("split-arrow screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug814_marked_row1_cursor_row2.png"))
|
||||
U.log("captured", DIR .. "/bug814_marked_row1_cursor_row2.png")
|
||||
|
||||
-- cursor back up onto the marked row: this is the shot the fix is judged
|
||||
-- on -- the shared cell must show only the filled arrow
|
||||
U.tap(game, "up")
|
||||
U.wait(10)
|
||||
check("cursor is back on move 1", battle.moveIndex == 1)
|
||||
check("the mark is still on move 1", battle.moveSwapIndex == 1)
|
||||
check("cursor-on-marked-row screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug814_cursor_on_marked_row.png"))
|
||||
U.log("captured", DIR .. "/bug814_cursor_on_marked_row.png")
|
||||
|
||||
-- ---- hand off ----------------------------------------------------------
|
||||
U.log("Move 1 is marked for swap and the cursor sits on it. That row wants")
|
||||
U.log("one solid black arrow only; a hollow outline there, or a smudge of")
|
||||
U.log("both shapes, is the bug (#814). Scroll DOWN and the hollow arrow")
|
||||
U.log("should reappear on row 1 while the solid one follows the cursor.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,327 @@
|
||||
-- T4: the gameplay pointer seam and source-safe mod input (#807), through
|
||||
-- the public mod API.
|
||||
--
|
||||
-- Two seams under test. "input.pointer" delivers uncaptured gameplay
|
||||
-- touch and mouse events to hook subscribers, with the on-screen touch
|
||||
-- controls keeping first refusal. mod.input taps and holds GB buttons as
|
||||
-- per-mod Input sources, so no mod can clear a hold it does not own.
|
||||
-- Both are driven the way main.lua drives them -- through Game's own
|
||||
-- handlers, against the real Input and TouchControls singletons -- so a
|
||||
-- green run means the wiring, not just the buses.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local Game = require("src.core.Game")
|
||||
local Input = require("src.core.Input")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
-- a Game stand-in that resolves the real methods (Game is the boot
|
||||
-- singleton, so its handlers expect to be their own self)
|
||||
local function fakeGame(loader, data)
|
||||
return setmetatable({ input = Input, mods = loader, data = data },
|
||||
{ __index = Game })
|
||||
end
|
||||
|
||||
-- ------- fixture mods
|
||||
|
||||
-- the watcher journals every pointer event it sees and exports its own
|
||||
-- mod.input facade, so the suite drives exactly the surface a mod
|
||||
-- compiles against; the peer exists to prove cross-mod token refusal
|
||||
local FIXTURES = {
|
||||
["mods/fix_pointer_watcher/manifest.json"] = [[{
|
||||
"id": "fix_pointer_watcher",
|
||||
"name": "Fixture Pointer Watcher",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_pointer_watcher/main.lua"] = [[
|
||||
local mod = ...
|
||||
local seen = {}
|
||||
mod.exports.seen = seen
|
||||
mod.hooks:wrap("input.pointer", function(nextFn, game, ev)
|
||||
seen[#seen + 1] = { phase = ev.phase, source = ev.source, id = ev.id,
|
||||
x = ev.x, y = ev.y, dx = ev.dx, dy = ev.dy,
|
||||
pressure = ev.pressure, button = ev.button }
|
||||
return nextFn(game, ev)
|
||||
end)
|
||||
mod.exports.input = mod.input
|
||||
]],
|
||||
["mods/fix_pointer_peer/manifest.json"] = [[{
|
||||
"id": "fix_pointer_peer",
|
||||
"name": "Fixture Pointer Peer",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_pointer_peer/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.input = mod.input
|
||||
]],
|
||||
}
|
||||
|
||||
-- ------- no-mod parity: the pointer path costs nothing unsubscribed
|
||||
|
||||
do
|
||||
local run = T.sdk.loadNone({})
|
||||
Input:init()
|
||||
TouchControls:init()
|
||||
local game = fakeGame(run.loader, run.data)
|
||||
T.eq(Runtime.wantsHook("input.pointer"), false,
|
||||
"no subscriber: wantsHook(\"input.pointer\") is false")
|
||||
Game.touchpressed(game, 1, 50, 60, 0, 0, 1)
|
||||
Game.touchmoved(game, 1, 55, 66, 5, 6, 1)
|
||||
Game.touchreleased(game, 1, 55, 66, 0, 0, 1)
|
||||
Game.mousepressed(game, 10, 10, 1, false)
|
||||
Game.mousemoved(game, 12, 12, 2, 2, false)
|
||||
Game.mousereleased(game, 12, 12, 1, false)
|
||||
Game.cancelPointers(game)
|
||||
T.eq(game.modPointers, nil,
|
||||
"no subscriber: the pointer path allocates no tracking state")
|
||||
run.release()
|
||||
end
|
||||
|
||||
-- ------- the subscribed path, through the loader-created mod API
|
||||
|
||||
local run = T.sdk.loadMods(
|
||||
{ "mods/fix_pointer_watcher", "mods/fix_pointer_peer" },
|
||||
{ fs = T.sdk.memfs(FIXTURES) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the fixture mods load clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local watcher = run.loader.exports.fix_pointer_watcher
|
||||
local peer = run.loader.exports.fix_pointer_peer
|
||||
local seen = watcher.seen
|
||||
local game = fakeGame(run.loader, run.data)
|
||||
|
||||
Input:init()
|
||||
TouchControls:init()
|
||||
|
||||
local function wipe()
|
||||
for i = #seen, 1, -1 do seen[i] = nil end
|
||||
end
|
||||
|
||||
-- one pressed/moved/released sequence per outside touch
|
||||
do
|
||||
wipe()
|
||||
Game.touchpressed(game, 7, 100, 120, 0, 0, 0.5)
|
||||
Game.touchmoved(game, 7, 110, 125, 10, 5, 0.5)
|
||||
Game.touchreleased(game, 7, 110, 125, 0, 0, 0.5)
|
||||
T.eq(#seen, 3, "an outside touch is one pressed/moved/released sequence")
|
||||
T.eq(seen[1].phase, "pressed", "the sequence begins with pressed")
|
||||
T.eq(seen[1].source, "touch", "a finger reports source touch")
|
||||
T.eq(seen[1].id, 7, "the touch id rides the payload")
|
||||
T.eq(seen[1].x, 100, "coordinates are the LOVE window units handed in")
|
||||
T.eq(seen[1].pressure, 0.5, "pressure rides the payload when present")
|
||||
T.eq(seen[2].phase, "moved", "then moved")
|
||||
T.eq(seen[2].dx, 10, "moved carries the event deltas")
|
||||
T.eq(seen[3].phase, "released", "then released")
|
||||
T.eq(game.modPointers[7], nil, "a released pointer leaves no record")
|
||||
|
||||
-- press+release inside one logic tick still yields both events: the
|
||||
-- seam is event-driven, not sampled at the step boundary
|
||||
wipe()
|
||||
Game.touchpressed(game, 8, 10, 10, 0, 0, 1)
|
||||
Game.touchreleased(game, 8, 10, 10, 0, 0, 1)
|
||||
T.eq(#seen, 2, "press+release inside one tick delivers both events")
|
||||
T.eq(seen[1].phase, "pressed", "...pressed first")
|
||||
T.eq(seen[2].phase, "released", "...released second")
|
||||
end
|
||||
|
||||
-- multi-id drags stay per-pointer
|
||||
do
|
||||
wipe()
|
||||
Game.touchpressed(game, "t1", 40, 40, 0, 0, 1)
|
||||
Game.touchpressed(game, "t2", 200, 90, 0, 0, 1)
|
||||
Game.touchmoved(game, "t1", 45, 44, 5, 4, 1)
|
||||
Game.touchmoved(game, "t2", 210, 80, 10, -10, 1)
|
||||
Game.touchreleased(game, "t2", 210, 80, 0, 0, 1)
|
||||
Game.touchreleased(game, "t1", 45, 44, 0, 0, 1)
|
||||
T.eq(#seen, 6, "two concurrent touches interleave without cross-talk")
|
||||
T.eq(seen[3].id, "t1", "each moved names its own pointer")
|
||||
T.eq(seen[4].id, "t2", "...and only its own pointer")
|
||||
T.eq(seen[5].id, "t2", "release order follows the fingers, not the presses")
|
||||
T.eq(seen[5].phase, "released", "t2 released first")
|
||||
T.eq(seen[6].id, "t1", "t1 released second")
|
||||
T.eq(next(game.modPointers), nil, "all records are gone after both lifts")
|
||||
end
|
||||
|
||||
-- the virtual pad keeps first refusal
|
||||
do
|
||||
-- force the overlay live the way a phone would have it; img is only
|
||||
-- tested for truthiness on the input path (soft_reset_bug563 idiom)
|
||||
TouchControls.active, TouchControls.enabled = true, true
|
||||
TouchControls.img = { stub = true }
|
||||
local L = TouchControls:layout()
|
||||
|
||||
wipe()
|
||||
Game.touchpressed(game, 21, L.a.cx, L.a.cy)
|
||||
T.eq(Input:isDown("a"), true, "the overlay captured the touch and holds A")
|
||||
Game.touchmoved(game, 21, L.a.cx + 4, L.a.cy + 4)
|
||||
Game.touchreleased(game, 21, L.a.cx + 4, L.a.cy + 4)
|
||||
T.eq(Input:isDown("a"), false, "lifting the finger releases A")
|
||||
T.eq(#seen, 0, "a touch that begins on a virtual control never reaches mods")
|
||||
|
||||
-- begins outside, wanders across A: stays mod-visible, never presses A
|
||||
wipe()
|
||||
Game.touchpressed(game, 22, 5, 5)
|
||||
Game.touchmoved(game, 22, L.a.cx, L.a.cy)
|
||||
T.eq(Input:isDown("a"), false, "crossing a control mid-drag never presses it")
|
||||
Game.touchreleased(game, 22, L.a.cx, L.a.cy)
|
||||
T.eq(#seen, 3, "a touch that begins outside stays mod-visible throughout")
|
||||
T.eq(seen[2].dx, L.a.cx - 5, "deltas derive from the last seen position "
|
||||
.. "when the event carries none")
|
||||
|
||||
TouchControls.active = false
|
||||
TouchControls.img = nil
|
||||
TouchControls:reset()
|
||||
end
|
||||
|
||||
-- real mouse events, and the synthesized istouch twins that must not fire
|
||||
do
|
||||
wipe()
|
||||
Game.mousepressed(game, 30, 30, 1, true)
|
||||
Game.mousemoved(game, 31, 31, 1, 1, true)
|
||||
Game.mousereleased(game, 31, 31, 1, true)
|
||||
T.eq(#seen, 0, "synthesized istouch mouse twins never reach the hook")
|
||||
|
||||
Game.mousepressed(game, 30, 30, 1, false)
|
||||
Game.mousemoved(game, 34, 32, 4, 2, false)
|
||||
Game.mousereleased(game, 34, 32, 1, false)
|
||||
T.eq(#seen, 3, "a real mouse reaches the hook without POKEPORT_TOUCH")
|
||||
T.eq(seen[1].source, "mouse", "the mouse reports source mouse")
|
||||
T.eq(seen[1].id, "mouse", "...under the fixed id mouse")
|
||||
T.eq(seen[1].button, 1, "pressed carries the button")
|
||||
T.eq(seen[2].dx, 4, "moved carries the event deltas")
|
||||
T.eq(seen[3].phase, "released", "the lifecycle closes with released")
|
||||
T.eq(game.modPointers.mouse, nil, "the mouse record is gone after release")
|
||||
end
|
||||
|
||||
-- focus loss cancels every live mod-visible pointer
|
||||
do
|
||||
wipe()
|
||||
Game.touchpressed(game, 40, 70, 80, 0, 0, 1)
|
||||
Game.focus(game, false)
|
||||
T.eq(#seen, 2, "focus loss follows the pressed with exactly one more event")
|
||||
T.eq(seen[2].phase, "cancelled", "...a cancelled")
|
||||
T.eq(seen[2].id, 40, "...for the pointer that was alive")
|
||||
T.eq(seen[2].x, 70, "...at its last seen position")
|
||||
T.eq(game.modPointers, nil, "cancel clears the tracking state")
|
||||
end
|
||||
|
||||
-- ------- mod.input: tap is one edge, no held state
|
||||
|
||||
do
|
||||
Input:init()
|
||||
watcher.input:tap(game, "a")
|
||||
T.eq(Input:isDown("a"), false, "tap holds nothing before the step")
|
||||
Input:step()
|
||||
T.eq(Input:wasPressed("a"), true, "tap queues exactly one wasPressed edge")
|
||||
T.eq(Input:isDown("a"), false, "the edge carries no held state")
|
||||
Input:step()
|
||||
T.eq(Input:wasPressed("a"), false, "the edge lasts exactly one step")
|
||||
T.eq(Input:isDown("a"), false, "and still nothing is held")
|
||||
end
|
||||
|
||||
-- mod.input: a press token holds across steps and respects other sources
|
||||
|
||||
do
|
||||
Input:init()
|
||||
local token = watcher.input:press(game, "b")
|
||||
Input:step()
|
||||
T.eq(Input:wasPressed("b"), true, "press queues the edge")
|
||||
T.eq(Input:isDown("b"), true, "and holds the button")
|
||||
Input:step()
|
||||
T.eq(Input:isDown("b"), true, "the hold survives steps until release")
|
||||
-- the keyboard joins on the same button (X is a default B binding)
|
||||
Input:keypressed("x")
|
||||
Input:step()
|
||||
T.eq(watcher.input:release(token), true, "release honors this mod's token")
|
||||
T.eq(Input:isDown("b"), true,
|
||||
"releasing the mod token leaves the keyboard's hold alone")
|
||||
T.eq(watcher.input:release(token), false, "release is idempotent")
|
||||
T.eq(Input:isDown("b"), true, "a second release changes nothing")
|
||||
Input:keyreleased("x")
|
||||
T.eq(Input:isDown("b"), false, "the keyboard's own release ends the hold")
|
||||
end
|
||||
|
||||
-- mod.input: one mod cannot release another mod's press
|
||||
|
||||
do
|
||||
Input:init()
|
||||
local token = watcher.input:press(game, "start")
|
||||
T.eq(peer.input:release(token), false,
|
||||
"a mod cannot release another mod's token")
|
||||
T.eq(Input:isDown("start"), true, "the refused release drops nothing")
|
||||
T.eq(watcher.input:release(token), true, "the owning mod still can")
|
||||
T.eq(Input:isDown("start"), false, "and then the button is up")
|
||||
T.raises(function() watcher.input:tap(game, "quit") end,
|
||||
"unknown GB button", "unknown buttons are refused")
|
||||
end
|
||||
|
||||
-- mod.input: input recovery retires outstanding tokens
|
||||
|
||||
do
|
||||
Input:init()
|
||||
local token = watcher.input:press(game, "a")
|
||||
T.eq(Input:isDown("a"), true, "the hold is live before recovery")
|
||||
Game.recoverInput(game, "joystickadded")
|
||||
T.eq(Input:isDown("a"), false,
|
||||
"input recovery drops mod holds with every other source")
|
||||
T.eq(watcher.input:release(token), false,
|
||||
"recovery retires the outstanding token")
|
||||
end
|
||||
|
||||
-- ------- cleanup: entry-chunk rollback releases what the chunk pressed
|
||||
|
||||
do
|
||||
local BROKEN = {
|
||||
["mods/fix_pointer_broken/manifest.json"] = [[{
|
||||
"id": "fix_pointer_broken",
|
||||
"name": "Fixture Pointer Broken",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/fix_pointer_broken/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.input:press(FIX_POINTER_GAME, "select")
|
||||
error("entry chunk exploded")
|
||||
]],
|
||||
}
|
||||
Input:init()
|
||||
_G.FIX_POINTER_GAME = { input = Input }
|
||||
local broken = T.sdk.loadMods({ "mods/fix_pointer_broken" },
|
||||
{ fs = T.sdk.memfs(BROKEN) })
|
||||
_G.FIX_POINTER_GAME = nil
|
||||
T.check(#broken.errors > 0, "the throwing entry chunk is reported")
|
||||
T.eq(Input:isDown("select"), false,
|
||||
"entry-chunk rollback releases the mod's outstanding holds")
|
||||
broken.release()
|
||||
end
|
||||
|
||||
-- ------- cleanup: hot reload retires the old loader's holds (LAST: it
|
||||
-- replaces the buses, so the watcher's subscription dies with it)
|
||||
|
||||
do
|
||||
Input:init()
|
||||
local token = watcher.input:press(game, "up")
|
||||
T.eq(Input:isDown("up"), true, "the hold is live before the reload")
|
||||
-- a fresh fixture dataset for the reload's re-merge, with the inherited
|
||||
-- Data:reloadGenerated shadowed out: it would read data/generated, and
|
||||
-- this tier is ROM-free
|
||||
local rdata = T.fixtures.fresh()
|
||||
rawset(rdata, "reloadGenerated", function() end)
|
||||
local rgame = fakeGame(run.loader, rdata)
|
||||
require("src.dev.HotReload").run(rgame, { fs = T.sdk.memfs({}) })
|
||||
T.eq(Input:isDown("up"), false, "hot reload retires the old loader's holds")
|
||||
T.eq(watcher.input:release(token), false,
|
||||
"a token from before the reload is refused, not re-released")
|
||||
end
|
||||
|
||||
run.release()
|
||||
|
||||
T.finish("pointer_input")
|
||||
Reference in New Issue
Block a user