mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +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
|
the wrapper is visible during that same fixed step. The callback receives
|
||||||
`(next, game, dt)` and must call `next(game, dt)`.
|
`(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
|
`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
|
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.
|
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).
|
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
Game:touchpressed(id, x, y)
|
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchmoved(id, x, y, dx, dy, pressure)
|
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
|
if Importer then
|
||||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
Game:touchmoved(id, x, y)
|
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.touchreleased(id, x, y, dx, dy, pressure)
|
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
|
if Importer then
|
||||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
Game:touchreleased(id, x, y)
|
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.wheelmoved(x, y)
|
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
|
if istouch and love.system.getOS() == "Android" then return end
|
||||||
return EditorApp.mousepressed(x, y, button)
|
return EditorApp.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
if mouseTouch and Game and button == 1 then
|
if mouseTouch then
|
||||||
Game:touchpressed("mouse", x, y)
|
-- 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
|
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
|
end
|
||||||
|
|
||||||
function love.mousereleased(x, y, button)
|
function love.mousereleased(x, y, button, istouch)
|
||||||
if TouchEditor then
|
if TouchEditor then
|
||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousereleased(x, y, button)
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
@@ -684,20 +691,24 @@ function love.mousereleased(x, y, button)
|
|||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
end
|
end
|
||||||
if mouseTouch and Game and button == 1 then
|
if mouseTouch then
|
||||||
Game:touchreleased("mouse", x, y)
|
if Game and button == 1 then Game:touchreleased("mouse", x, y) end
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
if Game then Game:mousereleased(x, y, button, istouch) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.mousemoved(x, y)
|
function love.mousemoved(x, y, dx, dy, istouch)
|
||||||
if TouchEditor then
|
if TouchEditor then
|
||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousemoved(x, y)
|
return TouchEditor.mousemoved(x, y)
|
||||||
end
|
end
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch and Game and love.mouse.isDown(1) then
|
if mouseTouch then
|
||||||
Game:touchmoved("mouse", x, y)
|
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
if Game then Game:mousemoved(x, y, dx, dy, istouch) end
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
@@ -708,10 +719,31 @@ function love.textinput(text)
|
|||||||
end
|
end
|
||||||
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()
|
function love.quit()
|
||||||
if editorMode and EditorApp.quit then
|
if editorMode and EditorApp.quit then
|
||||||
return EditorApp.quit() -- return true to abort quit
|
return EditorApp.quit() -- return true to abort quit
|
||||||
end
|
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()
|
pcall(function()
|
||||||
require("src.core.DiscordPresence").shutdown()
|
require("src.core.DiscordPresence").shutdown()
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -418,8 +418,11 @@ function StatBox:draw()
|
|||||||
Font.drawBox(9, 2, 11, 10)
|
Font.drawBox(9, 2, 11, 10)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
local s = self.mon.stats
|
local s = self.mon.stats
|
||||||
local rows = { { "ATTACK", s.attack }, { "DEFENSE", s.defense },
|
-- labels through Strings so a mod catalog translates them (#811)
|
||||||
{ "SPEED", s.speed }, { "SPECIAL", s.special } }
|
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
|
for i, r in ipairs(rows) do
|
||||||
Font.draw(r[1], 88, 24 + (i - 1) * 16)
|
Font.draw(r[1], 88, 24 + (i - 1) * 16)
|
||||||
Font.draw(("%3d"):format(r[2]), 128, 32 + (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).
|
-- tutorial passes failThrow -- it stands in for that event (#636).
|
||||||
function BattleState:makeOldManDemo(name, failThrow)
|
function BattleState:makeOldManDemo(name, failThrow)
|
||||||
self.demo = true
|
self.demo = true
|
||||||
self.demoName = name or "OLD MAN"
|
self.demoName = name or Strings("OLD MAN")
|
||||||
self.demoFails = failThrow and true or false
|
self.demoFails = failThrow and true or false
|
||||||
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
|
||||||
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
|
-- 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"
|
-- out of the ball after "X sent out Y!" (not the wild "already there"
|
||||||
-- intro that LinkBattle previously inherited from newWild).
|
-- intro that LinkBattle previously inherited from newWild).
|
||||||
self.enemySendingOut = true
|
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.enemy.name))
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self.enemySendingOut = false
|
self.enemySendingOut = false
|
||||||
@@ -2147,7 +2150,7 @@ function BattleState:oldManThrow()
|
|||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
self.afterQueue = "finish"
|
self.afterQueue = "finish"
|
||||||
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
|
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()
|
self:act(function()
|
||||||
require("src.core.Sound").play(self.data, "Ball_Toss")
|
require("src.core.Sound").play(self.data, "Ball_Toss")
|
||||||
-- ItemUseBall's beat before the toss chain (like throwBall)
|
-- ItemUseBall's beat before the toss chain (like throwBall)
|
||||||
@@ -5509,8 +5512,13 @@ function BattleState:drawTextArea()
|
|||||||
local def = self.data.moves[mv.id]
|
local def = self.data.moves[mv.id]
|
||||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||||
end
|
end
|
||||||
Font.drawCode((self.moveSwapIndex == self.moveIndex) and 0xEC or 0xED,
|
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
|
||||||
40, 96 + self.moveIndex * 8)
|
-- (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
|
if self.moveSwapIndex and self.moveSwapIndex ~= self.moveIndex then
|
||||||
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
|
Font.drawCode(0xEC, 40, 96 + self.moveSwapIndex * 8)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -26,9 +26,14 @@ local function displayName(b)
|
|||||||
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
return b.isPlayer and b.name or Strings("Enemy %s", b.name) -- #779
|
||||||
end
|
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 = {
|
local STAT_LABEL = {
|
||||||
attack = "ATTACK", defense = "DEFENSE", speed = "SPEED",
|
attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
|
||||||
special = "SPECIAL", accuracy = "ACCURACY", evasion = "EVADE",
|
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
|
who.hazeStatReset = nil
|
||||||
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
-- _MonsStatsRoseText/_MonsStatsFellText: "X's / STAT rose!"; the
|
||||||
-- two-stage variants scroll "greatly" onto a third line
|
-- 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
|
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
|
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
|
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
|
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
|
end
|
||||||
MoveEffects.changeStage = changeStage
|
MoveEffects.changeStage = changeStage
|
||||||
|
|
||||||
@@ -494,7 +500,9 @@ MoveEffects.full = {
|
|||||||
chooseDamage = function(ctx)
|
chooseDamage = function(ctx)
|
||||||
-- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616)
|
-- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616)
|
||||||
local dmg = fixedDamageFor(ctx)
|
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()
|
return dmg, plainInfo()
|
||||||
end,
|
end,
|
||||||
},
|
},
|
||||||
@@ -510,7 +518,7 @@ MoveEffects.full = {
|
|||||||
local blocked = immuneMsg(ctx)
|
local blocked = immuneMsg(ctx)
|
||||||
if blocked then return false, blocked end
|
if blocked then return false, blocked end
|
||||||
if TurnOrder.effectiveSpeed(ctx.user) < TurnOrder.effectiveSpeed(ctx.target) then
|
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
|
end
|
||||||
return true
|
return true
|
||||||
end,
|
end,
|
||||||
@@ -535,7 +543,9 @@ MoveEffects.full = {
|
|||||||
DREAM_EATER_EFFECT = {
|
DREAM_EATER_EFFECT = {
|
||||||
-- only works on sleeping targets (checked before damage)
|
-- only works on sleeping targets (checked before damage)
|
||||||
gate = function(ctx)
|
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
|
return true
|
||||||
end,
|
end,
|
||||||
afterDamage = drainHalf("_DreamWasEatenText", Strings.source("%s's\ndream was eaten!")),
|
afterDamage = drainHalf("_DreamWasEatenText", Strings.source("%s's\ndream was eaten!")),
|
||||||
|
|||||||
@@ -244,7 +244,10 @@ end
|
|||||||
|
|
||||||
local function drawMoveMenu(battle)
|
local function drawMoveMenu(battle)
|
||||||
drawMoveGrid(battle, battle.player.curMoves, battle.moveIndex)
|
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 col = (battle.moveSwapIndex - 1) % 2
|
||||||
local row = math.floor((battle.moveSwapIndex - 1) / 2)
|
local row = math.floor((battle.moveSwapIndex - 1) / 2)
|
||||||
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
|
Font.drawCode(0xEC, col == 0 and 8 or 112, 112 + row * 16)
|
||||||
|
|||||||
+114
-4
@@ -799,6 +799,7 @@ end
|
|||||||
function Game:focus(f)
|
function Game:focus(f)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
|
self:cancelPointers()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:visible(v)
|
function Game:visible(v)
|
||||||
@@ -807,12 +808,14 @@ function Game:visible(v)
|
|||||||
else
|
else
|
||||||
Input:reset()
|
Input:reset()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
|
self:cancelPointers()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:onResume()
|
function Game:onResume()
|
||||||
Input:reset()
|
Input:reset()
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
|
self:cancelPointers()
|
||||||
-- Chip music may survive NX suspend as a duplicate stream; stop it and let
|
-- 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).
|
-- the active screen re-cue on the next frame (hardware audio check: T19).
|
||||||
-- Desktop/mobile window-visible flips must not kill overworld music.
|
-- Desktop/mobile window-visible flips must not kill overworld music.
|
||||||
@@ -828,6 +831,11 @@ end
|
|||||||
function Game:recoverInput(event, joystick)
|
function Game:recoverInput(event, joystick)
|
||||||
Input:reset()
|
Input:reset()
|
||||||
TouchControls: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")
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||||
if SwitchDiagnostics.isEnabled() then
|
if SwitchDiagnostics.isEnabled() then
|
||||||
if joystick then
|
if joystick then
|
||||||
@@ -850,16 +858,118 @@ function Game:joystickremoved(joystick)
|
|||||||
TouchControls:joystickremoved()
|
TouchControls:joystickremoved()
|
||||||
end
|
end
|
||||||
|
|
||||||
function Game:touchpressed(id, x, y)
|
-- Gameplay pointer seam (#807). TouchControls keeps first refusal: a
|
||||||
TouchControls:touchpressed(id, x, y)
|
-- 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
|
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)
|
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
|
end
|
||||||
|
|
||||||
function Game:touchreleased(id, x, y)
|
function Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
TouchControls:touchreleased(id, x, y)
|
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
|
end
|
||||||
|
|
||||||
-- Point the loader's mod.save backing at this save's modData so per-mod
|
-- 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)
|
release(self, btn, "touch:" .. btn)
|
||||||
end
|
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)
|
function Input:gamepadpressed(joystick, button)
|
||||||
local btn = self.padBindings[button]
|
local btn = self.padBindings[button]
|
||||||
if btn then
|
if btn then
|
||||||
|
|||||||
@@ -423,11 +423,17 @@ local function setDpad(self, touch, dir)
|
|||||||
if dir then pressBtn(self, dir) end
|
if dir then pressBtn(self, dir) end
|
||||||
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)
|
function TouchControls:touchpressed(id, x, y)
|
||||||
-- preview mode is layout-edit only: never press GB buttons
|
-- preview mode is layout-edit only: never press GB buttons
|
||||||
if self.preview then return end
|
if self.preview then return end
|
||||||
if not (self.active and self.enabled ~= false and self.img) 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
|
-- 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
|
if self.controllerHidden then
|
||||||
self.controllerHidden = false
|
self.controllerHidden = false
|
||||||
return
|
return
|
||||||
@@ -437,7 +443,7 @@ function TouchControls:touchpressed(id, x, y)
|
|||||||
if inCircle(L[btn], x, y, SLOP[btn]) then
|
if inCircle(L[btn], x, y, SLOP[btn]) then
|
||||||
self.touches[id] = { control = btn }
|
self.touches[id] = { control = btn }
|
||||||
pressBtn(self, btn)
|
pressBtn(self, btn)
|
||||||
return
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- square hit zone a bit past the cross art; one owning finger at a time
|
-- 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 }
|
local touch = { control = "dpad", dir = nil }
|
||||||
self.touches[id] = touch
|
self.touches[id] = touch
|
||||||
setDpad(self, touch, dpadDir(dz, x, y))
|
setDpad(self, touch, dpadDir(dz, x, y))
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ function HotReload.run(game, opts)
|
|||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local data = game.data
|
local data = game.data
|
||||||
if data and data.reloadGenerated then data:reloadGenerated() end
|
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)
|
local loader = Loader.new(opts and { fs = opts.fs, dev = opts.dev } or nil)
|
||||||
loader.game = game
|
loader.game = game
|
||||||
-- mod.save keeps pointing at the live slot across the reload
|
-- 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 = {},
|
events = Events.new(), hooks = Hooks.new(), content = {}, assets = {},
|
||||||
exports = {}, migrations = {}, order = {},
|
exports = {}, migrations = {}, order = {},
|
||||||
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
modSave = {}, modOptions = {}, optionSchemas = {}, imageCache = {},
|
||||||
|
modInput = {},
|
||||||
fs = (opts and opts.fs) or (love and love.filesystem),
|
fs = (opts and opts.fs) or (love and love.filesystem),
|
||||||
dev = dev,
|
dev = dev,
|
||||||
}, Loader)
|
}, Loader)
|
||||||
@@ -527,6 +528,44 @@ function Loader:_registerCommand(modId, verb, fn)
|
|||||||
return self.content.commands:register(verb, fn, modId)
|
return self.content.commands:register(verb, fn, modId)
|
||||||
end
|
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)
|
function Loader:_api(mod)
|
||||||
local loader = self
|
local loader = self
|
||||||
local modId = mod.manifest.id
|
local modId = mod.manifest.id
|
||||||
@@ -559,6 +598,45 @@ function Loader:_api(mod)
|
|||||||
hooks = { wrap = function(_, name, callback, priority)
|
hooks = { wrap = function(_, name, callback, priority)
|
||||||
return loader.hooks:wrap(name, callback, priority, modId)
|
return loader.hooks:wrap(name, callback, priority, modId)
|
||||||
end },
|
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
|
-- the widget toolkit facade (12 4.5) is one shared surface, not
|
||||||
-- per-mod state; each widget inside it loads on first touch
|
-- per-mod state; each widget inside it loads on first touch
|
||||||
ui = ModUI,
|
ui = ModUI,
|
||||||
@@ -714,6 +792,7 @@ function Loader:_rollback(modId)
|
|||||||
end
|
end
|
||||||
self.events:removeOwner(modId)
|
self.events:removeOwner(modId)
|
||||||
self.hooks:removeOwner(modId)
|
self.hooks:removeOwner(modId)
|
||||||
|
self:releaseModInput(modId)
|
||||||
self.exports[modId] = nil
|
self.exports[modId] = nil
|
||||||
self.optionSchemas[modId] = nil
|
self.optionSchemas[modId] = nil
|
||||||
self.migrations[modId] = nil
|
self.migrations[modId] = nil
|
||||||
|
|||||||
+5
-2
@@ -237,8 +237,11 @@ function ListMenu:draw()
|
|||||||
if i == self.index then
|
if i == self.index then
|
||||||
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
|
||||||
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
|
-- pokered's PlaceUnfilledArrowMenuCursor (the old man demo's
|
||||||
-- auto A-press, home/list_menu.asm:89-91)
|
-- auto A-press, home/list_menu.asm:89-91). A swap-marked row does
|
||||||
Font.drawCode((self.swapIndex == i or self.hollowIndex == i)
|
-- 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)
|
and Theme.cursorHollow or Theme.cursor, 8, y)
|
||||||
end
|
end
|
||||||
if self.swapIndex == i and i ~= self.index then
|
if self.swapIndex == i and i ~= self.index then
|
||||||
|
|||||||
@@ -776,8 +776,10 @@ function PartyMenu:draw()
|
|||||||
if i == self.index then
|
if i == self.index then
|
||||||
Font.drawCode(Theme.cursor, 0, cursorY)
|
Font.drawCode(Theme.cursor, 0, cursorY)
|
||||||
end
|
end
|
||||||
if i == self.swapFrom or i == self.softboiledFrom then
|
-- the unfilled swap arrow; the filled cursor replaces it in the tilemap
|
||||||
Font.drawCode(Theme.cursorHollow, 0, cursorY) -- the unfilled swap arrow
|
-- 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
|
||||||
end
|
end
|
||||||
if self.swapFrom then
|
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