mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 12:40:21 +02:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69100301a1 | |||
| a5b674f9da | |||
| 4356b94483 | |||
| c8bd205d0c | |||
| 995444774b | |||
| 360b692963 | |||
| 051040371f | |||
| c5ff95edcf | |||
| d6627eda4c | |||
| 6e28cd5dca | |||
| 08ddb882af | |||
| 2f6f094559 | |||
| 6ac425144d | |||
| 73f561e256 | |||
| b739fa76c0 | |||
| 0b00faf38e | |||
| b20b1370ab | |||
| 467566c799 | |||
| d6ddf23f97 | |||
| 9423337bcc | |||
| c280119d03 | |||
| 24d0c6528d | |||
| 1fc34da2a9 | |||
| 70b9def0b0 | |||
| d03d2af5f8 | |||
| c23f85cba9 | |||
| 455ff21aff | |||
| 2da2168dac | |||
| f62b1268c8 | |||
| 82ae667611 | |||
| 2b5229e73f | |||
| 881670db91 | |||
| a542ed90ba | |||
| e9a4a592a4 | |||
| 70f7d5c028 | |||
| 46f73b7bb3 | |||
| 99d9908017 | |||
| b72d1d34b5 | |||
| f3619a00c2 | |||
| 524138ff27 | |||
| fdffb12571 | |||
| 0c941cecd4 | |||
| dd59175c71 | |||
| 114352b75f | |||
| 0e40a7a1f4 | |||
| fe6a580e20 | |||
| a910b65434 | |||
| 1f3d13adaf | |||
| 393a1013e4 | |||
| a3a20a07e1 | |||
| 1151c188a7 | |||
| b39e11b7cd | |||
| 65128e13a4 | |||
| d74662ba99 | |||
| 32e6e4cef0 | |||
| fc841f7525 | |||
| 12fdfa1e88 | |||
| 90eb53b00c | |||
| 6780393f45 | |||
| 530f2bdd15 |
@@ -37,12 +37,16 @@ body:
|
||||
id: os
|
||||
attributes:
|
||||
label: Which build are you running
|
||||
description: Official release targets. Pick Multiple platforms if you saw it on more than one.
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
- Android
|
||||
- iOS
|
||||
- Nintendo Switch
|
||||
- Xbox
|
||||
- Anbernic RG34XXSP
|
||||
- Multiple platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -6,18 +6,30 @@ function love.conf(t)
|
||||
|
||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||
local companion = nil
|
||||
if arg then
|
||||
for _, a in ipairs(arg) do
|
||||
if a == "--editor" then editor = true end
|
||||
if a == "--developer" then developer = true end
|
||||
local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
|
||||
if port then companion = { port = tonumber(port), token = token } end
|
||||
end
|
||||
end
|
||||
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
||||
-- decision in a global so it doesn't need to reparse `arg`.
|
||||
_G.POKEPORT_EDITOR_MODE = editor
|
||||
_G.POKEPORT_DEV_MODE = developer
|
||||
_G.POKEPORT_DISPLAY_COMPANION = companion
|
||||
|
||||
if editor then
|
||||
if companion then
|
||||
t.identity = "pokemon-love2d-companion"
|
||||
t.window.title = "gen1recomp Secondary Display"
|
||||
t.window.width = 640
|
||||
t.window.height = 576
|
||||
t.window.minwidth = 160
|
||||
t.window.minheight = 144
|
||||
t.window.resizable = true
|
||||
elseif editor then
|
||||
-- Same identity as the game, deliberately: the editor edits the game's
|
||||
-- saves and reads the game's ROM cache, both of which live under this
|
||||
-- folder. A private editor identity would point love.filesystem at an
|
||||
@@ -51,7 +63,8 @@ function love.conf(t)
|
||||
end
|
||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||
t.window.vsync = 1
|
||||
t.modules.joystick = true
|
||||
t.modules.audio = not companion
|
||||
t.modules.joystick = not companion
|
||||
t.modules.physics = false
|
||||
|
||||
-- love.system is not loaded during love.conf; love._os is set by the
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
-- guide and SUPER_NERD2 garden nerd.
|
||||
--
|
||||
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
||||
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
||||
-- YES/NO-branched flavor text is here.
|
||||
-- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
|
||||
-- is below.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -25,11 +24,138 @@ local function ask(game, s, cb)
|
||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||
end
|
||||
|
||||
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
|
||||
local museumGuySteps = {
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up",
|
||||
"left",
|
||||
}
|
||||
|
||||
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
|
||||
local museumPlayerRle = {
|
||||
"NO",
|
||||
"up", "up", "up",
|
||||
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||
"left", "left", "left", "left", "left",
|
||||
"up", "up", "up", "up", "up", "up",
|
||||
}
|
||||
|
||||
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
|
||||
local museumPreambles = {
|
||||
["27,18"] = { "up", "up" },
|
||||
["27,16"] = { "right", "left" },
|
||||
["26,17"] = { "up", "right" },
|
||||
["28,17"] = { "up", "left" },
|
||||
}
|
||||
|
||||
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
|
||||
-- pewterEscort.playerPlan in story5.lua
|
||||
local function museumPlan(x, y)
|
||||
local pre = museumPreambles[x .. "," .. y]
|
||||
if not pre then return nil end
|
||||
local buf = {}
|
||||
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
|
||||
buf[#buf] = pre[1]
|
||||
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||
local path = {}
|
||||
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||
local head = 0
|
||||
while path[head + 1] == "NO" do head = head + 1 end
|
||||
local tail = #path
|
||||
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||
local steps = {}
|
||||
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||
end
|
||||
|
||||
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
|
||||
local function museumEscortWalk(game, ow)
|
||||
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
|
||||
if not plan then return false end
|
||||
local Music = require("src.core.Music")
|
||||
local t = text(game)
|
||||
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
|
||||
local head = plan.guyHeadStart
|
||||
|
||||
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
|
||||
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
|
||||
local function walkOut()
|
||||
if not guy then return end
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
if i > 4 then
|
||||
guy.cellX, guy.cellY = 27, 17
|
||||
guy.px, guy.py = 27 * 16, 17 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "down"
|
||||
return
|
||||
end
|
||||
ow:scriptMove(guy, "down", 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
|
||||
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
|
||||
local function afterWalk()
|
||||
if guy then
|
||||
guy.stepFrames = nil
|
||||
guy.cellX, guy.cellY = 13, 8
|
||||
guy.px, guy.py = 13 * 16, 8 * 16
|
||||
guy.moving = false
|
||||
guy.targetX, guy.targetY = nil, nil
|
||||
guy.facing = "up"
|
||||
end
|
||||
Music.playMap(game.data, "PEWTER_CITY")
|
||||
push(game, t._PewterCitySuperNerd1ItsRightHereText
|
||||
or "It's right here!", walkOut)
|
||||
end
|
||||
|
||||
local function lockstep()
|
||||
local i = 0
|
||||
local function tick()
|
||||
i = i + 1
|
||||
local ps = plan.steps[i]
|
||||
if not ps then
|
||||
afterWalk()
|
||||
return
|
||||
end
|
||||
local gs = museumGuySteps[head + i]
|
||||
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||
ow:scriptMove(ow.player, ps, 1, tick)
|
||||
end
|
||||
tick()
|
||||
end
|
||||
|
||||
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||
if guy then
|
||||
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||
end
|
||||
Music.play(game.data, "Music_MuseumGuy")
|
||||
if guy and head > 0 then
|
||||
local h = 0
|
||||
local function headTick()
|
||||
h = h + 1
|
||||
if h > head then lockstep(); return end
|
||||
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
|
||||
end
|
||||
headTick()
|
||||
else
|
||||
lockstep()
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
M.PEWTER_CITY = {
|
||||
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
|
||||
talk = {
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you
|
||||
-- checked out the museum; YES -> fossils comment, NO -> "you have
|
||||
-- to go" (which in pokered also kicks off the escort script).
|
||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
|
||||
-- fossils comment, NO -> "you have to go" and the museum escort
|
||||
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
|
||||
@@ -39,7 +165,10 @@ M.PEWTER_CITY = {
|
||||
or "Weren't those\nfossils from MT.\nMOON amazing?", done)
|
||||
else
|
||||
push(game, t._PewterCitySuperNerd1YouHaveToGoText
|
||||
or "Really?\nYou absolutely\nhave to go!", done)
|
||||
or "Really?\nYou absolutely\nhave to go!", function()
|
||||
museumEscortWalk(game, ow)
|
||||
if done then done() end
|
||||
end)
|
||||
end
|
||||
end)
|
||||
end,
|
||||
|
||||
@@ -288,6 +288,7 @@ return {
|
||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||
local rows = {
|
||||
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
|
||||
@@ -263,6 +263,7 @@ return {
|
||||
local rival = ow:npcByIndex(RIVAL)
|
||||
if not rival then return false end
|
||||
local rows = {
|
||||
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
|
||||
{ "face_player_dir", "up" },
|
||||
{ "stop_music" },
|
||||
{ "play_music", "Music_MeetRival" },
|
||||
|
||||
@@ -140,6 +140,10 @@ M.VIRIDIAN_CITY = {
|
||||
-- Daisy hands over the TOWN MAP once Oak's errand is under way
|
||||
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
|
||||
M.BLUES_HOUSE = {
|
||||
-- scripts/BluesHouse.asm:12-16
|
||||
onEnter = function(game, ow)
|
||||
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
|
||||
end,
|
||||
talk = {
|
||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||
{ "face_player" },
|
||||
|
||||
@@ -87,6 +87,18 @@ end
|
||||
M.PALLET_TOWN = {
|
||||
talk = require("data.scripts.pallet_town").talk,
|
||||
escort = escort,
|
||||
-- scripts/PalletTown.asm:133-144
|
||||
onEnter = function(game, ow)
|
||||
local f = game.save.flags
|
||||
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
|
||||
and not f.EVENT_DAISY_WALKING then
|
||||
f.EVENT_DAISY_WALKING = true
|
||||
local Commands = require("src.script.Commands")
|
||||
local ctx = { save = game.save, game = game, overworld = ow }
|
||||
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
|
||||
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
|
||||
end
|
||||
end,
|
||||
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
||||
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
||||
-- PalletTownPikachuBattleScript).
|
||||
|
||||
@@ -937,15 +937,10 @@ M.VERMILION_DOCK = {
|
||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||
end
|
||||
puff(3, 15)
|
||||
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
||||
-- player alone ("south of the player and won't be redrawn"), so skip
|
||||
-- his own block: he must not spend the walk-out standing on water
|
||||
local pbx = math.floor(ow.player.cellX / 2)
|
||||
local pby = math.floor(ow.player.cellY / 2)
|
||||
-- scripts/VermilionDock.asm:182-203
|
||||
local rows = {}
|
||||
local function setBlock(bx, by, block)
|
||||
if bx < 1 or bx > 8 then return end
|
||||
if bx == pbx and by == pby then return end
|
||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||
end
|
||||
rows[#rows + 1] = { "wait", 120 }
|
||||
|
||||
@@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix.
|
||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||
`render.letterbox`, `render.hud`. Each sits
|
||||
`render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
|
||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||
first refusal on, the palette zone list handed to the present pass, the
|
||||
|
||||
+61
-14
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
|
||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
||||
are valid choices. The list is empty while the world is busy, and omits an
|
||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
||||
contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
|
||||
eligible `sources`; each source contains its eligible `targets`. Fishing rows
|
||||
include the owned rods that are valid choices. The list is empty while the
|
||||
world is busy, and omits an action whenever its item, move, badge, terrain, or
|
||||
engine state forbids it.
|
||||
The optional second return is `"world is busy"` during transient input locks
|
||||
or `"no overworld"` before a playable world exists.
|
||||
|
||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
||||
and chooses automatically when only one rod is available. Red's `softboiled`
|
||||
accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
|
||||
record. Invalid, stale, and busy requests return `nil` plus a reason without
|
||||
changing game state. Mods do not need generation-specific badge, terrain,
|
||||
bike, fishing, or field-move
|
||||
logic. Action lists are extensible; callers should render the records they
|
||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||
|
||||
Red exposes FLY separately because it requires a destination picker:
|
||||
`mod.world:canFly()` reports whether FLY is eligible at the current location,
|
||||
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||
Fly town list. Gold does not expose these two methods yet.
|
||||
|
||||
## Read-only battle snapshots
|
||||
|
||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||
@@ -278,10 +287,16 @@ The shared Red, Blue, Yellow, and Gold intents are:
|
||||
- `{ kind = "move", slot = 1..4 }`
|
||||
- `{ kind = "back" }` while the move menu is active
|
||||
|
||||
Red, Blue, and Yellow also expose their generation-specific choices:
|
||||
|
||||
- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
|
||||
other accepted actions)
|
||||
- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
|
||||
|
||||
Menu choices and moves use the same engine methods as the native controls;
|
||||
`party` and `item` open the native screens rather than exposing or duplicating
|
||||
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
|
||||
states refuse these core intents. Use `mod.input` for ordinary text advance.
|
||||
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||
refuse core intents. Use `mod.input` for ordinary text advance.
|
||||
|
||||
## Rendering pipelines
|
||||
|
||||
@@ -654,11 +669,14 @@ the wrapper is visible during that same fixed step. The callback receives
|
||||
|
||||
`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
|
||||
is `{ phase, source, id, x, y, gameX, gameY, insideGame, 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
|
||||
`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
|
||||
active game viewport and `insideGame` says whether the pointer is inside it.
|
||||
Without a custom viewport both coordinate pairs are identical. 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
|
||||
@@ -691,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
|
||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||
playfield or pushing an updating game state.
|
||||
|
||||
`render.viewport` lets a layout mod reserve the window-space rectangle in which
|
||||
the game renders. It receives `(next, ctx)` with the full window's `width`,
|
||||
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
|
||||
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
|
||||
window and makes game layout, safe-area calculations, and rendering use it as
|
||||
their display. Set `capture = true` to request a composition canvas even when
|
||||
the rectangle fills the window. With no subscriber, no canvas is allocated and
|
||||
the normal presentation path is unchanged.
|
||||
|
||||
When a viewport is active, `render.window` receives `(next, game, ctx)` after
|
||||
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
|
||||
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
|
||||
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
|
||||
a wrapper may instead compose that canvas with its own UI. Touch controls remain
|
||||
full-size OS-window chrome and draw after this hook.
|
||||
|
||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||
the mod full control of the window, while calling `next` runs the engine's
|
||||
@@ -699,11 +733,24 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
||||
`secondScreen` bridge (`available()` / `detected()` / `push(...)` /
|
||||
`pollTouch()` / `setEnabled`) for driving a second physical display.
|
||||
`detected()` reports a connected target even while its output is being created;
|
||||
`available()` means it can accept a frame now. `push(imageData, w, h)` retains
|
||||
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
|
||||
arguments request an extended presentation; a preference ending in `:cover`
|
||||
fills and crops the target, while other values preserve the whole frame.
|
||||
Android also accepts `handheld` or `secondary` (with an optional `:cover`
|
||||
suffix) as routing hints; unsupported or unavailable targets fall back to the
|
||||
other connected display.
|
||||
`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
|
||||
coordinates, or `nil`.
|
||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||
or push one onto a second screen, without the engine knowing the layout.
|
||||
On process-capable Windows, Linux and macOS hosts without a native display
|
||||
bridge, enabling this facade opens a second resizable app window instead. It
|
||||
uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
|
||||
contract, so a mod does not need a desktop-specific rendering path.
|
||||
|
||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||
for mods that need the engine's normal composite rather than its separate
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
-- opens the editor on that slot's file, and restores the launcher when
|
||||
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||
|
||||
if POKEPORT_DISPLAY_COMPANION then
|
||||
return require("src.render.DesktopCompanion").install(
|
||||
POKEPORT_DISPLAY_COMPANION)
|
||||
end
|
||||
|
||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||
|
||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||
@@ -15,6 +20,7 @@ local LaunchOptions = require("src.core.LaunchOptions")
|
||||
local NxDisplay = require("src.core.NxDisplay")
|
||||
local PlatformHooks = require("src.core.PlatformHooks")
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||
do
|
||||
@@ -503,24 +509,30 @@ end
|
||||
|
||||
function love.draw()
|
||||
if editorMode then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("editor", EditorApp)
|
||||
local result = EditorApp.draw()
|
||||
HostDisplay.endFrame("editor", EditorApp)
|
||||
return result
|
||||
end
|
||||
if TouchEditor then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("touch_editor", TouchEditor)
|
||||
local result = TouchEditor.draw()
|
||||
HostDisplay.endFrame("touch_editor", TouchEditor)
|
||||
return result
|
||||
end
|
||||
if Importer then
|
||||
GameViewport.reset()
|
||||
HostDisplay.beginFrame("launcher", Importer)
|
||||
local result = Importer:draw()
|
||||
HostDisplay.endFrame("launcher", Importer)
|
||||
return result
|
||||
end
|
||||
if not Game then return end
|
||||
if not Game then
|
||||
GameViewport.reset()
|
||||
return
|
||||
end
|
||||
|
||||
HostDisplay.beginFrame("game", Game)
|
||||
Game:draw()
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
android:allowBackup="true"
|
||||
android:icon="@drawable/love"
|
||||
android:label="${NAME}" >
|
||||
<meta-data
|
||||
android:name="android.allow_multiple_resumed_activities"
|
||||
android:value="true" />
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity"
|
||||
android:exported="true"
|
||||
@@ -49,5 +52,15 @@
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTask"
|
||||
android:resizeableActivity="false"
|
||||
android:screenOrientation="${ORIENTATION}"
|
||||
android:taskAffinity="${applicationId}.secondary"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
||||
return result;
|
||||
}
|
||||
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent)
|
||||
{
|
||||
if (url == nullptr || body == nullptr || bodyLen < 0)
|
||||
return false;
|
||||
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
// Same resolution rule as httpDownload: the activity's own class via
|
||||
// SDL_AndroidGetActivity, never FindClass -- this bridge is called off
|
||||
// the main thread (love.thread workers), whose class loader cannot see
|
||||
// app classes.
|
||||
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||
if (activityObj == nullptr)
|
||||
return false;
|
||||
jclass activity = env->GetObjectClass(activityObj);
|
||||
env->DeleteLocalRef(activityObj);
|
||||
|
||||
// Old APK / new liblove skew: report "no transport" the same way a
|
||||
// missing curl does, instead of aborting on a missing method (#597).
|
||||
jmethodID method = env->GetStaticMethodID(activity, "httpPost",
|
||||
"(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z");
|
||||
if (method == nullptr)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jstring jurl = env->NewStringUTF(url);
|
||||
// raw bytes across the bridge: a log ring can carry arbitrary UTF-8,
|
||||
// and a jstring would run it through modified UTF-8
|
||||
jbyteArray jbody = env->NewByteArray(bodyLen);
|
||||
if (jbody != nullptr)
|
||||
env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body);
|
||||
jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr;
|
||||
jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr;
|
||||
|
||||
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua);
|
||||
|
||||
env->DeleteLocalRef(jurl);
|
||||
if (jbody != nullptr)
|
||||
env->DeleteLocalRef(jbody);
|
||||
if (jct != nullptr)
|
||||
env->DeleteLocalRef(jct);
|
||||
if (jua != nullptr)
|
||||
env->DeleteLocalRef(jua);
|
||||
env->DeleteLocalRef(activity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/*
|
||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||
@@ -1180,6 +1229,68 @@ void love_android_secondary_enable(int on)
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
void love_android_secondary_target(int target)
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity,
|
||||
"setSecondaryDisplayTarget", "(I)V");
|
||||
if (method)
|
||||
env->CallStaticVoidMethod(activity, method, target);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_secondary_detected()
|
||||
{
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity,
|
||||
"hasSecondaryDisplayCandidate", "()Z");
|
||||
jboolean detected = JNI_FALSE;
|
||||
if (method)
|
||||
detected = env->CallStaticBooleanMethod(activity, method);
|
||||
else
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return detected ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
int love_android_present_secondary(const void *rgba, int width, int height,
|
||||
unsigned int background, int cover)
|
||||
{
|
||||
if (!rgba || width <= 0 || height <= 0)
|
||||
return 0;
|
||||
jlong size = (jlong) width * (jlong) height * 4;
|
||||
if (size <= 0)
|
||||
return 0;
|
||||
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||
jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame",
|
||||
"(Ljava/nio/ByteBuffer;IIIZ)Z");
|
||||
if (!method)
|
||||
{
|
||||
env->ExceptionClear();
|
||||
env->DeleteLocalRef(activity);
|
||||
return 0;
|
||||
}
|
||||
jobject frame = env->NewDirectByteBuffer((void *) rgba, size);
|
||||
if (!frame)
|
||||
{
|
||||
env->DeleteLocalRef(activity);
|
||||
return 0;
|
||||
}
|
||||
jboolean shown = env->CallStaticBooleanMethod(activity, method, frame,
|
||||
width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE);
|
||||
env->DeleteLocalRef(frame);
|
||||
env->DeleteLocalRef(activity);
|
||||
return shown ? 1 : 0;
|
||||
}
|
||||
|
||||
extern "C" __attribute__((visibility("default")))
|
||||
const char *love_android_poll_secondary_touch()
|
||||
{
|
||||
|
||||
@@ -98,6 +98,14 @@ bool restartApp();
|
||||
**/
|
||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The
|
||||
* mirror of httpDownload for mod.postLog log sends, which need POST and
|
||||
* have no curl on Android. contentType / userAgent may be null. Returns
|
||||
* whether the server accepted the send (2xx).
|
||||
**/
|
||||
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||
|
||||
/**
|
||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||
|
||||
@@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath,
|
||||
#endif
|
||||
}
|
||||
|
||||
bool System::httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType, const char *userAgent) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
return love::android::httpPost(url, body, bodyLen, contentType, userAgent);
|
||||
#else
|
||||
LOVE_UNUSED(url);
|
||||
LOVE_UNUSED(body);
|
||||
LOVE_UNUSED(bodyLen);
|
||||
LOVE_UNUSED(contentType);
|
||||
LOVE_UNUSED(userAgent);
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
int System::tlsOpen(const char *host, int port) const
|
||||
{
|
||||
#ifdef LOVE_ANDROID
|
||||
|
||||
@@ -151,6 +151,14 @@ public:
|
||||
virtual bool httpDownload(const char *url, const char *destPath,
|
||||
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST of a raw byte body (Android only; false
|
||||
* elsewhere). The mirror of httpDownload for mod.postLog log sends,
|
||||
* which need POST and have no curl on Android (#597).
|
||||
**/
|
||||
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||
|
||||
/**
|
||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||
|
||||
@@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L)
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_httpPost(lua_State *L)
|
||||
{
|
||||
const char *url = luaL_checkstring(L, 1);
|
||||
size_t bodyLen = 0;
|
||||
const char *body = luaL_checklstring(L, 2, &bodyLen);
|
||||
const char *ct = luaL_optstring(L, 3, nullptr);
|
||||
const char *ua = luaL_optstring(L, 4, nullptr);
|
||||
luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int w_hasBackgroundMusic(lua_State *L)
|
||||
{
|
||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||
@@ -233,6 +244,7 @@ static const luaL_Reg functions[] =
|
||||
{ "syncHealthSteps", w_syncHealthSteps },
|
||||
{ "restartApp", w_restartApp },
|
||||
{ "httpDownload", w_httpDownload },
|
||||
{ "httpPost", w_httpPost },
|
||||
{ "tlsOpen", w_tlsOpen },
|
||||
{ "tlsStatus", w_tlsStatus },
|
||||
{ "tlsSend", w_tlsSend },
|
||||
|
||||
@@ -60,6 +60,7 @@ import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Vibrator;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.view.*;
|
||||
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
onHostResume();
|
||||
refreshDualScreenDisplayMode();
|
||||
if (secondaryEnabled) registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||
// AYN's panel toggle emits virtual Right Shift, which SDL maps to a
|
||||
// gameplay button. The setting is absent on other Android devices.
|
||||
if (secondaryEnabled && dualScreenDisplayMode != -1
|
||||
&& event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
|
||||
&& event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
|
||||
return true;
|
||||
}
|
||||
return super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* SDL decides the activity's requested orientation at window creation
|
||||
* (SDLActivity.setOrientationBis). With a resizable window and no
|
||||
@@ -741,6 +755,76 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocking HTTPS POST, exposed as love.system.httpPost and used by
|
||||
* src/core/HostShell.lua for mod.postLog. The GET bridge above covers
|
||||
* downloads; log sends need POST, and Android ships no curl, so this is
|
||||
* the only POST transport the platform has. Strictly one-way, matching
|
||||
* the curl branch it mirrors: the response body is drained and
|
||||
* discarded, and only the 2xx verdict comes back.
|
||||
*
|
||||
* Same rules as httpDownload: https only, redirects followed by hand
|
||||
* (re-POSTing the body on each hop, the way curl -X POST behaves), and
|
||||
* the call is blocking on the Lua/worker thread -- never the UI thread.
|
||||
* The body arrives as raw bytes (a jbyteArray across the JNI) because a
|
||||
* log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8
|
||||
* corruption on characters outside the BMP.
|
||||
*/
|
||||
@Keep
|
||||
public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) {
|
||||
if (url == null || body == null) return false;
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
String current = url;
|
||||
for (int hop = 0; hop < 5; hop++) {
|
||||
URL parsed = new URL(current);
|
||||
if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
|
||||
conn = (HttpURLConnection) parsed.openConnection();
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setConnectTimeout(15000);
|
||||
conn.setReadTimeout(60000);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("User-Agent",
|
||||
userAgent == null ? "gen1recomp" : userAgent);
|
||||
conn.setRequestProperty("Content-Type",
|
||||
contentType == null ? "text/plain" : contentType);
|
||||
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
|
||||
try {
|
||||
out.write(body);
|
||||
} finally {
|
||||
try { out.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
|
||||
String next = conn.getHeaderField("Location");
|
||||
conn.disconnect();
|
||||
conn = null;
|
||||
if (next == null) return false;
|
||||
current = new URL(parsed, next).toString();
|
||||
continue;
|
||||
}
|
||||
if (code < 200 || code > 299) return false;
|
||||
// drain and discard, so a slow server cannot wedge the
|
||||
// worker on a full socket buffer
|
||||
InputStream in = new BufferedInputStream(conn.getInputStream());
|
||||
try {
|
||||
byte[] buf = new byte[16384];
|
||||
while (in.read(buf) > 0) {}
|
||||
} finally {
|
||||
try { in.close(); } catch (IOException ignored) {}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
Log.d("GameActivity", "httpPost failed: " + e.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||
@@ -1406,9 +1490,38 @@ public class GameActivity extends SDLActivity {
|
||||
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
||||
// physical display. Driven from the engine through love_android_secondary_*
|
||||
// in src/jni/love/src/common/android.cpp.
|
||||
private static final int SECONDARY_TARGET_AUTO = 0;
|
||||
private static final int SECONDARY_TARGET_HANDHELD = 1;
|
||||
private static final int SECONDARY_TARGET_EXTERNAL = 2;
|
||||
// AYN keeps disabled panels registered as ON. This optional setting is the
|
||||
// usable-state signal: 0 = both, 1 = main only, 2 = second only.
|
||||
private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
|
||||
private static final String AYN_SECOND_SCREEN = "Screen-2";
|
||||
private static volatile SecondaryPresentation secondaryPresentation;
|
||||
private static volatile SecondaryActivity secondaryActivity;
|
||||
private static volatile boolean secondaryActivityPending;
|
||||
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
private static volatile long secondaryRetryAfter;
|
||||
private static volatile boolean secondaryEnabled = false;
|
||||
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||
private static volatile int dualScreenDisplayMode = -1;
|
||||
private static volatile byte[] secondaryFrame;
|
||||
private static volatile int secondaryFrameWidth;
|
||||
private static volatile int secondaryFrameHeight;
|
||||
private static volatile int secondaryBackground;
|
||||
private static volatile boolean secondaryFrameCover;
|
||||
private static final Object secondaryFrameLock = new Object();
|
||||
private static volatile long secondaryDetectionAt;
|
||||
private static volatile boolean secondaryDetected;
|
||||
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
||||
private boolean dualScreenModeObserverRegistered;
|
||||
private final android.database.ContentObserver dualScreenModeObserver =
|
||||
new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
|
||||
@Override public void onChange(boolean selfChange, Uri uri) {
|
||||
refreshDualScreenDisplayMode();
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
};
|
||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||
new java.util.ArrayDeque<>();
|
||||
@@ -1421,86 +1534,206 @@ public class GameActivity extends SDLActivity {
|
||||
self.runOnUiThread(new Runnable() {
|
||||
@Override public void run() {
|
||||
if (on) {
|
||||
self.refreshDualScreenDisplayMode();
|
||||
self.registerSecondaryDisplayListener();
|
||||
setupSecondaryDisplay();
|
||||
rebindSecondaryDisplay();
|
||||
} else {
|
||||
self.unregisterSecondaryDisplayListener();
|
||||
teardownSecondaryDisplay();
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void setSecondaryDisplayTarget(int target) {
|
||||
int normalized = target == SECONDARY_TARGET_HANDHELD
|
||||
|| target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
|
||||
if (secondaryTarget == normalized) return;
|
||||
secondaryTarget = normalized;
|
||||
secondaryDetectionAt = 0;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
|
||||
private void refreshDualScreenDisplayMode() {
|
||||
int mode = Settings.System.getInt(
|
||||
getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
|
||||
if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
|
||||
dualScreenDisplayMode = mode;
|
||||
}
|
||||
|
||||
private void registerSecondaryDisplayListener() {
|
||||
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
|
||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||
if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
|
||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||
}
|
||||
if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
|
||||
getContentResolver().registerContentObserver(
|
||||
Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
|
||||
dualScreenModeObserver);
|
||||
dualScreenModeObserverRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterSecondaryDisplayListener() {
|
||||
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
|
||||
secondaryDisplayMonitor = null;
|
||||
if (monitor != null) monitor.unregister();
|
||||
if (dualScreenModeObserverRegistered) {
|
||||
getContentResolver().unregisterContentObserver(dualScreenModeObserver);
|
||||
dualScreenModeObserverRegistered = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void refreshSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled) return;
|
||||
SecondaryPresentation current = secondaryPresentation;
|
||||
Display display = current == null ? null : current.getDisplay();
|
||||
private static boolean secondaryOutputIsPreferred(GameActivity self) {
|
||||
Display preferred = findSecondaryDisplay(self, false);
|
||||
if (preferred == null) return false;
|
||||
SecondaryPresentation presentation = secondaryPresentation;
|
||||
Display display = presentation == null
|
||||
? null : presentation.getDisplay();
|
||||
if (display == null) {
|
||||
SecondaryActivity activity = secondaryActivity;
|
||||
display = activity == null ? null : getActivityDisplay(activity);
|
||||
}
|
||||
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
|
||||
if (current == null) {
|
||||
setupSecondaryDisplay();
|
||||
} else if (display == null || monitor == null
|
||||
|| !monitor.hasDisplay(display.getDisplayId())) {
|
||||
if (display == null || (monitor != null
|
||||
&& !monitor.hasDisplay(display.getDisplayId()))) return false;
|
||||
return display.getDisplayId() == preferred.getDisplayId();
|
||||
}
|
||||
|
||||
private static void rebindSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
self.runOnUiThread(() -> {
|
||||
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void setupSecondaryDisplay() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
||||
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
||||
|| secondaryActivity != null || secondaryActivityPending
|
||||
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||
try {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null) return;
|
||||
Display chosen = null;
|
||||
for (Display d : dm.getDisplays()) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
|
||||
+ " size=" + size.x + "x" + size.y);
|
||||
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
|
||||
chosen = d;
|
||||
}
|
||||
}
|
||||
if (chosen == null) {
|
||||
Display[] pres =
|
||||
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
if (pres != null && pres.length > 0) chosen = pres[0];
|
||||
}
|
||||
Display chosen = findSecondaryDisplay(self, true);
|
||||
if (chosen == null) {
|
||||
Log.d("GameActivity", "no secondary display found");
|
||||
return;
|
||||
}
|
||||
if (!isPresentationDisplay(chosen)) {
|
||||
if (android.os.Build.VERSION.SDK_INT < 29) return;
|
||||
secondaryActivityPending = true;
|
||||
secondaryActivityTarget = chosen.getDisplayId();
|
||||
Intent intent = new Intent(self, SecondaryActivity.class)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
|
||||
android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
|
||||
options.setLaunchDisplayId(secondaryActivityTarget);
|
||||
self.startActivity(intent, options.toBundle());
|
||||
final int requestedDisplay = secondaryActivityTarget;
|
||||
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||
if (secondaryActivityPending
|
||||
&& secondaryActivityTarget == requestedDisplay) {
|
||||
secondaryActivityPending = false;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
}
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
||||
p.setOnDismissListener(dialog -> {
|
||||
if (secondaryPresentation == p) {
|
||||
secondaryPresentation = null;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
});
|
||||
p.show();
|
||||
secondaryPresentation = p;
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame != null) {
|
||||
p.setBackground(secondaryBackground);
|
||||
p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||
}
|
||||
}
|
||||
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
||||
} catch (Throwable t) {
|
||||
Log.d("GameActivity", "secondary display setup failed: " + t);
|
||||
secondaryPresentation = null;
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
teardownSecondaryDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
|
||||
android.hardware.display.DisplayManager dm =
|
||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||
if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
|
||||
Display gameDisplay = getActivityDisplay(self);
|
||||
int gameDisplayId = gameDisplay == null
|
||||
? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
|
||||
Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
|
||||
boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
|
||||
&& gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
|
||||
Display external = null;
|
||||
Display[] presentations = dm.getDisplays(
|
||||
android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||
for (Display d : presentations) {
|
||||
if (logDisplays) {
|
||||
android.graphics.Point size = new android.graphics.Point();
|
||||
d.getRealSize(size);
|
||||
Log.d("GameActivity", "display id=" + d.getDisplayId()
|
||||
+ " name=" + d.getName() + " size=" + size.x + "x" + size.y);
|
||||
}
|
||||
if (external == null && d.getDisplayId() != gameDisplayId
|
||||
&& isDisplayUsable(d)) external = d;
|
||||
}
|
||||
if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
|
||||
if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
|
||||
return handheldAvailable ? handheld : external;
|
||||
}
|
||||
|
||||
private static Display getActivityDisplay(android.app.Activity activity) {
|
||||
return android.os.Build.VERSION.SDK_INT >= 30
|
||||
? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
|
||||
}
|
||||
|
||||
private static boolean isPresentationDisplay(Display display) {
|
||||
if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
|
||||
return android.os.Build.VERSION.SDK_INT < 20
|
||||
|| (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
|
||||
}
|
||||
|
||||
private static boolean isDisplayUsable(Display display) {
|
||||
if (display == null) return false;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 20
|
||||
&& display.getState() == Display.STATE_OFF) return false;
|
||||
if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
|
||||
return false;
|
||||
}
|
||||
return dualScreenDisplayMode != 2
|
||||
|| display.getDisplayId() != Display.DEFAULT_DISPLAY;
|
||||
}
|
||||
|
||||
private static void teardownSecondaryDisplay() {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
secondaryPresentation = null;
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
secondaryActivity = null;
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||
if (p != null) {
|
||||
try { p.dismiss(); } catch (Throwable t) {}
|
||||
}
|
||||
if (a != null) {
|
||||
try { a.finish(); } catch (Throwable t) {}
|
||||
}
|
||||
}
|
||||
|
||||
@android.annotation.TargetApi(17)
|
||||
@@ -1527,21 +1760,85 @@ public class GameActivity extends SDLActivity {
|
||||
return manager.getDisplay(displayId) != null;
|
||||
}
|
||||
|
||||
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
|
||||
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
|
||||
private void changed() {
|
||||
secondaryDetectionAt = 0;
|
||||
rebindSecondaryDisplay();
|
||||
}
|
||||
|
||||
@Override public void onDisplayAdded(int displayId) { changed(); }
|
||||
@Override public void onDisplayRemoved(int displayId) { changed(); }
|
||||
@Override public void onDisplayChanged(int displayId) { changed(); }
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplay() {
|
||||
return secondaryPresentation != null;
|
||||
return secondaryPresentation != null || secondaryActivity != null;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean hasSecondaryDisplayCandidate() {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self == null) return false;
|
||||
if (secondaryPresentation != null || secondaryActivity != null) return true;
|
||||
self.refreshDualScreenDisplayMode();
|
||||
long now = android.os.SystemClock.uptimeMillis();
|
||||
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
|
||||
return secondaryDetected;
|
||||
}
|
||||
secondaryDetected = findSecondaryDisplay(self, false) != null;
|
||||
secondaryDetectionAt = now;
|
||||
return secondaryDetected;
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static boolean presentSecondaryFrame(
|
||||
java.nio.ByteBuffer rgba, int width, int height,
|
||||
int backgroundColor, boolean cover) {
|
||||
long bytes = (long) width * height * 4;
|
||||
if (rgba == null || width <= 0 || height <= 0
|
||||
|| bytes <= 0 || bytes > Integer.MAX_VALUE
|
||||
|| rgba.capacity() < bytes) return false;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame == null || secondaryFrame.length != (int) bytes) {
|
||||
secondaryFrame = new byte[(int) bytes];
|
||||
}
|
||||
rgba.rewind();
|
||||
rgba.get(secondaryFrame, 0, (int) bytes);
|
||||
rgba.rewind();
|
||||
secondaryFrameWidth = width;
|
||||
secondaryFrameHeight = height;
|
||||
secondaryBackground = backgroundColor;
|
||||
secondaryFrameCover = cover;
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
if (p == null && a == null) return false;
|
||||
try {
|
||||
if (p != null) {
|
||||
p.setBackground(backgroundColor);
|
||||
p.updateFrame(rgba, width, height, cover);
|
||||
} else {
|
||||
a.setBackground(backgroundColor);
|
||||
a.updateFrame(rgba, width, height, cover);
|
||||
}
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
GameActivity self = (GameActivity) mSingleton;
|
||||
if (self != null) self.runOnUiThread(() -> {
|
||||
teardownSecondaryDisplay();
|
||||
setupSecondaryDisplay();
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
SecondaryPresentation p = secondaryPresentation;
|
||||
if (p != null && buf != null && w > 0 && h > 0) {
|
||||
p.updateFrame(buf, w, h);
|
||||
SecondaryActivity a = secondaryActivity;
|
||||
if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
|
||||
if (p != null) p.updateFrame(buf, w, h);
|
||||
else a.updateFrame(buf, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,6 +1849,102 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private static void applySecondaryImmersive(android.view.Window w) {
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
}
|
||||
|
||||
public static class SecondaryActivity extends android.app.Activity {
|
||||
private FrameView frameView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Display display = getActivityDisplay(this);
|
||||
if (!secondaryEnabled || display == null
|
||||
|| display.getDisplayId() != secondaryActivityTarget) {
|
||||
secondaryActivityPending = false;
|
||||
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
frameView = new FrameView(this);
|
||||
android.view.Window w = getWindow();
|
||||
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
|
||||
setContentView(frameView);
|
||||
applySecondaryImmersive(w);
|
||||
secondaryActivity = this;
|
||||
secondaryActivityPending = false;
|
||||
secondaryRetryAfter = 0;
|
||||
synchronized (secondaryFrameLock) {
|
||||
if (secondaryFrame != null) {
|
||||
setBackground(secondaryBackground);
|
||||
updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (secondaryActivity == this) secondaryActivity = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onWindowFocusChanged(boolean hasFocus) {
|
||||
super.onWindowFocusChanged(hasFocus);
|
||||
if (hasFocus) applySecondaryImmersive(getWindow());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchGenericMotionEvent(event)
|
||||
: super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
frameView.updateFrame(buf, w, h, cover);
|
||||
}
|
||||
|
||||
void setBackground(int color) {
|
||||
frameView.setFrameBackground(color);
|
||||
}
|
||||
}
|
||||
|
||||
private static class SecondaryPresentation extends android.app.Presentation {
|
||||
private final FrameView frameView;
|
||||
|
||||
@@ -1585,31 +1978,36 @@ public class GameActivity extends SDLActivity {
|
||||
if (hasFocus) applyImmersive();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||
GameActivity activity = (GameActivity) mSingleton;
|
||||
return activity != null
|
||||
? activity.dispatchGenericMotionEvent(event)
|
||||
: super.dispatchGenericMotionEvent(event);
|
||||
}
|
||||
|
||||
private void applyImmersive() {
|
||||
android.view.Window w = getWindow();
|
||||
if (w == null) return;
|
||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||
w.setDecorFitsSystemWindows(false);
|
||||
android.view.WindowInsetsController c = w.getInsetsController();
|
||||
if (c != null) {
|
||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||
c.setSystemBarsBehavior(
|
||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||
}
|
||||
} else {
|
||||
w.getDecorView().setSystemUiVisibility(
|
||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||
}
|
||||
applySecondaryImmersive(getWindow());
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
frameView.updateFrame(buf, w, h);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
frameView.updateFrame(buf, w, h, cover);
|
||||
}
|
||||
|
||||
void setBackground(int color) {
|
||||
frameView.setFrameBackground(color);
|
||||
}
|
||||
}
|
||||
|
||||
private static class FrameView extends View {
|
||||
@@ -1618,7 +2016,9 @@ public class GameActivity extends SDLActivity {
|
||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||
private final Object lock = new Object();
|
||||
private int fw, fh;
|
||||
private int backgroundColor = 0xFF000000;
|
||||
private int activePointer = -1;
|
||||
private boolean cover;
|
||||
|
||||
FrameView(Context context) {
|
||||
super(context);
|
||||
@@ -1628,7 +2028,12 @@ public class GameActivity extends SDLActivity {
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||
updateFrame(buf, w, h, false);
|
||||
}
|
||||
|
||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||
synchronized (lock) {
|
||||
this.cover = cover;
|
||||
if (bitmap == null || fw != w || fh != h) {
|
||||
if (bitmap != null) bitmap.recycle();
|
||||
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
||||
@@ -1640,6 +2045,13 @@ public class GameActivity extends SDLActivity {
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
void setFrameBackground(int color) {
|
||||
synchronized (lock) {
|
||||
backgroundColor = 0xFF000000 | (color & 0x00FFFFFF);
|
||||
}
|
||||
postInvalidate();
|
||||
}
|
||||
|
||||
private void enqueueTouch(String event) {
|
||||
synchronized (secondaryTouches) {
|
||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||
@@ -1691,12 +2103,15 @@ public class GameActivity extends SDLActivity {
|
||||
synchronized (lock) {
|
||||
if (bitmap == null || fw == 0 || fh == 0) return;
|
||||
int vw = getWidth(), vh = getHeight();
|
||||
int s = Math.min(vw / fw, vh / fh);
|
||||
if (s < 1) s = 1;
|
||||
int dw = fw * s, dh = fh * s;
|
||||
float fit = Math.min((float) vw / fw, (float) vh / fh);
|
||||
if (fit <= 0) return;
|
||||
float scale = cover
|
||||
? Math.max((float) vw / fw, (float) vh / fh)
|
||||
: fit >= 2f ? (float) Math.floor(fit) : fit;
|
||||
int dw = Math.round(fw * scale), dh = Math.round(fh * scale);
|
||||
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
||||
dst.set(dx, dy, dx + dw, dy + dh);
|
||||
canvas.drawColor(0xFF000000);
|
||||
canvas.drawColor(backgroundColor);
|
||||
canvas.drawBitmap(bitmap, null, dst, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,20 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.98",
|
||||
"date": "2026-08-16",
|
||||
"size": 11380117,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.97",
|
||||
"date": "2026-08-16",
|
||||
"size": 11376813,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.97/gen1recomp++-0.1.97-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1230 Couple of sound effects missing\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1340 Color palett doesn't affect attack animations\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.96",
|
||||
"date": "2026-08-16",
|
||||
|
||||
@@ -218,13 +218,20 @@ function BattleAPI:submit(intent)
|
||||
return nil, "stale battle context"
|
||||
end
|
||||
local kind = battle:battleKind()
|
||||
if kind == "oldman" or kind == "link" or kind == "safari" then
|
||||
if kind == "oldman" or kind == "link" then
|
||||
return nil, "battle kind is not controllable"
|
||||
end
|
||||
if top ~= battle then return nil, "battle menu is covered" end
|
||||
|
||||
local ok, err
|
||||
if intent.kind == "menu" then
|
||||
if intent.kind == "safari" then
|
||||
if kind ~= "safari" then return nil, "safari menu is not active" end
|
||||
ok, err = battle:chooseSafari(intent.action)
|
||||
elseif kind == "safari" then
|
||||
return nil, "battle kind is not controllable"
|
||||
elseif intent.kind == "mimic" then
|
||||
ok, err = battle:chooseMimic(intent.index)
|
||||
elseif intent.kind == "menu" then
|
||||
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||
if not MENU_CHOICES[intent.choice] then
|
||||
return nil, "unknown battle menu choice"
|
||||
|
||||
+90
-17
@@ -88,6 +88,33 @@ function BattleState:wantsFillScale()
|
||||
return options and options.battleFit == "fill" or false
|
||||
end
|
||||
|
||||
-- EXTENDED HUD configurations are admitted one at a time after their own
|
||||
-- placement and screenshot review. FIXED supports the three authored battle
|
||||
-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock
|
||||
-- battles retain the paper field required by Gen 1 back sprites, while arena
|
||||
-- providers may replace it with their own scene. Only the HUD moves to window
|
||||
-- space.
|
||||
function BattleState:extendedHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
local bg = options and options.battleBg
|
||||
return self:wideLayout()
|
||||
and options and options.battleHud == "extended"
|
||||
and ((options.battleFit == "fixed"
|
||||
and (bg == "world" or bg == "white" or bg == "black"))
|
||||
or (options.battleFit == "fill" and bg == "white"))
|
||||
end
|
||||
|
||||
function BattleState:extendedWorldHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
return self:extendedHUD() and options
|
||||
and options.battleFit == "fixed" and options.battleBg == "world"
|
||||
end
|
||||
|
||||
function BattleState:extendedBlackHUD()
|
||||
local options = self.game and self.game.save and self.game.save.options
|
||||
return self:extendedHUD() and options and options.battleBg == "black"
|
||||
end
|
||||
|
||||
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
||||
-- that grow as the window gets bigger or the view is zoomed out. The battle
|
||||
-- screen itself is untouched: it keeps its white paper field in every mode.
|
||||
@@ -583,7 +610,8 @@ end
|
||||
local function stampOT(save, mon)
|
||||
save.player.id = save.player.id or math.random(0, 65535)
|
||||
mon.ot = mon.ot or save.player.name
|
||||
mon.otId = mon.otId or save.player.id
|
||||
-- engine/battle/experience.asm:69
|
||||
if not mon.traded then mon.otId = mon.otId or save.player.id end
|
||||
end
|
||||
BattleState.stampOT = stampOT
|
||||
|
||||
@@ -1102,6 +1130,15 @@ function BattleState:stepHPDrain()
|
||||
if not b.shownPx then b.shownPx = targetPx end
|
||||
if (b.drainHold or 0) > 0 then
|
||||
b.drainHold = b.drainHold - 1
|
||||
-- Once the count runs out with nothing left pending (bar and
|
||||
-- number already on the final total), the drain is over, not just
|
||||
-- between steps: leave the field at 0 and BattleSafety.inspect
|
||||
-- reads it as still mid-animation for the rest of the battle,
|
||||
-- since drainHold ~= nil is its settled-presentation gate.
|
||||
if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal
|
||||
and not b.draining then
|
||||
b.drainHold = nil
|
||||
end
|
||||
busy = true
|
||||
elseif b.shownPx ~= targetPx then
|
||||
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||
@@ -2018,6 +2055,38 @@ function BattleState:cancelMove()
|
||||
return true
|
||||
end
|
||||
|
||||
local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
|
||||
|
||||
function BattleState:chooseSafari(action)
|
||||
if self.phase ~= "menu" or not self.safari then
|
||||
return nil, "safari menu is not active"
|
||||
end
|
||||
if self.safari.balls <= 0 then return nil, "no safari balls remain" end
|
||||
local index = SAFARI_ACTION_INDEX[action]
|
||||
if not index then return nil, "invalid safari action" end
|
||||
self.menuIndex = index
|
||||
self:safariAction(action)
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:chooseMimic(index)
|
||||
if self.phase ~= "mimicSelect" then
|
||||
return nil, "mimic menu is not active"
|
||||
end
|
||||
if type(index) ~= "number" or index % 1 ~= 0 then
|
||||
return nil, "invalid mimic slot"
|
||||
end
|
||||
local pick = self.mimicMoves and self.mimicMoves[index]
|
||||
local ctx = self.mimicCtx
|
||||
if not pick or not ctx then return nil, "invalid mimic slot" end
|
||||
self.mimicIndex = index
|
||||
self.mimicMoves, self.mimicCtx = nil, nil
|
||||
self.phase = "messages"
|
||||
self.nextInsert = 0 -- the copy's anim + text go to the queue head
|
||||
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleState:swapMoves(i, j)
|
||||
if i == j then return end
|
||||
local moves = self.player.curMoves
|
||||
@@ -2139,7 +2208,7 @@ function BattleState:update(dt)
|
||||
self.menuIndex = row * 2 + col + 1
|
||||
if input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||
self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -2247,12 +2316,7 @@ function BattleState:update(dt)
|
||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
require("src.core.Sound").play(self.data, "Press_AB")
|
||||
local pick = moves[self.mimicIndex]
|
||||
local ctx = self.mimicCtx
|
||||
self.mimicMoves, self.mimicCtx = nil, nil
|
||||
self.phase = "messages"
|
||||
self.nextInsert = 0 -- the copy's anim + text go to the queue head
|
||||
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
|
||||
self:chooseMimic(self.mimicIndex)
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -5923,10 +5987,16 @@ function BattleState:drawTextArea()
|
||||
Font.drawCode(Font.BORDER.h, 32, 96)
|
||||
Font.drawCode(Font.BORDER.br, 80, 96)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, mv in ipairs(self.player.curMoves) do
|
||||
-- unknown ids (mod-injected moves) print raw instead of crashing
|
||||
local def = self.data.moves[mv.id]
|
||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||
-- engine/battle/misc.asm:37
|
||||
for i = 1, 4 do
|
||||
local mv = self.player.curMoves[i]
|
||||
if mv then
|
||||
-- unknown ids (mod-injected moves) print raw instead of crashing
|
||||
local def = self.data.moves[mv.id]
|
||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||
else
|
||||
Font.draw("-", 48, 96 + i * 8)
|
||||
end
|
||||
end
|
||||
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
|
||||
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
|
||||
@@ -5953,13 +6023,16 @@ function BattleState:drawTextArea()
|
||||
end
|
||||
end
|
||||
elseif self.phase == "mimicSelect" then
|
||||
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:
|
||||
-- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names
|
||||
-- single-spaced from (2,8), cursor at column 1
|
||||
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517):
|
||||
-- 16x6 box at (0,7), names from (2,8), cursor at column 1
|
||||
Font.drawBox(0, 7, 16, 6)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, m in ipairs(self.mimicMoves) do
|
||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
||||
-- engine/battle/misc.asm:37
|
||||
for i = 1, 4 do
|
||||
local m = self.mimicMoves[i]
|
||||
local def = m and self.data.moves[m.id]
|
||||
Font.draw(m and (def and def.name or tostring(m.id)) or "-",
|
||||
16, (7 + i) * 8)
|
||||
end
|
||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||
|
||||
@@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
hitSfx = { sound = "Damage", pitch = 0x20 }
|
||||
end
|
||||
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
||||
-- the screen vertically) for a damaging move with no added effect, and
|
||||
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
|
||||
-- Bubblebeam and Confusion shake instead of blinking (#354)
|
||||
-- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once
|
||||
-- the move has an added effect (#354)
|
||||
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
||||
-- PlayApplyingAttackAnimation runs on both arms of the wOptions check
|
||||
-- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384)
|
||||
local hitFx = { sfx = hitSfx,
|
||||
animType = user.isPlayer and (added and 5 or 4)
|
||||
or (added and 2 or 1),
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
blink = target }
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
|
||||
@@ -33,6 +33,15 @@ end
|
||||
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
||||
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||
|
||||
-- Strings.source, not Strings: harvested at require time so the catalog
|
||||
-- generator can see the literal, same pattern as MoveEffects.lua's
|
||||
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
|
||||
-- the harvester can't discover.
|
||||
local STAT_LABEL = {
|
||||
attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
|
||||
speed = Strings.source("SPEED"),
|
||||
}
|
||||
|
||||
-- The trainer's ai_classes record from the merged registry; the direct
|
||||
-- require covers battles built without a loader. A trainer record's
|
||||
-- aiClass field picks a record other than its own id.
|
||||
@@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item)
|
||||
elseif X_STAT[item] then
|
||||
local stat = X_STAT[item]
|
||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
|
||||
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat])))
|
||||
elseif item == "GUARD_SPEC" then
|
||||
enemy.mist = true
|
||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
|
||||
|
||||
@@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y)
|
||||
end
|
||||
end
|
||||
|
||||
local function battleIsTopState(battle)
|
||||
local stack = battle.game and battle.game.stack
|
||||
return not (stack and stack.top) or stack:top() == battle
|
||||
end
|
||||
|
||||
local function anchorHUD(battle, x, y, w, h, anchor)
|
||||
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
|
||||
local renderer = battle.game and battle.game.renderer
|
||||
if not (renderer and renderer.setBattleUIAnchor) then return end
|
||||
x = x + (battle.extendedHUDOffsetX or 0)
|
||||
y = y + (battle.extendedHUDOffsetY or 0)
|
||||
local x2 = math.min(WideBattle.WIDTH, x + w)
|
||||
local y2 = math.min(WideBattle.HEIGHT, y + h)
|
||||
x, y = math.max(0, x), math.max(0, y)
|
||||
w, h = x2 - x, y2 - y
|
||||
if w > 0 and h > 0 then
|
||||
renderer:setBattleUIAnchor(x, y, w, h, anchor)
|
||||
end
|
||||
end
|
||||
|
||||
-- One side's status box: name and level on the first line, a long HP bar
|
||||
-- under it, and the numeric HP on the player's box only (the foe's exact
|
||||
-- HP is never shown, like the original).
|
||||
@@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||
x + tw * 8 - 64, y + 24)
|
||||
end
|
||||
anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top")
|
||||
end
|
||||
|
||||
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
||||
@@ -144,7 +165,6 @@ local function drawHUDs(battle, slide)
|
||||
and not battle.showPlayerBack and slide == 0 then
|
||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||
end
|
||||
drawIntroBalls(battle)
|
||||
end
|
||||
|
||||
local function drawMessageBox(battle)
|
||||
@@ -268,6 +288,8 @@ local function drawTextArea(battle)
|
||||
else
|
||||
Font.drawBox(0, 13, 38, 5)
|
||||
end
|
||||
anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM,
|
||||
WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom")
|
||||
end
|
||||
|
||||
-- Battle animations are authored in the original 160px coordinate space.
|
||||
@@ -309,17 +331,23 @@ end
|
||||
-- The whole 304x144 composition for one frame.
|
||||
function WideBattle.draw(battle)
|
||||
local g = love.graphics
|
||||
local renderer = battle.game and battle.game.renderer
|
||||
local extendedHUD = battle:extendedHUD() and renderer
|
||||
and renderer.beginBattleHUDPass
|
||||
and renderer.endBattleHUDPass
|
||||
-- The field is the display mode's paper. Under a forced-mono mode the
|
||||
-- whole surface is remapped downstream (WideBattle.zones), so the field
|
||||
-- goes down as DMG white and comes out of that pass as the mode's paper;
|
||||
-- painting the resolved shade there would run it through the remap twice
|
||||
-- and land a shade off the letterbox the renderer fills around it.
|
||||
if monoMode() then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
else
|
||||
g.setColor(PaletteFX.paperShade(battle.data))
|
||||
if not (extendedHUD and battle:extendedWorldHUD()) then
|
||||
if monoMode() then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
else
|
||||
g.setColor(PaletteFX.paperShade(battle.data))
|
||||
end
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
end
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
-- AskName clears the field the same way the classic layout does
|
||||
if battle.blankForAskName then return end
|
||||
|
||||
@@ -346,6 +374,7 @@ function WideBattle.draw(battle)
|
||||
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||
battle.wideRegion = nil
|
||||
drawIntroBalls(battle)
|
||||
|
||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
|
||||
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
|
||||
@@ -359,12 +388,26 @@ function WideBattle.draw(battle)
|
||||
if sx == 0 and sy == 0 then return fn() end
|
||||
g.push()
|
||||
g.translate(sx, sy)
|
||||
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy
|
||||
fn()
|
||||
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil
|
||||
g.pop()
|
||||
end
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
drawAnimationLayer(battle)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
|
||||
if extendedHUD then
|
||||
local previous = renderer:beginBattleHUDPass()
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||
end
|
||||
renderer:endBattleHUDPass(previous)
|
||||
else
|
||||
shaken(function() drawHUDs(battle, slide) end)
|
||||
shaken(function() drawTextArea(battle) end)
|
||||
end
|
||||
|
||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||
g.setColor(1, 1, 1, 0.85)
|
||||
|
||||
@@ -192,14 +192,8 @@ function Runner:loadGfx(names)
|
||||
end
|
||||
end
|
||||
|
||||
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
|
||||
-- APPENDED after whatever the script already loaded rather than replacing it,
|
||||
-- and they always land on the same two fixed tile ids.
|
||||
--
|
||||
-- (pokegold's jumptable has these two labels the other way round from the
|
||||
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
|
||||
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
|
||||
-- is what a script actually writes.)
|
||||
-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro
|
||||
-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401)
|
||||
function Runner:loadBattlerGfx(rows)
|
||||
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
|
||||
local slot = 1
|
||||
@@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows)
|
||||
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
|
||||
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
|
||||
battler = "player", rows = rows }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
|
||||
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7,
|
||||
battler = "enemy", rows = rows }
|
||||
self.loaded[#self.loaded + 1] =
|
||||
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6,
|
||||
battler = "player", rows = rows }
|
||||
end
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
|
||||
+82
-33
@@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = {
|
||||
EFFECT_PARALYZE_HIT = "paralyze",
|
||||
EFFECT_SLEEP_HIT = "sleep",
|
||||
EFFECT_CONFUSE_HIT = "confuse",
|
||||
EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696
|
||||
}
|
||||
|
||||
local function rand(random, n)
|
||||
@@ -1320,6 +1321,18 @@ function Battle:markMissed()
|
||||
if self.moveEvent then self.moveEvent.missed = true end
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:3615
|
||||
Battle.AI_FAIL_STATUSES = {
|
||||
sleep = true, poison = true, toxic = true, paralyze = true,
|
||||
}
|
||||
|
||||
-- engine/battle/effect_commands.asm:3615
|
||||
function Battle:aiRandomFail(attacker, defender)
|
||||
if self:sideOf(attacker) ~= "enemy" then return false end
|
||||
if self:volatile(defender).lockOn then return false end
|
||||
return rand(self.random, 256) < 64
|
||||
end
|
||||
|
||||
-- One attack, start to finish.
|
||||
function Battle:useMove(attacker, defender, moveId)
|
||||
local move = self:findMove(attacker, moveId)
|
||||
@@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
if charge and not charging then
|
||||
state.chargeMove = moveId
|
||||
state.vanished = charge.vanish or nil
|
||||
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table
|
||||
-- keyed by effect cannot tell them apart and DIG announced itself with
|
||||
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the
|
||||
-- effect: `cp DIG` and then the burrow text.
|
||||
-- engine/battle/effect_commands.asm:5458
|
||||
if self.moveEvent then self.moveEvent.animParam = 1 end
|
||||
-- BattleCommand_Charge picks the line off the MOVE, not the shared
|
||||
-- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464).
|
||||
local text = charge.text
|
||||
if moveId == "DIG" then text = "%s dug a hole!" end
|
||||
self:emit({ kind = "message", text = text:format(name) })
|
||||
@@ -1811,20 +1824,20 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
-- Defense Curl arms Rollout as well as raising Defense.
|
||||
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
|
||||
|
||||
-- Stat changes: the primary ones always land, the *_HIT ones roll the
|
||||
-- move's effect chance after a hit that connected.
|
||||
--
|
||||
-- A refused primary change is a failure the cart detects BEFORE its anim
|
||||
-- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` /
|
||||
-- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400),
|
||||
-- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER
|
||||
-- `attackup` / `attackdown` in the effect list (data/moves/effects.asm,
|
||||
-- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs
|
||||
-- after `moveanim` (AttackDownHit), so the animation has already played.
|
||||
-- A refused primary change writes wAttackMissed (effect_commands.asm:4191,
|
||||
-- :4380-4400); the *_HIT twins animate first and must stay unmarked.
|
||||
local change = Effects.STAT_CHANGES[def.effect]
|
||||
if change then
|
||||
local target = change[3] == "self" and attacker or defender
|
||||
if not self:changeStageAgainstMist(attacker, target, change[1], change[2])
|
||||
-- CheckMist first (effect_commands.asm:4290), then .ComputerMiss (:4318)
|
||||
local misted = target ~= attacker and (change[2] or 0) < 0
|
||||
and self:volatile(target).mist
|
||||
if not misted and change[3] == "foe"
|
||||
and def.effect ~= "EFFECT_ACCURACY_DOWN_HIT"
|
||||
and self:aiRandomFail(attacker, target) then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
elseif not self:changeStageAgainstMist(attacker, target, change[1], change[2])
|
||||
then
|
||||
self:markMissed()
|
||||
end
|
||||
@@ -1853,19 +1866,21 @@ function Battle:useMove(attacker, defender, moveId)
|
||||
local record = Battle.moveEffectRecordFor(self.data, def.effect)
|
||||
local status = record and record.kind == "primary" and record.status or nil
|
||||
if status and (def.power or 0) == 0 then
|
||||
-- Every status command's already-statused / immune arm ends on
|
||||
-- AnimateFailedMove (BattleCommand_Poison's `.failed`,
|
||||
-- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay,
|
||||
-- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the
|
||||
-- success path (:3752), and the effect scripts carry no moveanim of their
|
||||
-- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS
|
||||
-- branch below is the opposite case: that move already hit and already
|
||||
-- animated, so a refused secondary must leave the event unmarked.
|
||||
if not self:applyStatus(defender, status, attacker) then self:markMissed() end
|
||||
-- A refused primary status is a failed move (effect_commands.asm:3748,
|
||||
-- :6656); a refused secondary already animated and stays unmarked (:3752).
|
||||
if Battle.AI_FAIL_STATUSES[status]
|
||||
and self:aiRandomFail(attacker, defender) then
|
||||
self:markMissed()
|
||||
self:emit({ kind = "message", text = "But it failed!" })
|
||||
elseif not self:applyStatus(defender, status, attacker) then
|
||||
self:markMissed()
|
||||
end
|
||||
else
|
||||
local secondary = record and record.kind == "secondary"
|
||||
and record.status or nil
|
||||
if secondary and (defender.hp or 0) > 0 then
|
||||
-- engine/battle/effect_commands.asm:6325
|
||||
if secondary and (defender.hp or 0) > 0
|
||||
and not self:safeguarded(defender) then
|
||||
local chance = def.effectChance or 0
|
||||
if chance > 0 and rand(self.random, 100) < chance then
|
||||
self:applyStatus(defender, secondary, attacker)
|
||||
@@ -2414,7 +2429,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker)
|
||||
if (side.lightScreen or 0) > 0 then return fail(self) end
|
||||
side.lightScreen = Battle.SCREEN_TURNS
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. "'s SPCL.DEF rose!" })
|
||||
text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) })
|
||||
end
|
||||
|
||||
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
||||
@@ -2422,7 +2437,16 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
||||
if (side.reflect or 0) > 0 then return fail(self) end
|
||||
side.reflect = Battle.SCREEN_TURNS
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. "'s DEFENSE rose!" })
|
||||
text = Strings("%s's DEFENSE rose!", self:monName(attacker)) })
|
||||
end
|
||||
|
||||
-- engine/battle/move_effects/safeguard.asm:1
|
||||
Battle.MOVE_EFFECTS.EFFECT_SAFEGUARD = function(self, attacker)
|
||||
local side = self.screens[self:sideOf(attacker)]
|
||||
if (side.safeguard or 0) > 0 then return fail(self) end
|
||||
side.safeguard = Battle.SCREEN_TURNS
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(attacker) .. "'s covered by a veil!" })
|
||||
end
|
||||
|
||||
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
|
||||
@@ -2910,6 +2934,11 @@ function Battle.statusPenaltyFor(data, mon, stat, value)
|
||||
return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
|
||||
end
|
||||
|
||||
-- engine/battle/effect_commands.asm:6325
|
||||
function Battle:safeguarded(mon)
|
||||
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
|
||||
end
|
||||
|
||||
-- `source` is the battler that inflicted it, carried only so
|
||||
-- battle.status_inflicted can name it the way Gen 1's does.
|
||||
function Battle:applyStatus(mon, status, source)
|
||||
@@ -2917,7 +2946,14 @@ function Battle:applyStatus(mon, status, source)
|
||||
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
|
||||
-- in the volatile beside the major status, so a confused mon can still be
|
||||
-- burned and a switch shakes the confusion off.
|
||||
if status == "confuse" then return self:applyConfusion(mon) end
|
||||
if status == "confuse" then return self:applyConfusion(mon, nil, source) end
|
||||
-- engine/battle/effect_commands.asm:6338
|
||||
if source and self:sideOf(source) ~= self:sideOf(mon)
|
||||
and self:safeguarded(mon) then
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
|
||||
return false
|
||||
end
|
||||
-- One major status at a time.
|
||||
if mon.status then
|
||||
self:emit({ kind = "message",
|
||||
@@ -2953,8 +2989,15 @@ end
|
||||
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
|
||||
Battle.BERSERK_GENE_CONFUSE_TURNS = 256
|
||||
|
||||
function Battle:applyConfusion(mon, turns)
|
||||
function Battle:applyConfusion(mon, turns, source)
|
||||
if (mon.hp or 0) <= 0 then return false end
|
||||
-- engine/battle/effect_commands.asm:6338
|
||||
if source and self:sideOf(source) ~= self:sideOf(mon)
|
||||
and self:safeguarded(mon) then
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
|
||||
return false
|
||||
end
|
||||
local state = self:volatile(mon)
|
||||
if (state.substitute or 0) > 0 then return false end
|
||||
local held = self:heldEffect(mon, "confuse")
|
||||
@@ -4434,14 +4477,20 @@ Battle.SCREEN_FALL_TEXT = {
|
||||
function Battle:tickScreens()
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local screens = self.screens[side]
|
||||
for _, field in ipairs({ "lightScreen", "reflect" }) do
|
||||
for _, field in ipairs({ "lightScreen", "reflect", "safeguard" }) do
|
||||
if (screens[field] or 0) > 0 then
|
||||
screens[field] = screens[field] - 1
|
||||
if screens[field] <= 0 then
|
||||
screens[field] = nil
|
||||
self:emit({ kind = "message",
|
||||
text = Battle.SCREEN_SIDE_LABEL[side]
|
||||
.. Battle.SCREEN_FALL_TEXT[field] })
|
||||
if field == "safeguard" then
|
||||
-- engine/battle/core.asm:1527
|
||||
self:emit({ kind = "message",
|
||||
text = self:monName(self[side]) .. "'s SAFEGUARD faded!" })
|
||||
else
|
||||
self:emit({ kind = "message",
|
||||
text = Battle.SCREEN_SIDE_LABEL[side]
|
||||
.. Battle.SCREEN_FALL_TEXT[field] })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -72,11 +72,11 @@ function Pool:reset()
|
||||
for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end
|
||||
-- wBGP / wOBP0 / wOBP1, as DMG palette bytes.
|
||||
self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL
|
||||
-- Per-battler state the CGB paths write instead of touching wBGP: a DMG
|
||||
-- shade byte the view remaps that battler's pic through, whether the pic is
|
||||
-- hidden outright, and which of the six BG squares it is drawn at.
|
||||
-- Per-battler state the CGB paths write instead of touching wBGP: shade
|
||||
-- byte, hidden flag, lifted tile rows, and which BG square it is drawn at.
|
||||
self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL }
|
||||
self.hidden = { player = false, enemy = false }
|
||||
self.liftedRows = { player = nil, enemy = nil }
|
||||
self.picSize = { player = nil, enemy = nil }
|
||||
self.slide = { player = 0, enemy = 0 }
|
||||
-- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the
|
||||
@@ -488,6 +488,7 @@ local function runPicResize(self, st, script)
|
||||
self.picSize[side] = step
|
||||
self.hidden[side] = false
|
||||
end
|
||||
self.liftedRows[side] = nil
|
||||
incJt(st)
|
||||
elseif jt >= 1 and jt <= 2 then
|
||||
incJt(st)
|
||||
@@ -545,7 +546,7 @@ end
|
||||
|
||||
-- The two battler-pic objects: the animation borrows the mon's own tiles as
|
||||
-- an OBJ so it can be moved without touching the tilemap.
|
||||
local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
|
||||
local function battlerObj(self, st, objectPlayer, objectEnemy, rows)
|
||||
local jt = st.jt
|
||||
if jt == 0 then
|
||||
if self:flyDig(st) then
|
||||
@@ -562,25 +563,26 @@ local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
|
||||
}
|
||||
elseif jt == 1 then
|
||||
incJt(st)
|
||||
-- The rows the OBJ now covers are cleared out of the tilemap so the mon
|
||||
-- is not drawn twice.
|
||||
self.hidden[self:sideKey(st)] = clearRows
|
||||
-- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers
|
||||
-- come out of the tilemap, and .five never puts them back.
|
||||
self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)]
|
||||
elseif jt >= 2 and jt <= 4 then
|
||||
incJt(st)
|
||||
elseif jt == 5 then
|
||||
self.hidden[self:sideKey(st)] = false
|
||||
endEffect(st)
|
||||
end
|
||||
end
|
||||
|
||||
E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st)
|
||||
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW",
|
||||
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true)
|
||||
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
|
||||
{ player = { 0, 1 }, enemy = { 6, 1 } })
|
||||
end
|
||||
|
||||
E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st)
|
||||
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
|
||||
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true)
|
||||
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW",
|
||||
{ player = { 0, 2 }, enemy = { 5, 2 } })
|
||||
end
|
||||
|
||||
-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler
|
||||
|
||||
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
|
||||
if mon.dvs then
|
||||
mon.gender = Mon.gender(def, mon.dvs,
|
||||
{ species = mon.species, level = mon.level })
|
||||
mon.shiny = Mon.isShiny(mon.dvs,
|
||||
-- shiny is monotonic once true, the same as opts.shiny winning over
|
||||
-- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs
|
||||
-- that do not themselves read as shiny) must not un-shiny the moment
|
||||
-- this runs again, and it runs on every SummaryMenu open via
|
||||
-- refreshStats. A mon not already shiny still promotes normally if
|
||||
-- its DVs justify it, e.g. after an edit.
|
||||
mon.shiny = mon.shiny or Mon.isShiny(mon.dvs,
|
||||
{ species = mon.species, def = def, level = mon.level })
|
||||
if mon.species == Unown.SPECIES then
|
||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||
|
||||
+51
-11
@@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep")
|
||||
local Input = require("src.core.Input")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
@@ -305,12 +306,32 @@ function Game.worldBgBattleDim(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
-- The extended fixed HUD intentionally exposes the live world across
|
||||
-- the whole physical window. Keep this as a world-backed battle (zero
|
||||
-- is non-nil, so scaling and overlay holds remain active), but do not
|
||||
-- paint the standard dim veil around the native battle rectangle.
|
||||
if state.extendedWorldHUD and state:extendedWorldHUD() then
|
||||
return 0
|
||||
end
|
||||
return state.BG_WORLD_DIM or 0.55
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer
|
||||
-- uses this separately from battleDim: the world remains the surround, while
|
||||
-- the native-width battle field receives a paper backing from top to bottom.
|
||||
function Game.extendedWorldHUDInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.extendedWorldHUD and state:extendedWorldHUD() then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
|
||||
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
|
||||
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
|
||||
@@ -403,13 +424,13 @@ function Game.uiAnchorsHeldInStack(stack)
|
||||
end
|
||||
|
||||
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
|
||||
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
|
||||
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
|
||||
-- the bag) becomes that base, cutting the overworld -- and with it the world
|
||||
-- pass -- out of the frame entirely. The backdrop the battle established
|
||||
-- then collapses to endFrame's flat black clear for as long as the menu is
|
||||
-- up. So a world-bg battle keeps the frame starting from underneath itself
|
||||
-- until it leaves the stack, the same hold uiFill and the dim already use.
|
||||
-- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE
|
||||
-- battle must not prevent that battle from drawing. Native WIDE battles own
|
||||
-- the 304x144 surround around a centred classic menu, while external arena
|
||||
-- providers establish their window-sized scene from BattleState:draw. If the
|
||||
-- menu becomes the draw base, neither owner runs and the menu's white field
|
||||
-- replaces the whole presentation. BATTLE BG "world" additionally needs the
|
||||
-- overworld below the battle, as before.
|
||||
--
|
||||
-- Only the START of the draw moves. The clear stays keyed to the real
|
||||
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
|
||||
@@ -420,9 +441,13 @@ function Game.drawBaseInStack(stack, visibleBase)
|
||||
local states = stack and stack.states or {}
|
||||
for i = visibleBase - 1, 1, -1 do
|
||||
local state = states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
local worldBattle = state and state.bgMode and state:bgMode() == "world"
|
||||
local wideBattle = state and state.isWideBattleLayout
|
||||
and state:isWideBattleLayout()
|
||||
if worldBattle or wideBattle then
|
||||
-- restart the search from under the battle: the highest opaque state at
|
||||
-- or below it (the overworld), not the menu sitting over it
|
||||
-- or below it (the battle itself for white/black WIDE, the overworld for
|
||||
-- a non-opaque world-backed battle), not the menu sitting over it
|
||||
for j = i, 1, -1 do
|
||||
if states[j].isOpaque then return j end
|
||||
end
|
||||
@@ -432,6 +457,14 @@ function Game.drawBaseInStack(stack, visibleBase)
|
||||
return visibleBase
|
||||
end
|
||||
|
||||
-- A classic overlay above the approved world-backed extended HUD paints only
|
||||
-- its centred area. Keep the wider owner surface transparent so its margins
|
||||
-- continue to reveal the world instead of becoming an opaque white sheet.
|
||||
function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle)
|
||||
return worldBelow or (worldDrawn and wideBattle ~= nil
|
||||
and wideBattle.extendedHUD and wideBattle:extendedHUD())
|
||||
end
|
||||
|
||||
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||
-- sprite and status zones move with the classic UI content.
|
||||
@@ -452,6 +485,7 @@ local function centerClassicZones(zones, offset)
|
||||
end
|
||||
|
||||
function Game:draw()
|
||||
GameViewport.begin(1)
|
||||
-- the UI canvas clears transparent when the overworld's world pass
|
||||
-- shows through beneath it; opaque full-screen states get the classic
|
||||
-- white clear
|
||||
@@ -485,6 +519,7 @@ function Game:draw()
|
||||
-- the stack for the same reason as uiFill above -- a prompt opened during
|
||||
-- the battle must not drop the dim for a frame.
|
||||
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
||||
Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(self.stack)
|
||||
-- ...and for the same reason the UI's own scale has to know the world is
|
||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||
@@ -502,7 +537,8 @@ function Game:draw()
|
||||
-- menu to its top right, and the whole UI steps down with the zoom.
|
||||
Renderer.uiCentered = not Game.dynamicUI(self.save)
|
||||
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
|
||||
Renderer:beginFrame(worldBelow)
|
||||
Renderer:beginFrame(Game.uiCanvasTransparent(
|
||||
worldBelow, worldDrawn, wideBattle))
|
||||
for i = drawFrom, #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
local wideState = state and state.isWideBattleLayout
|
||||
@@ -563,7 +599,9 @@ function Game:draw()
|
||||
if ModRuntime.wantsHook("render.hud") then
|
||||
ModRuntime.call("render.hud", function() end, self, viewport)
|
||||
end
|
||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
||||
GameViewport.finish(self)
|
||||
-- OS-window chrome: keep the pad full-size and above any composed companion
|
||||
-- view instead of capturing and shrinking it with the game viewport.
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
@@ -939,8 +977,10 @@ 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)
|
||||
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
|
||||
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
||||
phase = phase, source = source, id = id, x = x, y = y,
|
||||
gameX = gameX, gameY = gameY, insideGame = insideGame,
|
||||
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
||||
})
|
||||
end
|
||||
|
||||
+28
-19
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
|
||||
-- The mod event/hook buses. Gold reaches them through Runtime like every
|
||||
-- other engine file, so a call site here is the same call site Gen 1 has.
|
||||
local ModRuntime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
||||
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
||||
-- IO is src/core/gen2/Save.lua.
|
||||
@@ -73,8 +74,8 @@ end
|
||||
--
|
||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||
-- (the FixedStep callback in Game2:load), so none of it goes through
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
|
||||
-- hooks below never used to fire here; it is not a reason they should not. A
|
||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
|
||||
-- below never used to fire here; it is not a reason they should not. A
|
||||
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
||||
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
||||
-- PAYLOAD, at the Gen 1 point in the order:
|
||||
@@ -86,6 +87,8 @@ end
|
||||
-- render.output* the normal composed frame (Renderer.lua:1063)
|
||||
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
||||
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
|
||||
-- render.window final OS-window composition (GameViewport.lua:145)
|
||||
--
|
||||
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
|
||||
-- world pass and the UI into ONE canvas, not two -- the call site says so and
|
||||
@@ -680,7 +683,10 @@ end
|
||||
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
|
||||
function Game2:usePartyItem(itemId)
|
||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||
local action = ItemEffects.partyAction(itemId)
|
||||
-- without the merged dataset this can only ever see RECORDS, the
|
||||
-- module's own built-ins, so a mod's field item resolves to no action
|
||||
-- at all and never gets past the .Oak refusal below
|
||||
local action = ItemEffects.partyAction(itemId, self.data)
|
||||
if not action then return end
|
||||
local party = (self.save and self.save.party) or {}
|
||||
if #party == 0 then
|
||||
@@ -1197,9 +1203,7 @@ function Game2:frameFit(w, h)
|
||||
dpi = tonumber(love.window.getDPIScale()) or 1
|
||||
end
|
||||
local pw, ph = w * dpi, h * dpi
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
pw, ph = GameViewport.pixelDimensions()
|
||||
return scale, ox, oy, dpi, pw, ph
|
||||
end
|
||||
|
||||
@@ -1217,18 +1221,15 @@ function Game2:viewport(w, h)
|
||||
}
|
||||
end
|
||||
|
||||
-- The screen-space layer, in the Gen 1 order: render.hud and then the
|
||||
-- on-screen pad (src/core/Game.lua:521 and :524, either side of
|
||||
-- Renderer:endFrame). Both are window-space, both sit over the finished
|
||||
-- frame -- post passes, letterbox and all -- and neither ever enters the game
|
||||
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
|
||||
-- true of the composed frame a mod owns as well as of the plain one.
|
||||
-- The render.hud layer, in Gen 1's order over the finished game frame. The
|
||||
-- on-screen pad is drawn separately after GameViewport.finish, because it is
|
||||
-- OS-window chrome and must not be captured or scaled with this canvas.
|
||||
--
|
||||
-- render.hud: persistent tool status. The call is fenced with
|
||||
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
|
||||
-- mod render callback: a subscriber that returns cleanly but leaves a shader
|
||||
-- bound, the canvas redirected or the colour changed must not corrupt the next
|
||||
-- frame -- or, now, the pad drawn immediately after it.
|
||||
-- frame.
|
||||
function Game2:drawHud(w, h)
|
||||
if ModRuntime.wantsHook("render.hud") then
|
||||
local G = love.graphics
|
||||
@@ -1236,10 +1237,6 @@ function Game2:drawHud(w, h)
|
||||
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
|
||||
G.pop()
|
||||
end
|
||||
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
|
||||
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
|
||||
-- forces it, and nothing ever while a controller is in use.
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
-- render.letterbox: SGB borders and custom void art in the bars around the
|
||||
@@ -1363,9 +1360,9 @@ end
|
||||
-- is being shown on. Mod post-processes fold in between the two, where
|
||||
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
|
||||
-- is then drawn over, rather than something that smears the grid itself.
|
||||
function Game2:draw()
|
||||
function Game2:drawViewportFrame()
|
||||
local G = love.graphics
|
||||
local w, h = G.getDimensions()
|
||||
local w, h = GameViewport.dimensions()
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
@@ -1477,6 +1474,16 @@ function Game2:draw()
|
||||
self:drawHud(w, h)
|
||||
end
|
||||
|
||||
function Game2:draw()
|
||||
GameViewport.begin(2)
|
||||
GameViewport.setTarget()
|
||||
self:drawViewportFrame()
|
||||
GameViewport.finish(self)
|
||||
-- OS-window chrome: draw after companion composition so viewport layouts
|
||||
-- neither shrink nor cover the touch pad.
|
||||
TouchControls:draw()
|
||||
end
|
||||
|
||||
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
|
||||
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
|
||||
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
|
||||
@@ -1803,8 +1810,10 @@ end
|
||||
|
||||
-- coordinates are LOVE window units, the same space render.hud's viewport is in
|
||||
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
||||
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
|
||||
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
||||
phase = phase, source = source, id = id, x = x, y = y,
|
||||
gameX = gameX, gameY = gameY, insideGame = insideGame,
|
||||
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
||||
})
|
||||
end
|
||||
|
||||
+47
-3
@@ -272,6 +272,38 @@ function HostShell.quote(s)
|
||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
-- Launch another instance of this packaged app without waiting for it. The
|
||||
-- same path works on all process-capable desktop hosts; only the shell's
|
||||
-- background spelling differs. Source checkouts include their game folder,
|
||||
-- while fused releases and AppImages already carry it in the executable.
|
||||
function HostShell.spawnSelfDetached(args)
|
||||
if not require("src.core.Platform").canSpawnProcess() then return false end
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.getExecutablePath) then return false end
|
||||
local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
|
||||
if type(executable) ~= "string" or executable == "" then return false end
|
||||
|
||||
local argv = {}
|
||||
local fused = fs.isFused and fs.isFused()
|
||||
if not os.getenv("APPIMAGE") and not fused and fs.getSource then
|
||||
argv[#argv + 1] = fs.getSource()
|
||||
end
|
||||
for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
|
||||
|
||||
local command = HostShell.quote(executable)
|
||||
for _, value in ipairs(argv) do
|
||||
command = command .. " " .. HostShell.quote(value)
|
||||
end
|
||||
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||
if osName == "Windows" then
|
||||
command = 'start "" /b ' .. command .. " >NUL 2>&1"
|
||||
else
|
||||
command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
|
||||
end
|
||||
local ok, _, code = os.execute(command)
|
||||
return ok == true or ok == 0 or code == 0
|
||||
end
|
||||
|
||||
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
|
||||
-- `curl --version` process on every single fetch -- twice for a GET through
|
||||
-- the Android-bridge fallback -- which doubled the number of spawns the lock
|
||||
@@ -420,9 +452,11 @@ end
|
||||
-- POST returning success/failure. Strictly one-way: the response body is
|
||||
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
||||
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
||||
-- large body never lands in the command line; the Android bridge has no POST
|
||||
-- transport, and httpPost reports that instead of half-working through
|
||||
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
||||
-- large body never lands in the command line; where curl is absent (Android
|
||||
-- and the other bridge-only platforms) the POST rides the JNI bridge --
|
||||
-- love.system.httpPost, the dedicated POST arm added beside httpDownload --
|
||||
-- instead of half-working through httpDownload (a GET round-trip to a POST
|
||||
-- endpoint would be a lie).
|
||||
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||
if type(body) ~= "string" then return nil, "missing body" end
|
||||
@@ -498,6 +532,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||
if not haveBridge() then
|
||||
return nil, "no network transport on this platform"
|
||||
end
|
||||
-- The GET bridge has no POST; the dedicated love.system.httpPost arm
|
||||
-- (GameActivity.httpPost) is the transport where curl is missing. A
|
||||
-- build without it reports the same "no POST transport" a missing curl
|
||||
-- would -- the old-APK skew path in the JNI bridge returns false.
|
||||
if love.system and type(love.system.httpPost) == "function" then
|
||||
local ok, sent = pcall(love.system.httpPost, url, body, contentType,
|
||||
userAgent)
|
||||
if ok and sent then return true end
|
||||
return nil, "log post rejected"
|
||||
end
|
||||
return nil, "no POST transport on this platform"
|
||||
end
|
||||
|
||||
|
||||
+3
-3
@@ -387,9 +387,9 @@ end
|
||||
|
||||
-- overworld map theme; onBike/surfing override outdoor themes with the
|
||||
-- bike/surf songs and restore the map theme when they end
|
||||
function Music.playMap(data, mapId, onBike, surfing, fade)
|
||||
local song = data and data.audio and data.audio.mapSongs
|
||||
and mapId and data.audio.mapSongs[mapId] or nil
|
||||
function Music.playMap(data, mapId, onBike, surfing, fade, song)
|
||||
song = song or (data and data.audio and data.audio.mapSongs
|
||||
and mapId and data.audio.mapSongs[mapId]) or nil
|
||||
state.mapSong = song
|
||||
state.onBike = not not onBike
|
||||
state.surfing = not not surfing
|
||||
|
||||
@@ -7,12 +7,14 @@
|
||||
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
local SafeArea = {}
|
||||
|
||||
function SafeArea.rect()
|
||||
function SafeArea.windowRect()
|
||||
local ww, wh = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
ww, wh = love.graphics.getDimensions()
|
||||
ww, wh = GameViewport.fullDimensions()
|
||||
end
|
||||
if ww <= 0 then ww = 1 end
|
||||
if wh <= 0 then wh = 1 end
|
||||
@@ -55,4 +57,8 @@ function SafeArea.rect()
|
||||
return x, y, w, h
|
||||
end
|
||||
|
||||
function SafeArea.rect()
|
||||
return GameViewport.localSafeRect(SafeArea.windowRect())
|
||||
end
|
||||
|
||||
return SafeArea
|
||||
|
||||
@@ -239,6 +239,11 @@ function SaveData.defaultOptions()
|
||||
-- scale the battle surface to the window so it fills vertically. See
|
||||
-- BattleState:wantsFillScale.
|
||||
battleFit = "fixed",
|
||||
-- BATTLE HUD: STANDARD keeps every wide-battle element inside the native
|
||||
-- 304x144 surface. EXTENDED is opt-in window-space placement for selected
|
||||
-- wide layouts; unsupported combinations deliberately fall back to the
|
||||
-- standard composition.
|
||||
battleHud = "standard",
|
||||
-- BATTLE BG: what fills the screen behind and around the battle.
|
||||
-- "white" = the display mode's paper shade (the classic look),
|
||||
-- "black" = plain black bars, "world" = the frozen overworld showing
|
||||
|
||||
@@ -339,7 +339,7 @@ end
|
||||
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
|
||||
-- which layout(), the editor chrome and the tests read.
|
||||
function TouchControls:currentBucket()
|
||||
local _, _, sw, sh = SafeArea.rect()
|
||||
local _, _, sw, sh = SafeArea.windowRect()
|
||||
local o = orientationFor(sw, sh)
|
||||
self.layouts = self.layouts or { portrait = {}, landscape = {} }
|
||||
local b = self.layouts[o]
|
||||
@@ -363,7 +363,7 @@ end
|
||||
-- while sizes stay derived from the short edge, times the orientation's
|
||||
-- size setting (#633).
|
||||
function TouchControls:layout()
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local ox, oy, sw, sh = SafeArea.windowRect()
|
||||
if self.layoutW == sw and self.layoutH == sh
|
||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||
return self.L
|
||||
@@ -397,7 +397,7 @@ end
|
||||
-- Move one control to a screen-space point and persist its normalized
|
||||
-- position within the safe rect. Used by the layout editor while dragging.
|
||||
function TouchControls:setControlCenter(name, cx, cy)
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local ox, oy, sw, sh = SafeArea.windowRect()
|
||||
local L = self:layout()
|
||||
local zone = L[name]
|
||||
if not zone then return end
|
||||
@@ -608,10 +608,10 @@ local function drawIcon(img, zone, pressed, alphaMul)
|
||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||
end
|
||||
|
||||
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by
|
||||
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top
|
||||
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher
|
||||
-- layout editor under preview mode.
|
||||
-- OS-window space, called after GameViewport.finish so the overlay rides on
|
||||
-- top of the game, companion composition and post-processing without being
|
||||
-- captured or scaled with any game viewport. Also used by the launcher layout
|
||||
-- editor under preview mode.
|
||||
function TouchControls:draw()
|
||||
if not self:visible() then return end
|
||||
local L = self:layout()
|
||||
|
||||
@@ -16,13 +16,56 @@
|
||||
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
|
||||
-- and World only ever reads it.
|
||||
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Clock = {}
|
||||
|
||||
Clock.MINUTES_PER_DAY = 24 * 60
|
||||
Clock.DAYS = 7
|
||||
|
||||
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0, so
|
||||
-- index 1 is SUNDAY -- matching both InitClock's `self.day + 1` and
|
||||
-- `Clock.weekday(save) + 1`. Strings.source, not Strings: built at require
|
||||
-- time, before Strings.load has a catalog, so Clock.weekdayName looks each
|
||||
-- name up at display time instead (src/battle/MoveEffects.lua's STAT_LABEL
|
||||
-- is the same pattern). One shared table and one lookup function, so
|
||||
-- InitClock's screens, the main menu clock box and the Pokegear clock card
|
||||
-- cannot drift apart on what a weekday is called.
|
||||
Clock.DAY_NAMES = {
|
||||
Strings.source("SUNDAY"), Strings.source("MONDAY"), Strings.source("TUESDAY"),
|
||||
Strings.source("WEDNESDAY"), Strings.source("THURSDAY"), Strings.source("FRIDAY"),
|
||||
Strings.source("SATURDAY"),
|
||||
}
|
||||
|
||||
-- The translated name for a 1-based weekday (SUNDAY = 1), or nil if `day` is
|
||||
-- out of range.
|
||||
function Clock.weekdayName(day)
|
||||
local name = Clock.DAY_NAMES[day]
|
||||
return name and Strings(name)
|
||||
end
|
||||
|
||||
-- The three words Palettes.clockDaytime can hand back, translated (never
|
||||
-- DARK: that one only comes out of Palettes.daytimeFor, for a PALETTE_DARK
|
||||
-- map, and is never printed as text). Strings.source, not Strings:
|
||||
-- clockDaytime's return value is also an internal key every
|
||||
-- FORCED_DAYTIME/palette lookup in Palettes.lua compares against, so THAT
|
||||
-- stays untranslated -- only this table, and Clock.daytimeLabel below, look
|
||||
-- a word up, at the UI call sites that actually print it.
|
||||
local DAYTIME_LABEL = {
|
||||
MORN = Strings.source("MORN"), DAY = Strings.source("DAY"),
|
||||
NITE = Strings.source("NITE"),
|
||||
}
|
||||
|
||||
-- clockDaytime's word, translated -- the one InitClock's clock-setting
|
||||
-- screen and the Pokegear's clock card print (DisplayHourOClock /
|
||||
-- Pokegear_UpdateClock).
|
||||
function Clock.daytimeLabel(hour)
|
||||
local daytime = Palettes.clockDaytime(hour)
|
||||
return Strings(DAYTIME_LABEL[daytime] or daytime)
|
||||
end
|
||||
|
||||
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
|
||||
-- buffer left at the zero ByteFill put there.
|
||||
Clock.DEFAULT_HOUR = 10
|
||||
|
||||
@@ -138,15 +138,72 @@ local function coreRows(opts, hooks)
|
||||
ladder(opts, "battleStyle",
|
||||
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
|
||||
add(Strings("BATTLE LAYOUT"),
|
||||
ladder(opts, "battleLayout",
|
||||
{ { "og", "OG" }, { "wide", "WIDE" } }, "og"))
|
||||
function()
|
||||
return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
|
||||
end,
|
||||
function()
|
||||
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
|
||||
if opts.battleLayout ~= "wide" then
|
||||
opts.battleHud = "standard"
|
||||
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end)
|
||||
add(Strings("BATTLE SIZE"),
|
||||
ladder(opts, "battleFit",
|
||||
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
|
||||
function()
|
||||
return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
|
||||
end,
|
||||
function()
|
||||
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
|
||||
if opts.battleFit == "fill" and opts.battleLayout == "wide"
|
||||
and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end)
|
||||
add(Strings("BATTLE HUD"),
|
||||
function()
|
||||
return opts.battleLayout == "wide" and opts.battleHud == "extended"
|
||||
and Strings("EXTENDED")
|
||||
or Strings("STANDARD")
|
||||
end,
|
||||
function()
|
||||
if opts.battleLayout ~= "wide" then
|
||||
opts.battleHud = "standard"
|
||||
return false
|
||||
end
|
||||
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
|
||||
if opts.battleHud == "extended" and opts.battleFit == "fill" then
|
||||
opts.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end)
|
||||
add(Strings("BATTLE BG"),
|
||||
ladder(opts, "battleBg",
|
||||
{ { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } },
|
||||
"white"))
|
||||
function()
|
||||
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
||||
and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
return Strings("AUTO")
|
||||
end
|
||||
if opts.battleBg == "black" then return Strings("BLACK") end
|
||||
if opts.battleBg == "world" then return Strings("WORLD") end
|
||||
return Strings("WHITE")
|
||||
end,
|
||||
function(dir)
|
||||
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
||||
and opts.battleHud == "extended" then
|
||||
opts.battleBg = "white"
|
||||
return false
|
||||
end
|
||||
local order = { "white", "black", "world" }
|
||||
local cur = 1
|
||||
for i, mode in ipairs(order) do
|
||||
if opts.battleBg == mode then cur = i break end
|
||||
end
|
||||
opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
|
||||
return true
|
||||
end)
|
||||
add(Strings("UI LAYOUT"),
|
||||
ladder(opts, "uiLayout",
|
||||
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
|
||||
|
||||
@@ -979,7 +979,7 @@ function LauncherView._updateControl(imp)
|
||||
end
|
||||
-- idle / uptodate / error: offer a manual check, with no glow.
|
||||
return status, Strings("Check for updates"),
|
||||
function() pcall(imp.Check.start) end, false
|
||||
function() pcall(imp.Check.start, true) end, false
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ game panel
|
||||
|
||||
@@ -59,6 +59,16 @@ local STONES = {
|
||||
LEAF_STONE = true, MOON_STONE = true,
|
||||
}
|
||||
|
||||
-- Strings.source, not Strings: harvested at require time so the catalog
|
||||
-- generator can see the literal, same pattern as MoveEffects.lua's
|
||||
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
|
||||
-- the harvester can't discover.
|
||||
local STAT_LABEL = {
|
||||
hp = Strings.source("HP"), attack = Strings.source("ATTACK"),
|
||||
defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"),
|
||||
special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"),
|
||||
}
|
||||
|
||||
-- vitamins: stat-exp boosters (ItemUseVitamin)
|
||||
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
|
||||
CARBOS = "speed", CALCIUM = "special" }
|
||||
@@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
"Nothing happened!") }
|
||||
end
|
||||
b.stages[stat] = cur + 1
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
||||
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
|
||||
end
|
||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||
-- the item, even when it is already active
|
||||
@@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||
-- cannot be filled positionally; the engine wording stands
|
||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
||||
Strings(STAT_LABEL[vitaminStat])) }
|
||||
end
|
||||
|
||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||
@@ -515,7 +525,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
||||
if not target then return "failed", { noEffect(data) } end
|
||||
local speciesDef = data.pokemon[target.species]
|
||||
local ok = false
|
||||
for _, m in ipairs(speciesDef.tmhm) do
|
||||
-- a species record with no tmhm list at all is "teaches nothing", the
|
||||
-- same as one whose list just does not name this move -- not a reason
|
||||
-- to crash instead of refusing normally
|
||||
for _, m in ipairs(speciesDef.tmhm or {}) do
|
||||
if m == itemDef.machine.move then ok = true break end
|
||||
end
|
||||
if not ok then
|
||||
|
||||
@@ -1297,6 +1297,7 @@ function ManagerState:drawOverlay()
|
||||
love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(2, ty, 16, th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
for i, line in ipairs(lines) do
|
||||
drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
|
||||
end
|
||||
@@ -1325,6 +1326,7 @@ function ManagerState:draw()
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(0, 0, 20, 18)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
|
||||
if self.screen == "list" then
|
||||
self:drawList()
|
||||
|
||||
@@ -12,7 +12,7 @@ local Manifest = {}
|
||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||
engine_internals = true, steps = true,
|
||||
background = true }
|
||||
background = true, compute = true }
|
||||
|
||||
-- link-relevant registries; a mod that writes into one of these while
|
||||
-- declaring affects_link = false gets an attributed warning from the loader
|
||||
|
||||
+22
-4
@@ -28,8 +28,14 @@ local DENIED = {
|
||||
}
|
||||
|
||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||
-- love.thread starts a Lua state this sandbox has no say over.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
||||
-- love.thread starts a Lua state this sandbox has no say over. jit.util
|
||||
-- is the LuaJIT-specific equal of the debug library above -- funcbc,
|
||||
-- funck and friends read the bytecode and constants of any function a
|
||||
-- chunk can reach, which is enough to walk back to upvalues (the real
|
||||
-- _G, love, io) the rest of this file exists to keep out of reach. The
|
||||
-- bare `jit` table stays -- env.jit above hands it over directly for
|
||||
-- jit.on/off/flush -- so only the submodule require is denied.
|
||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true }
|
||||
|
||||
-- The wire, which is what the network permission governs.
|
||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||
@@ -79,13 +85,25 @@ local BLOCKED_LOVE = {
|
||||
|
||||
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
||||
-- by that mod's own overlay and must not be shared.
|
||||
local function loveFacade(compat)
|
||||
local function loveFacade(compat, permissions)
|
||||
if not _G.love then return nil end
|
||||
local overrides = compat and compat.love
|
||||
return setmetatable({}, {
|
||||
__index = function(_, key)
|
||||
local override = overrides and overrides[key]
|
||||
if override ~= nil then return override end
|
||||
if key == "thread" then
|
||||
-- Threads open a fresh Lua state with a full standard library, so
|
||||
-- they stay blocked unless the mod declares the `compute`
|
||||
-- permission (the mod's own source runs in the worker, and the
|
||||
-- worker ships source-only like every other mod file). The mod
|
||||
-- must never receive arbitrary code from elsewhere: channels
|
||||
-- carry data only.
|
||||
if not (permissions or {}).compute then
|
||||
error('love.thread needs the "compute" permission in manifest.json', 2)
|
||||
end
|
||||
return _G.love.thread
|
||||
end
|
||||
local hint = BLOCKED_LOVE[key]
|
||||
if hint then
|
||||
error(("love.%s is not available to mods%s"):format(key,
|
||||
@@ -215,7 +233,7 @@ function Sandbox.envFor(opts)
|
||||
opts = opts or {}
|
||||
local compat = opts.compat
|
||||
local env = baseGlobals()
|
||||
env.love = loveFacade(compat)
|
||||
env.love = loveFacade(compat, opts.permissions)
|
||||
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
||||
local loader = sandboxedLoad(env)
|
||||
env.load = loader
|
||||
|
||||
+62
-7
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
|
||||
desc = "{" .. table.concat(parts, ", ") .. "}" }
|
||||
end
|
||||
|
||||
-- An open record: the listed fields are typed (including f.id
|
||||
-- cross-references) and everything else on the value passes through
|
||||
-- unexamined, unlike f.rec's nested shapes, which reject any key they do
|
||||
-- not name. Map objects are why this exists -- NPCs, signs, items, warps
|
||||
-- and static encounters all share one array, and only a full union of
|
||||
-- every kind's shape could describe it as f.rec; that is a lot of surface
|
||||
-- to keep in sync with the loader for fields nothing here needs to check.
|
||||
-- f.partial types just the field that actually names another registry and
|
||||
-- leaves every kind-specific field around it alone.
|
||||
function f.partial(fields)
|
||||
local names = {}
|
||||
for name in pairs(fields) do names[#names + 1] = name end
|
||||
table.sort(names)
|
||||
local parts = {}
|
||||
for _, name in ipairs(names) do
|
||||
local ft = fields[name]
|
||||
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
|
||||
end
|
||||
return { kind = "partial", fields = fields,
|
||||
desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
|
||||
end
|
||||
|
||||
function f.union(alts)
|
||||
local parts = {}
|
||||
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
|
||||
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
|
||||
end
|
||||
return
|
||||
end
|
||||
if kind == "partial" then
|
||||
-- the open counterpart of "rec": listed fields are checked exactly
|
||||
-- like a rec's, and any key not listed is left alone rather than
|
||||
-- flagged, so a heterogeneous blob (map objects) can have one field
|
||||
-- typed without every other shape sharing the array being rejected
|
||||
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
|
||||
for key, ft in pairs(t.fields) do
|
||||
local sub = value[key]
|
||||
if sub ~= nil then
|
||||
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
|
||||
elseif ft.kind ~= "opt" and not patchMode then
|
||||
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
|
||||
:format(path, key, ft.desc)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
if kind == "union" then
|
||||
for _, alt in ipairs(t.alts) do
|
||||
local scratch = {}
|
||||
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
|
||||
for k, v in pairs(value) do
|
||||
collectRefs(t.value, v, path .. "." .. tostring(k), out)
|
||||
end
|
||||
elseif kind == "rec" and type(value) == "table" then
|
||||
elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
|
||||
for key, ft in pairs(t.fields) do
|
||||
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
|
||||
end
|
||||
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
|
||||
and loader.content[ref.registry]
|
||||
-- A registry with no home in this generation has no id space to
|
||||
-- check against: its base view resolves to nothing, so EVERY
|
||||
-- reference into it would read as dangling. Gold's species carry a
|
||||
-- growthRate and an evolution method like Red's do; the ids are
|
||||
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
|
||||
-- namespaces that are not there to confirm them. Skipped for the
|
||||
-- same reason an undeclared registry is: unknown, not wrong.
|
||||
-- reference into it would read as dangling. `transitions` is the
|
||||
-- standing example -- Gold draws its own battle intro and never
|
||||
-- reads the merged table, so a mod's transition id there is
|
||||
-- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
|
||||
-- used to sit in that category too, back when Gold had no id space
|
||||
-- for either. Both are routed now: `growth_rates` keeps its Gen 1
|
||||
-- path and is seeded from data.pokemon.growthRates (the extractor's
|
||||
-- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods`
|
||||
-- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's
|
||||
-- literal EVOLVE_* ids, present with or without a ROM import). A
|
||||
-- Gold species' growthRate or evolution method is checked against
|
||||
-- real ids exactly like a Red one's, so a genuine typo is still
|
||||
-- caught here rather than waved through as "unknown, not wrong."
|
||||
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
|
||||
refRegistry = nil
|
||||
end
|
||||
@@ -887,7 +934,15 @@ R.maps = {
|
||||
destMap = f.str, destWarp = f.int(0),
|
||||
destGroup = f.opt(f.int(0)),
|
||||
destMapNum = f.opt(f.int(0)) })),
|
||||
objects = f.opt(f.list(f.any)),
|
||||
-- NPCs, signs, items, warps and static wild encounters all share this
|
||||
-- one array, with no field the loader could use to tell them apart
|
||||
-- ahead of time -- an f.rec strict enough to describe every kind would
|
||||
-- reject the others. f.partial types only `pokemon` (the static
|
||||
-- encounter's species, OverworldController.lua's `d.pokemon` ->
|
||||
-- BattleState.newWild) so a bad id is a load-time error, the same as
|
||||
-- an encounter slot's species, instead of the crash newWild has no
|
||||
-- guard against. Every other object field passes through untouched.
|
||||
objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
|
||||
signs = f.opt(f.list(f.any)),
|
||||
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
-- Minimal second-window process for src/render/DesktopScreen.lua.
|
||||
|
||||
local DesktopCompanion = {}
|
||||
|
||||
function DesktopCompanion.install(config)
|
||||
local enet = require("enet")
|
||||
local host = assert(enet.host_create())
|
||||
local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
|
||||
local image, sourceW, sourceH, preference
|
||||
local background = { 0, 0, 0, 1 }
|
||||
local connected, commandedQuit = false, false
|
||||
local pointerDown = false
|
||||
local started = love.timer.getTime()
|
||||
local lastContact = started
|
||||
|
||||
local function send(kind, payload)
|
||||
if not connected then return end
|
||||
pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
|
||||
end
|
||||
|
||||
local function receiveFrame(data)
|
||||
local prefix = "F" .. config.token .. "\n"
|
||||
if data:sub(1, #prefix) ~= prefix then return end
|
||||
local split = data:find("\n", #prefix + 1, true)
|
||||
if not split then return end
|
||||
local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
|
||||
:match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
|
||||
w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
|
||||
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
|
||||
local ok, raw = pcall(love.data.decompress, "string", "lz4",
|
||||
data:sub(split + 1))
|
||||
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
|
||||
local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
|
||||
if not made then return end
|
||||
if not image or sourceW ~= w or sourceH ~= h then
|
||||
if image and image.release then image:release() end
|
||||
image = love.graphics.newImage(pixels)
|
||||
else
|
||||
image:replacePixels(pixels)
|
||||
end
|
||||
sourceW, sourceH, preference = w, h, mode
|
||||
image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
|
||||
mode:find("cover", 1, true) and "linear" or "nearest")
|
||||
background = {
|
||||
math.floor(rgb / 0x10000) % 0x100 / 255,
|
||||
math.floor(rgb / 0x100) % 0x100 / 255,
|
||||
rgb % 0x100 / 255, 1,
|
||||
}
|
||||
end
|
||||
|
||||
local function service()
|
||||
while true do
|
||||
local event = host:service(0)
|
||||
if not event then break end
|
||||
if event.type == "connect" then
|
||||
connected, lastContact = true, love.timer.getTime()
|
||||
send("H")
|
||||
elseif event.type == "receive" then
|
||||
lastContact = love.timer.getTime()
|
||||
if event.data == "Q" .. config.token then
|
||||
commandedQuit = true
|
||||
love.event.quit()
|
||||
elseif event.data ~= "P" .. config.token then
|
||||
receiveFrame(event.data)
|
||||
end
|
||||
elseif event.type == "disconnect" then
|
||||
love.event.quit()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function placement()
|
||||
if not image then return 0, 0, 1 end
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local cover = preference and preference:find("cover", 1, true)
|
||||
local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
|
||||
return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
|
||||
end
|
||||
|
||||
local function input(action, x, y)
|
||||
if not image then return false end
|
||||
local dx, dy, scale = placement()
|
||||
local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
|
||||
if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
|
||||
send("I", ("\n%s,%d,%d"):format(action, sx, sy))
|
||||
return true
|
||||
end
|
||||
|
||||
function love.update()
|
||||
service()
|
||||
local t = love.timer.getTime()
|
||||
if (not connected and t - started > 5) or t - lastContact > 5 then
|
||||
love.event.quit()
|
||||
end
|
||||
end
|
||||
|
||||
function love.draw()
|
||||
love.graphics.clear(background[1], background[2], background[3], background[4])
|
||||
if not image then return end
|
||||
local x, y, scale = placement()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(image, x, y, 0, scale, scale)
|
||||
end
|
||||
|
||||
function love.mousepressed(x, y, button)
|
||||
if button == 1 then pointerDown = input("down", x, y) end
|
||||
end
|
||||
function love.mousereleased(x, y, button)
|
||||
if button == 1 and pointerDown then
|
||||
if not input("up", x, y) then send("I", "\ncancel,0,0") end
|
||||
pointerDown = false
|
||||
end
|
||||
end
|
||||
function love.touchpressed(_, x, y) input("down", x, y) end
|
||||
function love.touchreleased(_, x, y) input("up", x, y) end
|
||||
function love.keypressed(key)
|
||||
if key == "escape" then love.event.quit() end
|
||||
end
|
||||
function love.quit()
|
||||
if not commandedQuit then send("C") end
|
||||
pcall(peer.disconnect_now, peer)
|
||||
end
|
||||
end
|
||||
|
||||
return DesktopCompanion
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Cross-platform desktop secondary display. LOVE owns one window, so a
|
||||
-- second minimal instance of this same app owns the companion window. ENet
|
||||
-- is bundled with LOVE; binding it to loopback keeps frames and input local.
|
||||
|
||||
local Platform = require("src.core.Platform")
|
||||
local HostShell = require("src.core.HostShell")
|
||||
local okEnet, enet = pcall(require, "enet")
|
||||
|
||||
local DesktopScreen = {}
|
||||
local state = {
|
||||
enabled = false, blocked = false, host = nil, peer = nil,
|
||||
token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
|
||||
}
|
||||
|
||||
local function now()
|
||||
return love and love.timer and love.timer.getTime and love.timer.getTime()
|
||||
or os.clock()
|
||||
end
|
||||
|
||||
local function destroy(sendQuit)
|
||||
if state.peer and sendQuit then
|
||||
pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
|
||||
end
|
||||
if state.peer then pcall(state.peer.disconnect_now, state.peer) end
|
||||
if state.host then pcall(state.host.destroy, state.host) end
|
||||
state.host, state.peer, state.token, state.port = nil, nil, nil, nil
|
||||
state.touches = {}
|
||||
end
|
||||
|
||||
local function token()
|
||||
local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
|
||||
local digest = love.data.hash("sha256", seed)
|
||||
return love.data.encode("string", "hex", digest):sub(1, 24)
|
||||
end
|
||||
|
||||
local function start()
|
||||
if state.host or state.blocked or now() < state.retryAt then
|
||||
return state.host ~= nil
|
||||
end
|
||||
local base = 49152 + math.floor(now() * 1000) % 12000
|
||||
for attempt = 0, 31 do
|
||||
local port = 49152 + (base - 49152 + attempt * 37) % 12000
|
||||
local ok, host = pcall(enet.host_create,
|
||||
("127.0.0.1:%d"):format(port), 1, 2)
|
||||
if ok and host then
|
||||
state.host, state.port, state.token = host, port, token()
|
||||
local launched = HostShell.spawnSelfDetached({
|
||||
("--display-companion=%d,%s"):format(port, state.token),
|
||||
})
|
||||
if launched then return true end
|
||||
destroy(false)
|
||||
break
|
||||
end
|
||||
end
|
||||
state.retryAt = now() + 1
|
||||
return false
|
||||
end
|
||||
|
||||
local function service()
|
||||
if not state.enabled or state.blocked then return end
|
||||
if not state.host and not start() then return end
|
||||
while state.host do
|
||||
local ok, event = pcall(state.host.service, state.host, 0)
|
||||
if not ok then
|
||||
destroy(false)
|
||||
state.retryAt = now() + 1
|
||||
return
|
||||
end
|
||||
if not event then break end
|
||||
if event.type == "receive" then
|
||||
local data = event.data or ""
|
||||
if data == "H" .. state.token then
|
||||
state.peer = event.peer
|
||||
elseif event.peer == state.peer
|
||||
and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
|
||||
state.touches[#state.touches + 1] = data:sub(#state.token + 3)
|
||||
elseif event.peer == state.peer and data == "C" .. state.token then
|
||||
state.blocked = true
|
||||
destroy(false)
|
||||
return
|
||||
end
|
||||
elseif event.type == "disconnect" and event.peer == state.peer then
|
||||
destroy(false)
|
||||
state.retryAt = now() + 1
|
||||
return
|
||||
end
|
||||
end
|
||||
if state.peer and now() >= state.heartbeatAt then
|
||||
state.heartbeatAt = now() + 1
|
||||
pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
|
||||
end
|
||||
end
|
||||
|
||||
function DesktopScreen.usable()
|
||||
return okEnet and enet ~= nil and Platform.canSpawnProcess()
|
||||
end
|
||||
|
||||
function DesktopScreen.available()
|
||||
return DesktopScreen.detected()
|
||||
end
|
||||
|
||||
function DesktopScreen.detected()
|
||||
service()
|
||||
return state.peer ~= nil
|
||||
end
|
||||
|
||||
function DesktopScreen.push(imageData, w, h, background, preference)
|
||||
service()
|
||||
if not state.peer or not imageData or not imageData.getString then return false end
|
||||
w, h = tonumber(w), tonumber(h)
|
||||
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
|
||||
local ok, raw = pcall(imageData.getString, imageData)
|
||||
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
|
||||
local packed = love.data.compress("string", "lz4", raw, 1)
|
||||
local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
|
||||
tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
|
||||
local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
|
||||
return sent
|
||||
end
|
||||
|
||||
function DesktopScreen.pollTouch()
|
||||
service()
|
||||
return table.remove(state.touches, 1)
|
||||
end
|
||||
|
||||
function DesktopScreen.setEnabled(on)
|
||||
on = on == true
|
||||
if on == state.enabled then
|
||||
if on then service() end
|
||||
return
|
||||
end
|
||||
state.enabled = on
|
||||
state.blocked = false
|
||||
if on then start(); service() else destroy(true) end
|
||||
end
|
||||
|
||||
return DesktopScreen
|
||||
@@ -0,0 +1,185 @@
|
||||
-- Optional game viewport inside the OS window. A layout mod may reserve any
|
||||
-- window-space rectangle through render.viewport; the game then renders as if
|
||||
-- that rectangle were its whole display. With no subscriber this module is a
|
||||
-- pass-through and allocates no canvas.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local Viewport = {
|
||||
rect = nil,
|
||||
full = nil,
|
||||
canvas = nil,
|
||||
generation = nil,
|
||||
frameActive = false,
|
||||
}
|
||||
|
||||
local function finite(value)
|
||||
return type(value) == "number" and value == value
|
||||
and value > -math.huge and value < math.huge
|
||||
end
|
||||
|
||||
local function realMetrics()
|
||||
local G = love.graphics
|
||||
local w, h = G.getDimensions()
|
||||
local pw, ph = w, h
|
||||
if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
|
||||
local dpiX = w > 0 and pw / w or 1
|
||||
local dpiY = h > 0 and ph / h or 1
|
||||
if dpiX < 1e-6 then dpiX = 1 end
|
||||
if dpiY < 1e-6 then dpiY = 1 end
|
||||
return math.max(1, w), math.max(1, h),
|
||||
math.max(1, pw), math.max(1, ph), dpiX, dpiY
|
||||
end
|
||||
|
||||
local function clampRect(value, w, h)
|
||||
if type(value) ~= "table" then
|
||||
return { x = 0, y = 0, width = w, height = h }
|
||||
end
|
||||
local x = finite(value.x) and math.floor(value.x) or 0
|
||||
local y = finite(value.y) and math.floor(value.y) or 0
|
||||
local rw = finite(value.width) and math.floor(value.width) or w
|
||||
local rh = finite(value.height) and math.floor(value.height) or h
|
||||
x = math.max(0, math.min(x, w - 1))
|
||||
y = math.max(0, math.min(y, h - 1))
|
||||
rw = math.max(1, math.min(rw, w - x))
|
||||
rh = math.max(1, math.min(rh, h - y))
|
||||
return { x = x, y = y, width = rw, height = rh }
|
||||
end
|
||||
|
||||
local function sameSize(canvas, w, h)
|
||||
return canvas and canvas:getWidth() == w and canvas:getHeight() == h
|
||||
end
|
||||
|
||||
function Viewport.begin(generation)
|
||||
local w, h, pw, ph, dpiX, dpiY = realMetrics()
|
||||
local context = {
|
||||
width = w, height = h, pixelWidth = pw, pixelHeight = ph,
|
||||
dpiX = dpiX, dpiY = dpiY, generation = generation,
|
||||
}
|
||||
local requested
|
||||
if Runtime.wantsHook("render.viewport") then
|
||||
requested = Runtime.call("render.viewport", function(ctx)
|
||||
return { x = 0, y = 0, width = ctx.width, height = ctx.height }
|
||||
end, context)
|
||||
end
|
||||
local rect = clampRect(requested, w, h)
|
||||
Viewport.full = context
|
||||
Viewport.rect = rect
|
||||
Viewport.generation = generation
|
||||
local active = type(requested) == "table" and requested.capture == true
|
||||
or rect.x ~= 0 or rect.y ~= 0
|
||||
or rect.width ~= w or rect.height ~= h
|
||||
Viewport.frameActive = active
|
||||
if active then
|
||||
if not sameSize(Viewport.canvas, rect.width, rect.height) then
|
||||
if Viewport.canvas and Viewport.canvas.release then
|
||||
Viewport.canvas:release()
|
||||
end
|
||||
Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
|
||||
Viewport.canvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
else
|
||||
if Viewport.canvas and Viewport.canvas.release then
|
||||
Viewport.canvas:release()
|
||||
end
|
||||
Viewport.canvas = nil
|
||||
end
|
||||
return rect
|
||||
end
|
||||
|
||||
function Viewport.active()
|
||||
return Viewport.frameActive == true and Viewport.canvas ~= nil
|
||||
end
|
||||
|
||||
function Viewport.dimensions()
|
||||
if Viewport.active() then
|
||||
return Viewport.rect.width, Viewport.rect.height
|
||||
end
|
||||
return love.graphics.getDimensions()
|
||||
end
|
||||
|
||||
function Viewport.pixelDimensions()
|
||||
if Viewport.active() then
|
||||
if Viewport.canvas.getPixelDimensions then
|
||||
local w, h = Viewport.canvas:getPixelDimensions()
|
||||
return math.max(1, w), math.max(1, h)
|
||||
end
|
||||
return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
|
||||
math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
|
||||
end
|
||||
if love.graphics.getPixelDimensions then
|
||||
return love.graphics.getPixelDimensions()
|
||||
end
|
||||
return love.graphics.getDimensions()
|
||||
end
|
||||
|
||||
function Viewport.fullDimensions()
|
||||
if Viewport.full then return Viewport.full.width, Viewport.full.height end
|
||||
return love.graphics.getDimensions()
|
||||
end
|
||||
|
||||
function Viewport.target()
|
||||
return Viewport.canvas
|
||||
end
|
||||
|
||||
function Viewport.setTarget()
|
||||
love.graphics.setCanvas(Viewport.canvas)
|
||||
end
|
||||
|
||||
function Viewport.toLocal(x, y)
|
||||
local rect = Viewport.rect
|
||||
if not rect then return x, y, true end
|
||||
local lx, ly = x - rect.x, y - rect.y
|
||||
return lx, ly,
|
||||
lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
|
||||
end
|
||||
|
||||
function Viewport.localSafeRect(x, y, w, h)
|
||||
local rect = Viewport.rect
|
||||
if not Viewport.active() or not rect then return x, y, w, h end
|
||||
local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
|
||||
local x2 = math.min(x + w, rect.x + rect.width)
|
||||
local y2 = math.min(y + h, rect.y + rect.height)
|
||||
if x2 <= x1 or y2 <= y1 then
|
||||
return 0, 0, rect.width, rect.height
|
||||
end
|
||||
return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
|
||||
end
|
||||
|
||||
function Viewport.finish(game)
|
||||
if not Viewport.active() then return end
|
||||
local G = love.graphics
|
||||
local rect, full = Viewport.rect, Viewport.full
|
||||
G.setCanvas()
|
||||
G.push("all")
|
||||
G.origin()
|
||||
G.setScissor()
|
||||
G.setShader()
|
||||
G.setBlendMode("alpha")
|
||||
G.clear(0, 0, 0, 1)
|
||||
local context = {
|
||||
canvas = Viewport.canvas,
|
||||
x = rect.x, y = rect.y, width = rect.width, height = rect.height,
|
||||
windowWidth = full.width, windowHeight = full.height,
|
||||
dpiX = full.dpiX, dpiY = full.dpiY,
|
||||
generation = Viewport.generation,
|
||||
}
|
||||
Runtime.call("render.window", function(_, ctx)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(ctx.canvas, ctx.x, ctx.y)
|
||||
end, game, context)
|
||||
G.pop()
|
||||
end
|
||||
|
||||
function Viewport.reset()
|
||||
Viewport.frameActive = false
|
||||
Viewport.rect = nil
|
||||
Viewport.full = nil
|
||||
Viewport.generation = nil
|
||||
if Viewport.canvas and Viewport.canvas.release then
|
||||
Viewport.canvas:release()
|
||||
end
|
||||
Viewport.canvas = nil
|
||||
end
|
||||
|
||||
return Viewport
|
||||
+97
-12
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||
local FaithfulRes = require("src.core.FaithfulRes")
|
||||
|
||||
@@ -69,11 +70,9 @@ Renderer.UPRIGHT_MARGIN = 160
|
||||
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
||||
-- on BOTH axes (square).
|
||||
local function displayMetrics()
|
||||
local ww, wh = love.graphics.getDimensions()
|
||||
local ww, wh = GameViewport.dimensions()
|
||||
local pw, ph = ww, wh
|
||||
if love.graphics.getPixelDimensions then
|
||||
pw, ph = love.graphics.getPixelDimensions()
|
||||
end
|
||||
pw, ph = GameViewport.pixelDimensions()
|
||||
local dpiX, dpiY = 1, 1
|
||||
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
||||
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
||||
@@ -94,6 +93,7 @@ function Renderer:init()
|
||||
-- reason -- worldViewSize() already works in drawable pixels.
|
||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||
self.battleHUDCanvas = nil
|
||||
self.worldCanvas = nil
|
||||
self.worldActive = false
|
||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||
@@ -202,10 +202,38 @@ function Renderer:setUISize(w, h)
|
||||
w, h = math.floor(w), math.floor(h)
|
||||
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
||||
if self.canvas and self.canvas.release then self.canvas:release() end
|
||||
if self.battleHUDCanvas and self.battleHUDCanvas.release then
|
||||
self.battleHUDCanvas:release()
|
||||
end
|
||||
self.battleHUDCanvas = nil
|
||||
self.uiWidth, self.uiHeight = w, h
|
||||
self.canvas = PixelCanvas.new(w, h, "nearest")
|
||||
end
|
||||
|
||||
-- Transparent native-pixel surface for an extended WIDE battle HUD. The
|
||||
-- battle scene remains in `canvas`; endFrame places registered HUD regions
|
||||
-- afterward in physical-window space.
|
||||
function Renderer:beginBattleHUDPass()
|
||||
local w, h = self:uiSize()
|
||||
if not self.battleHUDCanvas
|
||||
or self.battleHUDCanvas:getWidth() ~= w
|
||||
or self.battleHUDCanvas:getHeight() ~= h then
|
||||
if self.battleHUDCanvas and self.battleHUDCanvas.release then
|
||||
self.battleHUDCanvas:release()
|
||||
end
|
||||
self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest")
|
||||
end
|
||||
local previous = love.graphics.getCanvas and love.graphics.getCanvas()
|
||||
or self.canvas
|
||||
love.graphics.setCanvas(self.battleHUDCanvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
return previous
|
||||
end
|
||||
|
||||
function Renderer:endBattleHUDPass(previous)
|
||||
love.graphics.setCanvas(previous or self.canvas)
|
||||
end
|
||||
|
||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
||||
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
||||
@@ -671,6 +699,17 @@ end
|
||||
-- centred letterbox. Declared during the element's own draw, in UI-canvas
|
||||
-- pixels, and consumed by endFrame this frame only.
|
||||
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
|
||||
local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped,
|
||||
canvas, extract)
|
||||
renderer.uiAnchors = renderer.uiAnchors or {}
|
||||
renderer.uiAnchors[#renderer.uiAnchors + 1] = {
|
||||
x = x, y = y, w = w, h = h, anchor = anchor,
|
||||
windowClamped = windowClamped and true or false,
|
||||
canvas = canvas,
|
||||
extract = extract ~= false,
|
||||
}
|
||||
end
|
||||
|
||||
function Renderer:setUIAnchor(x, y, w, h, anchor)
|
||||
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
|
||||
-- save.options.uiLayout): every element stays where it was drawn in the
|
||||
@@ -684,9 +723,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor)
|
||||
-- battle -- keeps every element inside it, so the box blits where it was
|
||||
-- drawn in the canvas instead of being pulled to the window edge.
|
||||
if self.uiAnchorHold then return end
|
||||
self.uiAnchors = self.uiAnchors or {}
|
||||
self.uiAnchors[#self.uiAnchors + 1] =
|
||||
{ x = x, y = y, w = w, h = h, anchor = anchor }
|
||||
addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true)
|
||||
end
|
||||
|
||||
-- Battle-owned window-space placement. Unlike ordinary UI anchors this is
|
||||
-- intentionally allowed while BattleState holds general dialogue/menu
|
||||
-- anchors inside the battle surface. Callers must gate it to an explicit
|
||||
-- battle HUD mode.
|
||||
function Renderer:setBattleUIAnchor(x, y, w, h, anchor)
|
||||
addUIAnchor(self, x, y, w, h, anchor, true,
|
||||
self.battleHUDCanvas or self.canvas, false)
|
||||
end
|
||||
|
||||
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||
@@ -698,7 +744,7 @@ end
|
||||
-- When GBC FX is active the composite is drawn into presentCanvas and
|
||||
-- presented through the GBC FX shader as a final pass.
|
||||
function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setCanvas()
|
||||
GameViewport.setTarget()
|
||||
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
||||
-- Sp = integer framebuffer pixels per GB pixel;
|
||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||
@@ -799,6 +845,8 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
||||
-- a visibly brighter border.
|
||||
local clearR, clearG, clearB = 0, 0, 0
|
||||
local extendedBlackBand = false
|
||||
local bandR, bandG, bandB = 1, 1, 1
|
||||
if not self.worldActive then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local stack = ok and Game and Game.stack
|
||||
@@ -825,7 +873,15 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
|
||||
-- painted the whole phone white on New Game and in battle (#864), so
|
||||
-- the lock keeps the default black bars.
|
||||
if state and state.letterboxWhite
|
||||
if state and state.extendedBlackHUD and state:extendedBlackHUD()
|
||||
and not FaithfulRes.scaleCap() then
|
||||
-- Extended/Black keeps the author's black surround, but extends the
|
||||
-- fixed battle's paper field vertically through the physical window.
|
||||
-- The band uses the exact centred fixed-width composition bounds, so
|
||||
-- only vertical black bars remain at the sides.
|
||||
extendedBlackBand = true
|
||||
bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data)
|
||||
elseif state and state.letterboxWhite
|
||||
and not (state.bgMode and state:bgMode() == "black")
|
||||
and not FaithfulRes.scaleCap() then
|
||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||
@@ -833,6 +889,10 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
if extendedBlackBand then
|
||||
love.graphics.setColor(bandR, bandG, bandB, 1)
|
||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||
@@ -975,6 +1035,22 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- Extended/WORLD keeps the frozen world as the physical surround, but stock
|
||||
-- Gen 1 back sprites rely on the battle's paper shade for visible highlights.
|
||||
-- Back the exact fixed-width composition from physical top to bottom so only
|
||||
-- the left and right sides expose the world. The battle canvas and detached
|
||||
-- HUD remain transparent layers composited afterward.
|
||||
-- A worldOverride is an arena provider's completed scene (for example,
|
||||
-- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle
|
||||
-- field, so never cover it with the native back-sprite fallback.
|
||||
if self.extendedWorldBand and not self.worldOverride
|
||||
and not FaithfulRes.scaleCap() then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
|
||||
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
-- UI: anchored regions against their screen edges, the rest in the classic
|
||||
-- centred letterbox. With nothing anchored this is the single blit it has
|
||||
-- always been.
|
||||
@@ -997,14 +1073,23 @@ function Renderer:endFrame(zones, worldZones)
|
||||
if a.anchor == "bottom" then
|
||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||
dy = wh - gapB - dh
|
||||
elseif a.anchor == "top" then
|
||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||
dy = a.y * Uy
|
||||
elseif a.anchor == "topright" then
|
||||
dx = ww - gapR - dw
|
||||
dy = a.y * Uy
|
||||
else -- unknown anchor: leave it where it is
|
||||
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
||||
end
|
||||
if a.windowClamped then
|
||||
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
|
||||
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
|
||||
end
|
||||
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
||||
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
||||
if a.extract then
|
||||
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
||||
end
|
||||
end
|
||||
for _, r in ipairs(rest) do
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
|
||||
@@ -1013,7 +1098,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
|
||||
-- The zone scissors are computed from the same origin, so an SGB
|
||||
-- region travels with the element instead of staying in the letterbox.
|
||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
||||
end
|
||||
end
|
||||
@@ -1064,7 +1149,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
GameViewport.setTarget()
|
||||
-- Post-process pipelines run over the finished composite -- world, UI
|
||||
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||
-- LCD grid is then drawn over rather than something that smears the
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
-- Bridge to native secondary-display output (Android Presentation). The C
|
||||
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
|
||||
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
|
||||
-- this stays inert and the renderer keeps the in-window stacked layout.
|
||||
-- Shared secondary-display facade. Android uses its native Presentation
|
||||
-- bridge; process-capable desktop hosts fall back to a companion window.
|
||||
-- Everything is guarded, so unsupported hosts keep the in-window layout.
|
||||
|
||||
local SecondScreen = {}
|
||||
local C = nil
|
||||
local ffi = nil
|
||||
local desktop = nil
|
||||
local nativePresent = false
|
||||
local nativeTarget = false
|
||||
|
||||
local function log(msg)
|
||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||
@@ -21,6 +23,10 @@ do
|
||||
int love_android_secondary_ready();
|
||||
void love_android_push_secondary(const void *rgba, int w, int h);
|
||||
void love_android_secondary_enable(int on);
|
||||
int love_android_secondary_detected();
|
||||
int love_android_present_secondary(const void *rgba, int w, int h,
|
||||
unsigned int background, int cover);
|
||||
void love_android_secondary_target(int target);
|
||||
const char *love_android_poll_secondary_touch();
|
||||
]])
|
||||
local okLib, lib = pcall(ffi.load, "love")
|
||||
@@ -34,21 +40,74 @@ do
|
||||
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
|
||||
:format(tostring(okLib)))
|
||||
end
|
||||
if C then
|
||||
local okDetected, detected = pcall(function()
|
||||
return C.love_android_secondary_detected
|
||||
end)
|
||||
local okPresent, present = pcall(function()
|
||||
return C.love_android_present_secondary
|
||||
end)
|
||||
local okTarget, target = pcall(function()
|
||||
return C.love_android_secondary_target
|
||||
end)
|
||||
nativePresent = okDetected and detected ~= nil
|
||||
and okPresent and present ~= nil
|
||||
nativeTarget = okTarget and target ~= nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not C then
|
||||
local ok, backend = pcall(require, "src.render.DesktopScreen")
|
||||
if ok and backend and backend.usable and backend.usable() then
|
||||
desktop = backend
|
||||
log("desktop companion backend ready")
|
||||
end
|
||||
end
|
||||
|
||||
function SecondScreen.usable()
|
||||
return C ~= nil
|
||||
return C ~= nil or desktop ~= nil
|
||||
end
|
||||
|
||||
function SecondScreen.available()
|
||||
if desktop then return desktop.available() end
|
||||
if not C then return false end
|
||||
local ok, r = pcall(C.love_android_secondary_ready)
|
||||
return ok and r ~= 0
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h)
|
||||
-- A connected display is not necessarily the current Presentation yet. This
|
||||
-- distinction lets a companion retry its first frame after hotplug/re-target.
|
||||
function SecondScreen.detected()
|
||||
if desktop then return desktop.detected() end
|
||||
if nativePresent then
|
||||
local ok, r = pcall(C.love_android_secondary_detected)
|
||||
return ok and r ~= 0
|
||||
end
|
||||
return SecondScreen.available()
|
||||
end
|
||||
|
||||
function SecondScreen.push(imageData, w, h, background, preference)
|
||||
if desktop then
|
||||
return desktop.push(imageData, w, h, background, preference)
|
||||
end
|
||||
if not C or not imageData then return false end
|
||||
if nativePresent and (background ~= nil or preference ~= nil) then
|
||||
if nativeTarget then
|
||||
local target = 0
|
||||
if preference == "handheld" or preference == "handheld:cover" then
|
||||
target = 1
|
||||
elseif preference == "secondary" or preference == "secondary:cover" then
|
||||
target = 2
|
||||
end
|
||||
pcall(C.love_android_secondary_target, target)
|
||||
end
|
||||
local cover = type(preference) == "string"
|
||||
and preference:sub(-6) == ":cover"
|
||||
local ok, shown = pcall(C.love_android_present_secondary,
|
||||
imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0)
|
||||
return ok and shown ~= 0
|
||||
end
|
||||
return pcall(function()
|
||||
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
|
||||
end)
|
||||
@@ -57,6 +116,7 @@ end
|
||||
-- Returns the oldest queued secondary-display event as "action,x,y", where
|
||||
-- coordinates are in the submitted frame's pixel space.
|
||||
function SecondScreen.pollTouch()
|
||||
if desktop then return desktop.pollTouch() end
|
||||
if not C then return nil end
|
||||
local ok, event = pcall(function()
|
||||
return C.love_android_poll_secondary_touch()
|
||||
@@ -66,6 +126,7 @@ function SecondScreen.pollTouch()
|
||||
end
|
||||
|
||||
function SecondScreen.setEnabled(on)
|
||||
if desktop then return desktop.setEnabled(on) end
|
||||
if not C then return end
|
||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||
end
|
||||
|
||||
@@ -358,42 +358,33 @@ end
|
||||
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
|
||||
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
|
||||
-- (constants/script_constants.asm).
|
||||
-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the
|
||||
-- routine that prints it is engine code and no script bytecode points at the
|
||||
-- string, so the extractor never reaches it.
|
||||
-- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so
|
||||
-- the extractor never reaches it.
|
||||
local CONTEST_NICKNAME_PROMPT =
|
||||
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
|
||||
|
||||
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123)
|
||||
function Specials.askNickname(vm, mon)
|
||||
nameMon(vm, mon.species)
|
||||
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
|
||||
if coroutine.yield({ kind = "yesorno" }) then
|
||||
-- InitNickname (engine/pokemon/move_mon.asm:1787)
|
||||
local h = hooks(vm)
|
||||
local name = h.renameMon and Specials.block(vm, function(done)
|
||||
h.renameMon(mon, done, { blank = true })
|
||||
end)
|
||||
-- _InitString's blank test (home/string.asm:6-30)
|
||||
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
|
||||
end
|
||||
end
|
||||
|
||||
H.CheckPartyFullAfterContest = function(vm)
|
||||
local Breeding = require("src.core.gen2.Breeding")
|
||||
local result, mon =
|
||||
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
|
||||
-- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the
|
||||
-- mon that joined the party and the one that went to the box -- and nowhere
|
||||
-- else in the contest: BugContest_SetCaughtContestMon merely holds the catch
|
||||
-- in wContestMon, so this is the only place the player is ever asked.
|
||||
-- GetPokemonName runs first, which is what {STRBUF} reads.
|
||||
-- GiveANickname_YesNo runs on both contest arms, party and box
|
||||
if mon and result ~= BugContest.NO_CATCH then
|
||||
nameMon(vm, mon.species)
|
||||
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is
|
||||
-- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page
|
||||
-- left standing.
|
||||
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
|
||||
if coroutine.yield({ kind = "yesorno" }) then
|
||||
-- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a
|
||||
-- fresh catch (the Name Rater is the one that pre-fills), and InitNickname
|
||||
-- copies the species name back over an empty entry -- so a cancelled
|
||||
-- keyboard is the same as answering NO.
|
||||
local h = hooks(vm)
|
||||
local name = h.renameMon and Specials.block(vm, function(done)
|
||||
h.renameMon(mon, done, { blank = true })
|
||||
end)
|
||||
-- _InitString's own blank test (home/string.asm:6-30): "zero or more
|
||||
-- spaces followed by a null". The keyboard's blank cells are real
|
||||
-- typeable characters, so an all-space entry has to be discarded the
|
||||
-- same way an empty one is, not stored as a name of spaces.
|
||||
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
|
||||
end
|
||||
Specials.askNickname(vm, mon)
|
||||
end
|
||||
answer(vm, result)
|
||||
end
|
||||
@@ -1005,7 +996,7 @@ end
|
||||
-- check is transcribed here rather than left to the screen, because its two
|
||||
-- refusals are TEXT and the script has to see them before the machine opens:
|
||||
-- no coins at all, or no COIN_CASE to hold them.
|
||||
local COIN_CASE = 0x47 -- constants/item_constants.asm
|
||||
local COIN_CASE = 0x36 -- constants/item_constants.asm:62
|
||||
|
||||
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
|
||||
local NO_COINS_TEXT = "You have no coins."
|
||||
@@ -2409,7 +2400,9 @@ local STUB_ROWS = {
|
||||
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
|
||||
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
|
||||
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
|
||||
{ "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" },
|
||||
-- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to
|
||||
-- WaitForLinkedFriend and lands on .FriendNotReady
|
||||
{ "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" },
|
||||
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
|
||||
{ "TradeCenter", nil, "link cable: no trade room" },
|
||||
{ "Colosseum", nil, "link cable: no battle room" },
|
||||
|
||||
@@ -544,7 +544,12 @@ local function runCmd(self, cmd, op)
|
||||
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
||||
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
||||
if self.givePokeFn then
|
||||
self.givePokeFn(species, level, item)
|
||||
local mon = self.givePokeFn(species, level, item)
|
||||
-- engine/pokemon/move_mon.asm:1753-1757
|
||||
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
||||
if mon and trainer == 0 then
|
||||
Specials.askNickname(self, mon)
|
||||
end
|
||||
end
|
||||
elseif op == "checkpoke" then
|
||||
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
|
||||
|
||||
+16
-1
@@ -4,6 +4,7 @@
|
||||
|
||||
local ItemEffects = require("src.inventory.ItemEffects")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local BagMenu = {}
|
||||
@@ -46,7 +47,16 @@ end
|
||||
-- the stack, so every exit that prints has to close it afterwards. For
|
||||
-- every other item the picker popped itself first and closePicker's identity
|
||||
-- check makes it a no-op (#252).
|
||||
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
--
|
||||
-- Every result string used to fall through to this one unconditional
|
||||
-- function with no seam around it: a mod could not suppress a message,
|
||||
-- delay it behind a screen of its own, or replace the outcome for one item
|
||||
-- id. The "item.use" hook wraps the whole dispatch (not a name per
|
||||
-- result -- a mod deciding what a Poké Doll or a stone does needs the
|
||||
-- SAME reach a vanilla `if result == ...` branch has, not a narrower one),
|
||||
-- the way "battle.overlay" and "ui.party.submenu" already wrap a
|
||||
-- screen's own default behavior elsewhere in src/ui.
|
||||
local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
|
||||
battle, moveIndex, game.overworld)
|
||||
local function closePicker()
|
||||
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
showMessages(game, payload, closePicker) -- failed
|
||||
end
|
||||
|
||||
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
return Runtime.call("item.use", vanillaUseOn,
|
||||
game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
local function pickTargetAndUse(game, battle, id, list)
|
||||
-- pick a target from the party
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
|
||||
+124
-38
@@ -14,6 +14,7 @@
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local DexEntryMenu = {}
|
||||
DexEntryMenu.__index = DexEntryMenu
|
||||
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
|
||||
return speciesOrOpts, false
|
||||
end
|
||||
|
||||
local function ownedFor(game, def, forceOwned)
|
||||
return forceOwned
|
||||
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
|
||||
end
|
||||
|
||||
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
|
||||
local function descPages(game, def, forceOwned)
|
||||
local e = def.dexEntry or {}
|
||||
local owned = ownedFor(game, def, forceOwned)
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
if not text then return nil end
|
||||
local pages = {}
|
||||
for chunk in (text .. "\f"):gmatch("(.-)\f") do
|
||||
local lines = {}
|
||||
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
lines[#lines + 1] = line
|
||||
end
|
||||
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
|
||||
if #lines > 0 then pages[#pages + 1] = lines end
|
||||
end
|
||||
if #pages == 0 then return nil end
|
||||
local last = pages[#pages]
|
||||
last[#last] = last[#last] .. "."
|
||||
return pages
|
||||
end
|
||||
|
||||
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
|
||||
local frameCache = {}
|
||||
local function frameSheet(game)
|
||||
local fx = game.data.field and game.data.field.overworldFx
|
||||
local def = fx and fx.pokedexFrame
|
||||
local path = def and def.path
|
||||
if not path then return nil end
|
||||
local hit = frameCache[path]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
if not ok or not img then
|
||||
frameCache[path] = false
|
||||
return nil
|
||||
end
|
||||
local iw, ih = img:getDimensions()
|
||||
local quads = {}
|
||||
for i = 0, 17 do
|
||||
quads[i] = love.graphics.newQuad((i % 3) * 8,
|
||||
math.floor(i / 3) * 8, 8, 8, iw, ih)
|
||||
end
|
||||
frameCache[path] = { img = img, quads = quads }
|
||||
return frameCache[path]
|
||||
end
|
||||
|
||||
-- engine/menus/pokedex.asm:601
|
||||
local DIVIDER = {
|
||||
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
|
||||
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
|
||||
}
|
||||
|
||||
function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||
local self = setmetatable({ game = game, forceOwned = forceOwned,
|
||||
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||
self.def = game.data.pokemon[species]
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
game.data, species, "front", { kind = "dex" })
|
||||
-- `path and pcall(...)` truncates to one value, so img was always nil and
|
||||
-- every dex page drew without its pic (#307); the guard has to be a
|
||||
-- statement for pcall's second return to survive.
|
||||
-- pcall's second return has to survive the guard (#307)
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprite = ok and img or nil
|
||||
self.spriteTrueColor = self.sprite and trueColor or false
|
||||
self.page = 1
|
||||
local pages = descPages(game, self.def, forceOwned)
|
||||
self.pageCount = pages and #pages or 1
|
||||
require("src.core.Sound").playCry(game.data, species)
|
||||
return self
|
||||
end
|
||||
@@ -57,6 +115,11 @@ end
|
||||
function DexEntryMenu:update(dt)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- home/text.asm:245
|
||||
if self.page < (self.pageCount or 1) then
|
||||
self.page = self.page + 1
|
||||
return
|
||||
end
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
@@ -64,64 +127,87 @@ end
|
||||
|
||||
function DexEntryMenu:draw()
|
||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||
self.spriteTrueColor)
|
||||
self.spriteTrueColor, self.page)
|
||||
end
|
||||
|
||||
-- Static entry-page renderer, shared with the printer stand-in
|
||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
||||
-- engine/menus/pokedex.asm:399
|
||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||
page = page or 1
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local frame = frameSheet(game)
|
||||
if frame then
|
||||
local function tile(code, tx, ty)
|
||||
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
|
||||
end
|
||||
-- engine/menus/pokedex.asm:418
|
||||
for tx = 1, 18 do
|
||||
tile(0x64, tx, 0)
|
||||
tile(0x6f, tx, 17)
|
||||
end
|
||||
for ty = 1, 16 do
|
||||
tile(0x66, 0, ty)
|
||||
tile(0x67, 19, ty)
|
||||
end
|
||||
tile(0x63, 0, 0)
|
||||
tile(0x65, 19, 0)
|
||||
tile(0x6c, 0, 17)
|
||||
tile(0x6e, 19, 17)
|
||||
-- engine/menus/pokedex.asm:445
|
||||
for tx = 0, 19 do
|
||||
tile(DIVIDER[tx + 1], tx, 9)
|
||||
end
|
||||
end
|
||||
if sprite then
|
||||
local y = math.max(0, 60 - sprite:getHeight())
|
||||
love.graphics.draw(sprite, 8, y)
|
||||
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
|
||||
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
|
||||
-- it renders to its own PNG canvas, and a mark left behind there would
|
||||
-- bleed into the next real frame.
|
||||
-- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
|
||||
local w, h = sprite:getDimensions()
|
||||
local x = 8 + math.floor((8 - w / 8) / 2) * 8
|
||||
local y = 8 + (7 - h / 8) * 8
|
||||
love.graphics.draw(sprite, x + w, y, 0, -1, 1)
|
||||
-- the unshaded pass needs the pic bounds (#350)
|
||||
if trueColor then
|
||||
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(def.name, 72, 8)
|
||||
-- engine/menus/pokedex.asm:454
|
||||
Font.draw(def.name, 72, 16)
|
||||
local e = def.dexEntry or {}
|
||||
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
|
||||
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
|
||||
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
|
||||
Font.draw(e.kind or "?", 72, 20)
|
||||
-- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
|
||||
Font.draw(e.kind or "?", 72, 32)
|
||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||
-- prints the extra digit everywhere at once
|
||||
local digits = (game.data.constants or {}).dexDigits or 3
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
||||
local owned = forceOwned
|
||||
or (game.save.pokedex and game.save.pokedex.owned[def.id])
|
||||
-- height/weight print only once owned, like the description
|
||||
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
||||
-- height, weight, or description")
|
||||
-- engine/menus/pokedex.asm:478
|
||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
|
||||
16, 64)
|
||||
local owned = ownedFor(game, def, forceOwned)
|
||||
-- engine/menus/pokedex.asm:449, numbers only once owned
|
||||
if owned and e.heightFt then
|
||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
||||
-- engine/gfx/load_pokedex_tiles.asm)
|
||||
if e.heightM then
|
||||
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
|
||||
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
|
||||
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
|
||||
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
|
||||
else
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 48)
|
||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
|
||||
end
|
||||
end
|
||||
local text = owned and e.text and game.data.text[e.text] or nil
|
||||
local y = 72
|
||||
if text then
|
||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||
if y > 132 then break end
|
||||
Font.draw(line, 8, y)
|
||||
y = y + 10
|
||||
local pages = descPages(game, def, forceOwned)
|
||||
if pages then
|
||||
-- engine/menus/pokedex.asm:568
|
||||
local lines = pages[page] or pages[#pages]
|
||||
for i, line in ipairs(lines) do
|
||||
Font.draw(line, 8, 72 + i * 16)
|
||||
end
|
||||
-- home/text.asm:245
|
||||
if page < #pages then
|
||||
Font.drawCode(Theme.moreArrow, 144, 128)
|
||||
end
|
||||
else
|
||||
Font.draw(Strings("Data unknown."), 8, y)
|
||||
Font.draw(Strings("Data unknown."), 8, 88)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Strings = require("src.core.Strings")
|
||||
local HallOfFame = require("src.ui.HallOfFame")
|
||||
|
||||
local LeaguePC = {}
|
||||
LeaguePC.__index = LeaguePC
|
||||
LeaguePC.isOpaque = true
|
||||
|
||||
-- constants/pokemon_data_constants.asm:65
|
||||
local CAPACITY = 50
|
||||
|
||||
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
|
||||
function LeaguePC:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local mon = self:currentMon()
|
||||
local c = mon and P.monPal(game.data, mon.species)
|
||||
if c then return { P.whole(c) } end
|
||||
return P.wholeNamed(game.data, "MEWMON")
|
||||
end
|
||||
|
||||
function LeaguePC.new(game, onDone)
|
||||
local self = setmetatable({}, LeaguePC)
|
||||
self.game = game
|
||||
self.onDone = onDone
|
||||
self.teams = (game.save and game.save.hallOfFame) or {}
|
||||
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
|
||||
self.monIndex = 1
|
||||
self.sprites = {}
|
||||
self.spriteTrueColor = {}
|
||||
self:loadMon()
|
||||
return self
|
||||
end
|
||||
|
||||
function LeaguePC:currentMon()
|
||||
local team = self.teams[self.teamIndex]
|
||||
return team and team[self.monIndex] or nil
|
||||
end
|
||||
|
||||
function LeaguePC:loadMon()
|
||||
local mon = self:currentMon()
|
||||
if not mon then return end
|
||||
local species = mon.species
|
||||
if self.sprites[species] == nil then
|
||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||
self.game.data, species, "front", { kind = "hof" })
|
||||
local ok, img = false, nil
|
||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||
self.sprites[species] = ok and img or false
|
||||
self.spriteTrueColor[species] = (ok and img and trueColor) or false
|
||||
end
|
||||
require("src.core.Sound").playCry(self.game.data, species)
|
||||
end
|
||||
|
||||
function LeaguePC:close()
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
function LeaguePC:update(dt)
|
||||
local input = self.game.input
|
||||
if input:wasPressed("b") then
|
||||
self:close()
|
||||
return
|
||||
end
|
||||
if input:wasPressed("a") then
|
||||
if not self:currentMon() then
|
||||
self:close()
|
||||
return
|
||||
end
|
||||
local team = self.teams[self.teamIndex]
|
||||
if self.monIndex < #team then
|
||||
self.monIndex = self.monIndex + 1
|
||||
elseif self.teamIndex < #self.teams then
|
||||
self.teamIndex = self.teamIndex + 1
|
||||
self.monIndex = 1
|
||||
else
|
||||
self:close()
|
||||
return
|
||||
end
|
||||
self:loadMon()
|
||||
end
|
||||
end
|
||||
|
||||
function LeaguePC:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local mon = self:currentMon()
|
||||
if not mon then return end
|
||||
local img = self.sprites[mon.species]
|
||||
if img then
|
||||
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
|
||||
local w, h = img:getDimensions()
|
||||
local x = 96 + math.floor((8 - w / 8) / 2) * 8
|
||||
local y = 40 + (7 - h / 8) * 8
|
||||
love.graphics.draw(img, x, y)
|
||||
if self.spriteTrueColor[mon.species] then
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
|
||||
end
|
||||
end
|
||||
-- engine/movie/hall_of_fame.asm:159
|
||||
HallOfFame.drawMonInfo(self, mon)
|
||||
-- engine/menus/league_pc.asm:102
|
||||
Font.drawBox(0, 13, 20, 4)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
|
||||
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return LeaguePC
|
||||
+11
-1
@@ -79,7 +79,16 @@ end
|
||||
function NamingScreen:enter()
|
||||
if self.presets and #self.presets > 0 then
|
||||
local Menu = require("src.ui.Menu")
|
||||
local items = { { label = Strings("NEW NAME") } }
|
||||
-- engine/movie/oak_speech/oak_speech2.asm:1
|
||||
self.choosing = true
|
||||
self.isOpaque = false
|
||||
local items = { {
|
||||
label = Strings("NEW NAME"),
|
||||
onSelect = function()
|
||||
self.choosing = nil
|
||||
self.isOpaque = nil
|
||||
end,
|
||||
} }
|
||||
for _, preset in ipairs(self.presets) do
|
||||
table.insert(items, {
|
||||
label = preset,
|
||||
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
|
||||
end
|
||||
|
||||
function NamingScreen:draw()
|
||||
if self.choosing then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
+42
-1
@@ -180,6 +180,11 @@ local function buildRows(game)
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||
if o.battleLayout ~= "wide" then
|
||||
o.battleHud = "standard"
|
||||
elseif o.battleFit == "fill" and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end },
|
||||
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
|
||||
@@ -195,6 +200,31 @@ local function buildRows(game)
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
|
||||
if o.battleFit == "fill" and o.battleLayout == "wide"
|
||||
and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end },
|
||||
{ id = "battleHud", label = Strings("BATTLE HUD"),
|
||||
value = function(g)
|
||||
local o = g.save.options
|
||||
return o.battleLayout == "wide" and o.battleHud == "extended"
|
||||
and Strings("EXTENDED")
|
||||
or Strings("STANDARD")
|
||||
end,
|
||||
step = function(g)
|
||||
local o = g.save.options
|
||||
-- The extended HUD is a widescreen-only composition. Keep OG locked
|
||||
-- to the author's standard HUD even if an older save says otherwise.
|
||||
if o.battleLayout ~= "wide" then
|
||||
o.battleHud = "standard"
|
||||
return false
|
||||
end
|
||||
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
|
||||
if o.battleHud == "extended" and o.battleFit == "fill" then
|
||||
o.battleBg = "white"
|
||||
end
|
||||
return true
|
||||
end },
|
||||
-- What sits behind and around the battle. WHITE is the classic paper
|
||||
@@ -203,13 +233,24 @@ local function buildRows(game)
|
||||
-- shows through everywhere the battle does not paint).
|
||||
{ id = "battleBg", label = Strings("BATTLE BG"),
|
||||
value = function(g)
|
||||
local m = g.save.options.battleBg
|
||||
local o = g.save.options
|
||||
if o.battleLayout == "wide" and o.battleFit == "fill"
|
||||
and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
return Strings("AUTO")
|
||||
end
|
||||
local m = o.battleBg
|
||||
if m == "black" then return Strings("BLACK") end
|
||||
if m == "world" then return Strings("WORLD") end
|
||||
return Strings("WHITE")
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
if o.battleLayout == "wide" and o.battleFit == "fill"
|
||||
and o.battleHud == "extended" then
|
||||
o.battleBg = "white"
|
||||
return false
|
||||
end
|
||||
local order = { "white", "black", "world" }
|
||||
local cur = 1
|
||||
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
|
||||
|
||||
+2
-16
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
if self.softboiledFrom then
|
||||
local user = party[self.softboiledFrom]
|
||||
local heal = math.floor(user.stats.hp / 5)
|
||||
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
|
||||
or user.hp <= heal then
|
||||
self.softboiledFrom = nil
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
|
||||
else
|
||||
user.hp = user.hp - heal
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
self.softboiledFrom = nil
|
||||
require("src.core.Sound").play(self.game.data, "Heal_HP")
|
||||
local def = self.game.data.pokemon[mon.species]
|
||||
local TextBox = require("src.render.TextBox")
|
||||
self.game.stack:push(TextBox.new(self.game,
|
||||
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
|
||||
end
|
||||
self.softboiledFrom = nil
|
||||
self.game.overworld:useSoftboiledFieldMove(user, mon)
|
||||
elseif self.swapFrom then
|
||||
if self.swapFrom ~= self.index then
|
||||
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
|
||||
|
||||
+32
-15
@@ -220,6 +220,20 @@ function TownMap.new(game, opts)
|
||||
-- the player's current location (guard: overworld may not be running)
|
||||
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
|
||||
self.playerLoc = mapId and self.byMap[mapId] or nil
|
||||
-- engine/items/town_map.asm:347
|
||||
do
|
||||
local playerSprites = (game.data.field and game.data.field.playerSprites)
|
||||
or {}
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
||||
or sprites.SPRITE_RED
|
||||
local ok, img = pcall(love.graphics.newImage, red and red.image)
|
||||
if ok and img then
|
||||
self.playerSheet = img
|
||||
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
||||
img:getDimensions())
|
||||
end
|
||||
end
|
||||
self.sel = 1
|
||||
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
|
||||
-- fly destination (PALLET_TOWN), never the player's current town (#795).
|
||||
@@ -345,18 +359,16 @@ function TownMap:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
-- the player's current location blinks (slow phase). Paint it with a
|
||||
-- palette-safe DARK shade (red 0), not red: this screen composites through
|
||||
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
|
||||
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
|
||||
-- {165,214,255}, the exact light-blue used for the water and the town-square
|
||||
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
|
||||
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
|
||||
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
local x, y = markerXY(self.playerLoc)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if self.playerSheet then
|
||||
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
|
||||
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
|
||||
@@ -389,11 +401,16 @@ function TownMap:draw()
|
||||
drawSquare(loc)
|
||||
end
|
||||
if self.playerLoc and self.blink < 20 then
|
||||
-- palette-safe dark, same red-channel shade-remap reason as the primary
|
||||
-- grid path above (#152); stale-asset builds hit this fallback square
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
|
||||
if self.playerSheet then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.playerSheet, self.playerQuad,
|
||||
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
self.playerLoc.y * 8 + 2, 4, 4)
|
||||
end
|
||||
end
|
||||
if selected and self.blink % 16 < 10 then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
@@ -150,6 +150,10 @@ end
|
||||
-- animation (most of them) skips the canvas entirely.
|
||||
local function needsCanvas(runner)
|
||||
local bg = runner.bg
|
||||
-- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays
|
||||
-- out of the BG until the next pic redraw, so those frames stay baked too.
|
||||
local lifted = bg.liftedRows
|
||||
if lifted and (lifted.player or lifted.enemy) then return true end
|
||||
if bg.scx ~= 0 or bg.scy ~= 0 then return true end
|
||||
if not bg.lcdc then return false end
|
||||
if bg.lyEnd <= bg.lyStart then return false end
|
||||
@@ -229,13 +233,12 @@ end
|
||||
-- times, and the grouping is by VALUE so a table that happens to repeat costs
|
||||
-- nothing extra.
|
||||
local function bgpBands(bg)
|
||||
local base = bg.bgp or GbcPalette.BGP_IDENTITY
|
||||
local order, bands = {}, {}
|
||||
for row = 0, SCREEN_H - 1 do
|
||||
local inWindow = row >= bg.lyStart and row < bg.lyEnd
|
||||
-- Outside the window the register still reads whatever wBGP holds, which
|
||||
-- for every effect that aims hLCDCPointer at rBGP is the identity.
|
||||
local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY)
|
||||
or GbcPalette.BGP_IDENTITY
|
||||
-- Outside the window the register still reads whatever wBGP holds.
|
||||
local byte = inWindow and (bg.lyBackup[row] or base) or base
|
||||
local band = bands[byte]
|
||||
if not band then
|
||||
band = { byte = byte, rows = {} }
|
||||
@@ -244,24 +247,56 @@ local function bgpBands(bg)
|
||||
end
|
||||
band.rows[#band.rows + 1] = row
|
||||
end
|
||||
-- Identity first so the fillBackground below it happens before any blit and
|
||||
-- the common band is the one drawn from the first bake.
|
||||
-- The base band first so the fillBackground below it happens before any blit
|
||||
-- and the common band is the one drawn from the first bake.
|
||||
table.sort(order, function(a, b)
|
||||
if a.byte == b.byte then return false end
|
||||
if a.byte == GbcPalette.BGP_IDENTITY then return true end
|
||||
if b.byte == GbcPalette.BGP_IDENTITY then return false end
|
||||
if a.byte == base then return true end
|
||||
if b.byte == base then return false end
|
||||
return a.rows[1] < b.rows[1]
|
||||
end)
|
||||
return order
|
||||
end
|
||||
|
||||
-- Runs `drawBg` (the battle panel) and then puts it on screen through the
|
||||
-- animation's BG registers. Returns without a canvas when nothing is
|
||||
-- displacing anything, which is the common case and costs nothing.
|
||||
function BattleAnimView:present(runner, drawBg)
|
||||
-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
|
||||
function BattleAnimView:panelPalettes(battle)
|
||||
local list = {}
|
||||
local shades = {}
|
||||
for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end
|
||||
list[#list + 1] = shades
|
||||
local function bracket(pair)
|
||||
if not (pair and pair[1] and pair[2]) then return end
|
||||
list[#list + 1] = {
|
||||
{ 255, 255, 255 },
|
||||
{ pair[1][1], pair[1][2], pair[1][3] },
|
||||
{ pair[2][1], pair[2][2], pair[2][3] },
|
||||
{ 0, 0, 0 },
|
||||
}
|
||||
end
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local mon = battle and battle[side]
|
||||
local colors = mon
|
||||
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
|
||||
if colors then list[#list + 1] = colors end
|
||||
end
|
||||
local hpBar = self.palettes and self.palettes.hpBar
|
||||
if hpBar then
|
||||
bracket(hpBar.green)
|
||||
bracket(hpBar.yellow)
|
||||
bracket(hpBar.red)
|
||||
end
|
||||
bracket(self.palettes and self.palettes.expBar)
|
||||
return list
|
||||
end
|
||||
|
||||
-- Runs `drawBg` (the battle panel) and puts it on screen through the
|
||||
-- animation's BG registers; skips the canvas when nothing needs one.
|
||||
function BattleAnimView:present(runner, drawBg, battle)
|
||||
if not (love and love.graphics) then return end
|
||||
local bg = runner.bg
|
||||
if not needsCanvas(runner) then
|
||||
local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY
|
||||
and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil
|
||||
if not invert and not needsCanvas(runner) then
|
||||
drawBg()
|
||||
return
|
||||
end
|
||||
@@ -292,9 +327,10 @@ function BattleAnimView:present(runner, drawBg)
|
||||
|
||||
self:bake(drawBg, nil)
|
||||
|
||||
-- A shifted scanline exposes whatever the BG map holds beside the pic, which
|
||||
-- outside the two pic boxes is the blank tile. Without this the exposed
|
||||
-- strip is the canvas's own transparency and every shake shows a seam.
|
||||
local remapped = invert
|
||||
and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp)
|
||||
-- A shifted scanline exposes the blank tile beside the pic boxes; without
|
||||
-- this the exposed strip is the canvas's own transparency.
|
||||
self:fillBackground()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
-- hSCX / hSCY move the whole background; the per-scanline overrides only
|
||||
@@ -314,6 +350,7 @@ function BattleAnimView:present(runner, drawBg)
|
||||
self:blitRow(row, dx, dy)
|
||||
end
|
||||
end
|
||||
if remapped then GbcPalette.clear() end
|
||||
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to
|
||||
-- permute and the entry's BRIGHTNESS is the only thing left to reproduce.
|
||||
if bg.lcdc == "BGP" then
|
||||
@@ -417,5 +454,6 @@ end
|
||||
|
||||
BattleAnimView.SCREEN_W = SCREEN_W
|
||||
BattleAnimView.SCREEN_H = SCREEN_H
|
||||
BattleAnimView.needsCanvas = needsCanvas
|
||||
|
||||
return BattleAnimView
|
||||
|
||||
+122
-46
@@ -49,6 +49,9 @@ BattleState.isOpaque = true
|
||||
-- the victory jingle can keep looping through the post-win prompts.
|
||||
local MESSAGE_FRAMES = 48
|
||||
|
||||
-- engine/battle/effect_commands.asm:6661
|
||||
local MOVE_DELAY_FRAMES = 40
|
||||
|
||||
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
|
||||
local HM_MOVES = {
|
||||
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
|
||||
@@ -239,15 +242,8 @@ function BattleState.new(game, opts)
|
||||
self.menuIndex = 1
|
||||
self.moveIndex = 1
|
||||
self.picCache = {}
|
||||
-- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_
|
||||
-- ReturnMon's last row (what swallows a mon into a thrown ball) and
|
||||
-- MonFaintedAnimation both clear the box and neither puts anything back: it
|
||||
-- stays blank until something DRAWS a pic into it, which on the cart is only
|
||||
-- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon).
|
||||
-- Without this latch the pic came back the instant the animation let go of
|
||||
-- the screen, so a caught mon stood there through "Gotcha!" and a fainted one
|
||||
-- popped back up for its own faint line. See stepAnim for why it is latched
|
||||
-- at those two moments rather than off the runner's own last frame.
|
||||
-- Which side's pic box stays EMPTY until a send-out redraws it: a catch
|
||||
-- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide.
|
||||
self.picHidden = { player = false, enemy = false }
|
||||
-- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in
|
||||
-- from opposite sides before the first message.
|
||||
@@ -665,7 +661,7 @@ function BattleState:drawPic(mon, back)
|
||||
-- the mon drawn at this frame.
|
||||
local scale = self:picScale(path, mon, back)
|
||||
if anim then
|
||||
px = px + (anim.slide or 0)
|
||||
if not self.liftedPass then px = px + (anim.slide or 0) end
|
||||
local resized = anim.size and PIC_RESIZE_TILES[anim.size]
|
||||
if resized then scale = scale * (resized / boxTiles) end
|
||||
end
|
||||
@@ -718,11 +714,57 @@ function BattleState:drawPic(mon, back)
|
||||
-- A mod-supplied pic that says it is already coloured is drawn as it is:
|
||||
-- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands
|
||||
-- back to its own draw site.
|
||||
if colors and not trueColor and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
local function paint()
|
||||
if colors and not trueColor and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
end
|
||||
local lifted = anim and anim.lifted
|
||||
if not lifted then
|
||||
paint()
|
||||
return
|
||||
end
|
||||
-- engine/battle_anims/bg_effects.asm:448-465: the ClearBoxed band is off the BG.
|
||||
local bandY = (back and BattleState.PLAYER_PIC_TILE_Y
|
||||
or BattleState.ENEMY_PIC_TILE_Y) * 8 + lifted[1] * 8
|
||||
local bandH = lifted[2] * 8
|
||||
local psx, psy, psw, psh
|
||||
if G.getScissor then psx, psy, psw, psh = G.getScissor() end
|
||||
if self.liftedPass then
|
||||
G.setScissor(0, bandY, 160, bandH)
|
||||
paint()
|
||||
else
|
||||
if bandY > 0 then
|
||||
G.setScissor(0, 0, 160, bandY)
|
||||
paint()
|
||||
end
|
||||
local below = 144 - bandY - bandH
|
||||
if below > 0 then
|
||||
G.setScissor(0, bandY + bandH, 160, below)
|
||||
paint()
|
||||
end
|
||||
end
|
||||
if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end
|
||||
end
|
||||
|
||||
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
|
||||
-- enemy's frontpic, facing-UP for the player's backpic.
|
||||
function BattleState:substituteDoll(back)
|
||||
if self.subDoll == nil then
|
||||
local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png")
|
||||
if ok and image then
|
||||
local w, h = image:getDimensions()
|
||||
self.subDoll = { image = image,
|
||||
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
|
||||
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
|
||||
else
|
||||
self.subDoll = false
|
||||
end
|
||||
end
|
||||
if not self.subDoll then return nil end
|
||||
return self.subDoll.image, back and self.subDoll.up or self.subDoll.down
|
||||
end
|
||||
|
||||
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
|
||||
@@ -1050,10 +1092,10 @@ function BattleState:afterAnimFor(side)
|
||||
return "ANIM_PLAYER_DAMAGE"
|
||||
end
|
||||
|
||||
function BattleState:animForMove(moveId, side)
|
||||
function BattleState:animForMove(moveId, side, param)
|
||||
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
|
||||
local started = self:startAnim(key, {
|
||||
turn = self:turnFor(side), animId = moveId, isMove = true,
|
||||
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
|
||||
})
|
||||
if started then
|
||||
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
|
||||
@@ -1091,14 +1133,22 @@ function BattleState:animForId(idName, side, param)
|
||||
})
|
||||
end
|
||||
|
||||
-- data/moves/animations.asm:379
|
||||
function BattleState:latchCaughtPic()
|
||||
local anim = self.anim
|
||||
if anim and anim.animId == "ANIM_THROW_POKE_BALL"
|
||||
and self.ballThrow and self.ballThrow.caught then
|
||||
self.picHidden.enemy = true
|
||||
end
|
||||
end
|
||||
|
||||
-- One logic frame of a running animation. B cuts it short, the way holding B
|
||||
-- pages a text box.
|
||||
function BattleState:stepAnim(input)
|
||||
if not self.anim then return end
|
||||
if input and (input:wasPressed("b") or input:wasPressed("start")) then
|
||||
-- Cut short: the BG effects never reached their own last step, so the
|
||||
-- tilemap is whatever they had got to and nothing is latched -- the
|
||||
-- explicit latches (a catch) are the only ones that survive a skip.
|
||||
-- Cut short: only the explicit latches (a caught mon) survive a skip.
|
||||
self:latchCaughtPic()
|
||||
self.anim = nil
|
||||
-- Cart still reaches the after-anim arm after a move script ends; a skip
|
||||
-- of the move should not drop the hit shake that follows it.
|
||||
@@ -1106,6 +1156,7 @@ function BattleState:stepAnim(input)
|
||||
return self:endSendOutAnim()
|
||||
end
|
||||
if not self.anim:step() then
|
||||
self:latchCaughtPic()
|
||||
-- pokegold data/moves/animations.asm .Click: anim_keepsprites means
|
||||
-- the OAM outlives the script, so keep the runner for drawing too.
|
||||
if not self.anim.keepSprites then self.anim = nil end
|
||||
@@ -1132,6 +1183,7 @@ function BattleState:animPicState(side)
|
||||
local bg = self.anim.bg
|
||||
return {
|
||||
hidden = bg.hidden[side],
|
||||
lifted = bg.liftedRows and bg.liftedRows[side] or nil,
|
||||
size = bg.picSize[side],
|
||||
slide = bg.slide[side] or 0,
|
||||
shade = bg.monShade[side],
|
||||
@@ -1391,16 +1443,14 @@ function BattleState:advanceQueue()
|
||||
end
|
||||
if event.text then
|
||||
self.message = event.text
|
||||
-- Lines that must not hold the queue for A/B:
|
||||
-- move UsedMoveText -> text_end, then moveanim
|
||||
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
|
||||
-- box's WaitPressAorB is the real hold
|
||||
-- experience keeps the wait: _ExpPointsText ends in `prompt`
|
||||
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
|
||||
-- so the bar crawls under the line and A dismisses it before the battle
|
||||
-- can end.
|
||||
-- move/level lines do not hold for A/B (battle.asm:336-343); experience
|
||||
-- keeps the wait (common_1.asm:1660-1665).
|
||||
if event.kind == "move" or event.kind == "level" then
|
||||
self.messageTimer = 0
|
||||
-- engine/battle/effect_commands.asm:1958-1961
|
||||
if event.kind == "move" and event.missed then
|
||||
self.messageDelay = MOVE_DELAY_FRAMES
|
||||
end
|
||||
else
|
||||
self.messageTimer = MESSAGE_FRAMES
|
||||
end
|
||||
@@ -1422,17 +1472,12 @@ function BattleState:advanceQueue()
|
||||
if event.waitSfx then self.waitSfx = event.sfx end
|
||||
end
|
||||
end
|
||||
-- The move's own animation plays over its "used X!" line, which is where
|
||||
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
|
||||
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
|
||||
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
|
||||
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
|
||||
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
|
||||
-- event.missed on every wAttackMissed path.
|
||||
-- engine/battle/effect_commands.asm:1958: a missed move burns the delay
|
||||
-- and plays nothing; the after-anim chain is animForMove / stepAnim's.
|
||||
if event.kind == "move" and not event.missed then
|
||||
self.afterAnimPlayed = nil
|
||||
self.pendingAfterAnim = nil
|
||||
if not self:animForMove(event.move, event.side) then
|
||||
if not self:animForMove(event.move, event.side, event.animParam) then
|
||||
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
|
||||
-- (anim_commands.asm:55-72 .disabled fallthrough).
|
||||
local options = self.game and self.game.options
|
||||
@@ -1854,6 +1899,11 @@ function BattleState:update(_dt)
|
||||
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
|
||||
-- before any PromptButton wait so the bar does not sit frozen until A.
|
||||
if self:stepExpAnim() then return end
|
||||
-- engine/battle/effect_commands.asm:6661
|
||||
if (self.messageDelay or 0) > 0 then
|
||||
self.messageDelay = self.messageDelay - 1
|
||||
return
|
||||
end
|
||||
if self.messageTimer > 0 then
|
||||
if self.tutorial then
|
||||
-- PromptButton waits for the button; the tutorial cannot press it, so
|
||||
@@ -2463,14 +2513,6 @@ function BattleState:pushCaught(enemy, itemId)
|
||||
local save = self.save
|
||||
self.battle.over = true
|
||||
self.battle.outcome = "caught"
|
||||
-- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught
|
||||
-- arm ends on the return-mon BG effect, which leaves the enemy pic box
|
||||
-- cleared, and PokeBallEffect never draws a frontpic again -- there is no
|
||||
-- send-out left in a battle that is already over. Latched here as well as
|
||||
-- from the animation's own last step so that a throw the player skipped with
|
||||
-- B (BattleAnimRunScript has no such skip; this port does) cannot put the
|
||||
-- caught mon back on the field for the "Gotcha!" line.
|
||||
self.picHidden.enemy = true
|
||||
-- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to
|
||||
-- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's
|
||||
-- whole effect; its catch rate is a plain ball's. It applies on the box
|
||||
@@ -2932,6 +2974,7 @@ function BattleState:useItem(itemId)
|
||||
-- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything
|
||||
-- pushed above is drained only once the ball has finished wobbling.
|
||||
self:startBallAnim(self:ballAnimParam(itemId), itemId)
|
||||
if caught and not self.anim then self.picHidden.enemy = true end
|
||||
self.message = nil
|
||||
self.messageTimer = 0
|
||||
self.phase = "resolving"
|
||||
@@ -2981,8 +3024,10 @@ function BattleState:useItem(itemId)
|
||||
-- Everything else the pack can spend on a party mon runs the same
|
||||
-- item_effects.asm routine the field pack runs: the potion line and the
|
||||
-- drinks, the status cures and their berries, REVIVE / MAX REVIVE, and
|
||||
-- the ETHER / ELIXER family.
|
||||
local action = ItemEffects.partyAction(itemId)
|
||||
-- the ETHER / ELIXER family. Without the merged dataset this can only
|
||||
-- ever see RECORDS, the module's own built-ins, the same gap
|
||||
-- Game2:usePartyItem had for the field pack.
|
||||
local action = ItemEffects.partyAction(itemId, self.game and self.game.data)
|
||||
if action then
|
||||
return self:useOnPartyMon(itemId, action)
|
||||
end
|
||||
@@ -3440,6 +3485,36 @@ function BattleState:drawScene()
|
||||
end
|
||||
end
|
||||
|
||||
-- data/battle_anims/objects.asm:390-397: the lifted band rides at ABSOLUTE_X,
|
||||
-- outside the scanline blit, so the attacker's SCX never moves it.
|
||||
function BattleState:drawLiftedRows()
|
||||
local battle = self.battle
|
||||
if not battle then return end
|
||||
local enemy = self:animPicState("enemy")
|
||||
local player = self:animPicState("player")
|
||||
local enemyLift = enemy and enemy.lifted
|
||||
local playerLift = player and player.lifted
|
||||
if not (enemyLift or playerLift) then return end
|
||||
local G = love.graphics
|
||||
if not self.liftCanvas then
|
||||
self.liftCanvas = G.newCanvas(160, 144)
|
||||
self.liftCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
local previous = G.getCanvas()
|
||||
G.setCanvas(self.liftCanvas)
|
||||
G.clear(0, 0, 0, 0)
|
||||
G.push()
|
||||
G.origin()
|
||||
self.liftedPass = true
|
||||
if enemyLift then self:drawPic(battle.enemy, false) end
|
||||
if playerLift then self:drawPic(battle.player, true) end
|
||||
self.liftedPass = nil
|
||||
G.pop()
|
||||
G.setCanvas(previous)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(self.liftCanvas, 0, 0)
|
||||
end
|
||||
|
||||
function BattleState:drawSceneBody()
|
||||
local panel = function() self:drawPanel() end
|
||||
if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then
|
||||
@@ -3460,7 +3535,8 @@ function BattleState:drawSceneBody()
|
||||
return
|
||||
end
|
||||
if self.anim and self.animView then
|
||||
self.animView:present(self.anim, panel)
|
||||
self.animView:present(self.anim, panel, self.battle)
|
||||
self:drawLiftedRows()
|
||||
self.animView:drawObjects(self.anim, self.battle)
|
||||
return
|
||||
end
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
-- covered by tests; the state at the bottom is the only part that draws.
|
||||
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
||||
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
|
||||
end
|
||||
|
||||
function BattleTransition:draw()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local w, h = GameViewport.dimensions()
|
||||
self:drawWidescreen(w, h)
|
||||
end
|
||||
|
||||
|
||||
@@ -60,10 +60,11 @@ InitClock.TEXT = TEXT
|
||||
-- same three and has always had them right.
|
||||
local MORN_HOUR, DAY_HOUR, NITE_HOUR = 4, 10, 18
|
||||
|
||||
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0.
|
||||
local DAYS = {
|
||||
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
|
||||
}
|
||||
-- Clock.DAY_NAMES / Clock.weekdayName is the single translated home for this
|
||||
-- table: MainMenu's clock box and the Pokegear's clock card read the same
|
||||
-- weekday off the same save and must never disagree about what it is
|
||||
-- called.
|
||||
local DAYS = Clock.DAY_NAMES
|
||||
InitClock.DAYS = DAYS
|
||||
|
||||
function InitClock:wantsFillScale() return true end
|
||||
@@ -88,12 +89,15 @@ function InitClock.hourString(hour)
|
||||
local h = math.floor(hour or 0) % 24
|
||||
local display = h % 12
|
||||
if display == 0 then display = 12 end
|
||||
local word = require("src.world.gen2.Palettes").clockDaytime(h)
|
||||
-- Clock.daytimeLabel, not Palettes.clockDaytime: the printed word,
|
||||
-- translated -- this string reaches the player as-is, unlike the internal
|
||||
-- MORN/DAY/NITE key other palette code compares against.
|
||||
local word = Clock.daytimeLabel(h)
|
||||
return ("%s %d"):format(word, display)
|
||||
end
|
||||
|
||||
function InitClock.oclockString(hour)
|
||||
return InitClock.hourString(hour) .. " o'clock"
|
||||
return Strings("%s o'clock", InitClock.hourString(hour))
|
||||
end
|
||||
|
||||
function InitClock.timeString(hour, minute)
|
||||
@@ -187,7 +191,7 @@ function InitClock:question()
|
||||
return Strings(TEXT.whoaMinutes, self.minute)
|
||||
end
|
||||
if self.phase == "confirm-day" then
|
||||
return Strings(TEXT.confirmDay, DAYS[self.day + 1] or "?")
|
||||
return Strings(TEXT.confirmDay, Clock.weekdayName(self.day + 1) or "?")
|
||||
end
|
||||
if self.phase == "response" then
|
||||
return Strings(TEXT[InitClock.responseKey(self.hour)],
|
||||
@@ -196,11 +200,15 @@ function InitClock:question()
|
||||
return ""
|
||||
end
|
||||
|
||||
-- data/text/common_1.asm's "@MIN." suffix (DisplayMinutesWithMinString),
|
||||
-- separate from TEXT.whoaMinutes' own "%d min.?" confirmation line above.
|
||||
local MINUTES = Strings.source("%d min.")
|
||||
|
||||
-- The value the picker box shows, or nil while a page is up with no picker.
|
||||
function InitClock:display()
|
||||
if self.phase == "hour" then return InitClock.oclockString(self.hour) end
|
||||
if self.phase == "minute" then return ("%d min."):format(self.minute) end
|
||||
if self.phase == "day" then return DAYS[self.day + 1] or "?" end
|
||||
if self.phase == "minute" then return Strings(MINUTES, self.minute) end
|
||||
if self.phase == "day" then return Clock.weekdayName(self.day + 1) or "?" end
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
@@ -29,10 +29,11 @@ local MainMenu = {}
|
||||
MainMenu.__index = MainMenu
|
||||
MainMenu.isOpaque = true
|
||||
|
||||
-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings.
|
||||
local DAYS = {
|
||||
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
|
||||
}
|
||||
-- MainMenu_PrintCurrentTimeAndDay's PrintDayOfWeek strings. Clock.DAY_NAMES
|
||||
-- / Clock.weekdayName is the single translated home for this table (see
|
||||
-- InitClock.lua's DAYS), so this screen's clock box cannot drift from the
|
||||
-- Pokegear's own.
|
||||
local DAYS = Clock.DAY_NAMES
|
||||
|
||||
-- MUSIC_MAIN_MENU; resolved by name so a cache without it just stays quiet.
|
||||
local MENU_MUSIC = "Music_MainMenu"
|
||||
@@ -164,7 +165,7 @@ function MainMenu:drawClockBox()
|
||||
-- Textbox at (0,12) with 4 interior rows and 13 interior columns.
|
||||
Chrome.textbox(0, 12, 13, 4)
|
||||
local hour, minute, weekday = self:clockParts()
|
||||
Chrome.print(DAYS[weekday] or "DAY", 1, 14)
|
||||
Chrome.print(Clock.weekdayName(weekday) or "DAY", 1, 14)
|
||||
-- PrintHour prints 1-12 with no leading zero, then ':' then two zero-padded
|
||||
-- minutes; the AM/PM half is drawn by PrintHour itself.
|
||||
local display = hour % 12
|
||||
|
||||
@@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts)
|
||||
self.game = game
|
||||
self.save = opts.save or (game and game.save)
|
||||
local data = game and game.data or {}
|
||||
-- Held, not just read into the fields below. CRY resolves its sample
|
||||
-- through data.audio.cries and AREA resolves nests and landmark names
|
||||
-- through data.maps / data.landmarks, and every one of those reads
|
||||
-- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry
|
||||
-- returned before it reached Sound, and the button did nothing at all.
|
||||
-- Taken by reference so a mod's merged cry or landmark is the one used.
|
||||
-- engine/pokedex/pokedex.asm:447
|
||||
self.data = data
|
||||
self.dex = opts.pokedex or data.gen2Pokedex
|
||||
self.pokemon = opts.pokemon or data.pokemon
|
||||
@@ -866,15 +861,6 @@ function PokedexMenu:drawArea()
|
||||
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
|
||||
|
||||
local G = love.graphics
|
||||
local table_ = self.data and self.data.landmarks
|
||||
local byIndex = self.landmarkByIndex
|
||||
if not byIndex then
|
||||
byIndex = {}
|
||||
for _, entry in pairs((table_ and table_.landmarks) or {}) do
|
||||
if entry and entry.index then byIndex[entry.index] = entry end
|
||||
end
|
||||
self.landmarkByIndex = byIndex
|
||||
end
|
||||
|
||||
if #nests == 0 then
|
||||
-- A species with no grass, water or roamer entry in this region. The cart
|
||||
@@ -883,11 +869,11 @@ function PokedexMenu:drawArea()
|
||||
return
|
||||
end
|
||||
|
||||
-- Blinking markers, the way the cart flashes its OBJs.
|
||||
-- engine/pokegear/pokegear.asm:2427
|
||||
local on = ((self.areaBlink or 0) % 32) < 20
|
||||
if cells and on then
|
||||
for _, index in ipairs(nests) do
|
||||
local mark = byIndex[index]
|
||||
local mark = Nests.landmark(self.data, index)
|
||||
if mark and mark.x and mark.y then
|
||||
G.setColor(0, 0, 0, 1)
|
||||
G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5)
|
||||
@@ -900,7 +886,7 @@ function PokedexMenu:drawArea()
|
||||
-- Name the first one in words as well as on the map: the flashing dot is
|
||||
-- unreadable at this size on a modern display, and the landmark name is what
|
||||
-- a player actually wants off this screen.
|
||||
local first = byIndex[nests[1]]
|
||||
local first = Nests.landmark(self.data, nests[1])
|
||||
if first and first.name then
|
||||
local name = tostring(first.name):gsub("\n", " ")
|
||||
self:text(name, 1, 16)
|
||||
|
||||
@@ -56,10 +56,6 @@ local CARDS = {
|
||||
-- card over. One row, so `#self.cards` stays 1 and nothing pages.
|
||||
local FLY_MAP_CARD = { id = "map", label = "FLY" }
|
||||
|
||||
local DAYS = {
|
||||
"SUNDAY", "MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY",
|
||||
}
|
||||
|
||||
-- ---------------------------------------------------------------- the radio
|
||||
--
|
||||
-- engine/pokegear/radio.asm is not a text table: it is a jumptable of code.
|
||||
@@ -1878,7 +1874,7 @@ function Pokegear:drawClock()
|
||||
-- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and
|
||||
-- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at
|
||||
-- column 12.
|
||||
self:text(DAYS[weekday] or "", 6, 6)
|
||||
self:text(Clock.weekdayName(weekday) or "", 6, 6)
|
||||
local display = hour % 12
|
||||
if display == 0 then display = 12 end
|
||||
self:text(Chrome.number(display, 2), 6, 8)
|
||||
@@ -2203,13 +2199,13 @@ function Pokegear:drawPlain()
|
||||
if id == "clock" then
|
||||
local hour, minute, weekday = self:clockParts()
|
||||
Chrome.box(1, 5, 18, 7)
|
||||
Chrome.print(DAYS[weekday] or "DAY", 3, 7)
|
||||
Chrome.print(Clock.weekdayName(weekday) or "DAY", 3, 7)
|
||||
local display = hour % 12
|
||||
if display == 0 then display = 12 end
|
||||
Chrome.print(("%s:%s %s"):format(
|
||||
Chrome.number(display, 2), Chrome.number(minute, 2, true),
|
||||
hour < 12 and "AM" or "PM"), 5, 9)
|
||||
Chrome.print(Palettes.clockDaytime(hour), 5, 11)
|
||||
Chrome.print(Clock.daytimeLabel(hour), 5, 11)
|
||||
elseif id == "radio" then
|
||||
-- Without the gear sheet there is no dial art, so the frequencies go down
|
||||
-- the screen as a list. A frequency whose test failed still gets a row:
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
local Kit = require("src.ui.kit.Kit")
|
||||
local Theme = require("src.ui.kit.Theme")
|
||||
local SafeArea = require("src.core.SafeArea")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
|
||||
local Layout = {}
|
||||
|
||||
@@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
|
||||
function Layout.metrics(maxAppW)
|
||||
local W, H = 0, 0
|
||||
if love and love.graphics and love.graphics.getDimensions then
|
||||
W, H = love.graphics.getDimensions()
|
||||
W, H = GameViewport.dimensions()
|
||||
end
|
||||
local ox, oy, sw, sh = SafeArea.rect()
|
||||
local s = Kit.layout(sw, sh)
|
||||
|
||||
@@ -155,11 +155,12 @@ local function drain()
|
||||
end
|
||||
|
||||
-- Begin (or, on a prior error, retry) an async check. Safe to call every frame:
|
||||
-- once a check is in flight or has reached a terminal state it is a no-op.
|
||||
function Check.start()
|
||||
-- once a check is in flight or has reached a terminal state it is a no-op unless
|
||||
-- force=true is passed (e.g. from an explicit button press).
|
||||
function Check.start(force)
|
||||
drain()
|
||||
if cache.status == "checking" or cache.status == "downloading" then return end
|
||||
if requested and cache.status ~= "error" and cache.status ~= "idle" then return end
|
||||
if not force and requested and cache.status ~= "error" and cache.status ~= "idle" then return end
|
||||
if not ensureWorker() then
|
||||
cache = { status = "error", error = "background threads unavailable" }
|
||||
return
|
||||
|
||||
@@ -16,6 +16,10 @@ PatchNotes.FILES = {
|
||||
"assets/PATCH_NOTES.md",
|
||||
}
|
||||
|
||||
PatchNotes.CACHE_FILES = {
|
||||
"updates/notes_cache.json",
|
||||
}
|
||||
|
||||
PatchNotes.REPO_FILES = {
|
||||
"mobile/ios/app-repo.json",
|
||||
}
|
||||
@@ -48,6 +52,29 @@ local function readPath(path)
|
||||
return nonempty(text) and text or nil
|
||||
end
|
||||
|
||||
function PatchNotes.fromCache(engine)
|
||||
for _, path in ipairs(PatchNotes.CACHE_FILES) do
|
||||
local text = readPath(path)
|
||||
if text then
|
||||
local ok, doc = pcall(Json.decode, text)
|
||||
if ok and type(doc) == "table" then
|
||||
if engine and engine ~= "0.0.0-dev" then
|
||||
if doc[engine] and nonempty(doc[engine]) then
|
||||
return doc[engine], engine
|
||||
end
|
||||
else
|
||||
for ver, notes in pairs(doc) do
|
||||
if nonempty(notes) then
|
||||
return notes, ver
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
function PatchNotes.fromFile()
|
||||
for _, path in ipairs(PatchNotes.FILES) do
|
||||
local text = readPath(path)
|
||||
@@ -88,6 +115,7 @@ function PatchNotes.fromRepo(engine)
|
||||
return row.notes, row.version
|
||||
end
|
||||
end
|
||||
return nil, nil
|
||||
end
|
||||
return list[1].notes, list[1].version
|
||||
end
|
||||
@@ -96,17 +124,24 @@ function PatchNotes.fromRepo(engine)
|
||||
end
|
||||
|
||||
function PatchNotes.body(Check)
|
||||
local notes, ver = PatchNotes.fromCheck(Check)
|
||||
if notes then return notes, ver end
|
||||
notes = PatchNotes.fromFile()
|
||||
if notes then return notes, ver end
|
||||
local Version = require("src.core.Version")
|
||||
local engine = (Version and Version.engine) or "?"
|
||||
|
||||
local notes, ver = PatchNotes.fromCheck(Check)
|
||||
if notes and (engine == "0.0.0-dev" or ver == engine or ver == nil) then
|
||||
return notes, ver or engine
|
||||
end
|
||||
|
||||
notes, ver = PatchNotes.fromCache(engine)
|
||||
if notes then return notes, ver end
|
||||
|
||||
notes = PatchNotes.fromFile()
|
||||
if notes then return notes, engine end
|
||||
|
||||
notes, ver = PatchNotes.fromRepo(engine)
|
||||
if notes then return notes, ver end
|
||||
return "No patch notes loaded yet for gen1recomp v" .. engine .. ".\n\n"
|
||||
.. "They appear here after the launcher checks GitHub for the latest "
|
||||
.. "release.", engine
|
||||
|
||||
return "Unable to fetch patch notes.", engine
|
||||
end
|
||||
|
||||
return PatchNotes
|
||||
|
||||
@@ -151,6 +151,28 @@ local function gatePasses(rel)
|
||||
return not (info.minShell and info.minShell > shell)
|
||||
end
|
||||
|
||||
local function cacheNotes(ver, notes)
|
||||
if not (ver and type(notes) == "string" and notes ~= "" and Json) then return end
|
||||
if not (love and love.filesystem) then return end
|
||||
pcall(function()
|
||||
love.filesystem.createDirectory("updates")
|
||||
local cachePath = "updates/notes_cache.json"
|
||||
local existing = {}
|
||||
if love.filesystem.getInfo and love.filesystem.getInfo(cachePath) then
|
||||
local text = love.filesystem.read(cachePath)
|
||||
if text then
|
||||
local ok, doc = pcall(Json.decode, text)
|
||||
if ok and type(doc) == "table" then existing = doc end
|
||||
end
|
||||
end
|
||||
existing[ver] = notes
|
||||
local ok, encoded = pcall(Json.encode, existing)
|
||||
if ok and encoded then
|
||||
love.filesystem.write(cachePath, encoded)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- check
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -177,6 +199,9 @@ local function doCheck()
|
||||
return
|
||||
end
|
||||
pending = rel
|
||||
if rel.version and type(rel.notes) == "string" and rel.notes ~= "" then
|
||||
cacheNotes(rel.version, rel.notes)
|
||||
end
|
||||
|
||||
-- Unstamped dev build: the working tree always looks "newer", so never
|
||||
-- pester the developer with an update (contract item, Check design).
|
||||
|
||||
+16
-29
@@ -33,6 +33,7 @@ function NPC.new(data, mapId, objDef)
|
||||
self.facing = FACING_FROM_RANGE[objDef.range] or "down"
|
||||
self.moving = false
|
||||
self.progress = 0
|
||||
self.animClock = 0
|
||||
self.stepFlip = false
|
||||
self.frozen = false -- scripts freeze NPCs while talking
|
||||
self.wanders = objDef.movement == "WALK"
|
||||
@@ -52,31 +53,15 @@ function NPC:facePlayer(player)
|
||||
end
|
||||
|
||||
function NPC:update(map, entities)
|
||||
-- An NPC tile is 32 frames, half the player's rate: TryWalking loads
|
||||
-- WALKANIMATIONCOUNTER with $10 and UpdateSpriteInWalkingAnimation adds
|
||||
-- the 1px step vector once per call (engine/overworld/movement.asm), but
|
||||
-- UpdateSprites runs once per OverworldLoop pass and every pass opens
|
||||
-- with two DelayFrame calls (home/overworld.asm) -- so those 16 ticks
|
||||
-- cost 32 frames for one 16px cell, against AdvancePlayerSprite's 8
|
||||
-- ticks of 2px. That halving is why pokeyellow's NormalPikachuFollow
|
||||
-- needs TryDoubleAddPikachuStepVectorToScreenPixelCoords to keep up.
|
||||
--
|
||||
-- self.stepFrames overrides the shared walk for an object whose
|
||||
-- step has to stay in phase with something else: Yellow's follower
|
||||
-- Pikachu takes the player's own step length, halved while it is more
|
||||
-- than a cell behind (FastPikachuFollow, engine/pikachu/
|
||||
-- pikachu_follow.asm). self.hopStep is the same file's $5-$8 hop
|
||||
-- command: two cells of travel inside one step's frames
|
||||
-- (DoubleAddPikachuStepVectorToScreenPixelCoords), which is why the
|
||||
-- pixel span doubles while the frame count does not. Nothing else sets
|
||||
-- either field, so every other object keeps the constant (#410, #409).
|
||||
-- engine/overworld/movement.asm:301, 32 frames per NPC cell; stepFrames is
|
||||
-- the follower's own step length (#410, #409).
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local span = self.hopStep and 2 or 1
|
||||
if self.moving then
|
||||
self.progress = self.progress + 1
|
||||
-- NPC_CHANGE_FACING: animate the walk cycle in place, no translation
|
||||
-- (movement.asm ChangeFacingDirection zeroes the delta); px/py stay
|
||||
-- pinned to the current cell while walkPhase() cycles.
|
||||
self.animClock = (self.animClock or 0) + 1
|
||||
-- NPC_CHANGE_FACING (movement.asm ChangeFacingDirection): walk cycle in
|
||||
-- place, no translation.
|
||||
if self.marching then
|
||||
if self.progress >= stepLen then
|
||||
self.progress = 0
|
||||
@@ -122,18 +107,20 @@ end
|
||||
|
||||
function NPC:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
local stepLen = self.stepFrames or STEP_FRAMES
|
||||
local p = self.progress % stepLen
|
||||
return (p >= stepLen / 4 and p < stepLen * 3 / 4) and 1 or 0
|
||||
-- engine/overworld/movement.asm:301
|
||||
local p = (self.animClock or 0) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
end
|
||||
|
||||
-- Same contract as Player:pose -- the sheet, position, facing and step
|
||||
-- phase this frame renders to -- so a render pipeline can pose an NPC
|
||||
-- without caring which kind of entity it is. An NPC never hops, so the
|
||||
-- trailing hop flag is always false.
|
||||
-- Same contract as Player:pose; an NPC never hops, so the trailing hop
|
||||
-- flag is always false.
|
||||
function NPC:pose()
|
||||
local flip = self.stepFlip
|
||||
if self.moving then
|
||||
flip = math.floor((self.animClock or 0) / 16) % 2 == 1
|
||||
end
|
||||
return self.sprite, self.px, self.py, self.facing,
|
||||
self:walkPhase(), self.stepFlip, false
|
||||
self:walkPhase(), flip, false
|
||||
end
|
||||
|
||||
function NPC:draw(camX, camY)
|
||||
|
||||
@@ -9,6 +9,7 @@ local Collision = require("src.world.Collision")
|
||||
local Encounter = require("src.world.Encounter")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local GameViewport = require("src.render.GameViewport")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Map = require("src.world.Map")
|
||||
local MapLoader = require("src.world.MapLoader")
|
||||
@@ -827,6 +828,23 @@ function OverworldState:useStrengthFieldMove(mon, onClose)
|
||||
return true
|
||||
end
|
||||
|
||||
function OverworldState:useSoftboiledFieldMove(user, target)
|
||||
local heal = user and user.stats and math.floor(user.stats.hp / 5) or 0
|
||||
if not user or not user.stats or not target or not target.stats
|
||||
or target == user or target.hp <= 0
|
||||
or target.hp >= target.stats.hp or user.hp <= heal then
|
||||
Game.stack:push(TextBox.new(Game, Strings("It won't have\nany effect.")))
|
||||
return false
|
||||
end
|
||||
user.hp = user.hp - heal
|
||||
target.hp = math.min(target.stats.hp, target.hp + heal)
|
||||
require("src.core.Sound").play(Game.data, "Heal_HP")
|
||||
local def = Game.data.pokemon[target.species]
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
Strings("%s's HP\nwas restored!", target.nickname or def.name)))
|
||||
return true
|
||||
end
|
||||
|
||||
-- The battle transition's dungeon wipe uses the explicit map lists in
|
||||
-- data/maps/dungeon_maps.asm (field.dungeonTransitionMaps): singles plus
|
||||
-- inclusive map-id ranges -- faithful to the original's omissions
|
||||
@@ -3090,9 +3108,12 @@ function OverworldState:finishNurseHeal(bye, onDone, npc)
|
||||
end))
|
||||
end
|
||||
if not npc then farewell() return end
|
||||
npc.frameOverride = 3
|
||||
-- engine/events/pokecenter.asm:36-39; Yellow's walk-down pose when the
|
||||
-- sheet has it (pokeyellow engine/events/pokecenter.asm:82-88)
|
||||
local yellow = GameVersion.isYellow()
|
||||
npc.frameOverride = (yellow and npc.sprite.frames[3]) and 3 or 1
|
||||
-- bubble = false is the silent world hold, this port's DelayFrames
|
||||
self.emote = { npc = npc, frames = 20, bubble = false, onDone = function()
|
||||
self.emote = { npc = npc, frames = yellow and 40 or 20, bubble = false, onDone = function()
|
||||
npc.frameOverride = nil
|
||||
npc:facePlayer(self.player)
|
||||
farewell()
|
||||
@@ -5128,7 +5149,7 @@ function OverworldState:drawWorld()
|
||||
-- point projects under the pipeline's own camera. That is the direct
|
||||
-- analogue of what :billboard does for tilt, and it keeps exactly one
|
||||
-- copy of every effect: the closures above are the ones that run.
|
||||
local pw, ph = love.graphics.getDimensions()
|
||||
local pw, ph = GameViewport.dimensions()
|
||||
local pscale = Zoom.scale(Game.renderer:fitScale())
|
||||
local ctx = {
|
||||
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
|
||||
|
||||
+91
-2
@@ -37,6 +37,57 @@ local function validPartySlot(party, slot)
|
||||
and party[slot] ~= nil
|
||||
end
|
||||
|
||||
local function outside(game, ow)
|
||||
return Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(game.data, "outsideTilesets"))
|
||||
end
|
||||
|
||||
local function knows(mon, moveId)
|
||||
for _, move in ipairs(mon.moves or {}) do
|
||||
if move.id == moveId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function monInfo(game, mon, slot)
|
||||
local def = game.data.pokemon[mon.species] or {}
|
||||
return { slot = slot, species = mon.species,
|
||||
name = mon.nickname or def.name or mon.species, level = mon.level,
|
||||
hp = mon.hp, maxHp = mon.stats and mon.stats.hp or mon.hp }
|
||||
end
|
||||
|
||||
local function softboiledSources(game)
|
||||
local party, sources = game.save.party or {}, {}
|
||||
for sourceSlot, source in ipairs(party) do
|
||||
local heal = source.stats and math.floor(source.stats.hp / 5) or 0
|
||||
if knows(source, "SOFTBOILED") and source.hp > heal then
|
||||
local info = monInfo(game, source, sourceSlot)
|
||||
info.targets = {}
|
||||
for targetSlot, target in ipairs(party) do
|
||||
if target ~= source and target.hp > 0 and target.stats
|
||||
and target.hp < target.stats.hp then
|
||||
info.targets[#info.targets + 1] = monInfo(game, target, targetSlot)
|
||||
end
|
||||
end
|
||||
if #info.targets > 0 then sources[#sources + 1] = info end
|
||||
end
|
||||
end
|
||||
return sources
|
||||
end
|
||||
|
||||
local function flyDestinationAvailable(game, mapId)
|
||||
local field, save = game.data.field or {}, game.save
|
||||
for _, id in ipairs(field.flyOrder or {}) do
|
||||
if id == mapId then
|
||||
local def = game.data.maps and game.data.maps[id]
|
||||
return not not (save.visited and save.visited[id]
|
||||
and field.flyWarps and field.flyWarps[id]
|
||||
and def and Map.isFlyTown(def))
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function WorldAPI.new(game, modId)
|
||||
return setmetatable({ game = game, modId = modId }, WorldAPI)
|
||||
end
|
||||
@@ -141,10 +192,14 @@ function WorldAPI:availableFieldActions()
|
||||
and ow:partyKnows("DIG") then
|
||||
out[#out + 1] = { id = "dig", label = "DIG" }
|
||||
end
|
||||
if ow:partyKnows("TELEPORT") and Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(game.data, "outsideTilesets")) then
|
||||
if ow:partyKnows("TELEPORT") and outside(game, ow) then
|
||||
out[#out + 1] = { id = "teleport", label = "TELEPORT" }
|
||||
end
|
||||
local sources = softboiledSources(game)
|
||||
if #sources > 0 then
|
||||
out[#out + 1] = { id = "softboiled", label = "SOFTBOILED",
|
||||
sources = sources }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -187,10 +242,44 @@ function WorldAPI:useFieldAction(id, opts)
|
||||
elseif id == "dig" or id == "teleport" then
|
||||
ow:beginTeleportOut()
|
||||
return true
|
||||
elseif id == "softboiled" then
|
||||
local sourceSlot = opts and tonumber(opts.sourceSlot)
|
||||
local targetSlot = opts and tonumber(opts.targetSlot)
|
||||
local allowed
|
||||
for _, source in ipairs(found.sources or {}) do
|
||||
if source.slot == sourceSlot then
|
||||
for _, target in ipairs(source.targets or {}) do
|
||||
if target.slot == targetSlot then allowed = true break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not allowed then return nil, "softboiled target unavailable" end
|
||||
if ow:useSoftboiledFieldMove(game.save.party[sourceSlot],
|
||||
game.save.party[targetSlot]) then return true end
|
||||
end
|
||||
return nil, "field action unavailable"
|
||||
end
|
||||
|
||||
-- FLY needs a destination choice, so it is exposed separately from the
|
||||
-- immediate actions above. The request is still checked against the same
|
||||
-- visited-town list as the native Town Map picker before the world may warp.
|
||||
function WorldAPI:canFly()
|
||||
local game, ow = self.game, self:overworld()
|
||||
return not not (ow and ow.map and outside(game, ow) and ow:partyKnows("FLY"))
|
||||
end
|
||||
|
||||
function WorldAPI:flyTo(mapId)
|
||||
local game, ow = self.game, self:overworld()
|
||||
if not ow then return nil, NO_OVERWORLD end
|
||||
if not self:canFly() then return nil, "fly unavailable" end
|
||||
if not acceptsMenuInput(game, ow) then return nil, "world is busy" end
|
||||
if not flyDestinationAvailable(game, mapId) then
|
||||
return nil, "destination unavailable"
|
||||
end
|
||||
ow:flyTo(mapId)
|
||||
return true
|
||||
end
|
||||
|
||||
-- A compact, read-only view of the active map for minimaps and companion UIs.
|
||||
-- `rows` describes collision terrain; optional `tileRows` reduces each real
|
||||
-- 8x8 map tile to its average Game Boy shade ("0" lightest, "3" darkest).
|
||||
|
||||
@@ -6,6 +6,7 @@ local Assets = require("src.render.Assets")
|
||||
local BorderFill = require("src.world.gen2.BorderFill")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local PixelCanvas = require("src.render.PixelCanvas")
|
||||
|
||||
local MapPreview = {}
|
||||
|
||||
@@ -96,9 +97,8 @@ function MapPreview.bake(baker, map, daytime)
|
||||
local blocks = tileset.blocks
|
||||
local tilesPerRow = tileset.tilesPerRow or 16
|
||||
local pw, ph = map.width * 32, map.height * 32
|
||||
local okCanvas, canvas = pcall(love.graphics.newCanvas, pw, ph)
|
||||
local okCanvas, canvas = pcall(PixelCanvas.new, pw, ph, "nearest")
|
||||
if not okCanvas or not canvas then return nil end
|
||||
if canvas.setFilter then canvas:setFilter("nearest", "nearest") end
|
||||
local quads = {}
|
||||
local function quadFor(tile)
|
||||
local q = quads[tile]
|
||||
|
||||
+268
-297
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local OUT = os.getenv("SHOT_DIR") or "battle-hud-layout-lock"
|
||||
local BEFORE = OUT .. "/battle_hud_wide_extended.png"
|
||||
local AFTER = OUT .. "/battle_hud_og_locked_standard.png"
|
||||
|
||||
return function(game)
|
||||
os.remove(BEFORE)
|
||||
os.remove(AFTER)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleHud = "extended"
|
||||
|
||||
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
local layoutRow, hudRow
|
||||
for _, row in ipairs(menu.rows) do
|
||||
if row.id == "battleLayout" then layoutRow = row end
|
||||
if row.id == "battleHud" then hudRow = row end
|
||||
end
|
||||
assert(layoutRow and hudRow, "battle layout/HUD rows are present")
|
||||
|
||||
menu.index = 6
|
||||
menu.scroll = 3
|
||||
assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED")
|
||||
assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written")
|
||||
|
||||
layoutRow.step(game, 1)
|
||||
assert(options.battleLayout == "og", "layout switched to OG")
|
||||
assert(options.battleHud == "standard", "OG normalized HUD to STANDARD")
|
||||
assert(hudRow.value(game) == "STANDARD", "OG displays STANDARD")
|
||||
assert(U.shot(game, AFTER), "OG/STANDARD screenshot was written")
|
||||
|
||||
print("[driver] BATTLE_HUD_LAYOUT_LOCK_PASS")
|
||||
game.driverDone = true
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Visual and behavioral acceptance for the adaptive BATTLE BG menu rule.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
love.window.setMode(1920, 1080, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "black"
|
||||
|
||||
local menu = require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
local fitRow, bgRow
|
||||
local bgIndex
|
||||
for i, row in ipairs(menu.rows) do
|
||||
if row.id == "battleFit" then fitRow = row end
|
||||
if row.id == "battleBg" then bgRow, bgIndex = row, i end
|
||||
end
|
||||
assert(fitRow and bgRow and bgIndex, "battle size/background rows are present")
|
||||
|
||||
fitRow.step(game, 1)
|
||||
assert(options.battleFit == "fill", "battle size switched to FILL")
|
||||
assert(options.battleBg == "white", "FILL + EXTENDED normalized background to WHITE")
|
||||
assert(bgRow.value(game) == "AUTO", "adaptive background is labeled AUTO")
|
||||
assert(bgRow.step(game, 1) == false, "AUTO background row is locked")
|
||||
assert(options.battleBg == "white", "locked AUTO retains the WHITE value")
|
||||
|
||||
menu.index = bgIndex
|
||||
menu.scroll = math.max(0, bgIndex - 5)
|
||||
U.wait(2)
|
||||
local autoPath = DIR .. "/fill_extended_auto_menu.png"
|
||||
os.remove(autoPath)
|
||||
local ok = U.shot(game, autoPath)
|
||||
|
||||
fitRow.step(game, -1)
|
||||
assert(options.battleFit == "fixed", "battle size switched back to FIXED")
|
||||
assert(bgRow.value(game) == "WHITE", "FIXED exposes the stored WHITE choice")
|
||||
assert(bgRow.step(game, 1) == true and options.battleBg == "black",
|
||||
"FIXED can select BLACK")
|
||||
assert(bgRow.step(game, 1) == true and options.battleBg == "world",
|
||||
"FIXED can select WORLD")
|
||||
U.wait(2)
|
||||
local fixedPath = DIR .. "/fixed_extended_background_choices.png"
|
||||
os.remove(fixedPath)
|
||||
ok = U.shot(game, fixedPath) and ok
|
||||
|
||||
U.log(ok and "FILL_EXTENDED_AUTO_MENU_PASS"
|
||||
or "FILL_EXTENDED_AUTO_MENU_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
-- Visual acceptance driver for WIDE + FILL + EXTENDED + WHITE.
|
||||
-- The full physical-window backing remains white while the four battle HUD
|
||||
-- panels move to their approved window anchors.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fill"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "white"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
|
||||
assert(battle:extendedHUD(), "FILL/WHITE activates the approved extended HUD")
|
||||
assert(not battle:extendedWorldHUD(), "FILL/WHITE does not use FIXED's paper band")
|
||||
|
||||
local path = DIR .. "/fill_extended_white_separate_layer.png"
|
||||
os.remove(path)
|
||||
local ok = U.shot(game, path)
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(5)
|
||||
local smallPath = DIR .. "/fill_extended_white_small_16x9.png"
|
||||
os.remove(smallPath)
|
||||
ok = U.shot(game, smallPath) and ok
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(5)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
game.stack:push(NamingScreen.new(game, {
|
||||
title = "NICKNAME?", maxLen = 10, onDone = function() end,
|
||||
}))
|
||||
U.wait(5)
|
||||
local overlayPath = DIR .. "/fill_extended_white_naming_overlay.png"
|
||||
os.remove(overlayPath)
|
||||
ok = U.shot(game, overlayPath) and ok
|
||||
|
||||
U.log(ok and "FILL_EXTENDED_WHITE_PASS" or "FILL_EXTENDED_WHITE_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-black"
|
||||
local FULL = OUT .. "/fixed_extended_black_full.png"
|
||||
local SMALL = OUT .. "/fixed_extended_black_small_16x9.png"
|
||||
local OVERLAY = OUT .. "/fixed_extended_black_overlay.png"
|
||||
|
||||
return function(game)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
os.remove(FULL)
|
||||
os.remove(SMALL)
|
||||
os.remove(OVERLAY)
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "black"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
|
||||
assert(battle:extendedHUD(), "BLACK activates the approved extended HUD")
|
||||
assert(battle:extendedBlackHUD(), "BLACK activates the white vertical battle band")
|
||||
assert(not battle:extendedWorldHUD(), "BLACK remains separate from WORLD")
|
||||
assert(U.shot(game, FULL), "full black-background screenshot was written")
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(10)
|
||||
assert(U.shot(game, SMALL), "small black-background screenshot was written")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(10)
|
||||
battle.blankForAskName = true
|
||||
local naming = require("src.ui.NamingScreen").new(game, {
|
||||
title = "NICKNAME?", maxLen = 10, onDone = function() end,
|
||||
})
|
||||
game.stack:push(naming)
|
||||
U.wait(10)
|
||||
assert(U.shot(game, OVERLAY), "black-background overlay screenshot was written")
|
||||
|
||||
print("[driver] FIXED_EXTENDED_BLACK_PASS")
|
||||
love.event.quit(0)
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local OUT = os.getenv("SHOT_DIR") or "fixed-extended-white"
|
||||
local FULL = OUT .. "/fixed_extended_white_full.png"
|
||||
local SMALL = OUT .. "/fixed_extended_white_small_16x9.png"
|
||||
local OVERLAY = OUT .. "/fixed_extended_white_overlay.png"
|
||||
|
||||
return function(game)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
os.remove(FULL)
|
||||
os.remove(SMALL)
|
||||
os.remove(OVERLAY)
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "white"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
assert(battle:extendedHUD(), "WHITE activates the approved extended HUD")
|
||||
assert(not battle:extendedWorldHUD(), "WHITE keeps its opaque paper field")
|
||||
assert(U.shot(game, FULL), "full WHITE screenshot was written")
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(10)
|
||||
assert(U.shot(game, SMALL), "small WHITE screenshot was written")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(10)
|
||||
battle.blankForAskName = true
|
||||
local naming = require("src.ui.NamingScreen").new(game, {
|
||||
title = "NICKNAME?",
|
||||
maxLen = 10,
|
||||
initial = "",
|
||||
onDone = function() end,
|
||||
})
|
||||
game.stack:push(naming)
|
||||
U.wait(10)
|
||||
assert(U.shot(game, OVERLAY), "WHITE overlay screenshot was written")
|
||||
|
||||
print("[driver] FIXED_EXTENDED_WHITE_PASS")
|
||||
love.event.quit(0)
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Visual regression coverage for Professor Oak's scripted Yellow capture Bag
|
||||
-- over the WIDE + FIXED + EXTENDED + WORLD composition.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "world"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(30)
|
||||
|
||||
local demo = BattleState.newWild(game, "CHARMANDER", 5)
|
||||
demo:makeOldManDemo("PROF.OAK")
|
||||
demo.onFinish = function() end
|
||||
game.overworld:pushBattle(demo)
|
||||
|
||||
for _ = 1, 100 do
|
||||
if demo.phase == "menu" and (demo.demoTimer or 0) > 5 then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
for _ = 1, 180 do
|
||||
if game.stack:top() ~= demo then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(3)
|
||||
|
||||
local path = DIR .. "/fixed_extended_world_oak_charmander_bag.png"
|
||||
os.remove(path)
|
||||
local ok = game.stack:top() ~= demo and U.shot(game, path)
|
||||
U.log(ok and "FIXED_EXTENDED_WORLD_BAG_PASS"
|
||||
or "FIXED_EXTENDED_WORLD_BAG_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Visual acceptance driver for the first EXTENDED HUD configuration only:
|
||||
-- WIDE + FIXED + EXTENDED + WORLD.
|
||||
-- POKEPORT_DRIVER=tests/drivers/fixed_extended_world_hud_test.lua \
|
||||
-- POKEPORT_IDENTITY=fixed-extended-world POKEPORT_TOUCH=0 \
|
||||
-- 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 BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
-- Match the 16:9 acceptance screenshot so the fixed 304x144 surface has
|
||||
-- measurable space above and below it.
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(3)
|
||||
|
||||
local options = game.save.options
|
||||
options.battleLayout = "wide"
|
||||
options.battleFit = "fixed"
|
||||
options.battleHud = "extended"
|
||||
options.battleBg = "world"
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) }
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 3,
|
||||
{ onFinish = function() end })
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(360)
|
||||
|
||||
battle.introSlide = 0
|
||||
battle.introBalls = nil
|
||||
battle.showEnemyTrainer = false
|
||||
battle.showPlayerBack = false
|
||||
battle.enemySendingOut = false
|
||||
battle.sendingOut = false
|
||||
battle.phase = "menu"
|
||||
battle.menuIndex = 1
|
||||
U.wait(2)
|
||||
|
||||
local path = DIR .. "/fixed_extended_world_separate_layer.png"
|
||||
os.remove(path)
|
||||
local ok = U.shot(game, path)
|
||||
|
||||
love.window.setMode(960, 540, { resizable = true })
|
||||
U.wait(5)
|
||||
local smallPath = DIR .. "/fixed_extended_world_small_16x9.png"
|
||||
os.remove(smallPath)
|
||||
ok = U.shot(game, smallPath) and ok
|
||||
|
||||
love.window.setMode(2048, 1152, { resizable = true })
|
||||
U.wait(5)
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
game.stack:push(NamingScreen.new(game, {
|
||||
title = "NICKNAME?", maxLen = 10, onDone = function() end,
|
||||
}))
|
||||
U.wait(5)
|
||||
local overlayPath = DIR .. "/fixed_extended_world_naming_overlay.png"
|
||||
os.remove(overlayPath)
|
||||
ok = U.shot(game, overlayPath) and ok
|
||||
|
||||
U.log(ok and "FIXED_EXTENDED_WORLD_PASS" or "FIXED_EXTENDED_WORLD_FAIL")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
local function read(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local source = file:read("*a")
|
||||
file:close()
|
||||
return source
|
||||
end
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local java = read(
|
||||
"mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
|
||||
local manifest = read("mobile/android/app/src/main/AndroidManifest.xml")
|
||||
|
||||
check(manifest:find("android.allow_multiple_resumed_activities", 1, true)
|
||||
and manifest:find("GameActivity$SecondaryActivity", 1, true)
|
||||
and manifest:find('android:exported="false"', 1, true),
|
||||
"the private companion Activity opts into Android multi-display resume")
|
||||
check(java:find("android.os.Build.VERSION.SDK_INT < 29", 1, true)
|
||||
and java:find("options.setLaunchDisplayId", 1, true),
|
||||
"the primary-display fallback is restricted to Android 10+")
|
||||
check(java:find("SECONDARY_TARGET_HANDHELD", 1, true)
|
||||
and java:find("SECONDARY_TARGET_EXTERNAL", 1, true)
|
||||
and java:find("handheldAvailable ? handheld : external", 1, true),
|
||||
"routing hints retain a safe available-display fallback")
|
||||
check(java:find("dualScreenDisplayMode != %-1")
|
||||
and java:find("AYN_SECOND_SCREEN", 1, true)
|
||||
and java:find("dualScreenModeObserverRegistered", 1, true),
|
||||
"the optional AYN state is guarded and lifecycle-bound")
|
||||
check(java:find("activity.dispatchKeyEvent", 1, true)
|
||||
and java:find("activity.dispatchGenericMotionEvent", 1, true),
|
||||
"companion windows forward controller input to the game Activity")
|
||||
|
||||
print("android asymmetric display routing: ok")
|
||||
@@ -0,0 +1,37 @@
|
||||
local function read(path)
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local source = file:read("*a")
|
||||
file:close()
|
||||
return source
|
||||
end
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local java = read(
|
||||
"mobile/android/love/src/main/java/org/love2d/android/GameActivity.java")
|
||||
local cpp = read("mobile/android/love/src/jni/love/src/common/android.cpp")
|
||||
|
||||
check(java:find("hasSecondaryDisplayCandidate", 1, true)
|
||||
and java:find("findSecondaryDisplay(self, false)", 1, true)
|
||||
and java:find("now %- secondaryDetectionAt < 500"),
|
||||
"Android exposes cached physical detection before Presentation is ready")
|
||||
check(java:find("presentSecondaryFrame", 1, true)
|
||||
and java:find("secondaryFrame = new byte", 1, true)
|
||||
and java:find("rgba.get(secondaryFrame", 1, true),
|
||||
"extended presentation reuses a retained frame buffer")
|
||||
check(java:find("java.nio.ByteBuffer.wrap(secondaryFrame)", 1, true),
|
||||
"a recreated Presentation receives the retained frame")
|
||||
check(java:find("0xFF000000 | (color & 0x00FFFFFF)", 1, true),
|
||||
"RGB companion backgrounds become opaque Android colors")
|
||||
check(java:find("Math.max((float) vw / fw, (float) vh / fh)", 1, true)
|
||||
and java:find("Math.floor(fit)", 1, true),
|
||||
"FrameView supports cover and pixel-friendly contain fits")
|
||||
check(cpp:find("love_android_secondary_detected", 1, true)
|
||||
and cpp:find("love_android_present_secondary", 1, true)
|
||||
and cpp:find("love_android_secondary_target", 1, true)
|
||||
and cpp:find('"(Ljava/nio/ByteBuffer;IIIZ)Z"', 1, true),
|
||||
"JNI exports the optional detected, routing, and presentation calls")
|
||||
|
||||
print("android secondary presentation: ok")
|
||||
@@ -131,4 +131,25 @@ T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "overworld",
|
||||
}, "settled overworld remains supported")
|
||||
|
||||
-- drainHold gates capture (see the refused() case above) exactly because it
|
||||
-- marks an HP bar mid-animation. Once stepHPDrain settles the bar it must
|
||||
-- let go of that gate too, or the very first drain of a battle leaves the
|
||||
-- checkpoint contract refused for everything after it.
|
||||
do
|
||||
local game3, _, battle3 = makeGame()
|
||||
battle3.enemy.mon.hp = battle3.enemy.mon.hp - 5
|
||||
local frames = 0
|
||||
while battle3:stepHPDrain() and frames < 10000 do
|
||||
frames = frames + 1
|
||||
end
|
||||
T.eq(battle3.enemy.shownHP, battle3.enemy.mon.hp,
|
||||
"the HP bar settles on the new total")
|
||||
T.eq(battle3.enemy.drainHold, nil,
|
||||
"drainHold releases the checkpoint gate once the bar finishes draining")
|
||||
local capability = Checkpoint.inspect(game3)
|
||||
T.check(capability.canCapture == true,
|
||||
"a checkpoint is capturable again after the drain settles: "
|
||||
.. tostring(capability.reason))
|
||||
end
|
||||
|
||||
T.finish()
|
||||
|
||||
@@ -92,6 +92,11 @@ local menu = { isOpaque = true } -- PartyMenu / ListMenu
|
||||
local whiteBattle = setmetatable(
|
||||
{ game = { save = { options = { battleBg = "white" } } } },
|
||||
{ __index = BattleState })
|
||||
local wideWhiteBattle = setmetatable(
|
||||
{ game = { save = { options = {
|
||||
battleBg = "white", battleLayout = "wide",
|
||||
} } } },
|
||||
{ __index = BattleState })
|
||||
local function stack(...) return { states = { ... },
|
||||
visibleBase = function(self)
|
||||
for i = #self.states, 1, -1 do
|
||||
@@ -111,7 +116,10 @@ T.eq(s2:visibleBase(), 1, "the battle alone already drew from the overworld")
|
||||
T.eq(Game.drawBaseInStack(s2, s2:visibleBase()), 1, "and still does")
|
||||
local s3 = stack(overworld, whiteBattle, menu)
|
||||
T.eq(Game.drawBaseInStack(s3, s3:visibleBase()), 3,
|
||||
"a white-bg battle has no map to hold, so nothing moves")
|
||||
"a classic white-bg battle has no presentation to hold, so nothing moves")
|
||||
local s3wide = stack(overworld, wideWhiteBattle, menu)
|
||||
T.eq(Game.drawBaseInStack(s3wide, s3wide:visibleBase()), 2,
|
||||
"an opaque WIDE battle still draws beneath its classic menu")
|
||||
local s4 = stack(overworld, menu)
|
||||
T.eq(Game.drawBaseInStack(s4, s4:visibleBase()), 2,
|
||||
"and a menu outside a battle is untouched")
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
-- engine/battle/misc.asm:37 FormatMovesString .printDashLoop
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local realDraw, realDrawCode, realDrawBox = Font.draw, Font.drawCode, Font.drawBox
|
||||
local drawn
|
||||
|
||||
local function stubFont()
|
||||
drawn = {}
|
||||
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = text, x = x, y = y } end
|
||||
Font.drawCode = function() end
|
||||
Font.drawBox = function() end
|
||||
end
|
||||
|
||||
local function unstubFont()
|
||||
Font.draw, Font.drawCode, Font.drawBox = realDraw, realDrawCode, realDrawBox
|
||||
end
|
||||
|
||||
-- a mon with fewer than four moves: the remaining rows must be dashes, not
|
||||
-- simply absent (ipairs used to stop at the last known move).
|
||||
do
|
||||
stubFont()
|
||||
local screen = setmetatable({
|
||||
phase = "moveSelect",
|
||||
player = { curMoves = { { id = "TACKLE", pp = 35 } } },
|
||||
data = { moves = { TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" } } },
|
||||
moveIndex = 1, frame = 0,
|
||||
}, { __index = BattleState })
|
||||
local ok, err = pcall(function() screen:drawTextArea() end)
|
||||
T.check(ok, "moveSelect draws without error (" .. tostring(err) .. ")")
|
||||
|
||||
local rows = {}
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
|
||||
end
|
||||
T.eq(#rows, 4, "all four move rows are drawn, even the unused ones")
|
||||
T.eq(rows[1], "TACKLE", "the one real move prints its name")
|
||||
T.eq(rows[2], "-", "an empty slot is a dash")
|
||||
T.eq(rows[3], "-", "so is the next one")
|
||||
T.eq(rows[4], "-", "and the last one")
|
||||
unstubFont()
|
||||
end
|
||||
|
||||
-- a full four-move mon: no dashes anywhere.
|
||||
do
|
||||
stubFont()
|
||||
local screen = setmetatable({
|
||||
phase = "moveSelect",
|
||||
player = { curMoves = {
|
||||
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
|
||||
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
|
||||
} },
|
||||
data = { moves = {
|
||||
TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" },
|
||||
GROWL = { name = "GROWL", pp = 40, type = "NORMAL" },
|
||||
} },
|
||||
moveIndex = 1, frame = 0,
|
||||
}, { __index = BattleState })
|
||||
screen:drawTextArea()
|
||||
local rows = {}
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
|
||||
end
|
||||
T.eq(#rows, 4, "still exactly four rows")
|
||||
for i, want in ipairs({ "TACKLE", "GROWL", "TACKLE", "GROWL" }) do
|
||||
T.eq(rows[i], want, "row " .. i .. " keeps its own move name")
|
||||
end
|
||||
unstubFont()
|
||||
end
|
||||
|
||||
-- the Mimic menu shares FormatMovesString on the cart, so it gets the same
|
||||
-- dash treatment.
|
||||
do
|
||||
stubFont()
|
||||
local screen = setmetatable({
|
||||
phase = "mimicSelect",
|
||||
mimicMoves = { { id = "TACKLE" }, { id = "GROWL" } },
|
||||
data = { moves = {
|
||||
TACKLE = { name = "TACKLE" }, GROWL = { name = "GROWL" },
|
||||
} },
|
||||
mimicIndex = 1, frame = 0,
|
||||
}, { __index = BattleState })
|
||||
local ok = pcall(function() screen:drawTextArea() end)
|
||||
T.check(ok, "mimicSelect draws without error")
|
||||
local rows = {}
|
||||
for _, d in ipairs(drawn) do
|
||||
if d.x == 16 and d.y >= 64 and d.y <= 88 then rows[#rows + 1] = d.text end
|
||||
end
|
||||
T.eq(rows[1], "TACKLE", "mimic row 1 is the enemy's first move")
|
||||
T.eq(rows[2], "GROWL", "mimic row 2 is its second")
|
||||
T.eq(rows[3], "-", "an enemy with fewer than four moves dashes out the rest")
|
||||
T.eq(rows[4], "-", "including the last row")
|
||||
unstubFont()
|
||||
end
|
||||
|
||||
T.finish("battle move slot dashes bug 1343")
|
||||
@@ -0,0 +1,80 @@
|
||||
-- #1338: after the TOWN MAP, Daisy has to swap from the sitting object to
|
||||
-- the walking one -- PalletTownDaisyScript, gated on both
|
||||
-- EVENT_GOT_TOWN_MAP and EVENT_ENTERED_BLUES_HOUSE.
|
||||
-- scripts/BluesHouse.asm:12-16; scripts/PalletTown.asm:133-144
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local Story = assert(loadfile("data/scripts/story.lua"))()
|
||||
local Story2 = assert(loadfile("data/scripts/story2.lua"))()
|
||||
|
||||
T.check(type(Story.BLUES_HOUSE.onEnter) == "function",
|
||||
"M.BLUES_HOUSE.onEnter exists")
|
||||
T.check(type(Story2.PALLET_TOWN.onEnter) == "function",
|
||||
"M.PALLET_TOWN.onEnter exists")
|
||||
|
||||
-- Entering Blue's House alone must set EVENT_ENTERED_BLUES_HOUSE and touch
|
||||
-- nothing else: BluesHouseDefaultScript is a plain SetEvent, no swap here.
|
||||
do
|
||||
local game = { save = { flags = {} } }
|
||||
Story.BLUES_HOUSE.onEnter(game, {})
|
||||
T.check(game.save.flags.EVENT_ENTERED_BLUES_HOUSE == true,
|
||||
"onEnter sets EVENT_ENTERED_BLUES_HOUSE")
|
||||
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
|
||||
"and does not itself start the swap")
|
||||
end
|
||||
|
||||
-- PALLET_TOWN's onEnter is the swap: both events must be set, and it must
|
||||
-- write the toggle even though BLUES_HOUSE is not the live map (the fix's
|
||||
-- own precondition, verified against src/script/Commands.lua's toggleObject
|
||||
-- writing save.objectToggles before its live-NPC early return).
|
||||
do
|
||||
local game = {
|
||||
save = {
|
||||
flags = { EVENT_GOT_TOWN_MAP = true, EVENT_ENTERED_BLUES_HOUSE = true },
|
||||
},
|
||||
}
|
||||
local ow = { map = { id = "PALLET_TOWN" } }
|
||||
Story2.PALLET_TOWN.onEnter(game, ow)
|
||||
T.check(game.save.flags.EVENT_DAISY_WALKING == true,
|
||||
"both prerequisites set: EVENT_DAISY_WALKING fires")
|
||||
local toggles = game.save.objectToggles and game.save.objectToggles.BLUES_HOUSE
|
||||
T.check(toggles ~= nil, "the swap reaches BLUES_HOUSE's toggle table")
|
||||
T.eq(toggles and toggles.BLUESHOUSE_DAISY1, false,
|
||||
"the sitting Daisy (DAISY1) is hidden")
|
||||
T.eq(toggles and toggles.BLUESHOUSE_DAISY2, true,
|
||||
"the walking Daisy (DAISY2) is shown")
|
||||
end
|
||||
|
||||
-- Only having the map, without ever entering the house, must not swap her:
|
||||
-- EVENT_ENTERED_BLUES_HOUSE is a real gate, not a formality.
|
||||
do
|
||||
local game = {
|
||||
save = { flags = { EVENT_GOT_TOWN_MAP = true } },
|
||||
}
|
||||
Story2.PALLET_TOWN.onEnter(game, { map = { id = "PALLET_TOWN" } })
|
||||
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
|
||||
"without EVENT_ENTERED_BLUES_HOUSE the swap does not fire")
|
||||
end
|
||||
|
||||
-- Re-entering Pallet Town after she has already swapped must not re-run
|
||||
-- the toggle writes (EVENT_DAISY_WALKING itself is the guard).
|
||||
do
|
||||
local game = {
|
||||
save = {
|
||||
flags = {
|
||||
EVENT_GOT_TOWN_MAP = true,
|
||||
EVENT_ENTERED_BLUES_HOUSE = true,
|
||||
EVENT_DAISY_WALKING = true,
|
||||
},
|
||||
objectToggles = {},
|
||||
},
|
||||
}
|
||||
local ow = { map = { id = "PALLET_TOWN" } }
|
||||
Story2.PALLET_TOWN.onEnter(game, ow)
|
||||
T.check(next(game.save.objectToggles) == nil,
|
||||
"already-walking Daisy: onEnter writes no toggle a second time")
|
||||
end
|
||||
|
||||
T.finish("blues_house_daisy_walking_bug1338")
|
||||
@@ -0,0 +1,56 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local clock, quit = 1, false
|
||||
local sent, queue, draws = {}, {}, 0
|
||||
local peer = {
|
||||
send = function(_, data) sent[#sent + 1] = data end,
|
||||
disconnect_now = function() end,
|
||||
}
|
||||
local host = {
|
||||
connect = function() return peer end,
|
||||
service = function()
|
||||
if #queue == 0 then return nil end
|
||||
return table.remove(queue, 1)
|
||||
end,
|
||||
}
|
||||
local rendered = {
|
||||
setFilter = function() end, replacePixels = function() end,
|
||||
release = function() end,
|
||||
}
|
||||
love = {
|
||||
timer = { getTime = function() return clock end },
|
||||
data = { decompress = function(_, _, value) return value end },
|
||||
image = { newImageData = function(w, h, format, raw)
|
||||
assert(w == 1 and h == 1 and format == "rgba8" and raw == "rgba")
|
||||
return {}
|
||||
end },
|
||||
graphics = {
|
||||
newImage = function() return rendered end,
|
||||
getDimensions = function() return 100, 100 end,
|
||||
clear = function() end, setColor = function() end,
|
||||
draw = function() draws = draws + 1 end,
|
||||
},
|
||||
event = { quit = function() quit = true end },
|
||||
}
|
||||
package.preload.enet = function()
|
||||
return { host_create = function() return host end }
|
||||
end
|
||||
|
||||
require("src.render.DesktopCompanion").install({ port = 50000, token = "token" })
|
||||
queue[#queue + 1] = { type = "connect" }
|
||||
love.update()
|
||||
assert(sent[#sent] == "Htoken", "companion authenticates after connecting")
|
||||
queue[#queue + 1] = {
|
||||
type = "receive", data = "Ftoken\n1,1,0,auto\nrgba",
|
||||
}
|
||||
love.update()
|
||||
love.draw()
|
||||
assert(draws == 1, "companion draws a received frame")
|
||||
love.mousepressed(50, 50, 1)
|
||||
love.mousereleased(50, 50, 1)
|
||||
assert(sent[#sent - 1] == "Itoken\ndown,0,0"
|
||||
and sent[#sent] == "Itoken\nup,0,0", "mouse input maps back to source pixels")
|
||||
queue[#queue + 1] = { type = "receive", data = "Qtoken" }
|
||||
love.update()
|
||||
assert(quit, "parent can close the companion")
|
||||
print("desktop companion: ok")
|
||||
@@ -0,0 +1,57 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local clock = 1
|
||||
love = {
|
||||
timer = { getTime = function() return clock end },
|
||||
data = {
|
||||
hash = function() return "digest" end,
|
||||
encode = function() return "0123456789abcdef0123456789abcdef" end,
|
||||
compress = function(_, _, value) return value end,
|
||||
},
|
||||
}
|
||||
|
||||
local sent, spawned, queue = {}, nil, {}
|
||||
local peer = {
|
||||
send = function(_, data, channel, flag)
|
||||
sent[#sent + 1] = { data = data, channel = channel, flag = flag }
|
||||
end,
|
||||
disconnect_now = function() end,
|
||||
}
|
||||
local host = {
|
||||
service = function()
|
||||
if #queue == 0 then return nil end
|
||||
return table.remove(queue, 1)
|
||||
end,
|
||||
destroy = function() end,
|
||||
}
|
||||
|
||||
package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
|
||||
package.loaded["src.core.HostShell"] = {
|
||||
spawnSelfDetached = function(args) spawned = args return true end,
|
||||
}
|
||||
package.preload.enet = function()
|
||||
return { host_create = function() return host end }
|
||||
end
|
||||
|
||||
local Screen = require("src.render.SecondScreen")
|
||||
assert(Screen.usable(), "the shared facade selects the desktop backend")
|
||||
Screen.setEnabled(true)
|
||||
assert(spawned and spawned[1]:match("^%-%-display%-companion=%d+,[%w]+$"),
|
||||
"enabling launches one companion of this app")
|
||||
local token = spawned[1]:match(",([%w]+)$")
|
||||
queue[#queue + 1] = { type = "receive", peer = peer, data = "H" .. token }
|
||||
assert(Screen.detected(), "a token-authenticated companion becomes detected")
|
||||
|
||||
local pixels = { getString = function() return "rgba" end }
|
||||
assert(Screen.push(pixels, 1, 1, 0x102030, "auto"),
|
||||
"a connected companion accepts a frame")
|
||||
assert(sent[#sent].data:find("^F" .. token .. "\n1,1,1056816,auto\nrgba"),
|
||||
"frame metadata and pixels stay in one loopback packet")
|
||||
|
||||
queue[#queue + 1] = {
|
||||
type = "receive", peer = peer, data = "I" .. token .. "\ndown,3,4",
|
||||
}
|
||||
assert(Screen.pollTouch() == "down,3,4", "companion input returns to the mod")
|
||||
Screen.setEnabled(false)
|
||||
assert(sent[#sent].data == "Q" .. token, "disabling closes the companion")
|
||||
print("desktop second screen: ok")
|
||||
@@ -0,0 +1,49 @@
|
||||
-- engine/battle/experience.asm:69
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local function newSave()
|
||||
return { player = { id = 12345, name = "RED" } }
|
||||
end
|
||||
|
||||
do
|
||||
local save = newSave()
|
||||
local homegrown = {}
|
||||
BattleState.stampOT(save, homegrown)
|
||||
T.eq(homegrown.otId, 12345, "a home-grown mon is still stamped with the player id")
|
||||
T.eq(homegrown.ot, "RED", "and the player's own name")
|
||||
end
|
||||
|
||||
-- The bug: a mon that arrived traded (traded = true) but whose OT id was
|
||||
-- never recorded (a legacy peer, a link mon with no otId in its packet) used
|
||||
-- to get save.player.id written into otId on the very first load, which then
|
||||
-- reads identically to a mon the player caught -- awardExp's OT-id compare
|
||||
-- (BattleState.lua ~4009) permanently loses the 1.5x boost.
|
||||
do
|
||||
local save = newSave()
|
||||
local tradedNoId = { traded = true }
|
||||
BattleState.stampOT(save, tradedNoId)
|
||||
T.eq(tradedNoId.otId, nil,
|
||||
"a traded mon with no OT id is left unstamped, not silently adopted")
|
||||
T.eq(tradedNoId.ot, "RED",
|
||||
"the OT NAME fill still happens (cosmetic, not the boost gate)")
|
||||
-- a second stampOT pass (a second save/load cycle) must not adopt it either
|
||||
BattleState.stampOT(save, tradedNoId)
|
||||
T.eq(tradedNoId.otId, nil, "repeated reloads do not eventually stamp it")
|
||||
end
|
||||
|
||||
-- A mon with its own foreign OT id (the ordinary traded-in case) is untouched
|
||||
-- either way; this is the arm the regression never broke.
|
||||
do
|
||||
local save = newSave()
|
||||
local tradedWithId = { traded = true, otId = 777 }
|
||||
BattleState.stampOT(save, tradedWithId)
|
||||
T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten")
|
||||
end
|
||||
|
||||
T.finish("exp traded ot survives reload bug 1265")
|
||||
@@ -0,0 +1,82 @@
|
||||
-- data/moves/animations.asm:379
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
|
||||
local function newSelf(opts)
|
||||
opts = opts or {}
|
||||
return setmetatable({
|
||||
anim = opts.anim,
|
||||
ballThrow = opts.ballThrow,
|
||||
picHidden = { player = false, enemy = false },
|
||||
pendingAfterAnim = nil,
|
||||
afterSendOut = nil,
|
||||
}, { __index = UI })
|
||||
end
|
||||
|
||||
-- pushCaught itself must never touch picHidden: only the animation's own
|
||||
-- steps (stepAnim) are allowed to latch the enemy pic box.
|
||||
do
|
||||
local self1 = setmetatable({
|
||||
battle = {}, tutorial = true, save = nil,
|
||||
queue = {}, picHidden = { player = false, enemy = false },
|
||||
}, { __index = UI })
|
||||
self1:pushCaught({ species = "RATTATA" }, "POKE_BALL")
|
||||
T.eq(self1.picHidden.enemy, false,
|
||||
"pushCaught alone does not hide the enemy pic")
|
||||
T.eq(self1.battle.outcome, "caught", "pushCaught still marks the battle caught")
|
||||
end
|
||||
|
||||
-- stepAnim, natural end (anim:step() returns false): a caught ball throw
|
||||
-- latches, everything else does not.
|
||||
do
|
||||
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return false end, keepSprites = false }
|
||||
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
|
||||
s:stepAnim(nil)
|
||||
T.eq(s.picHidden.enemy, true, "caught ball throw latches at the natural end")
|
||||
T.eq(s.anim, nil, "the finished runner is cleared")
|
||||
end
|
||||
|
||||
do
|
||||
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return false end, keepSprites = false }
|
||||
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
|
||||
s:stepAnim(nil)
|
||||
T.eq(s.picHidden.enemy, false, "a break-free throw does not latch")
|
||||
end
|
||||
|
||||
do
|
||||
local otherAnim = { animId = "ANIM_HYDRO_PUMP",
|
||||
step = function() return false end, keepSprites = false }
|
||||
local s = newSelf({ anim = otherAnim, ballThrow = { caught = true } })
|
||||
s:stepAnim(nil)
|
||||
T.eq(s.picHidden.enemy, false, "an unrelated animation never latches")
|
||||
end
|
||||
|
||||
-- stepAnim, cut short with B: the property the latch exists for -- a caught
|
||||
-- mon must not reappear even if the player skips past "Gotcha!".
|
||||
do
|
||||
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return true end, keepSprites = false }
|
||||
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, true, "a B-skipped catch still latches")
|
||||
T.eq(s.anim, nil, "B cuts the runner short")
|
||||
end
|
||||
|
||||
do
|
||||
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
|
||||
step = function() return true end, keepSprites = false }
|
||||
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
|
||||
local input = { wasPressed = function(_, key) return key == "b" end }
|
||||
s:stepAnim(input)
|
||||
T.eq(s.picHidden.enemy, false, "a B-skipped break-free does not latch")
|
||||
end
|
||||
|
||||
T.finish("gen2 ball throw pic latch bug 1232")
|
||||
@@ -0,0 +1,58 @@
|
||||
-- engine/battle_anims/anim_commands.asm:755 BattleAnimCmd_BattlerGFX_1Row
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local AnimRunner = require("src.battle.gen2.AnimRunner")
|
||||
|
||||
local function findLoaded(runner, gfx)
|
||||
for _, entry in ipairs(runner.loaded) do
|
||||
if entry.gfx == gfx then return entry end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
runner:loadBattlerGfx(1)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
|
||||
T.check(head and feet, "both pseudo-sheets registered")
|
||||
T.eq(head.battler, "enemy",
|
||||
"GFX_PLAYERHEAD's tiles are the ENEMY's feet row")
|
||||
T.eq(head.tiles, 7, "seven tiles, the enemy pic's width")
|
||||
T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the asm's fixed base")
|
||||
T.eq(feet.battler, "player",
|
||||
"GFX_ENEMYFEET's tiles are the PLAYER's head row")
|
||||
T.eq(feet.tiles, 6, "six tiles, the backpic's width")
|
||||
T.eq(feet.tile, (0x80 - 6) - 49, "at the asm's fixed base")
|
||||
T.eq(head.rows, 1, "one row each")
|
||||
T.eq(feet.rows, 1, "on both sheets")
|
||||
end
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
runner:loadBattlerGfx(2)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
|
||||
T.eq(head.battler, "enemy", "2ROW keeps the same crossing")
|
||||
T.eq(head.tiles, 14, "two enemy rows")
|
||||
T.eq(feet.battler, "player", "on both sides")
|
||||
T.eq(feet.tiles, 12, "two player rows")
|
||||
T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "2ROW base")
|
||||
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base")
|
||||
end
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
AnimRunner.COMMANDS.battlergfx_1row(runner)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
T.eq(head and head.battler, "enemy", "the script command routes the same way")
|
||||
end
|
||||
|
||||
T.finish("gen2 battler gfx row attribution bug 1231")
|
||||
@@ -0,0 +1,68 @@
|
||||
-- engine/battle_anims/bg_effects.asm:406-471 BattleBGEffect_BattlerObj_1Row
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local BgEffects = require("src.battle.gen2.BgEffects")
|
||||
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
|
||||
|
||||
do
|
||||
local bg = BgEffects.new(nil, { battleTurn = 0 })
|
||||
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
|
||||
bg:playFrame()
|
||||
local spawns = bg:takeSpawns()
|
||||
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
|
||||
"player attacking: the enemy's feet row becomes an OBJ")
|
||||
T.eq(spawns[1] and spawns[1].x, 16 * 8 + 4, "at the asm's fixed x")
|
||||
T.eq(bg.liftedRows.enemy, nil, "the tilemap row is intact on frame one")
|
||||
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
|
||||
"an intact tilemap with no scroll skips the bake canvas")
|
||||
bg:playFrame()
|
||||
local lifted = bg.liftedRows.enemy
|
||||
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
|
||||
"frame two ClearBoxes row 6 of the enemy box (hlcoord 12, 6)")
|
||||
T.eq(bg.hidden.enemy, false, "the rest of the pic stays on the BG")
|
||||
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
|
||||
"a lifted row keeps the panel on the bake canvas even with scx 0 and no"
|
||||
.. " lcdc pointer, so drawPic's 160x144 scissor stays in canvas space")
|
||||
for _ = 1, 4 do bg:playFrame() end
|
||||
T.eq(bg:activeCount(), 0, ".five ends the effect")
|
||||
lifted = bg.liftedRows.enemy
|
||||
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
|
||||
".five never restores the row")
|
||||
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
|
||||
"and the wait frames after .five stay baked as well")
|
||||
bg:queue("BATTLE_BG_EFFECT_SHOW_MON", 0, 0, 0)
|
||||
bg:playFrame()
|
||||
T.eq(bg.liftedRows.enemy, nil, "SHOW_MON's box redraw puts the row back")
|
||||
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
|
||||
"after which the plain no-canvas path returns")
|
||||
end
|
||||
|
||||
do
|
||||
local bg = BgEffects.new(nil, { battleTurn = 1 })
|
||||
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", 0, 0, 0)
|
||||
bg:playFrame()
|
||||
local spawns = bg:takeSpawns()
|
||||
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
|
||||
"enemy attacking: the player's head rows become an OBJ")
|
||||
T.eq(spawns[1] and spawns[1].x, 6 * 8, "at the asm's fixed x")
|
||||
bg:playFrame()
|
||||
local lifted = bg.liftedRows.player
|
||||
T.check(lifted and lifted[1] == 0 and lifted[2] == 2,
|
||||
"rows 0-1 of the player box (hlcoord 2, 6, two rows)")
|
||||
T.eq(bg.liftedRows.enemy, nil, "the attacker keeps its own rows")
|
||||
end
|
||||
|
||||
do
|
||||
local bg = BgEffects.new(nil, { battleTurn = 0, flying = { enemy = true } })
|
||||
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
|
||||
bg:playFrame()
|
||||
T.eq(#bg:takeSpawns(), 0, "a flying target spawns nothing")
|
||||
T.eq(bg:activeCount(), 0, "and the effect ends at once")
|
||||
T.eq(bg.liftedRows.enemy, nil, "with no row lifted")
|
||||
end
|
||||
|
||||
T.finish("gen2 battler row lift bug 1231")
|
||||
@@ -0,0 +1,94 @@
|
||||
-- engine/battle/effect_commands.asm:5458 BattleCommand_Charge
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
GROUND = { id = "GROUND", index = 1, category = "physical" },
|
||||
FLYING = { id = "FLYING", index = 2, category = "physical" },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND",
|
||||
accuracy = 100, pp = 10, effect = "EFFECT_FLY" },
|
||||
FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING",
|
||||
accuracy = 95, pp = 15, effect = "EFFECT_FLY" },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = {}, evolutions = {} },
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
local function highRoll(n) return (n or 1) - 1 end
|
||||
|
||||
local function newBattle(moveId)
|
||||
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
player.moves = { { id = moveId, pp = 20, maxPp = 20 } }
|
||||
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
return Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = highRoll }), player, wild
|
||||
end
|
||||
|
||||
local function moveEvent(events)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "move" then return e end
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle("DIG")
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "DIG")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.check(ev ~= nil, "the charge turn queues a move event")
|
||||
T.eq(ev and ev.animParam, 1,
|
||||
"DIG's charge (burrow) turn carries animParam 1, the take-cover script arm")
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "DIG")
|
||||
local ev2 = moveEvent(battle.events)
|
||||
T.check(ev2 ~= nil, "the strike turn also queues a move event")
|
||||
T.eq(ev2 and ev2.animParam, nil,
|
||||
"DIG's strike turn leaves animParam nil, the hit script arm")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle("FLY")
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "FLY")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.eq(ev and ev.animParam, 1, "FLY's take-off turn also carries animParam 1")
|
||||
end
|
||||
|
||||
-- a plain hit-and-run move never sets a parameter at all
|
||||
do
|
||||
local battle, player, wild = newBattle("DIG")
|
||||
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
battle.events = {}
|
||||
battle:useMove(player, wild, "TACKLE")
|
||||
local ev = moveEvent(battle.events)
|
||||
T.eq(ev and ev.animParam, nil, "a non-charge move never carries animParam")
|
||||
end
|
||||
|
||||
T.finish("gen2 charge move anim param bug 1293")
|
||||
@@ -0,0 +1,83 @@
|
||||
-- #1251: the Game Corner's `CheckCoinsAndCoinCase` transcription must ask
|
||||
-- the bag about the real COIN_CASE item id, not SILVER_WING.
|
||||
-- constants/item_constants.asm:62 (COIN_CASE = $36); SILVER_WING is $47.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local Vm = require("src.script.gen2.Vm")
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
local Events = require("src.world.gen2.Events")
|
||||
|
||||
local COIN_CASE = 0x36
|
||||
local SILVER_WING = 0x47
|
||||
|
||||
-- 1) the id the handler actually queries the bag with
|
||||
local seenId
|
||||
local vm = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 100 end,
|
||||
hasItem = function(id) seenId = id return true end,
|
||||
gameCornerGame = function(_, done) done() end,
|
||||
},
|
||||
})
|
||||
vm.showTextFn = function() end
|
||||
vm.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm) end)
|
||||
coroutine.resume(vm.co)
|
||||
T.eq(seenId, COIN_CASE,
|
||||
"SlotMachine's CheckItem call carries the real COIN_CASE id ($36)")
|
||||
T.check(seenId ~= SILVER_WING,
|
||||
"and specifically not SILVER_WING ($47), the pre-fix value")
|
||||
|
||||
-- 2) a bag holding ONLY the real coin case (not the silver wing) must be
|
||||
-- enough to open both machines: this is what would still fail if the id
|
||||
-- above were merely logged and not actually used to gate the machine.
|
||||
local bag = { [COIN_CASE] = true }
|
||||
local slotsOpened, flipOpened
|
||||
local vm2 = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 50 end,
|
||||
hasItem = function(id) return bag[id] == true end,
|
||||
gameCornerGame = function(kind, done) slotsOpened = kind done() end,
|
||||
},
|
||||
})
|
||||
vm2.showTextFn = function() end
|
||||
vm2.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm2) end)
|
||||
coroutine.resume(vm2.co)
|
||||
T.eq(slotsOpened, "slots",
|
||||
"a bag with the real COIN CASE and coins opens the slot machine")
|
||||
|
||||
local vm3 = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 50 end,
|
||||
hasItem = function(id) return bag[id] == true end,
|
||||
gameCornerGame = function(kind, done) flipOpened = kind done() end,
|
||||
},
|
||||
})
|
||||
vm3.showTextFn = function() end
|
||||
vm3.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm3) end)
|
||||
coroutine.resume(vm3.co)
|
||||
T.eq(flipOpened, "cardflip",
|
||||
"and the same bag opens card flip too")
|
||||
|
||||
-- 3) the mirror case: SILVER_WING in the bag, no coin case, must still
|
||||
-- refuse with _NoCoinCaseText (this is the exact symptom in #1251, and it
|
||||
-- is the yield the coroutine parks on, not a call, so opening never runs
|
||||
-- behind it).
|
||||
local wrongBag = { [SILVER_WING] = true }
|
||||
local opened = false
|
||||
local vm4 = Vm.new({ generation = 2 }, {}, Events.new(), {
|
||||
specials = {
|
||||
coins = function() return 50 end,
|
||||
hasItem = function(id) return wrongBag[id] == true end,
|
||||
gameCornerGame = function() opened = true end,
|
||||
},
|
||||
})
|
||||
vm4.showTextFn = function() end
|
||||
vm4.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm4) end)
|
||||
local _, refusal = coroutine.resume(vm4.co)
|
||||
T.eq(refusal and refusal.text, "You don't have a\nCOIN CASE.",
|
||||
"holding only SILVER_WING gets the real _NoCoinCaseText refusal")
|
||||
T.check(not opened, "and the machine never opens behind it")
|
||||
|
||||
T.finish("gen2_coin_case_bug1251")
|
||||
@@ -0,0 +1,166 @@
|
||||
-- engine/battle/effect_commands.asm:1958-1961 (the 40 frame hold),
|
||||
-- engine/battle/effect_commands.asm:3615 (.CheckAIRandomFail, the 25% roll)
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local UI = require("src.ui.gen2.BattleState")
|
||||
|
||||
local TYPES = {
|
||||
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
||||
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
|
||||
}
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
|
||||
accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
|
||||
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
|
||||
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
|
||||
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
||||
specialAttack = 35, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
||||
levelMoves = {}, evolutions = {} },
|
||||
}
|
||||
|
||||
local DATA = { pokemon = POKEMON, moves = MOVES,
|
||||
type_chart = { types = TYPES, matchups = {} }, items = {} }
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
-- a controllable roll queue; falls back to a high roll (never fails an AI
|
||||
-- check) once drained
|
||||
local rolls
|
||||
local function rng(n)
|
||||
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
|
||||
return (n or 1) - 1
|
||||
end
|
||||
|
||||
local function newBattle(pmoves, emoves)
|
||||
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
player.moves = pmoves
|
||||
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
|
||||
wild.moves = emoves
|
||||
return Battle.new({ data = DATA, party = { player }, wild = wild,
|
||||
random = rng }), player, wild
|
||||
end
|
||||
|
||||
local function findText(events, sub)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function moveEvent(events)
|
||||
for _, e in ipairs(events or {}) do
|
||||
if e.kind == "move" then return e end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- gap 2:
|
||||
-- the AI's 25% "miss" on a support move, and who is exempt from the roll.
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
|
||||
{ { id = "GROWL", pp = 40, maxPp = 40 } })
|
||||
battle.events = {}
|
||||
rolls = { 0, 10 } -- accuracy roll, then the AI roll (10 < 64: fails)
|
||||
battle:useMove(wild, player, "GROWL")
|
||||
T.check(findText(battle.events, "But it failed!"),
|
||||
"enemy GROWL fails 25% of the time with the specific line")
|
||||
T.eq(moveEvent(battle.events) and moveEvent(battle.events).missed, true,
|
||||
"an AI-failed move is marked missed (feeds the 40 frame hold)")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
|
||||
{ { id = "GROWL", pp = 40, maxPp = 40 } })
|
||||
battle.events = {}
|
||||
rolls = { 0, 200 } -- AI roll passes (>=64): lands
|
||||
battle:useMove(wild, player, "GROWL")
|
||||
T.check(not findText(battle.events, "But it failed!"),
|
||||
"the same move lands when the AI roll passes")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, player, wild = newBattle(
|
||||
{ { id = "GROWL", pp = 40, maxPp = 40 } },
|
||||
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
|
||||
battle.events = {}
|
||||
rolls = { 0, 10 } -- if the player rolled too, 10 would fail it
|
||||
battle:useMove(player, wild, "GROWL")
|
||||
T.check(not findText(battle.events, "But it failed!"),
|
||||
"the player's own GROWL is exempt from the AI roll")
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- gap 1:
|
||||
-- the reported symptom -- the "used X!" line must hold before a failure.
|
||||
do
|
||||
local input = { wasPressed = function() return false end }
|
||||
local ui = setmetatable({
|
||||
game = { input = input },
|
||||
phase = "resolving", slideFrame = 999, messageTimer = 0,
|
||||
picHidden = { player = false, enemy = false },
|
||||
queue = {
|
||||
{ kind = "move", side = "enemy", move = "GROWL",
|
||||
text = "Enemy MACHOP used GROWL!", missed = true },
|
||||
{ kind = "message", text = "But it failed!" },
|
||||
},
|
||||
updateAlarm = function() end,
|
||||
stepHpAnim = function() return false end,
|
||||
stepExpAnim = function() return false end,
|
||||
}, { __index = UI })
|
||||
|
||||
ui:advanceQueue()
|
||||
T.eq(ui.message, "Enemy MACHOP used GROWL!", "the used-move line is shown first")
|
||||
T.eq(ui.messageDelay, 40,
|
||||
"a missed move arms the 40 frame delay (effect_commands.asm's MoveDelay)")
|
||||
T.eq(ui.messageTimer, 0, "no separate A/B hold on the move line itself")
|
||||
|
||||
local frames = 0
|
||||
for _ = 1, 100 do
|
||||
if ui.message == "But it failed!" then break end
|
||||
ui:update(1 / 60)
|
||||
frames = frames + 1
|
||||
end
|
||||
T.eq(ui.message, "But it failed!", "the queue eventually reaches the failure line")
|
||||
T.eq(frames, 41, "the used line held for exactly the 40 delay frames")
|
||||
T.check(ui.messageTimer > 0, "the failure line itself still holds for A/B")
|
||||
end
|
||||
|
||||
do
|
||||
local input = { wasPressed = function() return false end }
|
||||
local ui2 = setmetatable({
|
||||
game = { input = input },
|
||||
phase = "resolving", slideFrame = 999, messageTimer = 0,
|
||||
picHidden = { player = false, enemy = false },
|
||||
queue = { { kind = "move", side = "player", move = "TACKLE",
|
||||
text = "MACHOP used TACKLE!" } },
|
||||
updateAlarm = function() end,
|
||||
stepHpAnim = function() return false end,
|
||||
stepExpAnim = function() return false end,
|
||||
animForMove = function() return false end,
|
||||
}, { __index = UI })
|
||||
ui2:advanceQueue()
|
||||
T.eq(ui2.messageDelay or 0, 0, "a move that lands arms no delay at all")
|
||||
end
|
||||
|
||||
T.finish("gen2 enemy move fail text bug 1296")
|
||||
@@ -0,0 +1,92 @@
|
||||
-- The fishgroup bite roll, missing entirely before #1368: .Fish rolls the
|
||||
-- group's OWN chance byte before the rod's cumulative list even runs
|
||||
-- (engine/events/fish.asm:24-30), so every rod bites at whatever that byte
|
||||
-- says (vanilla Gold is 50 percent + 1 for every group, not 2/3 or 1/2 by
|
||||
-- rod). A cache built before the extractor carried the byte has no
|
||||
-- `chance` field on the group row at all and must keep fishing unconditionally.
|
||||
-- luajit tests/engine/gen2_fishing_bite_gate_bug1368.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local DATA = {
|
||||
pokemon = {
|
||||
MAGIKARP = {
|
||||
name = "MAGIKARP", types = { "WATER", "WATER" },
|
||||
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
|
||||
specialAttack = 15, specialDefense = 20 },
|
||||
levelMoves = {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local COLL_FLOOR, COLL_WATER = 0x00, 0x29
|
||||
|
||||
local function fakeMap(waterCell)
|
||||
return {
|
||||
id = "TEST_MAP",
|
||||
def = { fishGroup = "FISHGROUP_POND" },
|
||||
cellCollision = function(_, x, y)
|
||||
return (x == waterCell[1] and y == waterCell[2])
|
||||
and COLL_WATER or COLL_FLOOR
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- Rod lists that always hand back a species once a bite happens, so only the
|
||||
-- group gate (or its absence) decides the outcome below.
|
||||
local function fishGroups(chance)
|
||||
return {
|
||||
FISHGROUP_POND = {
|
||||
chance = chance,
|
||||
old = { { chance = 256, species = "MAGIKARP", level = 10 } },
|
||||
good = { { chance = 256, species = "MAGIKARP", level = 20 } },
|
||||
super = { { chance = 256, species = "MAGIKARP", level = 40 } },
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
local function fakeWorld(chance)
|
||||
local game = { data = DATA, save = { party = {} } }
|
||||
local world = World.new(game)
|
||||
world.map = fakeMap({ 5, 4 })
|
||||
world.maps = { TEST_MAP = world.map.def }
|
||||
world.encounters = { fishGroups = fishGroups(chance) }
|
||||
world.player = { cellX = 5, cellY = 5, facing = "up" }
|
||||
return world
|
||||
end
|
||||
|
||||
-- ---- chance 0: the group byte fails Random every time, always a nibble ---
|
||||
-- engine/events/fish.asm:24-30
|
||||
do
|
||||
local world = fakeWorld(0)
|
||||
for rod = 1, 3 do
|
||||
local outcome = world:rollFishing(({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
|
||||
eq(outcome, "nibble",
|
||||
"chance 0 nibbles on " .. ({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- chance 256: the group byte always passes, every rod finds the mon ---
|
||||
do
|
||||
local world = fakeWorld(256)
|
||||
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
|
||||
local outcome, wild = world:rollFishing(rod)
|
||||
eq(outcome, "battle", "chance 256 always bites on " .. rod)
|
||||
check(wild and wild.species == "MAGIKARP",
|
||||
"and the rod's own list still resolves a species")
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- no chance field at all: an old cache keeps fishing unconditionally --
|
||||
do
|
||||
local world = fakeWorld(nil)
|
||||
local outcome = world:rollFishing("OLD_ROD")
|
||||
eq(outcome, "battle",
|
||||
"a cache with no group chance byte is not gated at all")
|
||||
end
|
||||
|
||||
T.finish("gen2 fishing bite gate bug1368")
|
||||
@@ -0,0 +1,115 @@
|
||||
-- Grass encounters must key off the CLOCK (wTimeOfDay), never the palette
|
||||
-- set a map header pins (wTimeOfDayPal): engine/overworld/wildmons.asm:283
|
||||
-- reads wTimeOfDay for both the rate (GetMapEncounterRate) and the slot list
|
||||
-- (ChooseWildEncounter). A PALETTE_DAY tower like Sprout Tower must still
|
||||
-- roll its night table after dark (#1389, Gastly unobtainable), and a
|
||||
-- PALETTE_NITE cave must still roll its morning/day table at noon.
|
||||
-- luajit tests/engine/gen2_grass_encounter_tod_bug1389.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local DATA = {
|
||||
pokemon = {
|
||||
RATTATA = {
|
||||
name = "RATTATA", types = { "NORMAL", "NORMAL" },
|
||||
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
|
||||
specialAttack = 25, specialDefense = 35 },
|
||||
levelMoves = {},
|
||||
},
|
||||
GASTLY = {
|
||||
name = "GASTLY", types = { "GHOST", "POISON" },
|
||||
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
|
||||
specialAttack = 100, specialDefense = 35 },
|
||||
levelMoves = {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local function fullList(species)
|
||||
local slots = {}
|
||||
for i = 1, 7 do slots[i] = { species = species, level = 10 } end
|
||||
return slots
|
||||
end
|
||||
|
||||
-- data/wild/johto_grass.asm's own shape for a pinned tower: day is common
|
||||
-- and worthless, night is the whole reason the room exists. A second,
|
||||
-- separate map stands in for a PALETTE_NITE dungeon (Ilex Forest, Mt Moon):
|
||||
-- the rates are flipped so the two fixtures cannot agree by accident.
|
||||
local ENCOUNTERS = {
|
||||
grass = {
|
||||
SPROUT_TOWER_2F = {
|
||||
rates = { MORN = 0, DAY = 0, NITE = 256 },
|
||||
slots = {
|
||||
MORN = fullList("RATTATA"),
|
||||
DAY = fullList("RATTATA"),
|
||||
NITE = fullList("GASTLY"),
|
||||
},
|
||||
},
|
||||
ILEX_FOREST = {
|
||||
rates = { MORN = 256, DAY = 256, NITE = 0 },
|
||||
slots = {
|
||||
MORN = fullList("RATTATA"),
|
||||
DAY = fullList("RATTATA"),
|
||||
NITE = fullList("GASTLY"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local COLL_FLOOR = 0x00
|
||||
|
||||
local function fakeWorld(mapId, tod, daytime)
|
||||
local game = { data = DATA,
|
||||
save = { party = { { species = "RATTATA", level = 5 } } } }
|
||||
local world = World.new(game)
|
||||
world.map = {
|
||||
id = mapId,
|
||||
def = { environment = "DUNGEON" },
|
||||
cellCollision = function() return COLL_FLOOR end,
|
||||
}
|
||||
world.maps = { [mapId] = world.map.def }
|
||||
world.encounters = ENCOUNTERS
|
||||
world.player = { cellX = 5, cellY = 5, facing = "down" }
|
||||
-- World:applyPalettes writes both fields every load; a PALETTE_DAY tower
|
||||
-- pins `daytime` to DAY no matter the hour, while `tod` keeps tracking the
|
||||
-- clock (src/world/gen2/World.lua:8271-8326).
|
||||
world.tod = tod
|
||||
world.daytime = daytime
|
||||
local battled
|
||||
world.startBattle = function(_, opts)
|
||||
battled = opts.wild and opts.wild.species
|
||||
return true
|
||||
end
|
||||
return world, function() return battled end
|
||||
end
|
||||
|
||||
-- ---- PALETTE_DAY tower, at night: the clock says NITE, the pin says DAY --
|
||||
do
|
||||
local world, battled = fakeWorld("SPROUT_TOWER_2F", "NITE", "DAY")
|
||||
check(world:tryWildEncounter(), "the tower rolls at night despite the pin")
|
||||
eq(battled(), "GASTLY",
|
||||
"the night list wins because the lookup is the clock, not the pin")
|
||||
end
|
||||
|
||||
-- ---- the same tower at actual daytime: the clock and the pin now agree ---
|
||||
do
|
||||
local world, battled = fakeWorld("SPROUT_TOWER_2F", "DAY", "DAY")
|
||||
check(not world:tryWildEncounter(),
|
||||
"DAY's rate is zero, so a daytime step in the tower rolls nothing")
|
||||
eq(battled(), nil, "and nothing battled")
|
||||
end
|
||||
|
||||
-- ---- a PALETTE_NITE dungeon at actual noon: the pin says NITE, clock DAY --
|
||||
do
|
||||
local world, battled = fakeWorld("ILEX_FOREST", "DAY", "NITE")
|
||||
check(world:tryWildEncounter(),
|
||||
"a pinned-night map still rolls its day table at the clock's noon")
|
||||
eq(battled(), "RATTATA",
|
||||
"the day list wins because the lookup ignores the palette pin")
|
||||
end
|
||||
|
||||
T.finish("gen2 grass encounter tod bug1389")
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Gen 2 map bakes take dpiscale 1 so map pixels stay square LCD pixels
|
||||
-- (#208 #1301, constants/hardware.inc:932; see src/render/PixelCanvas.lua).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local g = love.graphics
|
||||
local seen = {}
|
||||
local realNewCanvas = g.newCanvas
|
||||
g.newCanvas = function(w, h, settings)
|
||||
seen[#seen + 1] = { w = w, h = h, dpiscale = settings and settings.dpiscale }
|
||||
local c = realNewCanvas(w, h)
|
||||
c.renderTo = function(_, fn) fn() end
|
||||
c.setFilter = function() end
|
||||
return c
|
||||
end
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
local MapPreview = require("src.world.gen2.MapPreview")
|
||||
|
||||
local atlas = {
|
||||
getDimensions = function() return 128, 128 end,
|
||||
setFilter = function() end,
|
||||
}
|
||||
local tileset = { blocks = { [1] = {} }, tilesPerRow = 16 }
|
||||
local map = { def = { tileset = "TILESET_JOHTO" }, width = 20, height = 18,
|
||||
blocks = {}, borderBlock = 0 }
|
||||
|
||||
local world = setmetatable({
|
||||
atlasCache = {},
|
||||
atlasFor = function() return atlas, tileset end,
|
||||
}, { __index = World })
|
||||
|
||||
World.bakeMapImage(world, map, nil, nil)
|
||||
local bakeCount = #seen
|
||||
T.check(bakeCount >= 1, "the map bake allocated a canvas")
|
||||
|
||||
World.scrollStrip(world, map.def, tileset, 3, { h = 1, v = 0 })
|
||||
T.check(#seen > bakeCount, "the scroll strip allocated a canvas")
|
||||
local stripCount = #seen
|
||||
|
||||
local origAtlasFor = MapPreview.atlasFor
|
||||
MapPreview.atlasFor = function() return atlas, tileset end
|
||||
MapPreview.bake({ tilesets = {}, atlasCache = {}, mapImages = {} }, map, "DAY")
|
||||
MapPreview.atlasFor = origAtlasFor
|
||||
T.check(#seen > stripCount, "the save-editor bake allocated a canvas")
|
||||
|
||||
for i, c in ipairs(seen) do
|
||||
T.eq(c.dpiscale, 1,
|
||||
("canvas %d (%dx%d) is allocated at dpiscale 1"):format(i, c.w, c.h))
|
||||
end
|
||||
|
||||
g.newCanvas = realNewCanvas
|
||||
T.finish("gen2 map bake dpi")
|
||||
@@ -468,6 +468,32 @@ do
|
||||
T.eq(forced.shiny, true, "opts.shiny still wins over shiny.roll")
|
||||
end)
|
||||
|
||||
-- Mon.syncIdentity (wired into refreshStats, which SummaryMenu.new calls
|
||||
-- on every menu open) used to recompute mon.shiny from DVs unconditionally,
|
||||
-- so opening the summary screen on a forced shiny -- one whose DVs do not
|
||||
-- happen to match the natural pattern -- un-shinied it the moment the menu
|
||||
-- opened. shiny is monotonic once true: a natural roll or a forced one
|
||||
-- both stay shiny through any later refresh, the way opts.shiny already
|
||||
-- wins at construction.
|
||||
do
|
||||
local forced = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs, shiny = true })
|
||||
T.eq(forced.shiny, true, "still shiny straight out of Mon.new")
|
||||
Mon.syncIdentity(forced, DATA)
|
||||
T.eq(forced.shiny, true, "syncIdentity does not clobber a forced shiny")
|
||||
Mon.refreshStats(forced, DATA)
|
||||
T.eq(forced.shiny, true,
|
||||
"refreshStats (SummaryMenu.new's call) does not either")
|
||||
|
||||
-- the natural cases are unaffected: DVs that read shiny stay shiny,
|
||||
-- DVs that do not stay plain
|
||||
local natural = Mon.new(DATA, "SEEDMON", 5, { dvs = shinyDvs })
|
||||
Mon.syncIdentity(natural, DATA)
|
||||
T.eq(natural.shiny, true, "a naturally shiny mon still reads shiny")
|
||||
local plain = Mon.new(DATA, "SEEDMON", 5, { dvs = plainDvs })
|
||||
Mon.syncIdentity(plain, DATA)
|
||||
T.eq(plain.shiny, false, "a plain mon is not promoted to shiny")
|
||||
end
|
||||
|
||||
local genderCtx
|
||||
withHook("gender.roll", function(nextFn, ctx)
|
||||
genderCtx = ctx
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Gold #DEX AREA page drew no nest markers or landmark name because
|
||||
-- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of
|
||||
-- the gen2Landmarks table Nests already resolves through (#1267).
|
||||
-- engine/pokegear/pokegear.asm:2427
|
||||
-- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.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 PokedexMenu = require("src.ui.gen2.PokedexMenu")
|
||||
local Nests = require("src.core.gen2.Nests")
|
||||
|
||||
-- one Johto landmark (index 5), one species that nests there
|
||||
local data = {
|
||||
gen2Encounters = {
|
||||
grass = {
|
||||
ROUTE_30 = { slots = { day = { { species = "RATTATA" } } } },
|
||||
},
|
||||
},
|
||||
gen2Maps = {
|
||||
ROUTE_30 = { landmark = 5 },
|
||||
},
|
||||
gen2Landmarks = {
|
||||
landmarks = {
|
||||
LANDMARK_ROUTE_30 = { index = 5, x = 40, y = 60, name = "ROUTE 30" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
-- sanity: Nests.landmark itself resolves the index (never broken, per the
|
||||
-- verifier) so a failure below is isolated to drawArea's own lookup
|
||||
eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30",
|
||||
"Nests.landmark resolves index 5 to the ROUTE 30 record")
|
||||
|
||||
-- capture what drawArea actually paints, without needing a real tile sheet
|
||||
-- or font: fill/blank/current/monName are stubbed on the instance, which
|
||||
-- Lua resolves before the PokedexMenu metatable's own methods.
|
||||
local function newSelf()
|
||||
local texts = {}
|
||||
local rects = {}
|
||||
local self = setmetatable({
|
||||
game = { save = {} },
|
||||
data = data,
|
||||
mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet
|
||||
areaRegion = "johto",
|
||||
areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase
|
||||
current = function() return { species = "RATTATA" } end,
|
||||
monName = function() return "RATTATA" end,
|
||||
fill = function() end,
|
||||
blank = function() end,
|
||||
text = function(_, str, tx, ty)
|
||||
texts[#texts + 1] = { str = str, tx = tx, ty = ty }
|
||||
end,
|
||||
}, { __index = PokedexMenu })
|
||||
return self, texts, rects
|
||||
end
|
||||
|
||||
local realRect = love.graphics.rectangle
|
||||
local self, texts, rects
|
||||
do
|
||||
self, texts, rects = newSelf()
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
|
||||
end
|
||||
self:drawArea()
|
||||
love.graphics.rectangle = realRect
|
||||
end
|
||||
|
||||
local function hasRect(x, y)
|
||||
for _, r in ipairs(rects) do
|
||||
if r.x == x and r.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2")
|
||||
|
||||
local function hasText(str)
|
||||
for _, t in ipairs(texts) do
|
||||
if t.str == str then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
check(hasText("ROUTE 30"), "the landmark name is printed on row 16")
|
||||
|
||||
T.finish("gen2 pokedex area landmark bug 1267")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user