squashy squash (#127)

This commit is contained in:
bryanthaboi
2026-07-23 15:42:54 -04:00
committed by GitHub
parent c04b93ce1c
commit 1c4515eaaa
21 changed files with 1437 additions and 158 deletions
+11
View File
@@ -148,6 +148,17 @@ function Font.advanceOf(code)
return page and page.advance or GLYPH
end
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
-- Multi-byte charmap entries like "¥" are one glyph; callers that
-- right-align with `#text * 8` mis-place them.
function Font.width(text)
local w = 0
for _, code in ipairs(Font.encode(text)) do
w = w + Font.advanceOf(code)
end
return w
end
-- Draw a plain single-line string at pixel (x, y). Returns the width
-- drawn, which is #codes * 8 for every fixed-width page.
function Font.draw(text, x, y)
+13
View File
@@ -62,6 +62,9 @@ end
function Renderer:beginFrame(transparent)
self.worldActive = false
self.uprightActive = false
-- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil
-- last frame's trueColor rects and sprite redraws go before anything
-- draws this one
PaletteFX.clearTrueColor()
@@ -414,6 +417,16 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s)
love.graphics.setScissor()
end
-- Screen-space warp fade (Transition) over the full world composite so
-- survey zoom / tilt edges darken with the center, not only the 160x144
-- UI letterbox. Drawn before the UI blit so menus above a fade still
-- composite normally if one is ever stacked that way.
local fade = self.worldFadeAlpha
if fade and fade > 0 then
love.graphics.setColor(0, 0, 0, fade)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- UI stays in the classic centered GB letterbox
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
+10
View File
@@ -57,6 +57,16 @@ end
function Transition:draw()
local alpha = self.t / self.frames
if self.phase == "in" then alpha = 1 - alpha end
-- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
-- a screen-space overlay over the full composite instead.
local r = self.game and self.game.renderer
if r and r.worldActive then
r.worldFadeAlpha = alpha
return
end
love.graphics.setColor(0, 0, 0, alpha)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
+6
View File
@@ -438,6 +438,12 @@ function Commands.play_sound(ctx, soundId)
require("src.core.Sound").play(ctx.game.data, soundId)
end
-- play_once <songId>: one-shot jingle (Music_PkmnHealed, etc.); the map
-- theme resumes when it ends (Music.playOnce / pendingRestore)
function Commands.play_once(ctx, songId)
require("src.core.Music").playOnce(ctx.game.data, songId)
end
-- play_cry <species>: PlayCry (home/audio.asm). The text_asm bodies that
-- use it run text_far (a no-button-wait "...@" string) -> PlayCry ->
-- WaitForSoundToFinish, all within the same text ID -- the cry only starts
+5 -1
View File
@@ -145,6 +145,10 @@ end
function BoxMenu.new(game)
Boxes.ensure(game.save)
-- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior
-- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the
-- full interior (cursor col + label); the old tw=12 right-side box
-- drew the final glyph on the border.
return Menu.new(game, {
{ label = "WITHDRAW", onSelect = function() withdraw(game) end },
{ label = "DEPOSIT", onSelect = function() deposit(game) end },
@@ -153,7 +157,7 @@ function BoxMenu.new(game)
{ label = "SEE YA!" },
-- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND,
-- engine/menus/pokemon_pc.asm)
}, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true })
}, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true })
end
return BoxMenu
+13 -3
View File
@@ -117,7 +117,7 @@ function ListMenu:draw()
love.graphics.setColor(0, 0, 0, 1)
end
if item.right then
Font.draw(item.right, 160 - 8 - #item.right * 8, y)
Font.draw(item.right, 160 - 8 - Font.width(item.right), y)
end
if i == self.index then
-- hollowIndex: a chosen row keeps the hollow '▷' left behind by
@@ -136,7 +136,7 @@ function ListMenu:draw()
Font.drawBox(11, 0, 9, 3)
love.graphics.setColor(0, 0, 0, 1)
local money = ("¥%d"):format(self.money and self.money() or 0)
Font.draw(money, 152 - #money * 8, 8)
Font.draw(money, 152 - Font.width(money), 8)
-- the clerk's line in the standard bottom text box; long prompts
-- wrap and keep their last two lines, like the GB's scrolled box
Font.drawBox(0, 12, 20, 6)
@@ -153,7 +153,17 @@ function ListMenu:draw()
end
end
elseif self.footer then
Font.draw(self.footer, 8, 136)
-- PC deposit/withdraw footers use "\n"; draw the last two lines so
-- long item names are not clipped at the screen edge (#115).
local flat = {}
for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do
for _, line in ipairs(page) do flat[#flat + 1] = line end
end
local y = (#flat >= 2) and 120 or 136
for i = math.max(1, #flat - 1), #flat do
Font.draw(flat[i], 8, y)
y = y + 16
end
end
love.graphics.setColor(1, 1, 1, 1)
end
+3 -2
View File
@@ -182,12 +182,13 @@ function PartyMenu:update(dt)
-- and music return first (PlayDefaultMusic +
-- LoadWalkingPlayerSpriteGraphics), the menu closes with the
-- GBPalWhiteOutWithDelay3 blink, and the simulated pad press
-- steps the player forward onto land
-- steps the player forward onto land (or across a connection
-- strip when the shore is the next map's edge)
self.game.stack:pop()
ow.player.surfing = false
require("src.core.Music").setSurfing(self.game.data, false)
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
ow:scriptMove(ow.player, ow.player.facing, 1)
ow:stepForwardOrCrossEdge(ow.player.facing)
end))
return
end
+7 -3
View File
@@ -84,13 +84,17 @@ local function buy(game, stock)
end
local function sell(game)
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
-- in the quantity chooser. Stuffing "xN" into the label next to a
-- right-aligned ¥ price made long names overlap (issue #116).
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
table.insert(items, {
value = id,
label = (def and def.name or id) .. " x" .. game.save.inventory[id],
right = ("¥%d"):format(def and math.floor(def.price / 2) or 0),
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
})
end
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
@@ -128,7 +132,7 @@ local function sell(game)
Bag.remove(game.save, item.value, qty)
local left = game.save.inventory[item.value]
if left then
item.label = def.name .. " x" .. left
item.right = "x" .. left
else
list:removeCurrent()
end
+7 -3
View File
@@ -115,12 +115,16 @@ function SummaryMenu:draw()
drawLineBox(19, 1, 6, 10)
Font.draw("EXP POINTS", 72, 24)
Font.draw(("%d"):format(mon.exp), 96, 32)
Font.draw("LEVEL UP", 72, 44)
-- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols
-- at (7,6); space at (14,6); PrintLevel at (16,6). The old
-- "%d to L%d" string at x=88 overflowed the DrawLineBox edge.
Font.draw("LEVEL UP", 72, 40)
local Growth = require("src.pokemon.Growth")
local nextExp = mon.level < 100
and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0
Font.draw(("%d to L%d"):format(math.max(0, nextExp),
math.min(100, mon.level + 1)), 88, 52)
Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48)
HudTiles.tile(0x6E, 128, 48) -- <LV>
Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48)
Font.drawBox(0, 8, 20, 10)
for i = 1, 4 do
local mv = mon.moves[i]
+47 -26
View File
@@ -38,6 +38,51 @@ local function hashSet(list, into)
return into
end
-- Collision tile (bottom-left 8x8) of a cell on an UNLOADED map def --
-- the connected neighbor during an edge crossing. pokered's
-- GetTileAndCoordsInFrontOfPlayer / collision checks read the neighbor
-- strip's tile bytes the same way.
function Map.defCellTile(def, tilesetDef, cx, cy)
if not (def and tilesetDef and tilesetDef.blocks) then return nil end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return nil end
return block[(ty % 4) * 4 + (tx % 4) + 1]
end
local function defWaterTileSet(def, tilesetDef)
local water = {}
hashSet(tilesetDef.waterTiles or WATER_TILES, water)
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end
hashSet(shore or {}, water)
return water
end
-- Water/shore on an unloaded map def (same tile ids as Map:isWaterCell).
function Map.defIsWaterCell(def, tilesetDef, cx, cy)
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
return defWaterTileSet(def, tilesetDef)[tile] or false
end
function Map.defIsWalkableCell(def, tilesetDef, cx, cy)
if not (tilesetDef and tilesetDef.walkable) then return false end
local tile = Map.defCellTile(def, tilesetDef, cx, cy)
if tile == nil then return false end
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
return false
end
-- Passability of a cell of an UNLOADED map def -- the connected neighbor
-- during an edge crossing. pokered's collision check reads the neighbor
-- strip's tile bytes, so a step off the map edge onto a solid tile of
@@ -52,32 +97,8 @@ function Map.defPassable(def, tilesetDef, cx, cy, surfing)
if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then
return false
end
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then
id = def.borderBlock
else
id = def.blocks[by * def.width + bx + 1]
end
local block = tilesetDef.blocks[(id or 0) + 1]
if not block then return false end
local tile = block[(ty % 4) * 4 + (tx % 4) + 1]
for _, t in ipairs(tilesetDef.walkable) do
if t == tile then return true end
end
if surfing then
for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do
if t == tile then return true end
end
local shore = tilesetDef.shoreTiles
if shore == nil and not NO_SHORE_TILESETS[def.tileset] then
shore = SHORE_TILES
end
for _, t in ipairs(shore or {}) do
if t == tile then return true end
end
end
if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end
if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end
return false
end
+132 -32
View File
@@ -338,10 +338,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
or (fromMapId and "warp" or "boot"),
})
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers)
-- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers).
-- fromMapId lets elevators seed a valid walk-out floor when the ROM
-- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and
-- the player B-cancels the floor menu without .UpdateWarp.
local hooks = mapScripts.get(mapId)
if hooks and hooks.onEnter then
hooks.onEnter(Game, self)
hooks.onEnter(Game, self, fromMapId)
end
self:rebuildNeighbors()
@@ -1000,20 +1003,19 @@ function OverworldState:checkEdgeExit(dir)
return false
end
-- Map connections: the connected map's strip offset is in blocks; arriving
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
-- The crossing scrolls continuously: the map data swaps while the player
-- is placed one cell before the entry point (their old world position,
-- which the neighbor strips render identically) and walks the seam step.
function OverworldState:crossConnection(dir, conn)
-- Landing cell on the connected map for a step off this map's edge in
-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y
-- or nil when there is no usable connection.
function OverworldState:connectionLanding(dir)
local conn = self.map:connection(COMPASS[dir])
if not conn then return nil end
local dest = Game.data.maps[conn.map]
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn.map))
return false
end
if not dest then return nil end
local ts = Game.data.tilesets[dest.tileset]
if not ts then return nil end
local p = self.player
local x, y = p.cellX, p.cellY
local destW, destH = dest.width * 2, dest.height * 2
local x, y
if dir == "up" then
x, y = p.cellX - conn.offset * 2, destH - 1
elseif dir == "down" then
@@ -1025,13 +1027,27 @@ function OverworldState:crossConnection(dir, conn)
end
x = math.max(0, math.min(destW - 1, x))
y = math.max(0, math.min(destH - 1, y))
return dest, ts, x, y, conn
end
-- Map connections: the connected map's strip offset is in blocks; arriving
-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md).
-- The crossing scrolls continuously: the map data swaps while the player
-- is placed one cell before the entry point (their old world position,
-- which the neighbor strips render identically) and walks the seam step.
function OverworldState:crossConnection(dir, conn)
local dest, ts, x, y = self:connectionLanding(dir)
if not dest then
Logger.warn("connection to unknown map %s", tostring(conn and conn.map))
return false
end
local p = self.player
-- pokered's collision check reads the NEIGHBOR strip's tile bytes, so
-- stepping off the edge onto a solid tile of the connected map bumps
-- exactly like an in-map wall. Without this read, Pallet's south
-- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a
-- collision tile -- stranding the player on a cell no walk can leave.
if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y,
p.surfing) then
if not Map.defPassable(dest, ts, x, y, p.surfing) then
return false
end
self:setMap(conn.map, x, y, p.facing, { seamless = true })
@@ -1054,6 +1070,53 @@ function OverworldState:crossConnection(dir, conn)
return true
end
-- ItemUseSurfboard's simulated pad press: step onto the facing cell, or
-- cross a map connection when that cell is off this map's edge (Cinnabar
-- east coast -> Route 20 water, and the reverse dismount ashore).
function OverworldState:stepForwardOrCrossEdge(dir)
dir = dir or self.player.facing
local fx, fy = Collision.target(self.player.cellX, self.player.cellY, dir)
if not self.map:inBounds(fx, fy) then
return self:checkEdgeExit(dir)
end
self:scriptMove(self.player, dir, 1)
return true
end
-- IsNextTileShoreOrWater across a connection strip: pokered loads the
-- neighbor's tiles into the border, so wTileInFrontOfPlayer is the
-- connected map's tile even when the facing cell is off this map.
-- Shore/water classification still uses THIS map's tileset rules
-- (SHIP_PORT's $32 dock exception), matching the asm.
function OverworldState:facingIsShoreOrWater()
if not self:tilesetHasWater() then return false end
local fx, fy = self.player:facingCell()
if self.map:inBounds(fx, fy) then
return self.map:isWaterCell(fx, fy)
end
local dest, ts, x, y = self:connectionLanding(self.player.facing)
if not dest then return false end
local tile = Map.defCellTile(dest, ts, x, y)
if tile == nil then return false end
return self.map.waterTiles[tile] or false
end
-- tryToStopSurfing land check, including a land landing across a map
-- connection (surf off Cinnabar's east coast water back onto the coast).
function OverworldState:facingIsLandDismount()
local p = self.player
local fx, fy = p:facingCell()
if self.map:inBounds(fx, fy) then
return self.map:isWalkableCell(fx, fy)
and Collision.canMove(self.map, self.entities, p, p.facing)
end
local dest, ts, x, y = self:connectionLanding(p.facing)
if not dest then return false end
if not Map.defIsWalkableCell(dest, ts, x, y) then return false end
-- IsSpriteInFrontOfPlayer2: no current-map sprite can sit past the edge
return not Collision.occupied(self.entities, fx, fy, p)
end
-- -------------------------------------------------------------------------
-- interactions
-- -------------------------------------------------------------------------
@@ -1571,16 +1634,21 @@ function OverworldState:trashCanSwitch(canIndex)
end
-- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm
-- BillsHousePC): once Bill-the-Pokémon has climbed into the machine
-- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell
-- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and
-- Bill steps back out of the machine human again
-- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL).
-- BillsHousePC). Check order matches pokered:
-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list
-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text
-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene
-- 4) else -> teleporter monitor text
-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1).
function OverworldState:billsHousePC()
local t = Game.data.text
local flags = Game.save.flags
if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR
and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then
if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then
self:billsHousePokemonList()
return
end
if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL
or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then
Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText
or "TELEPORTER is\ndisplayed on the\nPC monitor."))
return
@@ -1604,9 +1672,40 @@ function OverworldState:billsHousePC()
end))
end
-- BillsHousePokemonList: EEVEE / FLAREON / JOLTEON / VAPOREON + CANCEL;
-- picking one runs DisplayPokedex (DexEntryMenu) and returns to the list.
function OverworldState:billsHousePokemonList()
local t = Game.data.text
local Menu = require("src.ui.Menu")
local function openList()
local species = { "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" }
local items = {}
for _, id in ipairs(species) do
local def = Game.data.pokemon[id]
table.insert(items, {
label = (def and def.name) or id,
keepOpen = true,
onSelect = function()
local dex = Game.save.pokedex
if dex then dex.seen[id] = true end
Screens.push(Game, "DexEntryMenu", id)
end,
})
end
table.insert(items, { label = "CANCEL" })
-- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12
Game.stack:push(Menu.new(Game, items,
{ tx = 0, ty = 0, tw = 11, th = 12 }))
end
Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1
or "BILL's favorite\nPOKéMON list!", openList))
end
-- BillsHouseBillExitsMachineScript: human Bill appears inside the machine
-- at (1,2) and walks out to his spot at (4,4); the map music resumes and
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue.
-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee
-- PC list arms later, on the first Route 25 load after the ticket
-- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING).
function OverworldState:billsHouseBillExits()
local Commands = require("src.script.Commands")
local ctx = { game = Game, save = Game.save, overworld = self }
@@ -1698,14 +1797,15 @@ function OverworldState:trySurf(fx, fy)
Game.stack:push(TextBox.new(Game, text, function()
-- start_sub_menus.asm .surf: UseItem returns (mount + text done),
-- then GBPalWhiteOutWithDelay3 blinks before the simulated forward
-- press steps onto the water
-- press steps onto the water (or across a connection strip, like
-- Cinnabar's east coast onto Route 20)
local Transition = require("src.render.Transition")
if Transition.whiteFlash then
Game.stack:push(Transition.whiteFlash(Game, nil, function()
self:scriptMove(p, p.facing, 1)
self:stepForwardOrCrossEdge(p.facing)
end))
else
self:scriptMove(p, p.facing, 1)
self:stepForwardOrCrossEdge(p.facing)
end
end))
end
@@ -1805,20 +1905,20 @@ function OverworldState:useSurfFieldMove()
-- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set.
if Game.save.forcedBike then return "forced_bike" end
if self:surfBlockedHere() then return "current" end
local fx, fy = p:facingCell()
if p.surfing then
-- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front
-- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a
-- facing tile that isn't in the tileset's land-passable list;
-- otherwise the player walks forward off the water.
if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy)
and Collision.canMove(self.map, self.entities, p, p.facing) then
-- otherwise the player walks forward off the water. Facing a land
-- cell across a map connection (Cinnabar east coast) counts too --
-- pokered reads that landing from the connection strip.
if self:facingIsLandDismount() then
return "dismount"
end
return "no_place"
end
if not self.map:inBounds(fx, fy)
or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then
-- IsNextTileShoreOrWater, including connection-strip water (issue #125)
if not self:facingIsShoreOrWater() then
return "no_water"
end
return "ok"