mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
@@ -93,6 +93,17 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
if result == "bicycle" then
|
||||
-- StartMenu_Item .useOrTossItem (engine/menus/start_sub_menus.asm):
|
||||
-- while BIT_ALWAYS_ON_BIKE of wStatusFlags6 is set -- the Cycling Road,
|
||||
-- armed by the forced-bike tiles and cleared by the Route 16/18 gate
|
||||
-- scripts -- the BICYCLE refuses with _CannotGetOffHereText and jumps
|
||||
-- back to ItemMenuLoop, so the bag stays open and no dismount happens
|
||||
-- (#513). The gate sits ahead of UseItem, before ItemUseBicycle ever
|
||||
-- runs, which is why it precedes list:close() here.
|
||||
if game.save.forcedBike then
|
||||
showMessages(game, { Strings("You can't get off\nhere.") })
|
||||
return
|
||||
end
|
||||
list:close()
|
||||
local ow = game.overworld
|
||||
local Music = require("src.core.Music")
|
||||
|
||||
+22
-2
@@ -64,9 +64,24 @@ function BindingsMenu.new(game)
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
self.onChoose = function(item) self:beginCapture(item) end
|
||||
-- A rebind reaches Input only when this screen closes (#510). The menu
|
||||
-- steers by the live map, so applying "B = Z" the instant it was captured
|
||||
-- turned the player's next confirm press into a cancel and shut the
|
||||
-- screen mid-swap. options.bindings is still written immediately, and
|
||||
-- Game:applyOptions re-applies it on load, so a close that skips this
|
||||
-- hook still ends up with the saved map.
|
||||
self.onCancel = function() self:commitBindings() end
|
||||
return self
|
||||
end
|
||||
|
||||
-- Cross-file contract with src/core/Input.lua: the saved overlay reaches
|
||||
-- the live map here, on close, and nowhere else in this screen.
|
||||
function BindingsMenu:commitBindings()
|
||||
local game = self.game
|
||||
local opts = game and game.save and game.save.options
|
||||
if opts then Input:applyBindings(opts.bindings) end
|
||||
end
|
||||
|
||||
-- the capture handlers are per-instance slots, so Game's raw-input
|
||||
-- routing only ever sees this screen while a capture is armed
|
||||
function BindingsMenu:beginCapture(item)
|
||||
@@ -75,7 +90,12 @@ function BindingsMenu:beginCapture(item)
|
||||
self.onGamepadPressed = BindingsMenu.capturePad
|
||||
end
|
||||
|
||||
-- Escape is the capture's way out, so it is never captured: every other
|
||||
-- key is bindable, which otherwise leaves an armed row with no exit but
|
||||
-- to bind something (#510). Escape stays START in Input's default map,
|
||||
-- which no rebind removes, so reserving it costs the player nothing.
|
||||
function BindingsMenu:captureKey(key)
|
||||
if key == "escape" then return self:storeBinding("key", nil) end
|
||||
self:storeBinding("key", key)
|
||||
end
|
||||
|
||||
@@ -100,7 +120,6 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
Input:applyBindings(opts.bindings)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
@@ -112,9 +131,10 @@ end
|
||||
function BindingsMenu:draw()
|
||||
ListMenu.draw(self)
|
||||
if self.capture then
|
||||
Font.drawBox(1, 6, 18, 4)
|
||||
Font.drawBox(1, 6, 18, 5)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PRESS A BUTTON"), 24, 60)
|
||||
Font.draw(Strings("ESC TO CANCEL"), 24, 72)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
-- surfing, Cut trees, trainer sight lines, and dispatches interactions to
|
||||
-- map scripts (data/scripts/), marts, nurses or extracted text.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Camera = require("src.render.Camera")
|
||||
local Collision = require("src.world.Collision")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
@@ -3932,6 +3933,31 @@ function OverworldState:draw()
|
||||
self:drawUI()
|
||||
end
|
||||
|
||||
-- The emote sheet is OBJ art (engine/overworld/emotion_bubbles.asm builds the
|
||||
-- bubble out of shadow OAM), so it renders through OBP0, and GBPalNormal
|
||||
-- (home/palettes.asm:20-26 `ld a, %11010000 ; 3100 / ldh [rOBP0], a`) holds
|
||||
-- OBP0 at "3100": OBJ color 1 shows as shade 0, color 2 as shade 1, color 3
|
||||
-- as shade 3. Blitting the raw sheet skipped that lift and left the "!"
|
||||
-- bubble's interior (color 1) at DMG shade 1 grey instead of white (#505).
|
||||
-- Same CPU-remap bake as SpriteRenderer.getObpImage and PartyMenu's obpIcon,
|
||||
-- and it resolves through Assets so a mod's emotes.png override still wins.
|
||||
-- Color 0's alpha (a tRNS entry on the extracted png) is what keys the
|
||||
-- bubble's corners out, so carry it through untouched.
|
||||
local function obpEmoteImage(path)
|
||||
if not (love.image and love.image.newImageData) then
|
||||
return love.graphics.newImage(Assets.resolve(path)) -- headless stub
|
||||
end
|
||||
local id = Assets.imageData(path)
|
||||
id:mapPixel(function(_, _, r, _, _, a)
|
||||
local v = 0
|
||||
if r > 0.5 then v = 1 -- OBJ colors 0 and 1 -> shade 0
|
||||
elseif r > 0.17 then v = 170 / 255 -- OBJ color 2 -> shade 1
|
||||
end -- OBJ color 3 -> shade 3
|
||||
return v, v, v, a
|
||||
end)
|
||||
return love.graphics.newImage(id)
|
||||
end
|
||||
|
||||
-- The SGB palette a tilt-mode billboard at flat foot (fx, fy) sits under.
|
||||
-- World zones are rectangles in flat world-canvas space (the current map's
|
||||
-- base fills the view; neighbour maps stack on top), so the last zone that
|
||||
@@ -4171,7 +4197,7 @@ function OverworldState:drawWorld()
|
||||
local drawn = false
|
||||
if bubble and bubble.path then
|
||||
local ok, img = pcall(function()
|
||||
self.emoteImg = self.emoteImg or love.graphics.newImage(bubble.path)
|
||||
self.emoteImg = self.emoteImg or obpEmoteImage(bubble.path)
|
||||
return self.emoteImg
|
||||
end)
|
||||
-- EXCLAMATION_BUBBLE is index 0 -> first crop; the emote command
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
-- Manual check that the BICYCLE refuses to come off on the Cycling Road (#513).
|
||||
-- pokered gates this in StartMenu_Item .useOrTossItem (engine/menus/
|
||||
-- start_sub_menus.asm): with BIT_ALWAYS_ON_BIKE set it prints
|
||||
-- _CannotGetOffHereText and `jp ItemMenuLoop`, so the bag stays open and
|
||||
-- ItemUseBicycle never runs. Do not add POKEPORT_SPEED: it scales only the
|
||||
-- logic clock, and the menu/text ordering under test is what is being judged.
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_dismount_bug513_test.lua POKEPORT_IDENTITY=bug513 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
-- pokered data/maps/force_bike_surf.asm: ROUTE_16 (17,10) and (17,11) are
|
||||
-- the two cells you land on walking out of the Route 16 gate's south door,
|
||||
-- and they are what arms BIT_ALWAYS_ON_BIKE. Landing there by warp is the
|
||||
-- real path, and setMap runs checkForcedMovement on entry, so a teleport
|
||||
-- onto the cell arms the flag exactly like the warp does.
|
||||
local FORCE_MAP, FORCE_X, FORCE_Y = "ROUTE_16", 17, 11
|
||||
-- the walking exit from the stretch (FieldDefaults forcedMovement.clearMaps,
|
||||
-- from scripts/Route16Gate1F.asm `res BIT_ALWAYS_ON_BIKE`)
|
||||
local GATE_MAP = "ROUTE_16_GATE_1F"
|
||||
-- pokered data/maps/objects/Route17.asm: the topmost bikers stand at
|
||||
-- (11,16), (12,19) and (4,18), so the y 8-14 stretch is the open top of the
|
||||
-- road with nobody's line of sight crossing it. Route 17 is 20x144 cells,
|
||||
-- and the bike rolls south on its own, so start high enough that the roll
|
||||
-- below cannot carry the player into BIKER2's row.
|
||||
local ROAD_MAP, ROAD_X, ROAD_Y = "ROUTE_17", 11, 9
|
||||
|
||||
local cannot = game.data.text._CannotGetOffHereText
|
||||
check("_CannotGetOffHereText was extracted",
|
||||
type(cannot) == "string" and cannot:find("get off", 1, true) ~= nil)
|
||||
local fm = game.data.field.forcedMovement
|
||||
local forced = fm and fm.tiles and fm.tiles[FORCE_MAP]
|
||||
check("the Route 16 forced-bike cells are in field.forcedMovement",
|
||||
type(forced) == "table" and #forced > 0)
|
||||
check("BICYCLE exists as an item", game.data.items.BICYCLE ~= nil)
|
||||
|
||||
game.save.player.name = "SEBAS"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.forcedBike = nil
|
||||
game.save.onBike = false
|
||||
|
||||
-- arm the flag the way the game does, by arriving on the forced cell
|
||||
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
|
||||
U.wait(20)
|
||||
check("stepping off the Route 16 gate mounts the BICYCLE", game.save.onBike == true)
|
||||
check("and sets the forced-bike flag", game.save.forcedBike == true)
|
||||
|
||||
-- the release half: the gate map has to clear it again, or the fix would
|
||||
-- trap the player on the bike forever, which reads on screen exactly like
|
||||
-- the refusal working
|
||||
-- pokered data/maps/objects/Route16Gate1F.asm: the warps sit on x 0 and 7,
|
||||
-- the guard on (4,5), so (3,6) is plain floor inside the gate
|
||||
U.teleport(game, GATE_MAP, 3, 6, "up")
|
||||
U.wait(20)
|
||||
check("walking into the Route 16 gate clears the flag again",
|
||||
game.save.forcedBike == nil or game.save.forcedBike == false)
|
||||
|
||||
-- back out onto the road, then down to Route 17 where the report's
|
||||
-- screenshot was taken
|
||||
game.save.onBike = false
|
||||
U.teleport(game, FORCE_MAP, FORCE_X, FORCE_Y, "down")
|
||||
U.wait(20)
|
||||
U.teleport(game, ROAD_MAP, ROAD_X, ROAD_Y, "down")
|
||||
|
||||
-- Route 17 is a slopeMap: with no button held the bike rolls south every
|
||||
-- frame the player is standing still (handleInput's simulated PAD_DOWN),
|
||||
-- and OverworldLoop only looks at START between steps. Holding B is the
|
||||
-- brake the Route 17 sign describes, so hold it while waiting, exactly
|
||||
-- like a player coming to a stop before opening the menu (#255).
|
||||
local function brake(frames)
|
||||
for _ = 1, frames do
|
||||
game.input.state.b = true
|
||||
coroutine.yield()
|
||||
end
|
||||
game.input.state.b = false
|
||||
end
|
||||
brake(30)
|
||||
|
||||
local ow = game.overworld
|
||||
local function freeCell(x, y)
|
||||
if not (ow and ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then
|
||||
return false
|
||||
end
|
||||
if ow:npcAtCell(x, y) then return false end
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if math.abs(n.cellX - x) + math.abs(n.cellY - y) <= 3 then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
if ow and not freeCell(ROAD_X, ROAD_Y) then
|
||||
-- a map edit or a mod moved the road: take any open cell in the top
|
||||
-- stretch rather than parking the player inside the guard rail
|
||||
local found
|
||||
for y = 8, 30 do
|
||||
for x = 0, 19 do
|
||||
if freeCell(x, y) then found = { x, y } break end
|
||||
end
|
||||
if found then break end
|
||||
end
|
||||
if found then
|
||||
U.log(("(%d,%d) is blocked, standing at"):format(ROAD_X, ROAD_Y),
|
||||
found[1], found[2])
|
||||
U.teleport(game, ROAD_MAP, found[1], found[2], "down")
|
||||
brake(30)
|
||||
ow = game.overworld
|
||||
else
|
||||
check("found an open cell on the Route 17 top stretch", false)
|
||||
end
|
||||
end
|
||||
check("on Route 17, still riding, flag still armed",
|
||||
game.overworld and game.overworld.map.id == ROAD_MAP
|
||||
and game.save.onBike == true and game.save.forcedBike == true)
|
||||
|
||||
-- open START -> ITEM -> BICYCLE -> USE for real, row by row, so a menu
|
||||
-- that reorders itself shows up as a FAIL instead of a silent misclick
|
||||
local function stepTo(state, wants, what)
|
||||
for _ = 1, 20 do
|
||||
if wants(state) then return true end
|
||||
U.tap(game, "down")
|
||||
U.wait(6)
|
||||
end
|
||||
check("found " .. what .. " in the menu", false)
|
||||
return false
|
||||
end
|
||||
|
||||
-- brake into a standstill first, then press START on a frame where the
|
||||
-- player is between no steps at all, or handleInput drops it
|
||||
for _ = 1, 90 do
|
||||
game.input.state.b = true
|
||||
if not (game.overworld and game.overworld.player.moving) then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
table.insert(game.input.pressQueue, "start")
|
||||
U.wait(1)
|
||||
game.input.state.start = false
|
||||
game.input.state.b = false
|
||||
U.wait(20)
|
||||
local menu = game.stack:top()
|
||||
local Strings = require("src.core.Strings")
|
||||
local ITEM = Strings("ITEM")
|
||||
if check("START opened a menu", menu and menu.items ~= nil) then
|
||||
if stepTo(menu, function(m) return m.items[m.index].label == ITEM end, "ITEM") then
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
end
|
||||
|
||||
local bag = game.stack:top()
|
||||
local isBag = bag and bag.items and bag.items[1] and bag.items[1].value ~= nil
|
||||
if check("the bag opened", isBag) then
|
||||
if stepTo(bag, function(b) return b.items[b.index].value == "BICYCLE" end,
|
||||
"BICYCLE") then
|
||||
U.tap(game, "a") -- USE / TOSS submenu
|
||||
U.wait(20)
|
||||
U.tap(game, "a") -- USE is the first row
|
||||
U.wait(30)
|
||||
end
|
||||
end
|
||||
|
||||
local top = game.stack:top()
|
||||
local box = getmetatable(top) == TextBox and top or nil
|
||||
if check("using the BICYCLE printed something", box ~= nil) then
|
||||
local lines = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
local said = table.concat(lines, " / ")
|
||||
U.log("the box reads:", said)
|
||||
check("it is the refusal, not the dismount message",
|
||||
said:find("get off", 1, true) ~= nil
|
||||
and said:find("BICYCLE", 1, true) == nil)
|
||||
end
|
||||
check("the player is still on the BICYCLE", game.save.onBike == true)
|
||||
-- pokered jumps back to ItemMenuLoop rather than closing the menu, so the
|
||||
-- bag list must still be underneath the box
|
||||
local bagStillUp = false
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if s == bag then bagStillUp = true end
|
||||
end
|
||||
check("the bag list is still open under the box", bagStillUp)
|
||||
|
||||
U.wait(90) -- let the box finish typing, or the shot catches half a word
|
||||
local shotPath = SHOT_DIR .. "/bug513_bike_refusal.png"
|
||||
check("screenshot reached disk", U.shot(game, shotPath))
|
||||
|
||||
U.log("")
|
||||
if ok then
|
||||
U.log("the BICYCLE has already been used once on Route 17; the box on")
|
||||
U.log("screen is that refusal. it should read \"You can't get off / here.\"")
|
||||
U.log("and B should drop you back into the bag list with the sprite still")
|
||||
U.log("on the bike, never onto your feet. shot saved to " .. shotPath)
|
||||
U.log("press B twice and try it again as often as you like. the road")
|
||||
U.log("rolls you south whenever you let go, so hold A or B to stop.")
|
||||
U.log("then ride north off Route 17 and into the Route 16 gate, where")
|
||||
U.log("USE should print \"SEBAS got off the BICYCLE.\" and put you on")
|
||||
U.log("foot -- if it refuses in there too, the guard never releases the")
|
||||
U.log("flag and the fix went a step too far.")
|
||||
else
|
||||
U.log("a check above failed, so nothing on screen is worth reading yet.")
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Manual check on the "!" bubble a trainer pops when he spots you (#505).
|
||||
-- The sheet is OBJ art, so it displays through OBP0, and GBPalNormal
|
||||
-- (pokered home/palettes.asm:20-26, `ld a, %11010000 ; 3100`) shows OBJ
|
||||
-- color 1 as shade 0. The draw site blitted the raw png instead, leaving
|
||||
-- the bubble's interior at its pre-OBP0 grey. Do not add POKEPORT_SPEED:
|
||||
-- the sight-line freeze and the 60-frame bubble hold are being watched.
|
||||
-- POKEPORT_DRIVER=tests/drivers/emote_bubble_bug505_test.lua POKEPORT_IDENTITY=bug505 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Assets = require("src.render.Assets")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local failed = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failed = failed + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
-- extraction, asset resolution and a renamed crop all look like the bug on
|
||||
-- screen (grey bubble, or no bubble at all), so separate them here first
|
||||
local bubble = game.data.field and game.data.field.emotionBubbles
|
||||
check("field data carries the emote sheet",
|
||||
type(bubble) == "table" and type(bubble.path) == "string")
|
||||
local crop = bubble and bubble.bubbles and bubble.bubbles[1]
|
||||
check("crop 1 is EXCLAMATION_BUBBLE 16x16 at the sheet origin",
|
||||
crop ~= nil and crop.name == "EXCLAMATION_BUBBLE"
|
||||
and crop.w == 16 and crop.h == 16 and crop.x == 0 and crop.y == 0)
|
||||
|
||||
-- the source art is the pre-OBP0 art and is supposed to look grey: the
|
||||
-- body is OBJ color 1, which the extractor writes as DMG shade 1 (170),
|
||||
-- and OBJ color 0 (the corners) carries the sheet's tRNS alpha. If this
|
||||
-- ever reads white the extractor changed and the bake below is moot.
|
||||
local BODY_X, BODY_Y = 4, 5 -- inside the outline, left of the "!" stem
|
||||
local EDGE_X, EDGE_Y = 1, 4 -- the black outline column
|
||||
if bubble and bubble.path then
|
||||
local ok, src = pcall(Assets.imageData, bubble.path)
|
||||
check("the sheet resolves and decodes: " .. bubble.path, ok and src ~= nil)
|
||||
if ok and src then
|
||||
local r = select(1, src:getPixel(BODY_X, BODY_Y))
|
||||
local _, _, _, ca = src:getPixel(0, 0)
|
||||
U.log(("source art body pixel %.3f, corner alpha %.3f"):format(r, ca))
|
||||
check("source body is the shade 1 grey the bug showed on screen",
|
||||
math.abs(r - 170 / 255) < 0.04)
|
||||
check("source corner is keyed out by tRNS", ca < 0.5)
|
||||
end
|
||||
end
|
||||
|
||||
-- pokered data/maps/objects/Route3.asm:22 -- the Bug Catcher stands at
|
||||
-- (10, 6) with range RIGHT, so his line runs east along row 6 and (12, 6)
|
||||
-- is the detection tile. Start one cell outside it and walk in.
|
||||
local MAP, TRAINER = "ROUTE_3", "ROUTE3_YOUNGSTER1"
|
||||
local stand = { x = 13, y = 6, facing = "left", walk = "left" }
|
||||
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
|
||||
local ow = game.overworld
|
||||
local npc
|
||||
for _, n in ipairs(ow.npcs or {}) do
|
||||
if n.def and n.def.name == TRAINER then npc = n end
|
||||
end
|
||||
check("the Bug Catcher loaded on " .. MAP, npc ~= nil)
|
||||
|
||||
-- a map edit that moves or re-aims him would park us facing empty grass,
|
||||
-- so re-derive the approach from where he actually is and which way he
|
||||
-- looks: stand three cells down his line and walk back along it
|
||||
if npc and (npc.cellX ~= 10 or npc.cellY ~= 6 or npc.facing ~= "right") then
|
||||
local away = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
local back = { up = "down", down = "up", left = "right", right = "left" }
|
||||
local d = away[npc.facing] or away.right
|
||||
local cx, cy = npc.cellX + d[1] * 3, npc.cellY + d[2] * 3
|
||||
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
|
||||
U.log("he is not at (10, 6) facing right any more, approaching from",
|
||||
cx, cy)
|
||||
stand = { x = cx, y = cy, facing = back[npc.facing] or "left",
|
||||
walk = back[npc.facing] or "left" }
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
|
||||
ow = game.overworld
|
||||
end
|
||||
end
|
||||
|
||||
-- walk into the line one frame at a time and stop the moment the bubble
|
||||
-- is up, so the shot lands well inside the 60-frame hold
|
||||
local spotted = false
|
||||
for _ = 1, 90 do
|
||||
U.hold(game, stand.walk, 1)
|
||||
if ow.emote and ow.emote.npc and ow.emote.bubble ~= false then
|
||||
spotted = true
|
||||
break
|
||||
end
|
||||
end
|
||||
check("walking in triggered the bubble", spotted)
|
||||
U.log("player at", ow.player.cellX, ow.player.cellY,
|
||||
"bubble frames left:", ow.emote and ow.emote.frames)
|
||||
|
||||
local shotPath = DIR .. "/bug505_emote.png"
|
||||
local wrote = U.shot(game, shotPath)
|
||||
check("the window rendered and the screenshot reached disk", wrote)
|
||||
|
||||
-- what the human sees is one 16x16 blit of ow.emoteImg, so read that
|
||||
-- image back rather than hunting pixels in the framebuffer: draw it to a
|
||||
-- scratch canvas and sample it. Canvas readback outside love.draw is not
|
||||
-- guaranteed on every driver, hence the pcall.
|
||||
local img = ow.emoteImg
|
||||
check("the draw site built its emote image", img ~= nil)
|
||||
if img then
|
||||
local ok, body, edge, corner = pcall(function()
|
||||
local canvas = love.graphics.newCanvas(img:getDimensions())
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, 0, 0)
|
||||
love.graphics.setCanvas()
|
||||
local id = canvas:newImageData()
|
||||
local b = select(1, id:getPixel(BODY_X, BODY_Y))
|
||||
local e = select(1, id:getPixel(EDGE_X, EDGE_Y))
|
||||
local _, _, _, a = id:getPixel(0, 0)
|
||||
return b, e, a
|
||||
end)
|
||||
if ok then
|
||||
U.log(("baked body %.3f, outline %.3f, corner alpha %.3f")
|
||||
:format(body, edge, corner))
|
||||
check("the bubble body came out white, not the 170 grey of #505",
|
||||
body > 0.9)
|
||||
check("the outline is still black", edge < 0.1)
|
||||
check("the corners are still keyed out", corner < 0.5)
|
||||
else
|
||||
U.log("could not read the baked image back:", tostring(body))
|
||||
U.log("judge the colour off the screenshot alone")
|
||||
end
|
||||
end
|
||||
|
||||
if failed > 0 then
|
||||
U.log(failed, "check(s) failed above; fix those before eyeballing anything")
|
||||
end
|
||||
|
||||
-- put him back on the board so the moment can be replayed by hand
|
||||
U.wait(20)
|
||||
U.teleport(game, MAP, stand.x, stand.y, stand.facing)
|
||||
U.log("the bubble has already been triggered once and saved to " .. shotPath)
|
||||
U.log("its interior should read pure white with a black outline and the")
|
||||
U.log("grass showing through the corners; #505 was a flat mid grey inside.")
|
||||
U.log("hold " .. stand.walk .. " to walk into his line and pop it again.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,116 @@
|
||||
-- CONTROLS rebinding: a captured key must not reach the live map while the
|
||||
-- screen that captured it is still steering by that map (#510). Swapping A
|
||||
-- and B used to close the screen mid-swap, because Input:applyBindings ran
|
||||
-- inside BindingsMenu:storeBinding and turned the player's next confirm
|
||||
-- press into a cancel. No pokered cite: rebinding is port-only (gap C2).
|
||||
-- luajit tests/engine/rebind_capture_bug510.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Input = require("src.core.Input")
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
|
||||
-- the two doubles BindingsMenu touches: a stack it can pop itself off and
|
||||
-- an input whose queue is one fixed step of edges
|
||||
local function newGame()
|
||||
local game = { save = { options = {} }, wroteOptions = 0 }
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(state, btn)
|
||||
state.game.input.queue = { [btn] = true }
|
||||
state:update(1 / 60)
|
||||
state.game.input.queue = {}
|
||||
end
|
||||
|
||||
local function openMenu(game)
|
||||
local bm = BindingsMenu.new(game)
|
||||
game.stack:push(bm)
|
||||
return bm
|
||||
end
|
||||
|
||||
-- rows are BindingsMenu's BUTTONS order; 5 = A, 6 = B
|
||||
local ROW_A, ROW_B = 5, 6
|
||||
|
||||
-- The mechanism, pinned so a future "just apply it immediately" revert
|
||||
-- fails here: a rebind overwrites whatever the key used to do, so B = Z
|
||||
-- costs Z its default A action.
|
||||
Input:init()
|
||||
eq(Input.keyBindings["z"], "a", "Z presses A in the default map")
|
||||
Input:applyBindings({ b = { key = "z" } })
|
||||
eq(Input.keyBindings["z"], "b",
|
||||
"applying B = Z takes Z away from A, so a live apply would flip confirm "
|
||||
.. "into cancel")
|
||||
Input:init()
|
||||
|
||||
-- The reporter's flow: arm B, bind Z to it, then keep using the menu.
|
||||
local game = newGame()
|
||||
local bm = openMenu(game)
|
||||
press(bm, "a") -- open the row the cursor starts on to prove arming
|
||||
eq(bm.capture, bm.items[1], "A on a row arms the capture")
|
||||
bm:onKeyPressed("escape")
|
||||
check(bm.capture == nil, "escape disarms an armed capture")
|
||||
check(game.save.options.bindings == nil,
|
||||
"escaping a capture writes no binding")
|
||||
eq(game.wroteOptions, 0, "and does not touch options on disk")
|
||||
|
||||
bm.index = ROW_B
|
||||
press(bm, "a")
|
||||
bm:onKeyPressed("z")
|
||||
eq(game.save.options.bindings.b.key, "z", "the capture stores B = Z")
|
||||
eq(bm.items[ROW_B].right, "Z", "the row shows the new key straight away")
|
||||
eq(game.wroteOptions, 1, "the choice persists immediately")
|
||||
eq(Input.keyBindings["z"], "a",
|
||||
"but the live map still reads Z as A while the screen is open (#510)")
|
||||
eq(#game.stack.states, 1, "capturing Z does not close the screen")
|
||||
|
||||
-- the next Z the player presses is still confirm, so the A row can be armed
|
||||
bm.index = ROW_A
|
||||
press(bm, "a")
|
||||
eq(bm.capture, bm.items[ROW_A], "the A row arms instead of the screen closing")
|
||||
bm:onKeyPressed("x")
|
||||
eq(game.save.options.bindings.a.key, "x", "the swap's other half stores")
|
||||
eq(Input.keyBindings["x"], "b", "and X is still cancel until the screen closes")
|
||||
|
||||
-- closing commits both halves at once, through ListMenu's onCancel
|
||||
press(bm, "b")
|
||||
eq(#game.stack.states, 0, "B closes the rebind screen")
|
||||
eq(Input.keyBindings["z"], "b", "closing puts the swap live: Z is B")
|
||||
eq(Input.keyBindings["x"], "a", "and X is A")
|
||||
|
||||
-- A close that never runs the hook still ends up correct, because
|
||||
-- Game:applyOptions re-applies save.options.bindings on load.
|
||||
Input:init()
|
||||
Input:applyBindings(game.save.options.bindings)
|
||||
eq(Input.keyBindings["z"], "b", "a reload reaches the same map as the close")
|
||||
|
||||
-- pad captures ride the same deferral
|
||||
Input:init()
|
||||
local padGame = newGame()
|
||||
local padBm = openMenu(padGame)
|
||||
padBm.index = ROW_B
|
||||
press(padBm, "a")
|
||||
padBm:onGamepadPressed("y")
|
||||
eq(padGame.save.options.bindings.b.pad, "y", "a pad capture stores")
|
||||
eq(Input.padBindings["y"], nil, "and stays out of the live pad map until close")
|
||||
press(padBm, "b")
|
||||
eq(Input.padBindings["y"], "b", "closing commits the pad half too")
|
||||
|
||||
Input:init()
|
||||
T.finish("rebind_capture_bug510")
|
||||
Reference in New Issue
Block a user