Merge pull request #1904 from bryanthaboi/dev

big freaky style with a twist of skrrrrrrrrrrrt
This commit is contained in:
bryanthaboi
2026-08-28 12:34:28 -04:00
committed by GitHub
238 changed files with 29174 additions and 2436 deletions
+1 -1
View File
@@ -812,7 +812,7 @@ jobs:
else .commit.author.name end' 2>/dev/null \
| grep -viE '\[bot\]$' | sort -uf | sed 's/^/- /' || true)"
notes="Download the correct version for your computer below."
notes="Download the right version for your device below."
if [ -n "$closed" ]; then
notes+=$'\n\n## Issues closed\n\n'"$closed"
fi
+14 -4
View File
@@ -130,13 +130,23 @@ keeps validating with the value it has shipped since before the taxonomy
existed.
A translation may also set `"language": true` in the manifest. That is the
one claim online play acts on: an install running nothing but verified
translations may take an ONLINE MATCH or a TOURNAMENT instead of being
asked to restart vanilla. The claim is checked, not taken -- the mod
one claim online play acts on. Online rooms, spectating and tournaments live
in the launcher's ONLINE tab, and a match is played in an **arena boot**: the
game starts with the mod loader in `disableAll` (nothing runs) or `cartOnly`
(one sealed cart and nothing else), never in the enable state the player has
saved. A verified translation is the single exception `disableAll` carves
out, so an install running nothing but translations still enters rooms and
tournaments. The claim is checked, not taken -- the mod
qualifies only if every record it writes lands in `text`, `strings` or
`font`, it wraps no hook, subscribes to no event and requests no
permission. Anything else and it is an ordinary content mod that happens to
ship text.
ship text, and it simply does not load in an arena.
A mod is not shut out of link play by any of that: it just cannot ride the
launcher's rooms. `LinkState.newFromSession(game, transport, mode, isHost,
opts)` still adopts an already-paired transport of the mod's own and runs a
LAN-style battle or trade on it, hello and fingerprint check intact (see
`docs/modding.md`).
### 4. `games` (and the legacy `gen2compat`)
+13
View File
@@ -178,6 +178,19 @@ famous bugs included:
| `hyperBeamSkipRechargeOnKO` | Off: HYPER BEAM always recharges, like Gen 2+ |
| `randMin` / `randMax` | Damage random factor 217-255, same as faithful |
## Online play
The launcher has an **ONLINE** tab. Connect once and you get a lobby of who
else is around, with what game and what rules: host a battle or join one,
watch any live match or tournament as a spectator, run a bracket where
everyone not playing watches the match that is on, or trade Pokemon between
save files, yours or someone else's. Picking a battle opens the game
straight into it, no intro and no overworld, and drops you back in the tab
when it ends. Every room names exactly what it runs, so both sides are on
the same engine, the same version and the same ruleset: vanilla, or a sealed
custom cart that both players have installed. The in-game LINK menu is still
there and is still local network only.
## Running From Source
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
+44 -49
View File
@@ -782,7 +782,7 @@ end
-- buying again means talking to the counter again (#623).
local function prizeCounter(window)
return function(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local PrizeCounter = require("src.ui.PrizeCounter")
local Commands = require("src.script.Commands")
local TextBox = require("src.render.TextBox")
local t = game.data.text
@@ -793,28 +793,25 @@ local function prizeCounter(window)
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
local rows = {}
for _, p in ipairs(prizeWindow(window)) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
rows[#rows + 1] = {
name = (p.kind == "mon")
and game.data.pokemon[p.species].name
or game.data.items[p.item].name,
cost = p.cost,
prize = p,
}
end
local function close()
game.stack:pop()
game.stack:pop()
end
-- NoThanksText (data/events/prizes.asm) sits under the three prizes
table.insert(items, { label = "NO THANKS" })
local list
-- close the window first: every ending in HandlePrizeChoice leaves
-- the menu for good, and the closing line belongs over the map
local function finish(msg)
list:close()
close()
game.stack:push(TextBox.new(game, msg, done))
end
local function buy(p)
@@ -844,40 +841,38 @@ local function prizeCounter(window)
game.save.coins = game.save.coins - p.cost
-- no thank-you line: HereYouGoText is unreferenced in the asm,
-- which just redraws the coin box (PrintPrizePrice) and returns
list:close()
close()
done()
end
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice)
list:close()
done()
return
end
local name = (p.kind == "mon")
and game.data.pokemon[p.species].name
or game.data.items[p.item].name
-- SoYouWantPrizeText names the prize out of wNameBuffer, which
-- is not one of TextBox's RAM tokens, so fill it in here
local ask = (t._SoYouWantPrizeText
or "So, you want\n{RAM:wNameBuffer}?")
:gsub("{RAM:wNameBuffer}", name)
game.stack:push(TextBox.new(game, ask, nil, {
choice = function(yes)
if not yes then
finish(t._OhFineThenText or "Oh, fine then.")
return
end
buy(p)
end,
}))
end,
onCancel = done,
})
game.stack:push(list)
end))
game.stack:push(TextBox.new(game,
t._WhichPrizeText or "Which prize do\nyou want?", nil, {
instant = true,
stay = { onShown = function()
game.stack:push(PrizeCounter.new(game, rows, {
onCancel = function()
close()
done()
end,
onPick = function(row)
local p = row.prize
local ask = (t._SoYouWantPrizeText
or "So, you want\n{RAM:wNameBuffer}?")
:gsub("{RAM:wNameBuffer}", row.name)
game.stack:push(TextBox.new(game, ask, nil, {
instant = true,
choice = function(yes)
if not yes then
finish(t._OhFineThenText or "Oh, fine then.")
return
end
buy(p)
end,
}))
end,
}))
end },
}))
end, { instant = true }))
end
end
+74 -20
View File
@@ -391,30 +391,84 @@ local DRINK_PRICES = {
{ id = "LEMONADE", price = 350 },
}
-- engine/events/vending_machine.asm:56-65
local function deliveryRumble(game, onDone)
local left, wait = 60, 2
return {
draw = function() end,
update = function(self)
wait = wait - 1
if wait > 0 then return end
wait = 2
require("src.core.Sound").play(game.data, "Push_Boulder")
left = left - 1
if left <= 0 then
game.stack:pop()
onDone()
end
end,
}
end
-- engine/events/vending_machine.asm
local function vendingMachine(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local t = text(game)
local Menu = require("src.ui.Menu")
local Font = require("src.render.Font")
local money = function() return game.save.money end
local function closeSession(msg, menuPopped)
if not menuPopped then game.stack:pop() end
game.stack:pop()
push(game, msg, done, { money = money })
end
local function notThirsty()
closeSession(t._VendingMachineText7 or "Not thirsty!", true)
end
local function buy(d)
if game.save.money < d.price then
closeSession(t._VendingMachineText4 or "Oops, not enough\nmoney!")
return
end
if not require("src.inventory.Bag").add(game.save, d.id, 1, game.data) then
closeSession(t._VendingMachineText6 or "There's no more\nroom for stuff!")
return
end
game.stack:push(deliveryRumble(game, function()
game.save.money = game.save.money - d.price
closeSession(fill(t._VendingMachineText5
or "{RAM:wStringBuffer}\npopped out!",
{ ram = game.data.items[d.id].name }))
end))
end
local items = {}
for _, d in ipairs(DRINK_PRICES) do
table.insert(items, {
value = d, label = ("%s ¥%d"):format(game.data.items[d.id].name, d.price),
})
items[#items + 1] = {
label = game.data.items[d.id].name,
keepOpen = true,
onSelect = function() buy(d) end,
}
end
game.stack:push(ListMenu.new(game, "VENDING MACHINE", items, {
onChoose = function(item, list)
local d = item.value
if game.save.money < d.price then
push(game, "Not enough\nmoney.")
return
end
if not require("src.inventory.Bag").add(game.save, d.id, 1) then
push(game, "You have no room\nfor it!")
return
end
game.save.money = game.save.money - d.price
push(game, ("%s\npopped out!"):format(game.data.items[d.id].name))
end,
onCancel = done,
}))
items[#items + 1] = { label = "CANCEL", onSelect = notThirsty }
push(game, t._VendingMachineText1 or "A vending machine!\nHere's the menu!",
nil, {
money = money,
stay = { prompt = true, onShown = function()
local menu = Menu.new(game, items, {
tx = 0, ty = 3, tw = 14, th = 10, itemY = 2,
noWrap = true,
onCancel = notThirsty,
})
menu.draw = function(self)
Menu.draw(self)
love.graphics.setColor(0, 0, 0, 1)
for i, d in ipairs(DRINK_PRICES) do
Font.draw(("¥%d"):format(d.price), 9 * 8, (6 + (i - 1) * 2) * 8)
end
love.graphics.setColor(1, 1, 1, 1)
end
game.stack:push(menu)
end },
})
end
-- drink -> TM (CeladonMartRoof.asm .gaveFreshWater/.gaveSodaPop/
+42
View File
@@ -61,6 +61,48 @@ the same core data and graphics into the source tree for verification.
| ui | `src/ui/*` | start menu, generic menu, yes/no box, party/bag lists |
| | `tools/save-editor/` | Save editor: shipped in every build, opened from the launcher's Edit button or standalone with `love . --editor` |
## Online play
Online play is owned by the launcher, not by a running game. `src/online/`
holds one persistent relay connection for the life of the process
(`main.lua` pumps it every frame, whether the launcher or a game is on
screen), and a battle is run by **arena booting** the game: no splash, no
title, no overworld, just the lockstep battle, then straight back to the
launcher with the room still selected. In-game link (`src/link/LinkState.lua`)
stays as it was and is LAN only. The relay lives in its own repo,
`../pokeserver`; `docs/link-security.md` describes protocol v2 and what it
does and does not guarantee.
- `src/online/Client.lua` - process singleton: connection, heartbeat,
reconnect with session resume, the inbox, and the local model (presence,
room, match, tournament). `Client.roomSession()` hands `LinkBattle` the
same shape a LAN `Session` does.
- `src/online/Protocol2.lua` - relay protocol v2 message builders; the
matching schemas live with the v1 ones in `src/link/Wire.lua`.
- `src/online/ArenaData.lua` - computes an ArenaProfile (engine, version,
engine/api version, fingerprint, ruleset, vanilla or sealed cart) headless
by mounting a version's cache, and compares two profiles
(`equal`, `describeMismatch`).
- `src/online/ArenaBoot.lua` - the ArenaSpec: profile, role, slot, team,
seed, parties, session, `onDone`; plus the battle options a spec turns
into.
- `src/online/TeamPick.lua` - headless slot read, rule validation and party
packing. `src/online/Convert.lua` - Gen 1 <-> Gen 2 mon conversion with
Time Capsule refusals. `src/online/Trade.lua` - launcher-side trade with a
two-file commit. `src/online/OnlineSprites.lua` - the cached party icons
and front sprites those pickers draw.
- Boot path: `main.lua` `bootGame(version, cartId, { arena = spec })` ->
`Game:load` / `Game2:load` skip the intro and push
`src/ui/ArenaState.lua` or `src/ui/gen2/ArenaState.lua`, which build
`src/link/LinkBattle.lua` (Gen 1) or `src/link/LinkBattle2.lua` (Gen 2),
host, guest or spectator, and return the result.
- Mods in an arena: `Loader:load(data, { mode = ... })` runs `disableAll`
(verified translations only) for a vanilla arena and `cartOnly` for a
sealed-cart one, without touching the player's saved enable state.
- `src/import/OnlinePanel.lua` plus `src/import/online/` are the launcher's
ONLINE tab: a small stack of screens (home, play, setup, room, watch,
tournaments, trade) drawn from `Client`, `ArenaData` and `TeamPick`.
## Map scripts
Map-specific behavior lives in `data/scripts/<map>.lua`, keyed by the
+249 -45
View File
@@ -4,6 +4,106 @@ Link play is the only part of this game that reads bytes written by
somebody else. This is what it defends against, what it does not, and
where each guarantee lives.
## Where link play lives now
Two separate things, one wire stack:
- **In-game link is LAN only.** `src/link/LinkState.lua` is the START menu /
Cable Club path over ENet on the local network. It has no relay, no room
codes and no bracket: the in-game ONLINE and TOURNAMENT rows are gone and
`src/link/Tournament.lua` no longer exists.
- **Online play lives in the launcher**, over relay protocol v2 against
`../pokeserver`. `src/online/Client.lua` holds one persistent TCP
connection for the life of the process (`main.lua` pumps it every frame,
whether the launcher or a booted game is on screen). Presence, rooms,
spectators and tournaments are objects addressed inside that one
connection. A tournament is a room of rooms: the relay creates each
bracket match as an ordinary v2 room and attaches every non-playing
entrant to it as a spectator.
- A battle never runs in the launcher. The launcher **arena boots** the
game (`src/online/ArenaBoot.lua` -> `Game:load` / `Game2:load` ->
`src/ui/ArenaState.lua` or `src/ui/gen2/ArenaState.lua`), which skips
splash, title and overworld, runs `LinkBattle` / `LinkBattle2` on the room
session, and returns to the launcher with the room still selected.
## The arena profile
The compatibility contract is one table, computed in the launcher without
booting a game (`src/online/ArenaData.lua`), attached to every lobby entry
and room, and checked twice:
| Field | What it pins |
| --- | --- |
| `engine` | 1 or 2: which battle engine the room runs |
| `version` | which generated cache boots (`red` ... `crystal`) |
| `engineVersion`, `apiVersion` | exact string match, no skew |
| `fingerprint` | `Fingerprint.compute` over the *arena* dataset |
| `rulesetId` | the host-dealt rulebook (see below) |
| `kind` | `vanilla` (all mods off) or `cart` (a sealed cart) |
| `cart` | `{ id, version, hash }`, the `CartManifest.hash` of that cart |
| `rule` | party size and level bounds, not part of the identity |
**The relay enforces it on join.** `room_join` walks
`PROFILE_MATCH_FIELDS` (`../pokeserver/relay.js:66`) in order -- `engine`,
`version`, `engineVersion`, `fingerprint`, `rulesetId`, `kind`, `cart.hash`
-- and refuses the first field that differs with
`join_error { reason: "profile_mismatch", field, detail }`. `room_ready` runs
the party against `rule` the same way and answers `party_ineligible`.
Spectators are held to the profile too: a spectator runs the same engine to
render the battle.
**The client checks it before it will join or boot.** The ONLINE tab runs
`ArenaData.equal` (everything except `rule`) against its own profile for
every lobby row and greys out the ones it cannot play, with
`ArenaData.describeMismatch` naming the first differing field so the reason
is *what* differs rather than "incompatible"
(`src/import/OnlinePanel.lua`). The battle it then boots is constructed
with `strict = true` (`ArenaBoot.battleOpts`), so a mon that cannot be
rebuilt from real species data refuses the battle instead of being
approximated into a desync.
A `vanilla` arena boots the mod loader in `disableAll` mode and a `cart`
arena in `cartOnly` (`Loader.ARENA_MODES`, `src/mods/Loader.lua:618`).
Neither reads or writes `options.safeMode` or `options.modsByVersion`, so an
arena never disturbs the player's own enable state. `disableAll` keeps
verified translations and only those: `_arenaDisableAll` keeps a mod that
declares `language = true`, `affects_link = false` and no permissions, and
`_arenaVerifyTranslations` then re-checks the survivors through
`Handshake.onlineBlockers` and rolls back anything that does not qualify.
`cartOnly` refuses to boot when the cart plan is not `enforced` or the
slot's seal is broken; `sealed+` and `open` carts are refused outright,
because a player-togglable pin set is not a fixed identity.
## Desync: the ruleset and the RNG counter
Nearly every reported desync was turn 1 or 2 with **identical engine
versions on both sides**. The cause was local: `BattleState` picked its
ruleset from `game.save.options.ruleset`, an ordinary OPTIONS row, and
`gen1_faithful` spends one RNG draw on the 1/256 miss that `modern_clean`
does not. Both machines share one Park-Miller stream, so a single skipped
draw offsets it permanently and the very next number is the damage factor.
The fix is three parts, all of them in the wire:
- The **host deals the ruleset**. `LinkBattle` takes `opts.ruleset` and
overwrites `self.ruleset` from it the way it already overwrote `rng`
(`src/link/LinkBattle.lua:275`, spectator at `:824`). In an arena that id
comes from the profile.
- The hello **carries** it (`Handshake.ruleset`, `src/link/Handshake.lua:180`)
and `checkCompat` refuses a mismatch as `ruleset_skew`, so a LAN pairing
cannot start split either.
- The rulesets registry is part of the **Gen 1 fingerprint surface**
(`src/link/Fingerprint.lua:319`), so a retuned rulebook is caught even when
both sides name the same id.
The other half of the diagnosis was that the state signature could not see a
draw-count split, so a desync was blamed on the wrong turn. The RNG draw
counter is now hashed into the `actives` component
(`src/link/LinkBattle.lua:196`; Gen 2 stamps it on the battle at
`src/link/LinkBattle2.lua:256` and hashes it in
`src/battle/gen2/Battle.lua:5173`), so the two sides disagree on the exact
turn the streams part.
## The boundary
Everything a peer or the relay sends arrives as one JSON object per line.
@@ -21,6 +121,11 @@ right by construction. A message with no schema (a mod's, or a future
build's) keeps a bounded, scalar-only copy of its payload instead of
being dropped.
The v2 types go through the same door: `SCHEMAS.lobby_*`, `room_*` and
`tour_*` live beside the v1 ones in `Wire.lua`, and `room_msg` sanitizes its
inner lockstep message as well as its envelope, so `Client.roomSession()`
hands `LinkBattle` the same shape a LAN `Session` does.
A message that fails its schema is **dropped and logged**, never fatal.
Latching a terminal failure would hand a hostile peer a cheaper
disconnect than sending nothing at all.
@@ -38,17 +143,22 @@ it without a Session (the mod API, `tests/`).
Assume something still gets through:
- `Game:step` pcalls the link pump, and pcalls `stack:update` **only
while a link session is active**. On a throw, `Game:breakLink` closes
the connection, unwinds to the overworld and says "The link was
broken." Outside link play the stack is unguarded on purpose: a blanket
pcall would swallow real engine bugs and leave the game silently wrong
instead of loudly broken.
- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer
that never sends a newline ends as a clean disconnect.
- `Json.decode` refuses documents nested past 64 levels, and takes an
optional length cap that the link path passes and the mod-manifest path
does not.
- `Game:step` pcalls the link pump, and xpcalls `stack:update` **only
while a link session is active** (`src/core/Game.lua:329`). A throw
reaches `Game:breakLink` (`:276`), which closes the connection, unwinds
to the overworld and says so. Transport failure and a caught Lua error are
distinguished on purpose: the player is told "The link was broken." for
one and "Something broke during the link." for the other, and the log
line differs, so an engine bug inside a link battle no longer reads as a
network fault. Outside link play the stack is unguarded on purpose: a
blanket pcall would swallow real engine bugs and leave the game silently
wrong instead of loudly broken.
- `Net` caps a line at 256KB (`Net.MAX_LINE`) and its per-frame read at
512KB (`Net.MAX_RX_PER_FRAME`), so a peer that never sends a newline ends
as a clean disconnect.
- `Json.decode` refuses documents nested past 64 levels (`Json.MAX_DEPTH`),
and takes an optional length cap that the link path passes and the
mod-manifest path does not.
## The relay (`../pokeserver`)
@@ -57,48 +167,106 @@ Assume something still gets through:
`server.js` installs `uncaughtException`/`unhandledRejection` handlers:
one bad packet must never take every live match down with the process.
- Line buffers are capped, lines per second are capped, connections per
IP and in total are capped, and an unbound connection that never hosts
or joins is swept after 30s.
- `SERVER_ONLY` is the set of message types the server is the only
legitimate author of (`peer_gone`, `bracket_update`, `match_start`,
`tournament_over`, `spectate`, ...). A peer that sends one has them
dropped rather than forwarded, so a bracket opponent cannot forge a
tournament result or fake "your opponent left".
- Trainer names are reduced to a printable subset and capped at the same
10 characters the game enforces, on the way in, because they are
IP and in total are capped, and an unbound connection that never hosts,
joins or binds as a lobby is swept after 30s.
- `SERVER_ONLY` (`relay.js:76`) is the set of message types the server is
the only legitimate author of. It covers the v1 names (`peer_gone`,
`bracket_update`, `match_start`, `tournament_over`, `spectate`, ...) and
every v2 one (`lobby_welcome`, `lobby_list`, `lobby_delta`, `room_state`,
`room_replay`, `room_deadline`, `room_result`, `room_closed`, `tour_state`,
`tour_match`, `tour_match_spectate`, `tour_bye`, `tour_deadline`,
`tour_over`, `tour_closed`). A peer that sends one has it dropped rather
than forwarded, so a bracket opponent cannot forge a result or fake "your
opponent left".
- `room_msg` only carries an inner `msg.type` from a fixed set (`hello`,
`party`, `action`, `hash`, `replace`, `bye`, `forfeit`), only from a
seated player, and only while the room is `battling`. Its `seq` must
rise, so a resumed client's replayed tail is idempotent.
- Names, notes, room codes and profile strings are reduced to a printable
subset and capped on the way in (10 characters for a trainer name, 16 for
a lobby display name, 40 for an advertisement note), because they are
rendered by the dashboard and broadcast to every participant.
`pokeserver/test/hostile.js` is the regression net for all of that.
### Identity: tickets
Going online verified takes a ticket, not a credential.
`POST /lobby/ticket` on the HTTPS port (`lobby.js`) authenticates with the
usual sync headers, mints 32 hex characters bound to
`{ account, displayName }`, valid for 60 seconds and **single use**, and the
client presents that on the plaintext relay in `lobby_hello`. The relay
redeems it in process (both modules live in `server.js`) and never sees the
device token: nothing on port 7778 can be replayed into an account. The
ticket store is memory only, so a restart invalidates every outstanding
ticket and clients simply mint another. A client with no ticket still
connects and still plays, as a guest, listed `verified: false`.
### Heartbeat, resume and the replay bound
- `ping`/`pong` runs both ways. The relay pings a connection idle for 20s
and drops one idle for 60s, on top of TCP keepalive; the client's own
heartbeat interval arrives in `lobby_welcome`.
- A dropped socket keeps its seat for **2 minutes**. A new connection sends
`resume { session, ack }` and gets its room, its match and the traffic it
missed back. `room_replay` also names `yourSeq`, the highest `seq` the
relay logged from that client, so the client re-sends only its own lost
tail.
- The room's message log is bounded at **512 messages or 256 KiB**,
whichever comes first, since the last `room_ready` pair. A spectator that
arrives while the log is intact gets `room_replay` and then live traffic;
one that arrives after the bound already dropped messages is refused with
`spectate_late` rather than being fed a battle it cannot reconstruct.
## What is NOT defended
**Party legality is trust-the-client.** Online play meets strangers, and
**Party legality is trust-the-client.** Online rooms, spectating and
tournaments live in the launcher, where play meets strangers, and
`Handshake.onlineAllowed` is a Lua function in the same VM the mods load
into. It cannot be made tamper-proof in-process, and pretending otherwise
would only cost honest mod authors. What lockstep and
`Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that
a cheater cannot invent stats, moves, or a shiny: every derived value is
rebuilt locally from real species data. They can send a legal party they
farmed or edited. That is the honest boundary.
rebuilt locally from real species data. The relay checks party size and
level bounds against the room's `rule`, and that is the only rule it
enforces. A player can still send a legal party they farmed or edited.
That is the honest boundary.
What the relay does instead is **observe and record**. It already sees
every `hello`, so it keeps each connection's self-reported
`engineVersion`, `fingerprint` and `linkModified`, compares the two sides
of a room or a live tournament match, and logs and surfaces a
`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard.
A patched client can still lie; what it cannot do is lie without the
tournament organizer having a record of it.
**Match results are trust-the-client too**, and this is where the relay
stopped being silent. Both sides report; agreement resolves the match, a
lone report waits out a grace window, a lone forfeit resolves at once, and
a stalled match resolves on its deadline. Two **disagreeing** reports are
counted as a dispute (`totals.disputes` and the room's or tournament's own
counter, both in the dashboard snapshot), logged with both sides' reports
verbatim, and settled **deterministically**, never by coin flip: a forfeit
or a gone player decides it, otherwise the earliest report wins
(`roomTiebreak`, `relay.js:1761`). The outcome carries `how` --
`reported`, `agreed`, `forfeit`, `timeout`, `disputed`, `disconnect` or
`closed` -- so a tournament organizer can see which matches were argued.
The same is true of the arena profile. A patched client can lie about its
fingerprint or its cart hash; what it cannot do is lie without the relay
having a record of it, since the relay sees every profile and compares the
two sides of every room. A sealed cart's hash proves the manifest, the
pins' sha256 prove the archives, and the fingerprint proves the merged
data -- against an honest client.
Client-side attestation is deliberately not built. This is an
open-source Lua game: it would be theater, and it would break honest
mods.
**The relay has no TLS.** Port 7778 is plaintext, so party contents,
trades and trainer names are visible to anyone on the network path. There
is nothing secret in a Pokemon party, but it is a real property of the
system and not an oversight. Fixing it means a TLS terminator in front of
the relay and a client that speaks it, which is a version break for every
shipped build.
trades and trainer names are visible to anyone on the network path. Tickets
keep account credentials off it (above), and there is nothing secret in a
Pokemon party, but it is a real property of the system and not an
oversight. Fixing it means a TLS terminator in front of the relay and a
client that speaks it, which is a version break for every shipped build.
**Presence is not durable.** The lobby, every room and every outstanding
ticket live in the relay process. A restart clears them and clients
reconnect and re-advertise within one heartbeat. Nothing about a match is
persisted, so nothing about a match can be audited after the fact beyond
the log lines.
**The dashboard has no default password.** `DASHBOARD_PASSWORD` is
required; with it unset the relay runs and the dashboard simply does not
@@ -107,14 +275,50 @@ IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`).
## Tests
luajit tests/link_hostile.lua every message type x every wrong type
luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode
luajit tests/run_link_tests.lua both of the above, plus the rest
cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile
luajit tests/link_hostile.lua v1 message types x every wrong type
luajit tests/link_desync_fuzz.lua fuzz: baseline, mutation, ruleset split
luajit tests/run_link_tests.lua the above, plus online_client and link2_*
cd ../pokeserver && npm test relay smoke, brackets, lobby, tour
`tests/link_hostile.lua` builds its corpus from a template per message
type, replaces each field (and several nested ones) with every wrong Lua
type, and drives the survivors through the real trade session, a real
lockstep battle, a real spectator battle, and the tournament screen
**including its draw** -- because the two nastiest payloads are
delayed-fuse ones that crash on render rather than on receipt.
What guards which claim:
- **Ruleset desync**: `run_link_tests.lua` asserts the draw-count difference
between `gen1_faithful` and `modern_clean` directly, then runs a loopback
battle where the guest's own `modern_clean` OPTIONS row is overridden by
the host's dealt ruleset. `link_desync_fuzz.lua`'s ruleset-split mode
deliberately configures the two sides differently and fails if the two
`LinkBattle`s ever end up holding different rulebooks.
- **Wire hostility**: `link_hostile.lua` builds a corpus from a template per
v1 message type, replaces each field (and several nested ones) with every
wrong Lua type, and drives the survivors through the real trade session, a
real lockstep battle and a real spectator battle, **including their draws**
-- because the two nastiest payloads are delayed-fuse ones that crash on
render rather than on receipt. The v2 envelopes are covered on the client
side by `tests/online_client.lua`, which sanitizes oversized `lobby_list`,
`room_replay`, `room_state` and `tour_state` payloads and asserts a stream
of malformed server messages is counted and dropped without throwing or
changing connection state.
- **Client, rooms, resume, tournaments**: `tests/online_client.lua` (run from
`run_link_tests.lua`) covers the handshake, lobby, advertising, rooms,
`match_start`, the room session, reconnect and resume replay, spectating,
results, a full tournament run and the lockstep path, against a fake relay
and, when a socket library is present, a real one.
- **Gen 2 lockstep**: `tests/link2_lockstep.lua` and
`tests/link2_desync_fuzz.lua`.
- **Relay**: `../pokeserver/test/lobby.js` (bind, presence, rooms,
spectating, the lockstep stream, deadlines, resume, a real HTTP ticket
from a real sync account, and a v1 room in the same process),
`test/tour.js` (a 5-player v2 bracket with byes, child rooms, spectator
fan-out, the server-side shot clock, disconnects and creator handoff),
`test/hostile.js`, plus `test/smoke.js`, `test/keepalive.js`,
`test/tournament16.js` and `test/tournament5.js` for the v1 path.
- **End to end**: the LÖVE drivers. `tests/drivers/online_relay_smoke.lua`
runs two `Client`s through a real pokeserver over TCP: room create, join,
ready, a full Gen 1 `LinkBattle` over `roomSession()`, both reports and the
relay's `room_result`. `tests/drivers/online_tour_smoke.lua` runs five
clients through a v2 tournament including a bye, an outside spectator, a
mid-battle socket kill with resume and replay, and a forfeit.
`tests/drivers/arena_boot_loopback.lua` and
`arena_boot_gen2_loopback.lua` (with their `*_spec.lua` payloads) boot
straight into an arena battle, play it against a headless guest and
screenshot the battle and the launcher it returns to.
+18 -5
View File
@@ -676,6 +676,17 @@ the same letter raises a second event.
| `phone.contact_list` | `Phone`'s `wPhoneList` read | called `(save, list)`, the shape the other list hooks use | the same list |
| `shiny.roll` | `Mon` | `dvs`, `species`, `def`, `level` | the DV-derived boolean |
| `gender.roll` | `Mon` | `def`, `dvs`, `ratio`, `species`, `level` | the DV-derived gender |
| `battle.enemy_switch_or_item` | `Battle:enemyTrySwitchOrItem` | called `(battle)` | `true` when the foe spent the turn rotating or drinking |
`battle.enemy_switch_or_item` is the companion to the shared
`battle.enemy_action`: that one rewrites which MOVE the foe picks, this one
decides whether the foe spends the whole turn on a rotation or an item instead
of moving at all. Red has no such branch, which is why the name is new. Return
a boolean to answer "the turn was spent" the way vanilla does, or an action
table -- `{ kind = "switch", index = n }` or `{ kind = "item", item = id }` --
to have the engine perform it. A link battle supplies both sides' actions
directly (`Battle:takeLinkTurn`) and consults neither this hook nor
`battle.enemy_action`, so a mod cannot desync a lockstep match through either.
`held_item.trigger` is one hook over eight call sites, because on the cart
those eight *are* one routine (`GetUserItem` / `GetOpponentItem` loading b and
@@ -737,7 +748,9 @@ change in the Gen 2 module first and a routing row second:
- `field`: the Gen 1 overworld's data grab bag. Gold's equivalents live in
`data.gen2Maps` and the VM's own tables.
- `text_pointers`: Gen 1's `TEXT_*` indirection. Gold's text *is* pointers.
- `link_fields`: link play is Gen 1 only.
- `link_fields`: gated until the Gen 2 mon wire format carries mod fields;
Gen 2 link battles exist (launcher arenas over `src/link/LinkBattle2.lua`)
but ship no extra mon fields yet.
- `map_scripts`: `data.gen2Scripts` is the cart's bytecode pool keyed by ROM
pointer, and a Lua row list merged into it is not something
`src/script/gen2/Vm.lua` can run. Routing it needs a Gen 2 side dispatcher in
@@ -796,10 +809,10 @@ The list is much shorter than it was. What is outstanding, in descending value:
when that selection is required.
- `pokemon.before_give` / `pokemon.received`: Gold has no give-mon seam of its
own yet.
- `link.*` and `trade.completed`: a Gold boot offers no link menu at all. The
Gen 2 fingerprint and handshake exist (`src/link/Fingerprint.lua` hashes a
Gen 2 surface and a cross-generation pairing is refused by name), but nothing
in `src/ui/gen2/` opens onto the protocol, so these raise nowhere.
- `link.*` and `trade.completed`: a Gold boot offers no in-game link menu.
Gen 2 battles run as launcher arenas (`src/ui/gen2/ArenaState.lua` over
`src/link/LinkBattle2.lua`), which raise `link.battle_ended`; trades happen
in the launcher, so `trade.completed` still raises nowhere in Gold.
Four groups that used to sit here have since landed and moved to the shared
table above: the frame seams (`render.compose` / `render.hud` /
+3 -1
View File
@@ -7,7 +7,8 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Multiple color modes**, including original, SGB, advanced GBC, monochrome, and classic green
* **Optional GBC screen effects**, including pixel grids, shadows, glare, and transparency
* **Performance presets** and configurable FPS limits
* **Peer-to-peer link play** for trades and battles between Red, Blue, and Yellow
* **Peer-to-peer LAN link play** for trades and battles between Red, Blue, and Yellow
* **Online lobby** in the launcher for battles, spectating and tournaments
* **Persistent custom options** stored separately from game saves
* **Optional widescreen battle layout**
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
@@ -25,3 +26,4 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
* **Pokémon Crystal** as an importable, launcher-selectable version alongside Gold and Silver
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
* **Followers** for mods, plus Gen 2-only registries and hooks
* **Battle screen options** on Gold, Silver and Crystal: BATTLE SIZE (fixed or window-filling) and BATTLE BG (white or black surround)
+57 -10
View File
@@ -126,6 +126,17 @@ local Game, EditorApp, Importer, TouchEditor, Studio
local launchedIntoGame = false
local RELAUNCH_MARKER = "relaunch_to_launcher.txt"
local onlineClient, onlineClientResolved
local function onlineClientModule()
if onlineClientResolved then return onlineClient end
onlineClientResolved = true
local ok, mod = pcall(require, "src.online.Client")
if ok and type(mod) == "table" and type(mod.update) == "function" then
onlineClient = mod
end
return onlineClient
end
local autopilot -- optional scripted-input dev tool (tests/autopilot.lua)
local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a
-- coroutine that receives `Game` and yields once per
@@ -357,15 +368,16 @@ function closeSkinStudio()
end
end
local function makeLauncher()
local function makeLauncher(launcherOpts)
local RomImporter = require("src.import.RomImporter")
local forceImport = os.getenv("POKEPORT_FORCE_IMPORT") == "1"
return RomImporter.new(function(version, cartId)
return RomImporter.new(function(version, cartId, opts)
Importer = nil
bootGame(version, cartId)
bootGame(version, cartId, opts)
end, {
launcher = true,
forceImport = forceImport,
initialTab = launcherOpts and launcherOpts.initialTab or nil,
onEditSave = openEditor,
onEditTouchControls = openTouchControlsEditor,
-- Skin Studio owns a touch-first layout as well as the desktop workspace.
@@ -375,13 +387,14 @@ local function makeLauncher()
})
end
local function returnToLauncher()
local function returnToLauncher(opts)
if not Game then return end
local GameVersion = require("src.core.GameVersion")
local currentVersion = GameVersion.get()
SessionLifecycle.endGameSession(Game)
Game = nil
pcall(function() require("src.online.Trade").hostIsLive = nil end)
autopilot = nil
driverCo = nil
-- Leave the cart's scope behind: the launcher's own settings and slots are
@@ -403,10 +416,16 @@ local function returnToLauncher()
love.window.setTitle(Version.title("Gen 1 Recompilation Project"))
end
Importer = makeLauncher()
Importer = makeLauncher({ initialTab = opts and opts.tab or nil })
end
function bootGame(version, cartId)
local pendingLauncherReturn
function bootGame(version, cartId, opts)
opts = opts or {}
pcall(function()
require("src.online.Trade").hostIsLive = function() return true end
end)
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
-- Set the active version and overlay its extracted cache BEFORE anything
@@ -453,9 +472,14 @@ function bootGame(version, cartId)
-- own service owner, which mounts src/world/gen2 (walk / warps /
-- connections) and the Gen 2 screens instead of src/core/Game.lua's Gen 1
-- wiring.
local arena = opts.arena
local loadOpts = { arena = arena, cartId = cartId }
if GameVersion.generation() == 2 then
Game = require("src.core.Game2").new()
Game:load()
if arena then
Game.returnToLauncher = function(o) pendingLauncherReturn = o or {} end
end
Game:load(loadOpts)
else
-- Gen1 Game is a module singleton. Always re-require after in-process
-- EXIT GAME so a prior session cannot leave a table whose rawget(load)
@@ -467,7 +491,10 @@ function bootGame(version, cartId)
error("src.core.Game missing load after reload")
end
Game = gameMod
Game:load()
if arena then
Game.returnToLauncher = function(o) pendingLauncherReturn = o or {} end
end
Game:load(loadOpts)
if os.getenv("POKEPORT_AUTOPILOT") then
autopilot = require("tests.autopilot")
end
@@ -562,6 +589,18 @@ function love.load(args)
local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER")
or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or importPath ~= nil
local scriptedOpts = nil
local specPath = os.getenv("POKEPORT_ARENA_SPEC")
if specPath and os.getenv("POKEPORT_DRIVER") then
local chunk, chunkErr = loadfile(specPath)
if not chunk then error("POKEPORT_ARENA_SPEC: " .. tostring(chunkErr)) end
local spec = chunk()
if type(spec) ~= "table" then
error("POKEPORT_ARENA_SPEC must return an ArenaSpec table")
end
scriptedOpts = { arena = spec }
end
if scripted then
if forceImport or not ready then
-- The importer detects the dropped/loaded ROM's version by SHA-1 and
@@ -572,12 +611,12 @@ function love.load(args)
return
end
Importer = nil
bootGame(version or scriptedVersion)
bootGame(version or scriptedVersion, nil, scriptedOpts)
end)
if importPath then Importer:startPath(importPath) end
return
end
bootGame(scriptedVersion)
bootGame(scriptedVersion, nil, scriptedOpts)
return
end
@@ -640,6 +679,14 @@ function love.update(dt)
if editorMode then return EditorApp.update(dt) end
if TouchEditor then return TouchEditor.update(dt) end
if Studio then return Studio.update(dt) end
local client = onlineClientModule()
if client then pcall(client.update, dt) end
if pendingLauncherReturn then
local opts = pendingLauncherReturn
pendingLauncherReturn = nil
returnToLauncher(opts)
return
end
if Importer then return Importer:update(dt) end
if not Game then return end
@@ -45,6 +45,7 @@ import android.Manifest;
import android.app.AlarmManager;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.app.UiModeManager;
import android.content.Context;
import android.content.ClipData;
import android.content.DialogInterface;
@@ -53,6 +54,7 @@ import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager;
import android.content.res.Configuration;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
@@ -112,6 +114,7 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag";
private static final String PICK_CANCELLED_PREFIX = "cancelled:";
// Written after a direct required-import copy has been fully published.
private static final String PICK_COMPLETE_FILENAME = "pick_complete.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
@@ -566,19 +569,37 @@ public class GameActivity extends SDLActivity {
* is. The picked file (if any) arrives later in onActivityResult, not
* synchronously here.
*
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT
* chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20
* the stock DocumentsUI is unreliable -- it launches and then hands back
* RESULT_CANCELED with no data, which onActivityResult cannot tell apart
* from the player cancelling (#584). GET_CONTENT lets any installed file
* manager serve the pick, and both intents return the same content:// or
* file:// URI shapes, so the result path in onActivityResult stays
* picker-agnostic and unchanged.
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 and television devices use
* an ACTION_GET_CONTENT chooser instead. Below 19 OPEN_DOCUMENT does not
* exist, and on 19/20 the stock DocumentsUI is unreliable -- it launches
* and then hands back RESULT_CANCELED with no data, which onActivityResult
* cannot tell apart from the player cancelling (#584); Android TV ships no
* DocumentsUI at all and behaves the same way (#1535). GET_CONTENT lets any
* installed file manager serve the pick, and both intents return the same
* content:// or file:// URI shapes, so the result path in onActivityResult
* stays picker-agnostic and unchanged.
*
* @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav, or
* picked_required_import.bin)
*/
private static boolean isTelevision(Context context) {
if (context == null) return false;
try {
UiModeManager modes =
(UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
if (modes != null
&& modes.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) {
return true;
}
} catch (Exception e) {
Log.d("GameActivity", "could not read ui mode: " + e.getMessage());
}
PackageManager packages = context.getPackageManager();
return packages != null
&& packages.hasSystemFeature(PackageManager.FEATURE_LEANBACK);
}
private static boolean isDirectRequiredDestination(String relative) {
if (relative == null || relative.length() == 0 || relative.startsWith("/")) return false;
String normalized = relative.replace('\\', '/');
@@ -635,7 +656,7 @@ public class GameActivity extends SDLActivity {
}
self.pendingPickFilename = normalizedDest;
if (android.os.Build.VERSION.SDK_INT >= 21) {
if (android.os.Build.VERSION.SDK_INT >= 21 && !isTelevision(self)) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
@@ -1499,6 +1520,16 @@ public class GameActivity extends SDLActivity {
outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName);
}
private static Uri pickedUri(int resultCode, Intent data) {
if (resultCode != RESULT_OK || data == null) return null;
if (data.getData() != null) return data.getData();
ClipData clip = data.getClipData();
if (clip != null && clip.getItemCount() > 0) {
return clip.getItemAt(0).getUri();
}
return null;
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
@@ -1523,19 +1554,22 @@ public class GameActivity extends SDLActivity {
return;
}
if (requestCode != FILE_PICKER_REQUEST_CODE) return;
if (resultCode != RESULT_OK || data == null || data.getData() == null) {
final String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
Uri uri = pickedUri(resultCode, data);
if (uri == null) {
Log.d("GameActivity", "file picker returned no file (cancelled?)");
if (isTelevision(this)) {
writeSaveDirFlag(PICK_ERROR_FILENAME, PICK_CANCELLED_PREFIX + destName);
}
return;
}
Uri uri = data.getData();
File destDir = saveIdentityDir();
if (!destDir.exists() && !destDir.mkdirs()) {
Log.d("GameActivity", "could not create " + destDir);
writeSaveDirFlag(PICK_ERROR_FILENAME, destName);
return;
}
final String destName = pendingPickFilename != null
? pendingPickFilename : PICKED_ROM_FILENAME;
final boolean directRequired = isDirectRequiredDestination(destName);
final File destFile;
try {
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+6
View File
@@ -401,6 +401,12 @@ function AnimPlayer:start(moveId, attackerIsPlayer, opts)
self.stepIndex, self.stepLeft = 1, 0
self.elapsed, self.eventCursor = 0, 1
-- animations.asm:452-473 (#1881)
if not attackerIsPlayer then
if moveId == "AMNESIA" then moveId = "CONF_ANIM"
elseif moveId == "REST" then moveId = "SLP_ANIM" end
end
local anim = self.data and self.data.moveAnims and self.data.moveAnims[moveId]
if not anim then
self:warnOnce("move:" .. tostring(moveId),
+10 -1
View File
@@ -1426,6 +1426,14 @@ function BattleState:updateQueue()
self.waitFrames = Timing.MOVE_ANIM_PRE
return true
end
-- animations.asm:431-437
if item.anim and not item.animOffDelayed
and not self:animationsOn() and not BALL_ANIMS[item.anim] then
item.animOffDelayed = true
table.insert(self.queue, 1, item)
self.waitFrames = Timing.MOVE_ANIM_OFF
return true
end
local mdef = item.anim and self.data.moves[item.anim]
local anim = mdef and mdef.anim
if item.anim == "POOF_ANIM" then
@@ -4906,7 +4914,8 @@ function BattleState:openReplacementMenu()
end
self:restoreMimicked(self.player)
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
self.player = makeBattler(self.data, mon, true,
self.kind ~= "link" and game.save or nil)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
+8 -3
View File
@@ -457,9 +457,14 @@ function Runner:step()
if first and first.index ~= 0 then first.yOffset = self.bg.rolloutYOffset end
end
if self.stopped then
-- BattleAnim_ClearOAM: unless the script asked to keep them, every object
-- goes at the end.
if not self.keepSprites then self.objects.oam = {} end
-- engine/battle_anims/anim_commands.asm:213
if not self.keepSprites then
self.objects.oam = {}
else
for _, obj in ipairs(self.objects.oam) do
obj.palette = "PAL_BATTLE_OB_ENEMY"
end
end
return false
end
return true
+296 -58
View File
@@ -885,8 +885,13 @@ function Battle:orderOf(playerMove, enemyMove)
local playerClaw = playerEffect == "HELD_QUICK_CLAW"
local enemyClaw = enemyEffect == "HELD_QUICK_CLAW"
if playerClaw and enemyClaw then
if rand(self.random, 256) < enemyParam then return "enemy" end
if rand(self.random, 256) < playerParam then return "player" end
if self.mirrored then
if rand(self.random, 256) < playerParam then return "player" end
if rand(self.random, 256) < enemyParam then return "enemy" end
else
if rand(self.random, 256) < enemyParam then return "enemy" end
if rand(self.random, 256) < playerParam then return "player" end
end
elseif playerClaw then
if rand(self.random, 256) < playerParam then return "player" end
elseif enemyClaw then
@@ -897,7 +902,9 @@ function Battle:orderOf(playerMove, enemyMove)
if playerSpeed ~= enemySpeed then
return playerSpeed > enemySpeed and "player" or "enemy"
end
return rand(self.random, 2) == 0 and "player" or "enemy"
local playerFirst = rand(self.random, 2) == 0
if self.mirrored then playerFirst = not playerFirst end
return playerFirst and "player" or "enemy"
end
-- Gen 2 priority moves. data/moves/effects_priorities.asm keys off the move
@@ -1377,6 +1384,7 @@ Battle.AI_FAIL_STATUSES = {
-- engine/battle/effect_commands.asm:3615
function Battle:aiRandomFail(attacker, defender)
if self.linkBattle then return false end
if self:sideOf(attacker) ~= "enemy" then return false end
if self:volatile(defender).lockOn then return false end
return rand(self.random, 256) < 64
@@ -3260,6 +3268,11 @@ function Battle:resolveFaints()
end
if (self.enemy.hp or 0) <= 0 then
if self.linkBattle and self.enemyFaintAnnounced == self.enemy then
self.faintInterrupt = true
return false
end
self.enemyFaintAnnounced = self.enemy
self:emit({ kind = "faint", side = "enemy",
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
.. " fainted!" })
@@ -3287,6 +3300,11 @@ function Battle:resolveFaints()
self:endBattle("win")
return true
end
if self.linkBattle then
self.pendingEnemySwitch = true
self.faintInterrupt = true
return false
end
local previous = self.enemy
self:clearVolatile(self.enemy)
self.enemyIndex = nextIndex
@@ -3579,6 +3597,7 @@ end
-- IsAnyMonHoldingExpShare's `cp EXP_SHARE` does, and a fainted holder gets
-- nothing (the pass loop skips fainted mons).
function Battle:awardExperience(loser)
if self.linkBattle then return self:resetParticipants() end
local def = self:speciesDef(loser)
local participants = {}
@@ -4183,6 +4202,25 @@ end
-- a trainer's class decides how eager it is. Returns true when the turn was
-- spent on the switch or the item.
function Battle:enemyTrySwitchOrItem()
if not Runtime.wantsHook("battle.enemy_switch_or_item") then
return Battle.vanillaEnemySwitchOrItem(self)
end
local chosen = Runtime.call("battle.enemy_switch_or_item", function(battle)
return Battle.vanillaEnemySwitchOrItem(battle)
end, self)
if type(chosen) == "table" then
if chosen.kind == "switch" then
return self:switchEnemy(tonumber(chosen.index) or 0)
end
if chosen.kind == "item" then
return self:enemyUseItem(chosen.item)
end
return false
end
return chosen and true or false
end
function Battle.vanillaEnemySwitchOrItem(self)
if self.wild or not self.trainer then return false end
local attributes = self.trainer.attributes
if type(attributes) ~= "table" then return false end
@@ -4229,39 +4267,7 @@ function Battle:enemyTrySwitchOrItem()
})
if not trapped and target
and Ai.shouldSwitch(attributes, score, self.random) then
self:clearVolatile(self.enemy)
-- AI_Switch prints EnemyWithdrewText BEFORE it farcalls EnemySwitch
-- (engine/battle/ai/items.asm:685), so a rotation announces the mon
-- coming OFF the field as well as the one coming on; without it a
-- trainer swapping between two of the same species looked like nothing
-- had happened. The line is skipped only when Pursuit hit the mon on
-- its way out, which this port has no analogue for yet.
local outgoing = self.enemy
self:emit({ kind = "message",
text = (self.trainer.name or "TRAINER") .. " withdrew "
.. self:monName(outgoing) .. "!" })
self.enemyIndex = target
self.enemy = self.enemyParty[target]
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears
-- Battle:switch makes for the player's side.
self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = (self.trainer.name or "TRAINER") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
previous = outgoing,
})
self:breakTrapsOnSend(self.enemy)
self:spikesDamage(self.enemy)
return true
return self:switchEnemy(target)
end
-- AI_TryItem: only the trainer's highest-level mon is worth an item.
@@ -4277,9 +4283,42 @@ function Battle:enemyTrySwitchOrItem()
status = self.enemy.status,
enemyTurns = self:volatile(self.enemy).turnsTaken or 0,
})
return self:enemyUseItem(item)
end
-- (engine/battle/ai/items.asm:685), so a rotation announces the mon coming
function Battle:switchEnemy(index)
local mon = self.enemyParty and self.enemyParty[index]
if not mon or (mon.hp or 0) <= 0 or mon == self.enemy then return false end
self:clearVolatile(self.enemy)
local outgoing = self.enemy
local trainerName = (self.trainer and self.trainer.name) or "TRAINER"
self:emit({ kind = "message",
text = trainerName .. " withdrew " .. self:monName(outgoing) .. "!" })
self.enemyIndex = index
self.enemy = mon
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = trainerName .. " sent out " .. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
previous = outgoing,
})
self:breakTrapsOnSend(self.enemy)
self:spikesDamage(self.enemy)
return true
end
function Battle:enemyUseItem(item)
if not item then return false end
-- Consume it, so a trainer with one Potion cannot drink it every turn.
for index, id in ipairs(self.trainer.items or {}) do
for index, id in ipairs((self.trainer and self.trainer.items) or {}) do
if id == item then table.remove(self.trainer.items, index) break end
end
local heal = Ai.HEAL_ITEMS[item]
@@ -4294,8 +4333,9 @@ function Battle:enemyTrySwitchOrItem()
self.enemy.status = nil
self:volatile(self.enemy).confuseCount = nil
end
self:emit({ kind = "message", text = (self.trainer.name or "TRAINER")
.. " used " .. item .. "!" })
self:emit({ kind = "message",
text = ((self.trainer and self.trainer.name) or "TRAINER")
.. " used " .. item .. "!" })
return true
end
@@ -4409,7 +4449,7 @@ end
-- { kind = "item", item = <id>, target = n } (handled by the caller, which
-- applies the effect and then calls this with kind = "item" so the enemy
-- still gets its turn)
local function runTurn(self, action)
local function runTurn(self, action, enemyAction)
if self.over then return self:takeEvents() end
self.turn = self.turn + 1
action = action or { kind = "move" }
@@ -4458,9 +4498,24 @@ local function runTurn(self, action)
if action.kind == "item" then self:cancelBide(self.player) end
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
-- to rotate or drink a potion spends its whole turn on it.
local enemyActed = self:enemyTrySwitchOrItem()
local enemyMoveId = (not enemyActed) and self:enemyMove() or nil
local enemyActed, enemyMoveId
if enemyAction then
if enemyAction.kind == "switch" then
self:switchEnemy(tonumber(enemyAction.index) or 0)
enemyActed = true
elseif enemyAction.kind == "item" then
self:enemyUseItem(enemyAction.item)
enemyActed = true
elseif enemyAction.kind == "move" then
enemyActed = false
enemyMoveId = enemyAction.move
else
enemyActed = true
end
else
enemyActed = self:enemyTrySwitchOrItem()
enemyMoveId = (not enemyActed) and self:enemyMove() or nil
end
-- battle.turn_started, where BattleState:resolveTurn raises it on Gen 1:
-- once both sides have chosen and before either acts. Gen 1's action tables
@@ -4483,6 +4538,8 @@ local function runTurn(self, action)
local playerFirst
if action.kind == "skip" or action.kind == "item" then
playerFirst = true
elseif self.linkBattle and enemyActed then
playerFirst = false
elseif Runtime.wantsHook("battle.turn_order") then
-- battle.turn_order, the same hook BattleState:resolveTurn calls on Gen 1
-- and with the same five arguments: both battlers, both move records, and
@@ -4504,6 +4561,7 @@ local function runTurn(self, action)
local function playerAttack()
if action.kind ~= "move" then return end
if self.linkBattle and (self.player.hp or 0) <= 0 then return end
local move = action.move
-- An encored mon has no choice, whatever the menu said.
local forced = self:forcedMove(self.player)
@@ -4564,6 +4622,32 @@ local function runTurn(self, action)
-- TryEnemyFlee sits here in both of the cart's turn orders, ahead of the
-- enemy's move and behind the faint checks.
if self:tryEnemyFlee() then return end
if self.linkBattle then
local forced = self:forcedMove(self.enemy)
if forced then enemyMoveId = forced end
local stored = self:volatile(self.enemy).chargeMove
if stored then enemyMoveId = stored end
if not self:canAct(self.enemy, enemyMoveId) then return end
local charging = stored == enemyMoveId
or self:lockedInMove(self.enemy) == enemyMoveId
local bideLocked = self:fightLockedMove(self.enemy) == enemyMoveId
if not charging and not bideLocked
and not self:hasUsableMoves(self.enemy) then
self:emit({ kind = "message",
text = self:monName(self.enemy) .. " has no moves left!" })
enemyMoveId = Battle.STRUGGLE
end
if not enemyMoveId then enemyMoveId = Battle.STRUGGLE end
if self:moveDisabled(self.enemy, enemyMoveId) then
local state = self:volatile(self.enemy)
state.chargeMove, state.vanished = nil, nil
self:emit({ kind = "message", text = self:monName(self.enemy)
.. "'s " .. enemyMoveId .. " is DISABLED!" })
return
end
self:useMove(self.enemy, self.player, enemyMoveId)
return
end
if not enemyMoveId then
-- `.struggle` (engine/battle/core.asm:5630-5632) sets STRUGGLE and
-- finishes silently: BattleText_MonHasNoMovesLeft is text_ram
@@ -4640,22 +4724,24 @@ local function runTurn(self, action)
-- weather, then status chip and the Leech Seed / Curse residuals, then
-- the wrap ticks, then held items, then Future Sight and Perish Song,
-- then the screens and the per-turn counters.
local firstMon, secondMon = self.player, self.enemy
if self.mirrored then firstMon, secondMon = self.enemy, self.player end
self:tickWeather()
self:tickStatus(self.player)
self:tickSeedAndCurse(self.player)
self:tickStatus(self.enemy)
self:tickSeedAndCurse(self.enemy)
self:tickWrap(self.player)
self:tickWrap(self.enemy)
self:tickHeldItem(self.player)
self:tickHeldItem(self.enemy)
self:tickFutureSight(self.player)
self:tickFutureSight(self.enemy)
self:tickPerish(self.player)
self:tickPerish(self.enemy)
self:tickStatus(firstMon)
self:tickSeedAndCurse(firstMon)
self:tickStatus(secondMon)
self:tickSeedAndCurse(secondMon)
self:tickWrap(firstMon)
self:tickWrap(secondMon)
self:tickHeldItem(firstMon)
self:tickHeldItem(secondMon)
self:tickFutureSight(firstMon)
self:tickFutureSight(secondMon)
self:tickPerish(firstMon)
self:tickPerish(secondMon)
self:tickScreens()
self:tickCounters(self.player)
self:tickCounters(self.enemy)
self:tickCounters(firstMon)
self:tickCounters(secondMon)
self:resolveFaints()
return self:takeEvents()
end
@@ -4666,7 +4752,15 @@ end
-- was already over) opened nothing and so closes nothing, which is what keeps
-- the two events paired the way Gen 1's endOfTurn keeps them.
function Battle:takeTurn(action)
local events = runTurn(self, action)
return self:closeTurn(runTurn(self, action))
end
function Battle:takeLinkTurn(playerAction, enemyAction)
return self:closeTurn(runTurn(self, playerAction,
enemyAction or { kind = "skip" }))
end
function Battle:closeTurn(events)
if self.turnOpen then
self.turnOpen = nil
if Runtime.wants("battle.turn_ended") then
@@ -4941,6 +5035,150 @@ function Battle:tickHeldItem(mon)
end
end
function Battle:forcedReplacement(side, index)
index = tonumber(index)
if side == "enemy" then
local party = self.enemyParty or {}
local mon = index and party[index]
if not mon or (mon.hp or 0) <= 0 or mon.isEgg then
index = Battle.firstHealthy(party)
mon = index and party[index]
end
if not mon then return false end
self.pendingEnemySwitch = nil
local previous = self.enemy
self:clearVolatile(previous)
self.enemyIndex = index
self.enemy = mon
self:clearVolatile(mon)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = mon, replacement = true,
hp = mon.hp or 0, status = mon.status or false,
level = mon.level, experience = mon.experience,
text = ((self.trainer and self.trainer.name) or "Foe") .. " sent out "
.. self:monName(mon) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(mon), battler = mon,
previous = previous,
})
self:breakTrapsOnSend(mon)
self:spikesDamage(mon)
return true
end
local mon = index and self.party[index]
if not mon or (mon.hp or 0) <= 0 or mon.isEgg then
index = Battle.firstHealthy(self.party)
end
if not index then return false end
return self:switch(index)
end
Battle.LINK_STAGES = { "attack", "defense", "speed", "specialAttack",
"specialDefense", "accuracy", "evasion" }
Battle.LINK_VOLATILE = {
"bideStored", "bideTurns", "chargeMove", "confuseCount", "curled", "cursed",
"disabled", "disabledTurns", "encore", "encoreTurns", "endure", "flinched",
"focusEnergy", "futureSight", "futureSightDamage", "futureSightSide",
"identified", "lastMove", "leechSeed", "lockOn", "mist", "perish", "protect",
"protectCount", "rage", "rampCount", "rampMove", "rampageMove",
"rampageTurns", "recharge", "rolloutLock", "substitute", "tookThisTurn",
"transformed", "trapsTarget", "turnsTaken", "vanished", "wrapCount",
"wrapMoveId", "xAccuracy",
}
Battle.LINK_SCREENS = { "lightScreen", "reflect", "safeguard" }
local function linkOff(v)
return v == nil or v == false or v == 0
end
local function linkScalar(v)
if type(v) == "table" then return tostring(v.id or v.move or "?") end
if type(v) == "boolean" then return v and "T" or "F" end
return tostring(v)
end
local function linkPp(mon)
local out = {}
for i, mv in ipairs((mon and mon.moves) or {}) do
out[i] = ("%s=%s"):format(tostring(mv.id), tostring(mv.pp or 0))
end
return table.concat(out, ",")
end
local function linkStages(self, key)
local stages = (self.stages and self.stages[key]) or {}
local out = {}
for i, stat in ipairs(Battle.LINK_STAGES) do
out[i] = tostring(stages[stat] or 0)
end
return table.concat(out, ",")
end
local function linkActive(self, mon, key)
if not mon then return "-" end
return ("%s:%d:%s:%s:%s:%s"):format(tostring(mon.species), mon.hp or 0,
tostring(mon.status or false), linkStages(self, key), linkPp(mon),
tostring(mon.item or "-"))
end
local function linkVolatile(mon)
if not mon then return "-" end
local state = mon.volatile or {}
local out = {}
for _, field in ipairs(Battle.LINK_VOLATILE) do
if not linkOff(state[field]) then
out[#out + 1] = field .. "=" .. linkScalar(state[field])
end
end
if not linkOff(mon.statusTurns) then
out[#out + 1] = "statusTurns=" .. tostring(mon.statusTurns)
end
if not linkOff(mon.toxicCounter) then
out[#out + 1] = "toxicCounter=" .. tostring(mon.toxicCounter)
end
return table.concat(out, ",")
end
local function linkSide(self, key)
local screens = (self.screens and self.screens[key]) or {}
local out = { ((self.spikes or {})[key] and "spikes" or "-") }
for _, field in ipairs(Battle.LINK_SCREENS) do
out[#out + 1] = field .. "=" .. tostring(screens[field] or 0)
end
return table.concat(out, ",")
end
local function linkBench(party)
local out = {}
for i, mon in ipairs(party or {}) do
out[i] = ("%s:%d:%s:%s"):format(tostring(mon.species), mon.hp or 0,
tostring(mon.status or false), tostring(mon.item or "-"))
end
return table.concat(out, "|")
end
function Battle:linkSignature(role)
local hostIsPlayer = role ~= "guest"
local hostKey = hostIsPlayer and "player" or "enemy"
local guestKey = hostIsPlayer and "enemy" or "player"
local hostMon = hostIsPlayer and self.player or self.enemy
local guestMon = hostIsPlayer and self.enemy or self.player
local hostParty = hostIsPlayer and self.party or self.enemyParty
local guestParty = hostIsPlayer and self.enemyParty or self.party
return {
actives = linkActive(self, hostMon, hostKey) .. "|"
.. linkActive(self, guestMon, guestKey)
.. "|r" .. tostring(self.rngDraws or 0),
volatile = linkVolatile(hostMon) .. "|" .. linkVolatile(guestMon)
.. "|" .. linkSide(self, hostKey) .. "|" .. linkSide(self, guestKey)
.. "|w" .. tostring(self.weather or "-")
.. ":" .. tostring(self.weatherTurns or 0),
bench = linkBench(hostParty) .. "|" .. linkBench(guestParty),
}
end
Battle.Damage = Damage
Battle.Mon = Mon
+24 -20
View File
@@ -410,6 +410,11 @@ end
local function locationName(game, mapId)
if not mapId then return nil end
local world = game and game.world
if world and world.landmarkName then
local ok, name = pcall(world.landmarkName, world)
if ok and type(name) == "string" and name ~= "" then return name end
end
local field = game and game.data and game.data.field
local townMap = field and field.townMap
local locations = townMap and (townMap.locations or townMap)
@@ -602,14 +607,14 @@ local function subscribe(game)
end)
end
-- Advertise (or clear, with code=nil) an online-match/tournament code as a
-- Discord join secret. LinkState/Tournament call this once they have a
-- real code from the relay, and clear it again once paired/started or the
-- hosting screen exits (an invite that's already full or gone is worse
-- than no invite). kind is "match" (default) or "tournament"; size/max are
-- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a
-- tournament passes its live roster count and a generous cap instead, and
-- should call this again whenever the roster changes, not just once.
-- Advertise (or clear, with code=nil) a room code as a Discord join
-- secret. The launcher's ONLINE tab calls this once the relay has given it
-- a real code, and clears it again once the room fills or closes (an invite
-- that's already full or gone is worse than no invite). kind is "match"
-- (default) or "tournament"; size/max are the party.size Discord shows
-- (default 1/2, a plain 1v1 room) -- a tournament passes its live roster
-- count and a generous cap instead, and should call this again whenever the
-- roster changes, not just once.
function DiscordPresence.setJoinCode(code, kind, size, max)
pcall(function()
state.joinCode = code
@@ -632,23 +637,22 @@ local function handleJoinRequest(secret)
if not secret or secret == "" then return end
if state.activity == "battle" then return end
local game = state.game
if not game or not game.stack then return end
local top = game.stack:top()
local top = game and game.stack and game.stack:top()
if top and top.stage and top.net then return end -- already in a link session
local kindTag, code = secret:match("^(%a):(.+)$")
if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match
Runtime.emit("discord.join_requested", { code = code, kind = kindTag })
if kindTag == "t" then
local ok, Tournament = pcall(require, "src.link.Tournament")
if ok and Tournament.newJoinOnline then
game.stack:push(Tournament.newJoinOnline(game, code))
end
else
local ok, LinkState = pcall(require, "src.link.LinkState")
if ok and LinkState.newJoinOnline then
game.stack:push(LinkState.newJoinOnline(game, code))
end
if game and not game.returnToLauncher then
print("[discord] no launcher to return to; ignoring join code " .. tostring(code))
return
end
local ok, Client = pcall(require, "src.online.Client")
if not ok or type(Client) ~= "table" or not Client.joinRoom then
print("[discord] online client unavailable; ignoring join code " .. tostring(code))
return
end
if game then game.returnToLauncher({ tab = "online", joinCode = code }) end
Client.joinRoom(code, "player")
end
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
+1
View File
@@ -24,6 +24,7 @@ local function commandOutput(command)
if not pipe then return nil end
local result = pipe:read("*a")
HostShell.pclose(pipe)
HostShell.pumpHostEvents()
result = trim(result)
return result ~= "" and result or nil
end
+48 -8
View File
@@ -32,7 +32,9 @@ local function bootScreens(game)
return (boot and boot.screens) or {}
end
function Game:load()
function Game:load(opts)
opts = opts or {}
local arena = opts.arena
self.data = Data
self.sessionStartedAt = os.time()
Data:load()
@@ -42,7 +44,15 @@ function Game:load()
-- the rest of the game consumes. A broken mod is reported and skipped by
-- the loader without preventing the base game from booting.
self.mods = ModLoader.new()
self.mods:load(Data)
if arena then
self.mods:load(Data, {
mode = (arena.profile and arena.profile.kind == "cart")
and "cartOnly" or "disableAll",
cartId = opts.cartId,
})
else
self.mods:load(Data)
end
self.modStatus = self.mods:status()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
@@ -102,7 +112,9 @@ function Game:load()
-- boot into the title screen (engine/movie/title.asm); NEW GAME runs
-- the Oak speech + naming, CONTINUE restores the save. The headless
-- autopilot skips straight into the overworld.
if os.getenv("POKEPORT_AUTOPILOT") then
if arena then
self:enterArena(arena)
elseif os.getenv("POKEPORT_AUTOPILOT") then
StateStack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y, self.save.player.facing)
else
@@ -176,6 +188,28 @@ function Game:startNewGame(opts)
end
end
function Game:enterArena(spec)
local version = require("src.core.GameVersion").get()
if spec.slotId then pcall(SaveData.setActiveSlot, version, spec.slotId) end
local loaded = SaveData.load()
if loaded then
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
self.saveReport = SaveData.validate(loaded, self.data)
self.save = loaded
loaded.startMenuIndex = nil
self.startMenuIndex = nil
self:adoptSave(loaded)
self:applyOptions(loaded.options)
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
else
Logger.warn("arena: save slot %s could not be loaded", tostring(spec.slotId))
end
self.linkSession = true
StateStack:push(require("src.ui.ArenaState").new(self, spec))
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
@@ -239,8 +273,12 @@ function Game:touchSkinHotkey(action, pressed)
end
end
function Game:breakLink(err)
Logger.error("link: torn down after an error\n%s", tostring(err))
function Game:breakLink(err, source)
if source == "engine" then
Logger.error("link: engine error, link torn down\n%s", tostring(err))
else
Logger.error("link: torn down after an error\n%s", tostring(err))
end
self.linkSession = nil
local net = self.linkNet
self.linkNet = nil
@@ -255,7 +293,9 @@ function Game:breakLink(err)
pcall(function()
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
stack:push(TextBox.new(self, Strings("The link was\nbroken.")))
stack:push(TextBox.new(self, source == "engine"
and Strings("Something broke\nduring the link.")
or Strings("The link was\nbroken.")))
end)
end
@@ -288,7 +328,7 @@ function Game:step(dt)
if self.linkNet and not self.linkNet.closed then
local ok, err = pcall(self.linkNet.update, self.linkNet)
if not ok then
self:breakLink(err)
self:breakLink(err, "transport")
return
end
end
@@ -296,7 +336,7 @@ function Game:step(dt)
local ok, err = xpcall(function() self.stack:update(dt) end,
function(e) return debug.traceback(tostring(e), 2) end)
if not ok then
self:breakLink(err)
self:breakLink(err, "engine")
return
end
else
+69 -14
View File
@@ -173,6 +173,7 @@ function Game2.new()
options = Save.loadOptions(),
}, Game2)
self.save.options = self.options
self.sessionStartedAt = os.time()
anchorNewGameClock(self.save)
return self
end
@@ -205,6 +206,12 @@ function Game2:adoptSave(save, seedBuckets)
loader.modSave = save.modData
end
function Game2:enterArena(spec)
self.phase = "boot"
self.stack:clear()
self.stack:push(require("src.ui.gen2.ArenaState").new(self, spec))
end
function Game2:startWorld()
if self.world and self.world.map then
self.phase = "play"
@@ -238,6 +245,7 @@ end
function Game2:newGame()
self.save = Save.newGame({ playerName = self.save.player.name })
self.save.options = self.options
self.sessionStartedAt = os.time()
-- InitClock re-anchors this the moment the player answers Oak; the default
-- only has to hold for a run that skips the screen.
anchorNewGameClock(self.save)
@@ -275,6 +283,7 @@ function Game2:continueGame(save)
SaveData.runMigrations(save, self.mods and self.mods.migrations, activeMods)
local modsDiff = SaveData.modsDiff(save, activeMods)
self.save = save
self.sessionStartedAt = os.time()
self:adoptSave(save)
-- Editor species swaps used to leave mon.name on the previous species.
-- CONTINUE rewrites party, boxes, and Day-Care copies from the live record.
@@ -876,17 +885,41 @@ function Game2:writeSave()
return false
end
local save = self:snapshotSave()
-- The snapshot is complete, so this payload carries exactly the table the
-- file gets; mods stash runtime state into their own keys now. `meta` is
-- the Gen 1 key, absent rather than renamed: a Gold save stamps no meta
-- block yet (see save.loaded in continueGame).
save.meta = SaveData.buildMeta(
self.modStatus and self.modStatus.loaded, save.meta, self.sessionStartedAt)
if ModRuntime.wants("save.writing") then
ModRuntime.emit("save.writing", { save = save, meta = save.meta })
end
return Save.save(save)
local written, err = Save.save(save)
if written then
local eng = self:syncEngine()
if eng then pcall(eng.noteSaveWritten, eng) end
end
return written, err
end
function Game2:load()
function Game2:syncEngine()
if self._syncOff then return nil end
local eng = self._syncEngineRef
if not eng then
local ok, SyncEngine = pcall(require, "src.sync.SyncEngine")
if not ok or type(SyncEngine) ~= "table" then
self._syncOff = true
return nil
end
eng = SyncEngine.shared()
if not eng then
self._syncOff = true
return nil
end
self._syncEngineRef = eng
end
return eng
end
function Game2:load(opts)
opts = opts or {}
local arena = opts.arena
Input:init()
-- Before applyOptions, which is what pushes options.touchControls into it:
-- init() decides whether the platform wants the overlay at all and loads the
@@ -990,13 +1023,18 @@ function Game2:load()
-- self.data and the ones without report instead of silently vanishing. The
-- whole thing is behind a pcall so a mod problem can never cost Gold its
-- boot.
local modOpts = arena and {
mode = (arena.profile and arena.profile.kind == "cart")
and "cartOnly" or "disableAll",
cartId = opts.cartId,
} or nil
local ok, loader = pcall(function()
local mods = require("src.mods.Loader").new()
-- the live service owner, before load: mod.world and mod.input resolve
-- through this, and without it the facade would bind to the Gen 1
-- src/core/Game.lua singleton that a Gold boot never loads
mods.game = self
mods:load(self.data)
mods:load(self.data, modOpts)
return mods
end)
if ok and loader then
@@ -1067,12 +1105,15 @@ function Game2:load()
-- a mod to hold). Emitted where Gen 1 emits it -- every service up, the
-- stack still empty -- so a listener that pushes a state lands underneath
-- the boot cinema rather than being buried by it.
pcall(function() require("src.core.DiscordPresence").init(self) end)
ModRuntime.emit("game.ready", { game = self })
-- Drivers that walk the overworld skip boot cinema so smoke stays stable.
-- POKEPORT_BOOT_CINEMA=1 opts back in, which is how the boot-chain driver
-- exercises copyright -> title -> intro menu -> Oak -> naming.
if os.getenv("POKEPORT_DRIVER")
if arena then
self:enterArena(arena)
elseif os.getenv("POKEPORT_DRIVER")
and os.getenv("POKEPORT_BOOT_CINEMA") ~= "1" then
self:startWorld()
else
@@ -1166,6 +1207,7 @@ function Game2:update(dt)
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
-- they are presentational, so fast-forward must not speed them up.
require("src.render.Pipelines").update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
-- audio runs off its own real-time accumulator, so music and sfx keep their
-- tempo at every multiplier. speedOverride is the driver/CLI hook and wins
@@ -1569,6 +1611,23 @@ function Game2:drawContained(w, h)
if not ok then error(err, 0) end
end
local function panelBlit(stack, w, h)
local states = stack and stack.states or {}
for i = #states, 1, -1 do
local state = states[i]
if state then
if state.battlePanelScale then
local scale = state:battlePanelScale(w, h)
if scale then return scale, Chrome.fitOrigin(w, h, scale) end
end
if state.drawsWidescreen and state:drawsWidescreen() then break end
end
end
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
return scale, ox, oy
end
-- BATTLE BG (#1709): the surround around the centred GB screen, taken from
-- whichever state in the stack owns a battle.
function Game2:paintBattleSurround(w, h)
@@ -1580,8 +1639,7 @@ function Game2:paintBattleSurround(w, h)
end
if mode ~= "black" then return end
local G = love.graphics
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
local scale, ox, oy = panelBlit(self.stack, w, h)
local pw, ph = 160 * scale, 144 * scale
G.setColor(0, 0, 0, 1)
if oy > 0 then G.rectangle("fill", 0, 0, w, oy) end
@@ -1663,10 +1721,7 @@ function Game2:drawScene(w, h)
self:paintBattleSurround(w, h)
self:letterbox(w, h, false)
if wide ~= top then
-- The pushed box blits at the same integer fit the widescreen layer
-- used, or it lands on a different grid than the panel underneath it.
local scale = Chrome.fitScale(w, h)
local ox, oy = Chrome.fitOrigin(w, h, scale)
local scale, ox, oy = panelBlit(self.stack, w, h)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
+5
View File
@@ -213,6 +213,11 @@ function HostShell.pclose(pipe)
withPopenLock(function() pcall(function() pipe:close() end) end)
end
function HostShell.pumpHostEvents()
if not (love and love.event and love.event.pump) then return end
pcall(love.event.pump)
end
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
-- boot in-process, which calls love.filesystem.init a second time -- and inside
-- an AppImage physfs is already initialized, so that second init throws
+1 -1
View File
@@ -140,7 +140,7 @@ end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
if not version or version == "0.0.0" or version:match("^0%.0%.0%-dev") then return "" end
return version
end
+2
View File
@@ -93,6 +93,8 @@ Timing.TRAINER_INTRO_SFX_GAP = 20
Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
-- animations.asm:431-433 (pokeyellow animations.asm:445-446)
Timing.MOVE_ANIM_OFF = 30
-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
+6 -13
View File
@@ -779,19 +779,12 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end
local function drawCovered(img, x, y, w, h, alpha)
local function drawOverlayImage(img, x, y, w, h, alpha)
if not img or alpha <= 0 then return end
local iw, ih = img:getWidth(), img:getHeight()
if iw <= 0 or ih <= 0 then return end
-- Cover the assigned box with one uniform scale and crop the excess. The
-- old independent X/Y scale made portrait art visibly squash on wide
-- displays (and vice versa).
local s = math.max(w / iw, h / ih)
local dw, dh = iw * s, ih * s
local sx, sy = TouchSkin.imageFit(img:getWidth(), img:getHeight(), w, h)
if not sx then return end
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
love.graphics.setScissor(x, y, w, h)
love.graphics.draw(img, x + (w - dw) * 0.5, y + (h - dh) * 0.5, 0, s, s)
love.graphics.setScissor()
love.graphics.draw(img, x, y, 0, sx, sy)
end
function TouchControls:drawSkin(alphaMul)
@@ -803,7 +796,7 @@ function TouchControls:drawSkin(alphaMul)
love.graphics.push("all")
love.graphics.origin()
drawCovered(page.image, bx, by, bw, bh, opacity)
drawOverlayImage(page.image, bx, by, bw, bh, opacity)
local pressed = {}
for _, touch in pairs(self.touches or {}) do
@@ -818,7 +811,7 @@ function TouchControls:drawSkin(alphaMul)
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
local alpha = opacity
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
drawCovered(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
drawOverlayImage(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
end
end
+5
View File
@@ -1352,6 +1352,11 @@ function TouchSkin.pageBox(page, w, h, ox, oy)
return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh
end
function TouchSkin.imageFit(iw, ih, w, h)
if not iw or not ih or iw <= 0 or ih <= 0 then return nil end
return w / iw, h / ih
end
function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
local cx, cy = bx + ctl.x * bw, by + ctl.y * bh
+65
View File
@@ -22,6 +22,71 @@ local Version = {
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
local DEV_PLACEHOLDER = "0.0.0-dev"
local function readFile(path)
local handle = io.open(path, "rb")
if not handle then return nil end
local text = handle:read("*a")
handle:close()
if type(text) ~= "string" or text == "" then return nil end
return text
end
local function commitFromBuildInfo()
local fs = rawget(_G, "love") and love.filesystem
if not (fs and fs.read) then return nil end
local ok, text = pcall(fs.read, "build-info.json")
if not ok or type(text) ~= "string" then return nil end
return text:match('"gitCommitFull"%s*:%s*"(%x+)"')
or text:match('"gitCommit"%s*:%s*"(%x+)"')
end
local function commitFromGit(source, read)
read = read or readFile
if type(source) ~= "string" or source == "" then return nil end
local gitDir = source .. "/.git"
local head = read(gitDir .. "/HEAD")
if not head then
local pointer = read(gitDir)
local target = pointer and pointer:match("^gitdir:%s*(%S+)")
if not target then return nil end
if not target:match("^/") then target = source .. "/" .. target end
gitDir = target
head = read(gitDir .. "/HEAD")
if not head then return nil end
end
local ref = head:match("^ref:%s*(%S+)")
if not ref then return head:match("^(%x+)") end
local hash = read(gitDir .. "/" .. ref)
if hash then return hash:match("^(%x+)") end
local packed = read(gitDir .. "/packed-refs")
if packed then
return packed:match("(%x+)%s+" .. ref:gsub("%p", "%%%0"))
end
return nil
end
function Version.devEngine(engine, source, read)
engine = tostring(engine or "")
if engine ~= DEV_PLACEHOLDER then return engine end
local commit = commitFromBuildInfo() or commitFromGit(source, read)
if type(commit) ~= "string" or #commit < 7 then return engine end
return engine .. "+" .. commit:sub(1, 12):lower()
end
do
local ok, derived = pcall(function()
local source
local fs = rawget(_G, "love") and love.filesystem
if fs and fs.getSource then source = fs.getSource() end
return Version.devEngine(Version.engine, source)
end)
if ok and type(derived) == "string" and derived ~= "" then
Version.engine = derived
end
end
-- True for the working-tree placeholder and any stamped "-dev" pre-release.
-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here.
function Version.isDev()
+4 -1
View File
@@ -382,7 +382,9 @@ function Evolution.apply(data, mon, entry)
local level = mon.level or 1
-- CalcMonStats runs through the one builder, so an evolved mon's stats can
-- never disagree with a freshly built one's.
local stats = Mon.stats(def.baseStats, mon.dvs, level, mon.statExp)
-- engine/pokemon/evolve.asm:261-264
local statExp = mon.statExp
local stats = Mon.stats(def.baseStats, mon.dvs, level, statExp)
local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or stats.hp
local hp = (mon.hp or previousMax) + (stats.hp - previousMax)
-- The cart does not clamp; the bound only matters for data where an
@@ -398,6 +400,7 @@ function Evolution.apply(data, mon, entry)
local evolved = Mon.new(data, species, level, {
dvs = mon.dvs,
statExp = statExp,
moves = mon.moves,
hp = hp,
item = heldItem,
+5
View File
@@ -152,6 +152,11 @@ function NpcTrade.perform(data, save, row, index)
-- already disagree and a traded mon has to answer both.
received.ot, received.otName = row.otName, row.otName
received.otId = row.otId
-- engine/events/npc_trade.asm:189-197
if Mon.hasCaughtData(save.version) then
Mon.setGiftCaughtData(received,
row.dialog == "TRADE_DIALOGSET_GIRL" and "girl" or "unknown")
end
table.remove(party, index)
-- RemoveMonFromPartyOrBox's "Mail time!" tail. NPCTrade itself has no mail
-- check -- unlike the Day-Care and the PC, it will trade a mon holding a
+23 -1
View File
@@ -312,6 +312,8 @@ Save.DEFAULT_OPTIONS = {
palette = "",
videoMode = "windowed",
fpsCap = 60,
-- BATTLE SIZE (#1709): fixed | fill
battleFit = "fixed",
-- BATTLE BG (#1709): white | black, the surround around the battle screen.
battleBg = "white",
-- VOID FILL: fade | water | trees | black. fade is each map header's own
@@ -409,6 +411,22 @@ local function normalizePokerus(mons)
end
end
-- constants/pokemon_data_constants.asm:79, :90
local function normalizeMoves(mons)
for _, mon in ipairs(mons or {}) do
if type(mon) == "table" and type(mon.moves) == "table" then
local rebuilt = false
for i, entry in ipairs(mon.moves) do
if type(entry) ~= "table" then
mon.moves[i] = { id = entry, pp = (mon.pp or {})[i] }
rebuilt = true
end
end
if rebuilt then mon.pp = nil end
end
end
end
-- ../pokecrystal/ram/sram.asm:140 sGSBallFlag. nil is the cleared byte.
Save.GS_BALL_STATES = { have = true, given = true, used = true }
@@ -558,8 +576,12 @@ function Save.normalize(save)
table.remove(save.party)
end
normalizePokerus(save.party)
normalizeMoves(save.party)
for _, box in pairs(save.boxes) do
if type(box) == "table" then normalizePokerus(box) end
if type(box) == "table" then
normalizePokerus(box)
normalizeMoves(box)
end
end
-- move_mon.asm:143-149: a mon the player owns carries wPlayerID; saves
-- written before the stamp existed get it here.
+13
View File
@@ -27,6 +27,9 @@ CacheContract.REQUIRED_FILES = {
"assets/generated/battle/anims/move_anim_1.png",
"assets/generated/audio/programs.bin",
"assets/generated/trade/game_boy.png",
-- engine/items/town_map.asm:296, 150
"assets/generated/townmap/nest.png",
"assets/generated/townmap/up_arrow.png",
}
CacheContract.VERSION_REQUIRED_FILES = {
@@ -83,6 +86,11 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
"assets/generated/pc/mail_item.png",
-- engine/events/fishing_gfx.asm:23
"assets/generated/emotes/fishing.png",
-- data/sprites/emotes.asm:19, engine/events/field_moves.asm:390
"assets/generated/emotes/jump_shadow.png",
"assets/generated/emotes/cut_grass.png",
-- engine/pokegear/pokegear.asm:2298
"assets/generated/pokegear/nest_icon.png",
},
crystal = {
"data/generated/constants.lua",
@@ -133,6 +141,11 @@ CacheContract.VERSION_REQUIRED_FILES_OVERRIDE = {
"assets/generated/battle/trainers/chris.png",
-- ../pokecrystal/engine/events/fishing_gfx.asm:38-42
"assets/generated/emotes/fishing.png",
-- data/sprites/emotes.asm:19, engine/events/field_moves.asm:390
"assets/generated/emotes/jump_shadow.png",
"assets/generated/emotes/cut_grass.png",
-- engine/pokegear/pokegear.asm:2298
"assets/generated/pokegear/nest_icon.png",
},
}
CacheContract.VERSION_REQUIRED_FILES_OVERRIDE.silver =
+20
View File
@@ -675,6 +675,9 @@ local function gen2Rows(opts, hooks, shared)
end)
end
add(Strings("BATTLE SIZE"), ladder(opts, "battleFit",
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
-- BATTLE BG (#1709): the WHITE/BLACK pair Gold's battle screen honours.
add(Strings("BATTLE BG"), ladder(opts, "battleBg",
{ { "white", "WHITE" }, { "black", "BLACK" } }, "white"))
@@ -714,6 +717,23 @@ function LauncherSettings.open(hooks, version)
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
}
end
sections[#sections + 1] = {
title = Strings("LAUNCHER"),
rows = {
{
label = Strings("REDUCE MOTION"),
value = function()
return opts.reduceMotion == true and Strings("ON") or Strings("OFF")
end,
step = function()
opts.reduceMotion = not (opts.reduceMotion == true)
local okT, Transition = pcall(require, "src.ui.kit.Transition")
if okT then Transition.reduceMotion = opts.reduceMotion end
return true
end,
},
},
}
-- Mod options are generation-agnostic (the manager's options_schema
-- contract), so they ride along either way.
for _, mod in ipairs(discoverModSchemas(opts)) do
+214 -51
View File
@@ -31,6 +31,7 @@ local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Layout = require("src.ui.kit.Layout")
local Loader = require("src.ui.kit.Loader")
local Transition = require("src.ui.kit.Transition")
local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version")
local Strings = require("src.core.Strings")
@@ -116,6 +117,7 @@ function LauncherView.detach(imp)
elseif imp and imp._restoreNxPointerBridge then
pcall(imp._restoreNxPointerBridge, imp)
end
Transition.reset()
if not imp or not imp._flex then return end
imp._flex = nil
if love.keyboard and love.keyboard.setKeyRepeat then
@@ -330,9 +332,15 @@ function LauncherView.keypressed(imp, key)
Kit.navigate(key)
return true
end
if imp._ringArmed and (key == "return" or key == "kpenter" or key == "space") then
Kit.activateFocused()
return true
if key == "return" or key == "kpenter" or key == "space" then
if imp._ringArmed then
Kit.activateFocused()
return true
end
if imp._tradeModal then
require("src.import.OnlinePanel").tradeModalAction(imp, "a")
return true
end
end
return false
end
@@ -370,6 +378,10 @@ local function rowHit(imp, x, y, w, h, selected, key, action)
return ink
end
LauncherView.btn = btn
LauncherView.rowHit = rowHit
LauncherView.queueAction = queueAction
-- ------------------------------------------------------- shared widgets
-- Read-only text field. The importer owns the string (its textinput /
@@ -405,8 +417,9 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
end
end
if (Kit.press(x, y, w, h) or Kit._activateId == key) then
local osk = false
if Kit.VirtualKeyboard then
Kit.VirtualKeyboard.open({
osk = Kit.VirtualKeyboard.open({
text = text,
targetId = key,
title = placeholder or "Enter Text",
@@ -423,14 +436,14 @@ local function textField(imp, x, y, w, h, key, rawText, placeholder, focused, ac
elseif imp._settingsText and key:find("settext") then
imp._settingsText.text = newText
elseif imp.tab == "find" then
imp._findQuery = newText
imp.findQuery = newText
if imp._refreshFind then imp:_refreshFind() end
end
end
end
})
}) and true or false
end
if action then
if action and not osk then
queueAction(imp, key, action)
end
end
@@ -1292,13 +1305,29 @@ local GAME_TABS = {
color = PAL.railCrystal, label = "Crystal" },
}
local function drawOnlineGlyph(x, y, w, h, hot)
local box = math.min(w, h)
local bx = x + (w - box) / 2
local by = y + (h - box) / 2
local pad = box * 0.24
local d = box - 2 * pad
local ink = hot and PAL.inverse or PAL.ink
local lw = math.max(1, math.floor(Kit.scale + 0.5))
Theme.strokeRounded(bx + pad, by + pad, d, d, ink, 1, lw, d / 2)
Theme.fill(bx + pad, by + pad + d / 2 - lw / 2, d, lw, ink, 1)
Theme.strokeRounded(bx + pad + d * 0.30, by + pad, d * 0.40, d, ink, 0.8, lw,
d * 0.20)
end
local HEADER_TABS = {
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "online", key = "tab-online", glyph = true, beta = true },
{ id = "skins", key = "tab-skins", glyph = true, beta = true },
{ id = "bug", key = "tab-bug" },
}
LauncherView.HEADER_TABS = HEADER_TABS
local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse }
local function drawBetaTag(x, y, w, h)
@@ -1314,7 +1343,7 @@ end
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then
t.opts.drawFn = drawSkinGlyph
t.opts.drawFn = t.id == "online" and drawOnlineGlyph or drawSkinGlyph
end
end
@@ -1359,6 +1388,7 @@ local function headerChrome(imp)
local g = currentGame(imp)
if imp.tab == g.id then
imp._gamePopup = true
Kit.setFocus("gamepop-" .. g.id)
else
imp:_switchTab(g.id)
end
@@ -1467,7 +1497,6 @@ local function buildHeader(imp, m)
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
if t.id == "bug" then t.icon = imp._bugIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
@@ -1487,10 +1516,8 @@ local function buildHeader(imp, m)
chrome0.game.active = imp.tab == game.id
local gameHot = Kit.hover(tx, ty, dropW, tabH)
local gameDown = gameHot and Kit.mouseDown
-- face "tab" inverts on hover as well as when active, so the caret has to
-- flip with it or it vanishes into the cartridge colour
local gameInvert = chrome0.game.active or gameHot
chrome0.game.ring = gameHot and not chrome0.game.active or nil
local gameInvert = chrome0.game.active
chrome0.game.ring = nil
btn(imp, tx, ty, dropW, tabH, "tab-game", "", chrome0.game)
do
local cw = math.floor(7 * m.s)
@@ -1519,11 +1546,7 @@ local function buildHeader(imp, m)
if t.beta then overlayBeta(tx, ty, w, tabH, m) end
tx = tx + w + tabGap
end
-- The bug-report chip sits LAST, past the sync chip.
local bugTab
for _, t in ipairs(tabs) do
if t.id == "bug" then bugTab = t else headerTab(t) end
end
for _, t in ipairs(tabs) do headerTab(t) end
do
local w = tabH
@@ -1542,7 +1565,6 @@ local function buildHeader(imp, m)
end
tx = tx + w + tabGap
end
if bugTab then headerTab(bugTab) end
-- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s)
@@ -1550,6 +1572,8 @@ local function buildHeader(imp, m)
return y + math.floor(10 * m.s)
end
LauncherView.textField = textField
-- The state of the self-updater, shown in the launcher footer.
-- Returns status, label, action, glow.
function LauncherView._updateControl(imp)
@@ -3517,23 +3541,58 @@ end
-- underneath is inert, then lowers it for its own panel. There is no
-- z-ordered hit test, so this ordering IS the z-order.
local modalRect = { x = 0, y = 0, w = 0, h = 0 }
local modalTransform = false
local function modalAmount()
local L = Transition.get("modal")
if not L then return 1 end
local p = Transition.progress("modal")
if L.kind == "out" then return 1 - p end
return p
end
local function modalPanel(m, w, h)
-- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark
-- still read through the settings panel and the screen looked like two
-- layouts fighting rather than one panel on top ("the settings is covering
-- the logo"); at this weight the page behind is present but plainly out of
-- play, which is what a modal is supposed to say.
Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93)
local amt = modalAmount()
Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93 * amt)
Kit.blockClicks = true
local pw = math.floor(math.min(w, m.W - 2 * m.pad))
local ph = math.floor(math.min(h, m.H - 2 * m.pad))
local px = math.floor((m.W - pw) / 2)
local py = math.floor((m.H - ph) / 2)
modalRect.x, modalRect.y, modalRect.w, modalRect.h = px, py, pw, ph
if amt < 1 and love.graphics and love.graphics.push then
local s = 0.96 + 0.04 * amt
love.graphics.push()
love.graphics.translate(m.W / 2, m.H / 2)
love.graphics.scale(s, s)
love.graphics.translate(-m.W / 2, -m.H / 2)
modalTransform = true
end
Kit.card(px, py, pw, ph, true)
Kit.blockClicks = false
Kit.blockClicks = Transition.active()
return px, py, pw, ph
end
local function endModalDraw(m)
if not modalTransform then return end
modalTransform = false
local fade = 1 - modalAmount()
if fade > 0 then
local pad = math.floor(6 * m.s)
Theme.fillRounded(modalRect.x - pad, modalRect.y - pad,
modalRect.w + 2 * pad, modalRect.h + 2 * pad, PAL.bg, fade, 10)
end
love.graphics.pop()
end
LauncherView.modalPanel = modalPanel
-- Shared prompt: title, read-only field over the importer's text, buttons.
local function buildPrompt(imp, m, spec)
local pad = math.floor(18 * m.s)
@@ -4795,6 +4854,19 @@ local function buildGameManageModal(imp, m)
action = function() imp._gameManage = nil end })
end
local function buildBugModal(imp, m)
local pad = math.floor(18 * m.s)
local w = math.floor(560 * m.s)
local btnH = math.max(m.btnH, Kit.tapMin())
local h = math.floor(math.min(m.H - 2 * m.pad, 420 * m.s))
local px, py, pw, ph = modalPanel(m, w, h)
buildBugPanel(imp, px + pad, py + pad, pw - 2 * pad,
ph - 2 * pad - btnH - m.gap, m)
btn(imp, px + pad, py + ph - pad - btnH, pw - 2 * pad, btnH, "bug-close",
Strings("Close"), { kind = "primary", font = "small",
action = function() imp:_closeBugPanel() end })
end
local function buildSettingsModal(imp, m)
local model = imp._settings
local SaveData = require("src.core.SaveData")
@@ -4809,6 +4881,11 @@ local function buildSettingsModal(imp, m)
btn(imp, px + pw - pad - cw, cy, cw, m.btnH, "settings-close",
Strings("Close"), { font = "small",
action = function() imp:_closeSettings() end })
local bugLabel = Strings("Troubleshooting")
local bw = Kit.textWidth("small", bugLabel) + math.floor(24 * m.s)
btn(imp, px + pw - pad - cw - m.gap - bw, cy, bw, m.btnH, "settings-bug",
bugLabel, { kind = "ghost", font = "small",
action = function() imp:_openBugPanel() end })
cy = cy + math.max(Kit.textHeight("stat"), m.btnH) + math.floor(6 * m.s)
-- WRAPPED, not printed flat: on a portrait panel this line ran straight off
-- the right edge and the sentence ended mid-word at the card border.
@@ -5645,6 +5722,19 @@ end
-- panels underneath a modal must run with Kit.blockClicks already raised or
-- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below.
local MODAL_KEYS = {
"_profileRenamePrompt", "_profileSavePrompt", "_settingsText",
"_cartSave", "_bugModal", "_settings", "_rename", "_indexPrompt",
"_modConfirm", "_appPatchNotes", "_modReleaseNotes", "_findDetails",
"_modVersions", "_modDepResolver", "_modImports", "_singleProfileActions",
"_profilesPopup", "_modHeaderActionsPopup", "_sortPopup", "_gamePopup",
"_cartPopup", "_modScopePopup", "_filterPopup", "_indexManage",
"_syncModal", "_pcPicker", "_tradeModal", "_skinActions", "_modActions",
"_findEntry", "_gameManage",
}
LauncherView.MODAL_KEYS = MODAL_KEYS
local function modalUp(imp)
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
@@ -5656,9 +5746,22 @@ local function modalUp(imp)
or imp._gamePopup or imp._cartPopup or imp._cartSave
or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage
or imp._tradeModal or imp._bugModal or imp._pcPicker) ~= nil
end
local function modalKey(imp)
if not modalUp(imp) then return nil end
if Kit.FileBrowser and Kit.FileBrowser.active then return "filebrowser" end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return "vkeyboard" end
for i = 1, #MODAL_KEYS do
if imp[MODAL_KEYS[i]] ~= nil then return MODAL_KEYS[i] end
end
return nil
end
LauncherView.modalKey = modalKey
local function buildModals(imp, m)
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.draw(m)
@@ -5730,6 +5833,7 @@ local function buildModals(imp, m)
return true
end
if imp._cartSave then buildCartSaveModal(imp, m) return true end
if imp._bugModal then buildBugModal(imp, m) return true end
if imp._settings then buildSettingsModal(imp, m) return true end
if imp._rename then
buildPrompt(imp, m, {
@@ -5788,9 +5892,15 @@ local function buildModals(imp, m)
local ModUpdate = require("src.mods.ModUpdate")
local d = imp._findDetails
local body = ModUpdate.cleanBody(d.body or "", 0)
if body == "" then body = Strings("(No description.)") end
if body == "" then
body = d.loading and Strings("Loading description...")
or Strings("(No description.)")
end
buildTextModal(imp, m, "find-details", d.title, body,
function() imp._findDetails = nil end)
function()
imp._findDetails = nil
if imp._cancelFindDetails then imp:_cancelFindDetails() end
end)
return true
end
if imp._modVersions then buildVersionsModal(imp, m) return true end
@@ -5810,6 +5920,12 @@ local function buildModals(imp, m)
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
if imp._syncModal then buildSyncModal(imp, m) return true end
if imp._pcPicker then
return require("src.import.online.PcPicker").draw(imp, m) == true
end
if imp._tradeModal then
return require("src.import.online.TradeScreen").drawModal(imp, m) == true
end
if imp._skinActions then buildSkinActionsModal(imp, m) return true end
if imp._modActions then buildModActionsModal(imp, m) return true end
if imp._findEntry then buildFindEntryModal(imp, m) return true end
@@ -5843,10 +5959,8 @@ local function drawPadCursor(imp)
or (Kit.VirtualKeyboard and Kit.VirtualKeyboard.active) then
return
end
-- Pixel-snap on NX: subpixel polygon edges shimmer on the 720p Switch
-- framebuffer when the stick advances by fractional pixels each frame.
local x, y = imp._padCursor.x, imp._padCursor.y
if imp.isNX then
if imp._consolePointerHost and imp:_consolePointerHost() then
x, y = math.floor(x + 0.5), math.floor(y + 0.5)
end
love.graphics.push("all")
@@ -5894,6 +6008,43 @@ local function minPanelHeight(m)
return math.floor((m.twoCol and 340 or 470) * m.s)
end
local function buildTabPanel(imp, x, y, w, availH, budgetH, m)
if imp.tab == "mods" then
return buildModsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "find" then
return buildFindPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "skins" then
return buildSkinsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "online" then
return require("src.import.OnlinePanel")
.buildOnlinePanel(imp, x, y, w, budgetH, m)
end
return buildGamePanel(imp, x, y, w, availH, m, imp.tab, budgetH)
end
local function drawTabLayer(imp, tabId, x, contentY, w, viewH, availH, m, dx)
local prevTab = imp.tab
imp.tab = tabId
local at = tabScrollAt(imp)
local maxAt = tabScrollMax(imp)
if dx ~= 0 then
love.graphics.push()
love.graphics.translate(dx, 0)
end
local py = Kit.scrollBegin(x, contentY, w, viewH, at, maxAt)
local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN))
local panelW = math.max(0, w - Kit.scrollGutter(m.s))
local contentH = buildTabPanel(imp, x, py, panelW, availH, budgetH, m)
contentH = contentH or availH
imp._tabContentH[tabId] = contentH
imp._tabScrollMax[tabId] = Kit.scrollExtent(contentH, viewH)
at = clamp(at, 0, tabScrollMax(imp))
imp._tabScroll[tabId] = at
Kit.scrollEnd(x, contentY, w, viewH, at, maxAt)
if dx ~= 0 then love.graphics.pop() end
imp.tab = prevTab
end
function LauncherView.draw(imp)
ensureState(imp)
local m = Layout.metrics(1200)
@@ -5929,9 +6080,28 @@ function LauncherView.draw(imp)
-- Everything from here to buildModals sits UNDER any open modal, so the
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
-- one is up; buildModals lowers the shield for the modal's own controls.
imp._modalUpNow = modalUp(imp)
local mkey = modalKey(imp)
if mkey ~= imp._modalKey then
if mkey then
if imp._modalHeld then
Transition.clear("modal")
imp._modalHeld = nil
end
Transition.start("modal", "in")
elseif imp._modalKey and imp._modalLastValue ~= nil then
if Transition.start("modal", "out") then
imp._modalHeld = { key = imp._modalKey, value = imp._modalLastValue }
end
end
imp._modalKey = mkey
end
imp._modalLastValue = mkey and imp[mkey] or nil
if imp._modalHeld and not Transition.active("modal") then
imp._modalHeld = nil
end
imp._modalUpNow = mkey ~= nil
if imp._modalUpNow then imp:_blurPanelFields() end
Kit.blockClicks = imp._modalUpNow
Kit.blockClicks = imp._modalUpNow or Transition.active()
local step = Kit.scrollStep(m.s)
do
@@ -5983,32 +6153,25 @@ function LauncherView.draw(imp)
if not rect then rect = {}; imp._tabRegionRect = rect end
rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH
local at = tabScrollAt(imp)
local py = Kit.scrollBegin(x, contentY, w, viewH, at, tabScrollMax(imp))
local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN))
local panelW = math.max(0, w - Kit.scrollGutter(m.s))
local contentH
if imp.tab == "mods" then
contentH = buildModsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "find" then
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "skins" then
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "bug" then
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
local tabTr = Transition.get("tabs")
if tabTr and tabTr.from and tabTr.from ~= tabKeyOf(imp) then
local p = Transition.progress("tabs")
local dir = tabTr.dir >= 0 and 1 or -1
pcall(drawTabLayer, imp, tabTr.from, x, contentY, w, viewH, availH, m,
-dir * p * w)
drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m,
dir * (1 - p) * w)
else
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m, 0)
end
contentH = contentH or availH
imp._tabContentH[tabKeyOf(imp)] = contentH
imp._tabScrollMax[tabKeyOf(imp)] = Kit.scrollExtent(contentH, viewH)
at = clamp(at, 0, tabScrollMax(imp))
imp._tabScroll[tabKeyOf(imp)] = at
Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp))
buildFooter(imp, m, footY)
Kit.blockClicks = false
Kit.blockClicks = Transition.active()
local held = imp._modalHeld
if held then imp[held.key] = held.value end
buildModals(imp, m)
endModalDraw(m)
if held then imp[held.key] = nil end
-- The loader sits above everything, including modals: it is the one thing
-- that must never be clicked around.
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -1871,7 +1871,7 @@ end
function RomExtractor:extractField()
self:beginStage("Interface artwork")
local done, total = 0, 51
local done, total = 0, 53
local function tick()
done = done + 1
self:tick("Interface artwork", math.min(done, total), total)
@@ -2199,8 +2199,26 @@ function RomExtractor:extractField()
"townmap/tiles.png"); tick()
self:raw1bpp("TownMapCursor", 16, 16,
"townmap/cursor.png", true); tick()
-- engine/items/town_map.asm:296, 150
local nestArt, upArrowArt
if self.symbols["MonNestIcon"] then
self:raw1bpp("MonNestIcon", 8, 8, "townmap/nest.png", true); tick()
nestArt = {
path = "assets/generated/townmap/nest.png", width = 8, height = 8,
}
end
if self.symbols["TownMapUpArrow"] then
self:raw1bpp("TownMapUpArrow", 8, 8, "townmap/up_arrow.png", true); tick()
upArrowArt = {
path = "assets/generated/townmap/up_arrow.png", width = 8, height = 8,
}
end
local data = copy(self.manifest.field)
if type(data.townMap) == "table" then
data.townMap.nest = nestArt
data.townMap.upArrow = upArrowArt
end
local adjacency = data.hiddenExtras.trashCans.adjacent
local converted = {}
for index, values in pairs(adjacency) do converted[tonumber(index)] = values end
+22
View File
@@ -5875,6 +5875,20 @@ function RomExtractorGen2:extractMenuGfx()
8, 8, "emotes/grass_rustle.png", true)
emotes.grassRustle = "assets/generated/emotes/grass_rustle.png"
end
-- data/sprites/emotes.asm:19, engine/overworld/map_objects.asm:1995
local jumpShadow = self.symbols["JumpShadowGFX"]
if jumpShadow then
self:write2bpp(self.rom:bytes(jumpShadow[1], jumpShadow[2], 16),
8, 8, "emotes/jump_shadow.png", true)
emotes.jumpShadow = "assets/generated/emotes/jump_shadow.png"
end
-- engine/events/field_moves.asm:390-407
local cutGrass = self.symbols["CutGrassGFX"]
if cutGrass then
self:write2bpp(self.rom:bytes(cutGrass[1], cutGrass[2], 4 * 16),
32, 8, "emotes/cut_grass.png", true)
emotes.cutGrass = "assets/generated/emotes/cut_grass.png"
end
-- LoadFishingGFX (engine/events/fishing_gfx.asm:1-21): three 16x8 pose rows
-- over the standing frames' bottom tiles, then the rod tiles $fc/$fd (#1708).
local fishing = self.symbols["FishingGFX"]
@@ -6492,6 +6506,14 @@ function RomExtractorGen2:pokegearGfx()
gear.sprites = "assets/generated/pokegear/sprites.png"
gear.spritesWide = 2
-- engine/pokegear/pokegear.asm:2298
if self.symbols["PokedexNestIconGFX"] then
local nest = self:symbol("PokedexNestIconGFX")
self:write2bpp(self.rom:bytes(nest.bank, nest.address, 16), 8, 8,
"pokegear/nest_icon.png", true)
gear.nestIcon = "assets/generated/pokegear/nest_icon.png"
end
local cells = SCREEN_AREA
gear.cards = {
clock = self:readTilemapRLE("ClockTilemapRLE", cells),
+456 -44
View File
@@ -39,7 +39,25 @@ local function cartOfScope(scope)
end
local CacheContract = require("src.import.CacheContract")
local Transition = require("src.ui.kit.Transition")
local COMMUNITY_URL = "https://bois.icu"
local TAB_ORDER, TAB_AT = {}, {}
local function tabOrder()
if #TAB_ORDER == 0 then
for _, version in ipairs(GameVersion.ORDER) do
TAB_ORDER[#TAB_ORDER + 1] = version
end
local okView, View = pcall(require, "src.import.LauncherView")
if okView then
for _, t in ipairs(View.HEADER_TABS or {}) do
TAB_ORDER[#TAB_ORDER + 1] = t.id
end
end
for i, id in ipairs(TAB_ORDER) do TAB_AT[id] = i end
end
return TAB_AT
end
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
"or a link from the discord that bryanthaboi himself posted, just know " ..
"it might have been tampered with. go to the discord to verify " ..
@@ -420,6 +438,7 @@ local function commandOutput(command)
-- lock can free a FILE while a worker thread's popen is walking the stream
-- list, which deadlocks that thread for good (see HostShell).
HostShell.pclose(pipe)
HostShell.pumpHostEvents()
result = trim(result)
return result ~= "" and result or nil
end
@@ -1447,7 +1466,24 @@ function RomImporter.new(onComplete, opts)
and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
Transition.reset()
Transition.armed = false
local okMotion, motionOpts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
Transition.reduceMotion = os.getenv("POKEPORT_REDUCE_MOTION") == "1"
or opts.reduceMotion == true
or (okMotion and type(motionOpts) == "table"
and motionOpts.reduceMotion == true) or false
self:_applyLastVersionTab()
if type(opts.initialTab) == "string" and opts.initialTab ~= "" then
self:_switchTab(opts.initialTab)
end
if type(opts.joinCode) == "string" and opts.joinCode ~= "" then
self:_switchTab("online")
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline then pcall(OnlinePanel.deepLink, self, opts.joinCode, "player") end
end
self:_queueBaseRomScan()
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
@@ -1585,9 +1621,15 @@ function RomImporter:focus(f)
and love.filesystem.read("pick_error.flag")
if pickError then
love.filesystem.remove("pick_error.flag")
local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory()
local text
if pickError:find("cancelled:", 1, true) == 1 then
text = "The file manager did not return a file. Try a different file "
.. "manager, or copy it into: " .. love.filesystem.getSaveDirectory()
else
text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory()
end
local legacyRequiredPick = self.requiredImportLegacyRomPick
and self.pickerPendingKind == "required_import"
if self.pickerPendingKind == "required_import"
@@ -2819,6 +2861,10 @@ end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
if not Transition.armed then
self._motionFrames = (self._motionFrames or 0) + 1
if self._motionFrames > 1 then Transition.armed = true end
end
if self._launchFade then
self._launchFade.elapsed = self._launchFade.elapsed + dt
if self._launchFade.elapsed >= self._launchFade.duration then
@@ -2842,12 +2888,14 @@ function RomImporter:update(dt)
-- nothing. They run whether or not the view is up, so a refresh started
-- before a tab switch still completes.
self:_pumpFindFetch()
self:_pumpFindDetails()
self:_pumpModInfoFetch()
self:_queueFindEnrichment()
self:_pumpFindStats()
self:_pumpFindThumbs()
self:_pumpSkinFetch()
self:_pumpSync(dt)
self:_pumpOnline(dt)
self:_pumpModCheck()
self:_pumpModInstall()
self:_pumpCartInstall()
@@ -2861,6 +2909,9 @@ function RomImporter:update(dt)
if shot and not self._shotDone then
if not self._shotSized then
self._shotSized = true
if os.getenv("POKEPORT_REDUCE_MOTION") ~= "0" then
Transition.reduceMotion = true
end
local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$")
if w and love.window and love.window.setMode then
pcall(love.window.setMode, tonumber(w), tonumber(h),
@@ -2886,6 +2937,9 @@ function RomImporter:update(dt)
if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then
self:_openSettings()
end
if os.getenv("POKEPORT_LAUNCHER_BUG") == "1" then
self:_openBugPanel()
end
-- POKEPORT_LAUNCHER_FIND_KIND=mods|carts picks which half of the feed
-- the FIND tab is browsing; the switch is otherwise only a click.
local findKind = os.getenv("POKEPORT_LAUNCHER_FIND_KIND")
@@ -2927,7 +2981,48 @@ function RomImporter:update(dt)
{ resizable = true })
end
end
if self._shotTimer > 1.2 then
local motion = os.getenv("POKEPORT_LAUNCHER_MOTION")
if motion and motion ~= "" and not self._shotMotionAt
and self._shotTimer > 0.9 then
self._shotMotionAt = 0
local ms = tonumber(os.getenv("POKEPORT_MOTION_MS") or "")
if ms and ms > 0 then
for kind in pairs(Transition.DURATIONS) do
Transition.DURATIONS[kind] = ms / 1000
end
end
if motion:match("^go:") then
local okOP, OP = pcall(require, "src.import.OnlinePanel")
if okOP then OP.go(self, motion:sub(4)) end
elseif motion == "modal-close" then
self._modConfirm = nil
elseif motion == "modal" then
self._modConfirm = {
kind = "update", title = "Install mod", yesLabel = "Install",
lines = { "JP GREEN - Poketto Monsuta Midori v0.4.4",
"by bryanthaboi",
"Mods are not reviewed - trust the author." },
}
else
self:_switchTab(motion)
end
end
if self._shotMotionAt then
self._shotMotionAt = self._shotMotionAt + 1
local want = tonumber(os.getenv("POKEPORT_LAUNCHER_SHOT_SEQ") or "4") or 4
if self._shotMotionAt <= want then
local path = (shot:gsub("%.png$", ""))
.. "-" .. self._shotMotionAt .. ".png"
love.graphics.captureScreenshot(function(imagedata)
local fd = imagedata:encode("png")
local f = io.open(path, "wb")
if f then f:write(fd:getString()) f:close() end
end)
else
self._shotDone = true
love.event.quit()
end
elseif self._shotTimer > 1.2 then
self._shotDone = true
love.graphics.captureScreenshot(function(imagedata)
local fd = imagedata:encode("png")
@@ -3013,6 +3108,10 @@ local PAD_DEAD = 0.28
local PAD_SPEED = 560 -- px/s at full stick deflection
local PAD_DPAD_SPEED = 420
function RomImporter:_consolePointerHost()
return (self.isNX or Platform.isUWP()) and true or false
end
function RomImporter:_activatePadCursor()
if self._padCursorActive then return end
local ox, oy, w, h = SafeArea.rect()
@@ -3029,7 +3128,7 @@ end
-- and makes the virtual cursor lag. Expose the pad pointer through a getPosition
-- shim instead; desktop keeps the setPosition path unchanged.
function RomImporter:_ensureNxPointerBridge()
if not self.isNX or self._nxPointerBridge then return end
if not self:_consolePointerHost() or self._nxPointerBridge then return end
if not (love and love.mouse and love.mouse.getPosition) then return end
self._nxRealGetPosition = love.mouse.getPosition
local importer = self
@@ -3054,7 +3153,7 @@ end
-- NX only: drop the getPosition shim + hide the virtual cursor before a host
-- takes over input (embedded save editor). Desktop is a no-op.
function RomImporter:parkNxPointerForHost()
if not self.isNX then return end
if not self:_consolePointerHost() then return end
self._padCursorActive = false
self:_restoreNxPointerBridge()
end
@@ -3090,7 +3189,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "mods", "find", "skins", "bug" }
local order = { "mods", "find", "skins" }
for i = #GameVersion.ORDER, 1, -1 do
table.insert(order, 1, GameVersion.ORDER[i])
end
@@ -3101,6 +3200,48 @@ function RomImporter:_cycleTab(delta)
self:_switchTab(order[((idx - 1 + delta) % #order) + 1])
end
local STICK_NAV_ON = 0.5
local STICK_NAV_OFF = 0.3
local STICK_NAV_DELAY = 0.28
local STICK_NAV_REPEAT = 0.12
function RomImporter:_stickNavDir()
local nav = self._stickNav
local on = (nav and nav.dir) and STICK_NAV_OFF or STICK_NAV_ON
local ax = self._padAxis.leftx or 0
local ay = self._padAxis.lefty or 0
if math.abs(ax) >= math.abs(ay) then
if math.abs(ax) > on then return ax > 0 and "right" or "left" end
else
if math.abs(ay) > on then return ay > 0 and "down" or "up" end
end
if self._padDir.dpleft then return "left" end
if self._padDir.dpright then return "right" end
if self._padDir.dpup then return "up" end
if self._padDir.dpdown then return "down" end
return nil
end
function RomImporter:_navigateWithStick(dt, Kit)
local nav = self._stickNav
if not nav then nav = {}; self._stickNav = nav end
local dir = self:_stickNavDir()
if not dir then
nav.dir, nav.t, nav.fired = nil, 0, false
return
end
if nav.dir ~= dir then
nav.dir, nav.t, nav.fired = dir, 0, false
if Kit then Kit.navigate(dir) end
return
end
nav.t = (nav.t or 0) + dt
if nav.t >= (nav.fired and STICK_NAV_REPEAT or STICK_NAV_DELAY) then
nav.t, nav.fired = 0, true
if Kit then Kit.navigate(dir) end
end
end
function RomImporter:_updatePadCursor(dt)
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
@@ -3110,7 +3251,7 @@ function RomImporter:_updatePadCursor(dt)
end
end
if self.isNX then
if self:_consolePointerHost() then
self:_ensureNxPointerBridge()
-- Cap dt so a hitch in the FlexLove immediate-mode frame does not fling
-- the cursor; desktop keeps raw dt (setPosition path already smooth there).
@@ -3121,7 +3262,7 @@ function RomImporter:_updatePadCursor(dt)
-- pointer after bumping a stick once. On NX this must stay off: love-nx /
-- SDL often drifts the system mouse with the stick (or touch), and axis
-- events are not every frame, so yield+reactivate flickers the overlay.
if not self.isNX then
if not self:_consolePointerHost() then
local mx, my = love.mouse.getPosition()
if self._lastMouseX and self._padCursorActive then
if math.abs(mx - self._lastMouseX) > 3 or math.abs(my - self._lastMouseY) > 3 then
@@ -3131,6 +3272,17 @@ function RomImporter:_updatePadCursor(dt)
self._lastMouseX, self._lastMouseY = mx, my
end
if not self._padCursorActive and not self.isNX
and (Platform.isUWP() or self._padNavChosen) then
self:_navigateWithStick(dt, okKit and Kit or nil)
local scrollY = self._padAxis.righty or 0
if math.abs(scrollY) > PAD_DEAD and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -scrollY * 8 * dt)
end
return
end
self._stickNav = nil
local ax = self._padAxis.leftx or 0
local ay = self._padAxis.lefty or 0
local dx, dy = 0, 0
@@ -3159,7 +3311,7 @@ function RomImporter:_updatePadCursor(dt)
if overY ~= 0 and self._flex then
require("src.import.LauncherView").wheelmoved(self, 0, -overY / 48)
end
if not self.isNX and love.mouse.setPosition then
if not self:_consolePointerHost() and love.mouse.setPosition then
pcall(love.mouse.setPosition, self._padCursor.x, self._padCursor.y)
self._lastMouseX, self._lastMouseY = self._padCursor.x, self._padCursor.y
end
@@ -3173,7 +3325,8 @@ function RomImporter:_updatePadCursor(dt)
end
function RomImporter:gamepadpressed(_, button)
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
if Transition.active() then return end
local action =(GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
@@ -3187,6 +3340,7 @@ function RomImporter:gamepadpressed(_, button)
-- Y button toggle between Native Controller Navigation and Virtual Pointer Cursor:
if action == "y" or button == "y" then
self._padCursorActive = not self._padCursorActive
self._padNavChosen = not self._padCursorActive
if okKit then Kit._ringShown = not self._padCursorActive end
self._cursorModeToast = self._padCursorActive and "Cursor Navigation [Y]" or "Controller Menu Navigation [Y]"
self._cursorModeToastTime = love.timer.getTime()
@@ -3252,11 +3406,11 @@ function RomImporter:gamepadpressed(_, button)
return
elseif self.tab == "find" then
Kit.VirtualKeyboard.open({
text = self._findQuery or "",
text = self.findQuery or "",
title = "Search Mods",
onDone = function(newText, confirmed)
if confirmed then
self._findQuery = newText
self.findQuery = newText
if self._refreshFind then self:_refreshFind() end
end
end
@@ -3314,6 +3468,11 @@ function RomImporter:gamepadpressed(_, button)
end
return
elseif action == "b" then
if self._bugModal then self:_closeBugPanel(); return end
if self.tab == "online" then
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
@@ -3341,6 +3500,11 @@ function RomImporter:gamepadpressed(_, button)
self._padCursor.x, self._padCursor.y)
end
elseif action == "b" then
if self._bugModal then self:_closeBugPanel(); return end
if self.tab == "online" then
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
@@ -3448,7 +3612,6 @@ function RomImporter:joystickhat(joystick, hat, direction)
})[direction] or {}
for _, dir in ipairs(dirs) do self._padDir[dir] = true end
self._rawHatDirs[hat] = dirs
if #dirs > 0 then self:_activatePadCursor() end
end
-- Player pressed Play on a game whose ROM is imported: hand off to boot.
@@ -3665,10 +3828,19 @@ end
-- the soft keyboard drop with the panel they belonged to; each tab's scroll
-- offset persists inside the view's per-tab scroll container.
function RomImporter:_switchTab(id)
if id == "bug" then return self:_openBugPanel() end
if self.tab and self.tab ~= id then
local at = tabOrder()
Transition.start("tabs", "tab", {
dir = ((at[id] or 0) >= (at[self.tab] or 0)) and 1 or -1,
from = self.tab, to = id,
})
end
self.tab = id
if id ~= "find" then self._findVisibleEntries = nil end
self._findSearchFocus = false
self._skinUrlFocus = false
self._onlineFocus = nil
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit
@@ -3985,6 +4157,13 @@ function RomImporter:_syncSupported()
return self._syncTransportOk
end
function RomImporter:_pumpOnline(dt)
if not self._online then return end
local ok, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if not ok then return end
pcall(OnlinePanel.update, self, dt)
end
function RomImporter:_pumpSync(dt)
if self._sync == nil then
if not self.launcher or self._syncBooted then return end
@@ -4199,6 +4378,17 @@ end
-- ------- settings gear (options.lua + enabled mods' option schemas)
function RomImporter:_openBugPanel()
self._settings = nil
self._bugModal = true
return true
end
function RomImporter:_closeBugPanel()
self._bugModal = nil
return true
end
function RomImporter:_openSettings()
-- The touch-overlay editor is a host screen, so the model gets it as a
-- hook rather than reaching for main.lua's handler itself. Closing the
@@ -4305,6 +4495,13 @@ end
-- notice line used.
-- Desktop picks a .cart file; everywhere else CartStore's stray scan already
-- adopts anything dropped in the folder, so we just point at it.
function RomImporter:_resyncPointerAfterDialog()
self._mouseAt = nil
self._clickPt = nil
self._prevMouseDown = (love and love.mouse and love.mouse.isDown
and love.mouse.isDown(1)) and true or false
end
function RomImporter:importCartFile(version)
local CartStore = require("src.carts.CartStore")
local FilePicker = require("src.core.FilePicker")
@@ -4317,6 +4514,7 @@ function RomImporter:importCartFile(version)
end
local path = FilePicker.open("Choose a cart",
{ label = "Cart", exts = { CartStore.EXT:gsub("^%.", "") } })
self:_resyncPointerAfterDialog()
if not path then return false end
local bytes = FilePicker.read(path)
if not bytes then
@@ -4352,6 +4550,7 @@ function RomImporter:fileUrl(path)
end
function RomImporter:keypressed(key)
if Transition.active() then return end
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
if Kit.FileBrowser and Kit.FileBrowser.active then
@@ -4483,6 +4682,7 @@ function RomImporter:keypressed(key)
if key == "escape" then
if self._findDetails then
self._findDetails = nil
self:_cancelFindDetails()
elseif self._modReleaseNotes then
self._modReleaseNotes = nil
elseif self._appPatchNotes then
@@ -4501,6 +4701,41 @@ function RomImporter:keypressed(key)
if key == "escape" then self._cartPopup = nil end
return
end
if self._bugModal and key == "escape" then
self:_closeBugPanel()
return
end
if self._pcPicker and key == "escape" and not self._onlineFocus then
require("src.import.OnlinePanel").pcClose(self)
return
end
if self._onlineFocus then
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
local field = self._onlineFocus
if key == "backspace" then
if field == "online-name" then
st.nameDraft = utf8Back(st.nameDraft or "")
elseif field == "online-note" then
st.note = utf8Back(st.note or "")
elseif field == "online-code" then
st.joinCode = utf8Back(st.joinCode or "")
elseif field == "online-trade-code" then
local tr = OnlinePanel.tradeState(self)
tr.code = utf8Back(tr.code or "")
elseif field == OnlinePanel.PC_FIELD then
local pc = OnlinePanel.pcPicker(self)
OnlinePanel.pcQuery(self, utf8Back((pc and pc.query) or ""))
end
elseif key == "return" or key == "kpenter" then
self:_commitOnlineField()
elseif key == "escape" then
self._onlineFocus = nil
st.nameDraft = nil
self:_disarmTextInput()
end
return
end
if self._skinUrlFocus then
if key == "backspace" then
self.skinUrl = utf8Back(self.skinUrl or "")
@@ -4526,6 +4761,10 @@ function RomImporter:keypressed(key)
end
return
end
if key == "escape" and self.tab == "online" then
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self.workState == "working" then return end
-- Keyboard focus ring: arrows move it, Enter activates it -- but only once
-- the arrows have been used, so the long-standing "Enter plays the visible
@@ -4612,7 +4851,7 @@ function RomImporter:_cartSealSlot(version)
return nil, scope
end
function RomImporter:cartPlan(version)
function RomImporter:cartPlan(version, listed)
local id = self.activeCart and self.activeCart[version] or nil
if not id then return nil, nil end
local slot = self:_cartSealSlot(version)
@@ -4623,7 +4862,7 @@ function RomImporter:cartPlan(version)
if cached and cached.key == key then return cached.report, slot end
local installed = {}
pcall(function()
local rows = require("src.mods.LauncherMods").list(version) or {}
local rows = listed or require("src.mods.LauncherMods").list(version) or {}
for _, row in ipairs(rows) do
local manifest = type(row.manifest) == "table" and row.manifest or row
if type(manifest.id) == "string" then
@@ -5124,7 +5363,40 @@ function RomImporter:_disarmTextInput()
end
end
function RomImporter:_focusOnlineField(key)
self._onlineFocus = key
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
if key == "online-name" then st.nameDraft = st.name or "" end
self:_armTextInput()
end
function RomImporter:_commitOnlineField()
local key = self._onlineFocus
self._onlineFocus = nil
self:_disarmTextInput()
if key ~= "online-name" then return end
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
OnlinePanel.setName(self, st.nameDraft or "")
st.nameDraft = nil
end
function RomImporter:playArena(version, cartId, spec)
if not version or not spec then return false end
self._handedOff = true
resetPointerCursor(self)
if self._flex then require("src.import.LauncherView").detach(self) end
if self.onComplete then self.onComplete(version, cartId, { arena = spec }) end
return true
end
function RomImporter:_blurPanelFields()
if self._pcPicker then return end
if self._onlineFocus then
self:_commitOnlineField()
return
end
if not (self._findSearchFocus or self._skinUrlFocus) then return end
if self._indexPrompt or self._rename or self._settingsText
or self._profileSavePrompt or self._profileRenamePrompt
@@ -5193,6 +5465,24 @@ function RomImporter:textinput(text)
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
return
end
if self._onlineFocus then
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
if self._onlineFocus == "online-name" then
st.nameDraft = OnlinePanel.sanitizeName((st.nameDraft or "") .. text)
elseif self._onlineFocus == "online-note" then
st.note = utf8Cap((st.note or "") .. text, OnlinePanel.NOTE_MAX)
elseif self._onlineFocus == "online-code" then
st.joinCode = OnlinePanel.sanitizeCode((st.joinCode or "") .. text)
elseif self._onlineFocus == "online-trade-code" then
local tr = OnlinePanel.tradeState(self)
tr.code = OnlinePanel.sanitizeCode((tr.code or "") .. text)
elseif self._onlineFocus == OnlinePanel.PC_FIELD then
local pc = OnlinePanel.pcPicker(self)
OnlinePanel.pcQuery(self, ((pc and pc.query) or "") .. text)
end
return
end
if self._skinUrlFocus then
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
MAX_SKIN_URL)
@@ -5294,18 +5584,18 @@ function RomImporter:_refreshMods()
end
-- a pin is judged against the whole listing: the cart named it, so it is
-- listed even where the game filter above would have dropped it
local cartId, report = self:modCartPlan()
local cartId, report = self:modCartPlan(listed)
if cartId then self.mods = self:_cartPinRows(cartId, report, listed) end
end
-- The cart the MODS panel is answering for, with the plan that resolves its
-- pins, or nil when the panel is on a base game.
function RomImporter:modCartPlan()
function RomImporter:modCartPlan(listed)
local version = self.modScope
if not version then return nil end
local id = self.activeCart and self.activeCart[version] or nil
if not id then return nil end
local report = self:cartPlan(version)
local report = self:cartPlan(version, listed)
if type(report) ~= "table" or type(report.pins) ~= "table" then return nil end
return id, report, version
end
@@ -5460,6 +5750,29 @@ function RomImporter:_modUpdateInfo(id)
return self.modUpdateInfo and self.modUpdateInfo[id] or nil
end
function RomImporter:_rejudgeModUpdate(id, fallbackVersion)
if type(id) ~= "string" then return end
local info = self:_modUpdateInfo(id)
local releases = info and info.releases
if type(releases) ~= "table" or #releases == 0 then return end
local installed
for _, m in ipairs(self.mods or {}) do
if m.id == id then installed = m.version break end
end
installed = installed or fallbackVersion
if type(installed) ~= "string" or installed == "" then return end
local ModUpdate = require("src.mods.ModUpdate")
local status, best = ModUpdate.statusFor(installed, releases)
info.status = status
info.latest = best and best.version or nil
info.best = best
info.err = nil
self._modUpdateRev = (self._modUpdateRev or 0) + 1
for _, item in ipairs(self._modInfoFetch or {}) do
if item.mod and item.mod.id == id then item.mod.version = installed end
end
end
-- Flip one game's mod flag (persisted via LauncherMods.setEnabled) and relist
-- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game.
@@ -5876,6 +6189,7 @@ function RomImporter:_pumpModInstall()
-- The installed list is what the Install / Installed labels read, so it has
-- to be re-derived before the next paint or the card lies.
pcall(self._refreshMods, self)
self:_rejudgeModUpdate(spec.modId, resErr or job.version)
local shown = tostring(resErr or job.version or "")
local text = ("%s %s %s"):format(spec.verb or "Installed",
tostring(spec.name or spec.modId), shown)
@@ -6015,6 +6329,91 @@ function RomImporter:_installCartPins(version, id)
self:pressInstallCartMods(version)
end
-- ------- one-call cart install for the ONLINE tab
function RomImporter:installCartForOnline(cartId, version, onDone)
if type(cartId) ~= "string" or cartId == "" then
if onDone then onDone(false, "no cart was named") end
return false
end
if self._onlineCart then return false end
self._onlineCart = { id = cartId, base = version, done = onDone,
stage = "index" }
self:_ensureFind()
return true
end
function RomImporter:_finishOnlineCart(ok, text)
local job = self._onlineCart
self._onlineCart = nil
if job and job.done then pcall(job.done, ok, text) end
end
function RomImporter:pumpOnlineCartInstall()
local job = self._onlineCart
if not job then return end
local CartStore = require("src.carts.CartStore")
if job.stage == "index" then
if self._findFetch then return end
if not self.findLoaded then
self:_ensureFind()
return
end
local entry
for _, row in ipairs((self.findIndex and self.findIndex.carts) or {}) do
if row.id == job.id then
entry = row
break
end
end
if not entry then
return self:_finishOnlineCart(false,
Strings("No mod index lists a cart called %s.", tostring(job.id)))
end
if self._modInstall or self._cartInstall then return end
self.findNotice = nil
self:_beginCartInstall(entry)
if not self._cartInstall then
return self:_finishOnlineCart(false,
tostring((self.findNotice and self.findNotice.text)
or "that cart has no download"))
end
job.stage = "download"
return
end
if job.stage == "download" then
if self._cartInstall then return end
local ok, cart = pcall(CartStore.get, job.id)
if not ok or type(cart) ~= "table" then
return self:_finishOnlineCart(false,
tostring((self.findNotice and self.findNotice.text)
or "that cart did not install"))
end
self._modConfirm = nil
job.base = cart.base
job.title = cart.title or cart.id
if #self:_cartPinsMissing(cart.base, job.id) == 0 then
return self:_finishOnlineCart(true,
Strings("%s is installed.", tostring(job.title)))
end
self:_installCartPins(cart.base, job.id)
job.stage = "pins"
return
end
if job.stage == "pins" then
if self._cartFill then return end
if #self:_cartPinsMissing(job.base, job.id) > 0 then
return self:_finishOnlineCart(false,
tostring((self.cartFillNotice and self.cartFillNotice.text)
or "some of this cart's mods could not be installed"))
end
return self:_finishOnlineCart(true,
Strings("%s and its mods are installed.", tostring(job.title or job.id)))
end
end
-- Start an async pull for a single dependency
function RomImporter:_startDepPull(dep)
if not dep or not dep.github then return end
@@ -6391,27 +6790,17 @@ function RomImporter:_findRows()
category = (not carts) and self.findCategory or nil,
base = carts and self.findBase or nil,
})
if self.modScope then
if carts then
-- A cart plays as exactly one game, so the scope is a plain match on
-- the base rather than a ModTargets coverage question.
local kept = {}
for _, entry in ipairs(rows) do
if entry.base == self.modScope then kept[#kept + 1] = entry end
if self.modScope and not carts then
local ModTargets = require("src.mods.ModTargets")
local gen = GameVersion.generation(self.modScope)
local kept = {}
for _, entry in ipairs(rows) do
local versions = ModTargets.normalize(entry.games)
if #versions == 0 or ModTargets.covers(versions, gen) then
kept[#kept + 1] = entry
end
rows = kept
else
local ModTargets = require("src.mods.ModTargets")
local gen = GameVersion.generation(self.modScope)
local kept = {}
for _, entry in ipairs(rows) do
local versions = ModTargets.normalize(entry.games)
if #versions == 0 or ModTargets.covers(versions, gen) then
kept[#kept + 1] = entry
end
end
rows = kept
end
rows = kept
end
self._findRowsCache = { src = all, query = self.findQuery,
category = self.findCategory, base = self.findBase,
@@ -6724,16 +7113,39 @@ end
function RomImporter:_findShowDetails(entry)
local ModIndex = require("src.mods.ModIndex")
local url = ModIndex.joinUrl(entry._base, entry.description_url)
local body = entry.summary or ""
if url then
local ok, text = pcall(ModIndex.fetchText, url)
if ok and type(text) == "string" and text ~= "" then body = text end
end
self:_cancelFindDetails()
self._findDetails = {
title = entry.title or entry.id,
body = body,
body = entry.summary or "",
loading = url ~= nil,
scroll = 0,
}
if url then
self._findDetailsFetch = ModIndex.beginFetchText(url)
end
end
function RomImporter:_cancelFindDetails()
local h = self._findDetailsFetch
if not h then return end
self._findDetailsFetch = nil
pcall(require("src.mods.ModIndex").cancelFetchText, h)
end
function RomImporter:_pumpFindDetails()
local h = self._findDetailsFetch
if not h then return end
if not self._findDetails then
self:_cancelFindDetails()
return
end
local ok, done, text = pcall(require("src.mods.ModIndex").pumpFetchText, h)
if ok and not done then return end
self._findDetailsFetch = nil
local d = self._findDetails
if not d then return end
d.loading = false
if ok and type(text) == "string" and text ~= "" then d.body = text end
end
-- Arm the install confirm. The compatibility list is the whole point of the
+142
View File
@@ -0,0 +1,142 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Home = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
local STATE_TEXT = {
online = "Connected to the lobby.",
connecting = "Connecting...",
reconnecting = "Reconnecting...",
}
function Home.identity(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local client = Client()
local pad, gap = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local state = client.state()
local you = client.you()
local verified = type(you) == "table" and you.verified == true
local offline = state == "offline" or state == "error"
local statusLine = st.status
if not statusLine then
if state == "error" then
statusLine = tostring(client.error() or "Disconnected.")
else
statusLine = Strings(STATE_TEXT[state]
or "Offline. Connect to see who is playing.")
end
end
local statusH = Kit.wrapHeight("small", statusLine, w - 2 * pad, 2)
local stacked = not m.twoCol
local cardH = pad + Kit.textHeight("small") + gap + rowH + gap + statusH + pad
if stacked then cardH = cardH + rowH + gap end
Kit.card(x, y, w, cardH)
local cx, cy = x + pad, y + pad
local inner = w - 2 * pad
cy = cy + Ui.label(Strings("Display name"), cx, cy) + gap
local connectLabel = offline and Strings("Connect") or Strings("Disconnect")
local connectW = math.min(math.floor(inner * (stacked and 0.5 or 0.4)),
Kit.textWidth("small", connectLabel) + math.floor(36 * m.s))
local pillW = math.floor(64 * m.s)
local badgeW = verified and (Kit.textWidth("micro", "VERIFIED")
+ math.floor(18 * m.s)) or 0
local fieldW = stacked and inner
or math.max(math.floor(60 * m.s),
inner - connectW - badgeW - pillW - 3 * gap)
local editing = imp._onlineFocus == "online-name"
local shown = editing and (st.nameDraft or "") or OnlinePanel.ensureName(imp)
Ui.field(imp, cx, cy, fieldW, rowH, "online-name", shown,
Strings("Display name"), editing, function(text)
OnlinePanel.setName(imp, text)
end)
if stacked then cy = cy + rowH + gap end
local px = stacked and cx or (cx + fieldW + gap)
local badgeY = cy + math.floor((rowH - 18 * m.s) / 2)
if verified then
Kit.tag(px, badgeY, badgeW, math.floor(18 * m.s), "VERIFIED", PAL.green,
{ fill = true, bold = true })
px = px + badgeW + gap
end
Kit.tag(px, badgeY, pillW, math.floor(18 * m.s),
offline and "OFFLINE" or "ONLINE", offline and PAL.line or PAL.green)
LV().btn(imp, x + w - pad - connectW, cy, connectW, rowH, "online-connect",
connectLabel, {
kind = offline and "primary" or "ghost", font = "small",
enabled = st.job == nil,
action = function()
if offline then
OnlinePanel.connect(imp)
else
OnlinePanel.disconnect(imp)
end
end })
cy = cy + rowH + gap
Kit.textWrapped("small", statusLine, cx, cy, inner,
st.statusOk and PAL.green or (state == "error" and PAL.red or PAL.muted), 2)
return cardH
end
local CARDS = {
{ id = "play", title = "Play",
note = "Find an open lobby or host your own battle." },
{ id = "watch", title = "Watch",
note = "Open matches and running tournaments to spectate." },
{ id = "trade", title = "Trade",
note = "Swap POKeMON with another save or another trainer." },
}
function Home.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local c = OnlinePanel.cache(imp)
local pad, gap = Ui.pads(m)
local online = Client().state() == "online"
local cy = y + Home.identity(imp, x, y, w, m) + gap
if online then
Kit.text("small", Strings("%d players online, %d open lobbies",
c.counts.players, c.counts.lobbies), x, cy, PAL.heading)
else
Kit.text("small", Strings("Connect to see who is playing."), x, cy,
PAL.muted)
end
cy = cy + Kit.textHeight("small") + gap
local cardH = math.max(math.floor(74 * m.s), m.rowH * 2)
if m.twoCol then
local colW = math.floor((w - 2 * gap) / 3)
for i, card in ipairs(CARDS) do
local cx = x + (i - 1) * (colW + gap)
Ui.entryCard(imp, cx, cy, colW, cardH, "online-go-" .. card.id,
Strings(card.title), Strings(card.note),
online or card.id == "trade",
function() OnlinePanel.go(imp, card.id) end)
end
cy = cy + cardH
else
for _, card in ipairs(CARDS) do
Ui.entryCard(imp, x, cy, w, cardH, "online-go-" .. card.id,
Strings(card.title), Strings(card.note),
online or card.id == "trade",
function() OnlinePanel.go(imp, card.id) end)
cy = cy + cardH + gap
end
cy = cy - gap
end
cy = cy + gap
return (cy - y) + pad
end
return Home
+92
View File
@@ -0,0 +1,92 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local PcPicker = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Sprites() return require("src.online.OnlineSprites") end
function PcPicker.draw(imp, m)
local OnlinePanel = OP()
local pc = OnlinePanel.pcPicker(imp)
if not pc then return false end
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local tiny = math.floor(4 * m.s)
local w = math.floor(520 * m.s)
local btnH = math.max(m.btnH, Kit.tapMin())
local rowH = math.max(m.rowH, Kit.tapMin())
local rows = OnlinePanel.pcRows(imp)
local perPage = 6
local h = math.floor(math.min(m.H - 2 * m.pad,
pad + Kit.textHeight("button") + gap + Kit.textHeight("small") + tiny
+ btnH + gap + perPage * (rowH + tiny) + gap + btnH + pad))
local px, py, pw, ph = LV().modalPanel(m, w, h)
local innerW = pw - 2 * pad
local cy = py + pad
Kit.textBold("button", Strings("From the PC"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
cy = cy + Ui.label(Strings("Search"), px + pad, cy) + tiny
Ui.field(imp, px + pad, cy, innerW, btnH, OnlinePanel.PC_FIELD, pc.query,
Strings("Name, species or box"),
imp._onlineFocus == OnlinePanel.PC_FIELD,
function(text) OnlinePanel.pcQuery(imp, text) end)
cy = cy + btnH + gap
local listBottom = py + ph - pad - btnH - gap
local fits = math.max(1, math.floor((listBottom - cy) / (rowH + tiny)))
if #rows == 0 then
Kit.emptyBox(px + pad, cy, innerW, rowH * 2,
Strings("Nothing in the PC matches that."))
else
imp._pages = imp._pages or {}
local pageKey = "online-pc"
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#rows, fits)
imp._pages[pageKey] = pageNow
local sprites = Sprites()
local iconSize = math.max(16, math.floor(rowH * 0.62))
for i = first, last do
local row = rows[i]
local ref = row.ref
local ink = LV().rowHit(imp, px + pad, cy, innerW, rowH,
row.order ~= nil, "online-pc-" .. row.key, function()
OnlinePanel.pcPick(imp, row)
end)
local tx = px + pad + math.floor(10 * m.s)
local sprite = sprites.get(row.version, row.mon)
if sprite and sprites.drawIcon(sprite, tx, cy + (rowH - iconSize) / 2,
iconSize) then
tx = tx + iconSize + math.floor(6 * m.s)
end
if row.order then
Kit.textBold("small", ("%d."):format(row.order), tx,
cy + (rowH - Kit.textHeight("small")) / 2, ink or PAL.heading)
tx = tx + math.floor(20 * m.s)
end
local textW = innerW - (tx - px - pad) - math.floor(10 * m.s)
Kit.text("small", Kit.ellipsize("small", row.label, textW), tx,
cy + math.floor(5 * m.s), ink or PAL.text)
Kit.text("micro", Kit.ellipsize("micro",
tostring(row.source or ""), textW), tx,
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted)
cy = cy + rowH + tiny
if ref == nil then break end
end
end
local by = math.floor(py + ph - pad - btnH)
LV().btn(imp, px + pad, by, innerW, btnH, OnlinePanel.PC_CLOSE,
Strings("Done"), { kind = "primary", font = "small",
action = function() OnlinePanel.pcClose(imp) end })
return true
end
return PcPicker
+188
View File
@@ -0,0 +1,188 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Play = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
function Play.yourLobby(imp, x, y, w, m)
local OnlinePanel = OP()
local c = OnlinePanel.cache(imp)
local mine = c.mine
if not mine then return 0 end
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local h = rowH + btnH + tiny + math.floor(16 * m.s)
Kit.card(x, y, w, h)
local tx = x + math.floor(12 * m.s)
local cy = y + math.floor(8 * m.s)
Kit.textBold("small", Strings("Your lobby"), tx, cy, PAL.heading)
cy = cy + Kit.textHeight("small") + tiny
Kit.text("micro", Kit.ellipsize("micro",
(mine.players or 0) < 2 and Strings("Waiting for an opponent - code %s",
tostring(mine.code or "------"))
or Strings("Code %s", tostring(mine.code or "------")),
w - math.floor(24 * m.s)), tx, cy, PAL.muted)
cy = cy + Kit.textHeight("micro") + tiny
local half = math.floor((w - math.floor(24 * m.s) - gap) / 2)
LV().btn(imp, tx, cy, half, btnH, "online-mine-return", Strings("Return"),
{ kind = "primary", font = "small",
action = function() OnlinePanel.go(imp, "room") end })
LV().btn(imp, tx + half + gap, cy, half, btnH, "online-mine-close",
Strings("Close"),
{ kind = "danger", font = "small",
action = function()
pcall(Client().closeRoom)
OnlinePanel.invalidate(imp, "lobby")
end })
return h + gap
end
function Play.filters(imp, x, y, w, m)
local OnlinePanel = OP()
local _, gap, tiny = Ui.pads(m)
local h = math.max(math.floor(26 * m.s), Kit.tapMin())
local cy = y + Ui.label(Strings(OnlinePanel.FILTER_LABEL), x, y) + tiny
local current = OnlinePanel.filter(imp)
local n = #OnlinePanel.FILTERS
local chipW = math.floor((w - gap * (n - 1)) / n)
for i, filter in ipairs(OnlinePanel.FILTERS) do
local id = filter.id
if Kit.chip(x + (i - 1) * (chipW + gap), cy, chipW, h,
Strings(filter.label), current == id, PAL.lineStrong,
"online-filter-" .. id) then
LV().queueAction(imp, "online-filter-" .. id, function()
OnlinePanel.setFilter(imp, id)
end)
end
end
return (cy - y) + h + tiny
end
function Play.roomList(imp, x, y, w, availH, m, rows, idPrefix, action, label)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local online = Client().state() == "online"
local cy = y
if #rows == 0 then
Kit.emptyBox(x, cy, w, rowH * 2, online
and Strings("No open lobbies right now. Host one and it shows up here.")
or Strings("Connect to see who is playing."))
return (rowH * 2) + gap
end
imp._pages = imp._pages or {}
local pageKey = idPrefix
local perPage = Kit.rowsThatFit(math.max(rowH * 2, availH), rowH, tiny, 2, 10)
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#rows, perPage)
imp._pages[pageKey] = pageNow
local joinW = math.floor(84 * m.s)
for i = first, last do
local row = rows[i]
local ink = LV().rowHit(imp, x, cy, w, rowH, false,
idPrefix .. "-" .. row.id, nil)
local tx = x + math.floor(10 * m.s)
local textW = w - joinW - math.floor(24 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), tx,
cy + math.floor(5 * m.s),
row.reason and PAL.faint or (ink or PAL.heading))
if row.verified then
local bw = math.floor(16 * m.s)
Kit.tag(tx + Kit.textWidth("small", row.name) + math.floor(6 * m.s),
cy + math.floor(5 * m.s), bw, math.floor(14 * m.s), "*", PAL.green)
end
local sub = row.reason and Strings("Can't join: %s", row.reason)
or ("%s %s %s %s"):format(row.game, row.arena, row.rule,
Strings("%d watching", row.spectators))
if not row.reason and row.note then sub = sub .. " " .. row.note end
Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx,
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s),
row.reason and PAL.yellow or PAL.muted)
if row.reason == nil then
local code, rule = row.code, row.ruleTable
LV().btn(imp, x + w - joinW - math.floor(6 * m.s),
cy + (rowH - btnH) / 2, joinW, btnH, idPrefix .. "-go-" .. row.id,
Strings(label),
{ kind = "accent", font = "small", enabled = online,
action = function() action(code, rule, row) end })
end
cy = cy + rowH + tiny
end
if #rows > perPage then
local page, ph = Kit.pager(x, cy, w, pageNow, #rows, perPage, pageKey)
imp._pages[pageKey] = page
cy = cy + ph
end
return (cy - y) + gap
end
function Play.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local client = Client()
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local online = client.state() == "online"
local version = OnlinePanel.engineVersion(imp)
local profile = OnlinePanel.myProfile(imp)
local canBattle = online and OnlinePanel.canBattleWith(version)
local cy = y + Ui.header(imp, x, y, w, m, Strings("Play"),
online and "ONLINE" or "OFFLINE", online and PAL.green or PAL.line)
cy = cy + Play.yourLobby(imp, x, cy, w, m)
if version and OnlinePanel.isGen2(version) and not OnlinePanel.gen2Battles() then
cy = cy + Kit.textWrapped("small",
Strings("Gen 2 battles come later; you can still browse and trade."),
x, cy, w, PAL.yellow, 2) + tiny
end
LV().btn(imp, x, cy, w, btnH, "online-host", Strings("Host a battle"),
{ kind = "primary", font = "small", enabled = canBattle,
action = function() OnlinePanel.startWizard(imp, "hostBattle") end })
cy = cy + btnH + gap
cy = cy + Ui.label(Strings("Join by code"), x, cy) + tiny
local codeW = math.floor(w * 0.5)
Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode,
Strings("Six characters"), imp._onlineFocus == "online-code",
function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end)
LV().btn(imp, x + codeW + gap, cy, w - codeW - gap, btnH,
"online-join-code", Strings("Join"),
{ kind = "accent", font = "small",
enabled = online and #st.joinCode == OnlinePanel.CODE_LEN,
action = function() OnlinePanel.startJoin(imp, st.joinCode) end })
cy = cy + btnH + gap
local rows = c.rooms
if #rows > OnlinePanel.FILTER_AT then
cy = cy + Play.filters(imp, x, cy, w, m)
end
cy = cy + Ui.label(Strings("Open lobbies"), x, cy) + tiny
cy = cy + Play.roomList(imp, x, cy, w,
math.max(m.rowH * 2, availH - (cy - y) - btnH * 2), m, rows,
"online-entry", function(code, rule)
OnlinePanel.startJoin(imp, code, rule)
end, "Join")
LV().btn(imp, x, cy, w, btnH, "online-tour-open",
Strings("Host a tournament"),
{ kind = "ghost", font = "small", enabled = online and profile ~= nil,
action = function() OnlinePanel.startWizard(imp, "hostTournament") end })
cy = cy + btnH + tiny
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Play
+225
View File
@@ -0,0 +1,225 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Room = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
Room.TRADE_STAGE_TEXT = {
waiting = "Waiting for the other trainer",
ready = "Both trainers are here",
battling = "Trade in progress",
ended = "Trade over",
}
Room.STAGE_TEXT = {
waiting = "Waiting for a challenger",
ready = "Both trainers are picking a team",
battling = "Battle in progress",
ended = "Match over",
}
function Room.codeCard(imp, x, y, w, m, code, idPrefix, copied)
local _, gap = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local copyW = math.floor(80 * m.s)
Kit.card(x, y, w, rowH)
Kit.text("title", tostring(code or "------"), x + math.floor(12 * m.s),
y + (rowH - Kit.textHeight("title")) / 2, PAL.heading)
LV().btn(imp, x + w - copyW - math.floor(8 * m.s), y + (rowH - btnH) / 2,
copyW, btnH, idPrefix .. "-copy", Strings("Copy"),
{ kind = "ghost", font = "small",
action = function()
if love.system and love.system.setClipboardText then
pcall(love.system.setClipboardText, tostring(code or ""))
end
local st = OP().state(imp)
st.status, st.statusOk = Strings(copied), true
end })
return rowH + gap
end
function Room.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local client = Client()
local room = client.room()
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
if not room then
local cy = y + Ui.header(imp, x, y, w, m, Strings("Room"))
Kit.emptyBox(x, cy, w, rowH * 2, Strings("You are not in a room."))
return (cy - y) + rowH * 2
end
local stage = tostring(room.stage or "waiting")
local trade = tostring(room.intent or "battle") == "trade"
local cy = y + Ui.header(imp, x, y, w, m,
trade and Strings("Trade room") or Strings("Room"),
Strings(trade and (Room.TRADE_STAGE_TEXT[stage] or stage)
or (Room.STAGE_TEXT[stage] or stage)), PAL.lineStrong)
cy = cy + Room.codeCard(imp, x, cy, w, m, room.code, "online",
"Room code copied.")
cy = cy + Kit.textWrapped("small",
Strings("Give this code to your friend."), x, cy, w, PAL.muted, 1) + tiny
if not trade then
local rule = room.profile and room.profile.rule
cy = cy + Ui.label(Strings("Rules"), x, cy) + tiny
cy = cy + Kit.textWrapped("small",
Strings("%s - %s", OnlinePanel.arenaText(room.profile or {}),
OnlinePanel.ruleText(rule)), x, cy, w, PAL.muted, 1) + tiny
end
local deadline = not trade and room.deadlines
and (room.deadlines.ready or room.deadlines.shot)
if type(deadline) == "number" then
local left = OnlinePanel.countdown(deadline, client.serverTime() or 0)
if left then
cy = cy + Kit.textWrapped("small", Strings("%ds left", left), x, cy, w,
left <= 10 and PAL.red or PAL.muted, 1) + tiny
end
end
if OnlinePanel.lastResult then
cy = cy + Kit.textWrapped("small",
Strings(OnlinePanel.RESULT_TEXT[OnlinePanel.lastResult]
or "The match ended."), x, cy, w, PAL.green, 1) + tiny
end
local need = st.roomCart
if need then
cy = cy + Kit.textWrapped("small",
Strings("This room plays the %s cart.", tostring(need.id)),
x, cy, w, PAL.yellow, 2) + tiny
LV().btn(imp, x, cy, w, btnH, "online-install-cart",
st.cartInstall and Strings("Installing...") or Strings("Install cart"),
{ kind = "accent", font = "small", enabled = st.cartInstall == nil,
action = function() OnlinePanel.installCart(imp, need) end })
cy = cy + btnH + gap
end
local me = OnlinePanel.mySeatId()
local hosting = me ~= nil and room.host == me
local playing = false
cy = cy + Ui.label(Strings("Players"), x, cy) + tiny
for _, player in ipairs(room.players or {}) do
if me and player.id == me then playing = true end
Kit.card(x, cy, w, rowH, "badge")
local text = tostring(player.name or "?")
if player.verified then text = text .. " *" end
if player.online == false then text = text .. Strings(" (away)") end
Kit.text("small", Kit.ellipsize("small", text, w - math.floor(96 * m.s)),
x + math.floor(10 * m.s), cy + (rowH - Kit.textHeight("small")) / 2,
PAL.heading)
if not trade then
local tagW = math.floor(74 * m.s)
Kit.tag(x + w - tagW - math.floor(8 * m.s), cy + (rowH - 18 * m.s) / 2,
tagW, math.floor(18 * m.s), player.ready and "READY" or "PICKING",
player.ready and PAL.green or PAL.line)
end
cy = cy + rowH + tiny
end
if trade then
cy = cy + Kit.textWrapped("small",
(#(room.players or {}) < 2)
and Strings("Waiting for the other trainer.")
or Strings("Opening the trade..."),
x, cy, w, PAL.muted, 2) + tiny
LV().btn(imp, x, cy, w, btnH, "online-leave",
st.confirmLeave and Strings("Really leave?") or Strings("Leave"),
{ kind = "danger", font = "small",
action = function()
if not st.confirmLeave then
st.confirmLeave = true
return
end
st.confirmLeave, st.ready = nil, false
client.leaveRoom()
OnlinePanel.clearPresence()
OnlinePanel.go(imp, "trade")
end })
cy = cy + btnH + tiny
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
local spectators = room.spectators or {}
cy = cy + Ui.label(Strings("Spectators (%d)", #spectators), x, cy) + gap
local rematch = OnlinePanel.lastResult ~= nil and stage ~= "battling"
local half = math.floor((w - gap) / 2)
LV().btn(imp, x, cy, half, btnH, "online-ready",
st.ready and Strings("Unready")
or (rematch and Strings("Rematch") or Strings("Ready")),
{ kind = st.ready and "ghost" or "primary", font = "small",
enabled = playing and stage ~= "battling",
action = function()
if st.ready then
OnlinePanel.unready(imp)
else
OnlinePanel.sendReady(imp)
end
end })
LV().btn(imp, x + half + gap, cy, half, btnH, "online-leave",
st.confirmLeave and Strings("Really leave?") or Strings("Leave"),
{ kind = "danger", font = "small",
action = function()
if not st.confirmLeave then
st.confirmLeave = true
return
end
st.confirmLeave, st.ready = nil, false
client.leaveRoom()
OnlinePanel.clearPresence()
OnlinePanel.go(imp, "play")
end })
cy = cy + btnH + tiny
if hosting then
LV().btn(imp, x, cy, w, btnH, "online-host-more",
st.hostMore and Strings("Hide host controls")
or Strings("Host controls"),
{ kind = "ghost", font = "small",
action = function() st.hostMore = not st.hostMore end })
cy = cy + btnH + tiny
if st.hostMore then
for _, watcher in ipairs(spectators) do
local kickW = math.floor(64 * m.s)
Kit.text("small", Kit.ellipsize("small",
tostring(watcher.name or "?"), w - kickW), x + math.floor(10 * m.s),
cy + math.floor(4 * m.s), PAL.muted)
local id = watcher.id
LV().btn(imp, x + w - kickW, cy, kickW, btnH,
"online-kick-" .. tostring(id), Strings("Kick"),
{ kind = "danger", font = "small",
action = function()
if type(client.kick) == "function" then pcall(client.kick, id) end
end })
cy = cy + btnH + tiny
end
LV().btn(imp, x, cy, w, btnH, "online-close-room",
Strings("Close the room"),
{ kind = "danger", font = "small",
action = function()
pcall(client.closeRoom)
OnlinePanel.go(imp, "play")
end })
cy = cy + btnH + tiny
end
end
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Room
+214
View File
@@ -0,0 +1,214 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local Room = require("src.import.online.Room")
local PAL = Theme.PAL
local Tournaments = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
Tournaments.STAGE_TEXT = {
registering = "Waiting for trainers",
running = "Bracket in progress",
finished = "Tournament over",
}
function Tournaments.bracket(imp, x, y, w, m, tour)
local OnlinePanel = OP()
local gap = math.floor(6 * m.s)
local columns = OnlinePanel.bracketColumns(tour)
local cardH = math.floor(34 * m.s)
if #columns == 0 then
Kit.emptyBox(x, y, w, cardH,
Strings("The bracket is drawn when the tournament starts."))
return cardH
end
local minColW = math.floor(96 * m.s)
local fit = math.max(1, math.floor((w + gap) / (minColW + gap)))
local shown = math.min(#columns, fit)
local first = 1
if shown < #columns then
first = math.max(1, math.min(#columns - shown + 1, tonumber(tour.round) or 1))
end
local colW = math.floor((w - gap * (shown - 1)) / shown)
local tallest = 0
for i = 0, shown - 1 do
local column = columns[first + i]
local cx = x + i * (colW + gap)
local cy = y
Kit.text("micro", Strings("Round %d", column.round or (first + i)), cx, cy,
PAL.faint)
cy = cy + Kit.textHeight("micro") + math.floor(3 * m.s)
for _, entry in ipairs(column.matches) do
Kit.card(cx, cy, colW, cardH, entry.live and "badge" or nil)
if entry.live then
Theme.stroke(cx, cy, colW, cardH, PAL.green, Theme.A.focus, 2)
end
local pad = math.floor(6 * m.s)
local ink = PAL.text
if entry.state == "done" then ink = PAL.muted end
if entry.live then ink = PAL.heading end
Kit.text("micro",
Kit.ellipsize("micro", OnlinePanel.matchText(entry), colW - 2 * pad),
cx + pad, cy + math.floor(4 * m.s), ink)
local sub
if entry.bye and not entry.b then
sub = Strings("bye")
elseif entry.winnerName then
sub = entry.how and ("%s (%s)"):format(entry.winnerName, entry.how)
or entry.winnerName
elseif entry.live then
sub = Strings("live")
else
sub = Strings("pending")
end
Kit.text("micro", Kit.ellipsize("micro", sub, colW - 2 * pad),
cx + pad, cy + cardH - Kit.textHeight("micro") - math.floor(4 * m.s),
entry.live and PAL.green or PAL.faint)
cy = cy + cardH + math.floor(4 * m.s)
end
tallest = math.max(tallest, cy - y)
end
return tallest
end
local function lobbyView(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local online = Client().state() == "online"
local profile = OnlinePanel.myProfile(imp)
local version = OnlinePanel.engineVersion(imp)
local canPlay = online and profile ~= nil
and OnlinePanel.canBattleWith(version)
local cy = y
LV().btn(imp, x, cy, w, btnH, "online-tour-host",
Strings("Host a tournament"),
{ kind = "primary", font = "small", enabled = online and profile ~= nil,
action = function()
OnlinePanel.startWizard(imp, "hostTournament")
end })
cy = cy + btnH + gap
cy = cy + Ui.label(Strings("Join by code"), x, cy) + tiny
local codeW = math.floor(w * 0.5)
Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode,
Strings("Six characters"), imp._onlineFocus == "online-code",
function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end)
local restW = math.floor((w - codeW - 2 * gap) / 2)
LV().btn(imp, x + codeW + gap, cy, restW, btnH, "online-tour-join",
Strings("Join"),
{ kind = "accent", font = "small",
enabled = canPlay and #st.joinCode == OnlinePanel.CODE_LEN,
action = function()
OnlinePanel.startJoinTournament(imp, st.joinCode)
end })
LV().btn(imp, x + codeW + restW + 2 * gap, cy, restW, btnH,
"online-tour-watch", Strings("Watch"),
{ kind = "ghost", font = "small",
enabled = online and #st.joinCode == OnlinePanel.CODE_LEN,
action = function()
OnlinePanel.joinTournamentByCode(imp, st.joinCode, "spectator")
end })
cy = cy + btnH + tiny
return cy - y
end
function Tournaments.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local client = Client()
local tour = client.tournament()
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
if not tour then
local cy = y + Ui.header(imp, x, y, w, m, Strings("Tournaments"))
cy = cy + lobbyView(imp, x, cy, w, m)
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
local me = OnlinePanel.mySeatId()
local controls = OnlinePanel.creatorControls(tour, me)
local stage = tostring(tour.stage or "registering")
local cy = y + Ui.header(imp, x, y, w, m, Strings("Tournament"),
Strings(Tournaments.STAGE_TEXT[stage] or stage), PAL.lineStrong)
local banner = OnlinePanel.bannerText(tour, me) or OnlinePanel.tourNotice
if banner then
cy = cy + Kit.textWrapped("small", banner, x, cy, w, PAL.green, 2) + tiny
end
local left = OnlinePanel.tourDeadline(tour)
if left then
cy = cy + Kit.textWrapped("small", Strings("%ds left", left), x, cy, w,
left <= 10 and PAL.red or PAL.muted, 1) + tiny
end
cy = cy + Tournaments.bracket(imp, x, cy, w, m, tour) + gap
cy = cy + Room.codeCard(imp, x, cy, w, m, tour.code, "online-tour",
"Tournament code copied.")
cy = cy + Ui.label(Strings("Players (%d) - spectators (%d)",
#(tour.players or {}), #(tour.spectators or {})), x, cy) + tiny
for _, player in ipairs(tour.players or {}) do
local kickW = math.floor(64 * m.s)
local wide = controls.canKick and player.id ~= me and st.hostMore
local text = tostring(player.name or "?")
if player.verified then text = text .. " *" end
if player.eliminated then text = text .. Strings(" (out)") end
if player.online == false then text = text .. Strings(" (away)") end
Kit.text("small", Kit.ellipsize("small", text,
w - (wide and kickW or 0) - math.floor(20 * m.s)),
x + math.floor(10 * m.s), cy,
player.eliminated and PAL.faint or PAL.text)
if wide then
local id = player.id
LV().btn(imp, x + w - kickW, cy - math.floor(4 * m.s), kickW, btnH,
"online-tour-kick-" .. tostring(id), Strings("Kick"),
{ kind = "danger", font = "small",
action = function() pcall(client.kickFromTournament, id) end })
cy = cy + btnH + tiny
else
cy = cy + Kit.textHeight("small") + tiny
end
end
cy = cy + gap
local third = math.floor((w - 2 * gap) / 3)
LV().btn(imp, x, cy, third, btnH, "online-tour-start", Strings("Start"),
{ kind = "primary", font = "small", enabled = controls.canStart,
action = function() pcall(client.startTournament) end })
LV().btn(imp, x + third + gap, cy, third, btnH, "online-tour-more",
st.hostMore and Strings("Hide host") or Strings("Host controls"),
{ kind = "ghost", font = "small", enabled = controls.isCreator,
action = function() st.hostMore = not st.hostMore end })
LV().btn(imp, x + 2 * (third + gap), cy, third, btnH, "online-tour-leave",
Strings("Leave"),
{ kind = "danger", font = "small",
action = function()
OnlinePanel.leaveTournament(imp)
OnlinePanel.go(imp, "play")
end })
cy = cy + btnH + tiny
if st.hostMore and controls.canClose then
LV().btn(imp, x, cy, w, btnH, "online-tour-close",
Strings("Close the tournament"),
{ kind = "danger", font = "small",
action = function() pcall(client.closeTournament) end })
cy = cy + btnH + tiny
end
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Tournaments
+318
View File
@@ -0,0 +1,318 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local TradeScreen = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
local function Sprites() return require("src.online.OnlineSprites") end
local function rowId(side, row, at)
local key = tostring(row.key or row.index or at):gsub("|", "-")
return ("online-trade-%s-%s"):format(side, key)
end
local function column(imp, x, y, w, m, side, rows, onPick)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local cy = y
if #rows == 0 then
Kit.emptyBox(x, cy, w, rowH, Strings("No POKeMON here."))
return rowH + gap
end
local sprites = Sprites()
local iconSize = math.max(16, math.floor(rowH * 0.62))
for at, row in ipairs(rows) do
local pick = row.ref ~= nil and row.ref or row.index
local ink = LV().rowHit(imp, x, cy, w, rowH, row.picked,
rowId(side, row, at), function()
if row.pickable == false then return end
onPick(pick)
end)
local tx = x + math.floor(10 * m.s)
local sprite = sprites.get(row.version, row.mon)
if sprite and sprites.drawIcon(sprite, tx, cy + (rowH - iconSize) / 2,
iconSize) then
tx = tx + iconSize + math.floor(6 * m.s)
end
Kit.text("small", Kit.ellipsize("small", row.label,
w - (tx - x) - math.floor(10 * m.s)), tx,
cy + (rowH - Kit.textHeight("small")) / 2,
(row.pickable == false) and PAL.faint or (ink or PAL.text))
cy = cy + rowH + tiny
end
return (cy - y) + gap
end
local function localTrade(imp, x, y, w, m)
local OnlinePanel = OP()
local tr = OnlinePanel.tradeState(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y
local colW = math.floor((w - gap) / 2)
if #c.tradeSlots < 2 then
Kit.emptyBox(x, cy, w, rowH * 2,
Strings("Trading needs two saves on this machine."))
return (rowH * 2) + gap
end
cy = cy + Ui.label(Strings("Saves"), x, cy) + tiny
for i, side in ipairs({ "a", "b" }) do
local entry = tr.sides[side]
Ui.chooser(imp, x + (i - 1) * (colW + gap), cy, colW, rowH,
"online-trade-slot-" .. side,
entry and entry.label or Strings("Pick a save"),
function() OnlinePanel.tradeCycleSide(imp, side, -1) end,
function() OnlinePanel.tradeCycleSide(imp, side, 1) end)
end
cy = cy + rowH + gap
local tallest = 0
for i, side in ipairs({ "a", "b" }) do
local sx = x + (i - 1) * (colW + gap)
local view = OnlinePanel.tradeSideView(imp, side)
local h
if view and not view.handle then
Kit.emptyBox(sx, cy, colW, rowH,
tostring(view.reason or "that save can't be read"))
h = rowH + gap
else
h = column(imp, sx, cy, colW, m, side, c.tradeRows[side] or {},
function(ref) OnlinePanel.tradePick(imp, side, ref) end)
if OnlinePanel.tradePcAllowed(imp, side) then
LV().btn(imp, sx, cy + h, colW, rowH, "online-trade-pc-" .. side,
Strings("From PC"),
{ kind = "ghost", font = "small",
action = function()
OnlinePanel.pcOpen(imp, { side = side })
end })
h = h + rowH + tiny
end
end
tallest = math.max(tallest, h)
end
cy = cy + tallest
LV().btn(imp, x, cy, w, rowH, "online-trade-preview", Strings("Preview"),
{ kind = "primary", font = "small",
enabled = tr.picks.a ~= nil and tr.picks.b ~= nil,
action = function() OnlinePanel.tradeModalPreview(imp) end })
cy = cy + rowH + tiny
if tr.status then
cy = cy + Kit.textWrapped("small", tostring(tr.status), x, cy, w,
tr.statusOk and PAL.green or PAL.yellow, 3) + tiny
end
return cy - y
end
local function remoteTrade(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local tr = OnlinePanel.tradeState(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y
local colW = math.floor((w - gap) / 2)
local online = Client().state() == "online"
if tr.remote then
local stage = tr.remote:stage()
cy = cy + Kit.textWrapped("small", OnlinePanel.remoteStageText(stage),
x, cy, w, PAL.heading, 2) + tiny
local mine, theirs = OnlinePanel.remoteRows(tr.remote)
local left = column(imp, x, cy, colW, m, "mine", mine, function(ref)
OnlinePanel.remotePick(imp, ref)
end)
local right = column(imp, x + colW + gap, cy, colW, m, "theirs", theirs,
function() end)
cy = cy + math.max(left, right)
local third = math.floor((w - 2 * gap) / 3)
LV().btn(imp, x, cy, third, rowH, "online-trade-yes", Strings("Confirm"),
{ kind = "primary", font = "small", enabled = stage == "confirming",
action = function() OnlinePanel.remoteConfirm(imp, true) end })
LV().btn(imp, x + third + gap, cy, third, rowH, "online-trade-no",
Strings("Say no"),
{ kind = "ghost", font = "small", enabled = stage == "confirming",
action = function() OnlinePanel.remoteConfirm(imp, false) end })
LV().btn(imp, x + 2 * (third + gap), cy, third, rowH,
"online-trade-cancel", Strings("Cancel"),
{ kind = "danger", font = "small",
action = function() OnlinePanel.endRemoteTrade(imp) end })
return (cy + rowH + gap) - y
end
local refusal = OnlinePanel.remoteTradeRefusal(imp)
if refusal then
cy = cy + Kit.textWrapped("small", refusal, x, cy, w, PAL.yellow, 3) + tiny
end
LV().btn(imp, x, cy, w, rowH, "online-trade-remote-start",
Strings("Set up a trade"),
{ kind = "primary", font = "small", enabled = online,
action = function() OnlinePanel.startWizard(imp, "tradeRemote") end })
cy = cy + rowH + tiny
local note = tr.remoteError or tr.remoteResult or tr.status or st.status
if note then
cy = cy + Kit.textWrapped("small", tostring(note), x, cy, w,
tr.remoteError and PAL.red or PAL.muted, 3) + tiny
end
return cy - y
end
local MODES = {
{ id = "local", title = "On this device",
note = "Trade between two saves on this device." },
{ id = "remote", title = "Over the internet",
note = "Trade with another trainer over the relay." },
}
function TradeScreen.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local tr = OnlinePanel.tradeState(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y + Ui.header(imp, x, y, w, m, Strings("Trade"))
if tr.remote then tr.chosen = true end
if not tr.chosen then
local cardH = math.max(math.floor(74 * m.s), m.rowH * 2)
if m.twoCol then
local colW = math.floor((w - gap) / 2)
for i, mode in ipairs(MODES) do
local id = mode.id
Ui.entryCard(imp, x + (i - 1) * (colW + gap), cy, colW, cardH,
"online-trade-mode-" .. id, Strings(mode.title), Strings(mode.note),
true, function()
OnlinePanel.tradeMode(imp, id)
tr.chosen = true
end)
end
cy = cy + cardH + gap
else
for _, mode in ipairs(MODES) do
local id = mode.id
Ui.entryCard(imp, x, cy, w, cardH, "online-trade-mode-" .. id,
Strings(mode.title), Strings(mode.note), true, function()
OnlinePanel.tradeMode(imp, id)
tr.chosen = true
end)
cy = cy + cardH + gap
end
end
return cy - y
end
cy = cy + Ui.label(Strings("Trade with"), x, cy) + tiny
local half = math.floor((w - gap) / 2)
for i, mode in ipairs(MODES) do
local id = mode.id
if Kit.chip(x + (i - 1) * (half + gap), cy, half, rowH,
Strings(mode.title), tr.mode == id, PAL.lineStrong,
"online-trade-pick-" .. id) then
LV().queueAction(imp, "online-trade-pick-" .. id, function()
OnlinePanel.tradeMode(imp, id)
end)
end
end
cy = cy + rowH + gap
if tr.mode == "remote" then
cy = cy + remoteTrade(imp, x, cy, w, m)
else
cy = cy + localTrade(imp, x, cy, w, m)
end
return cy - y
end
local function modalMon(m, entry, label, x, w, y, box)
local sprites = Sprites()
local sprite = entry and entry.mon
and sprites.get(entry.version, entry.mon) or nil
if sprite then
sprites.drawFront(sprite, x + math.floor((w - box) / 2), y, box)
end
local cy = y + box + math.floor(4 * m.s)
Kit.textCenter("micro", label, x, cy, w, PAL.faint)
cy = cy + Kit.textHeight("micro") + math.floor(2 * m.s)
Kit.textCenter("small",
Kit.ellipsize("small", (entry and entry.label) or "?", w),
x, cy, w, PAL.heading)
return (cy + Kit.textHeight("small")) - y
end
function TradeScreen.drawModal(imp, m)
local OnlinePanel = OP()
local mo = OnlinePanel.tradeModal(imp)
if not mo then return false end
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(460 * m.s)
local innerW = w - 2 * pad
local btnH = math.max(m.btnH, Kit.tapMin())
local box = math.floor(56 * m.s)
local monH = box + math.floor(6 * m.s) + Kit.textHeight("micro")
+ Kit.textHeight("small")
local result = mo.view == "result"
local lines = result and (mo.resultLines or {}) or (mo.lines or {})
local heading = result and (mo.message or "")
or ((#lines > 0) and Strings("What changes") or nil)
local linesH = heading and (Kit.textHeight("small") + math.floor(4 * m.s))
or 0
for _, line in ipairs(lines) do
linesH = linesH + Kit.wrapHeight("small", tostring(line), innerW, 2)
+ math.floor(3 * m.s)
end
local h = pad + Kit.textHeight("button") + gap + monH + gap + linesH + gap
+ btnH + pad
local px, py, pw, ph = LV().modalPanel(m, w, h)
innerW = pw - 2 * pad
local cy = py + pad
Kit.textBold("button", Strings("Trade"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
local slotW = math.floor((innerW - gap) / 2)
local left = modalMon(m, mo.give, Strings("Give"), px + pad, slotW, cy, box)
local right = modalMon(m, mo.get, Strings("Get"),
px + pad + slotW + gap, slotW, cy, box)
cy = cy + math.max(left, right) + gap
if heading then
Kit.text("small", Kit.ellipsize("small", heading, innerW), px + pad, cy,
result and (mo.ok and PAL.green or PAL.yellow) or PAL.detail)
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
end
for _, line in ipairs(lines) do
cy = cy + Kit.textWrapped("small", tostring(line), px + pad, cy, innerW,
PAL.muted, 2) + math.floor(3 * m.s)
end
cy = math.floor(py + ph - pad - btnH)
if result then
LV().btn(imp, px + pad, cy, innerW, btnH,
OnlinePanel.TRADE_MODAL_DONE, Strings("Done"),
{ kind = "primary", font = "small",
action = function() OnlinePanel.tradeModalClose(imp) end })
return true
end
local half = math.floor((innerW - gap) / 2)
LV().btn(imp, px + pad, cy, half, btnH,
OnlinePanel.TRADE_MODAL_CANCEL, Strings("Cancel"),
{ kind = "ghost", font = "small",
action = function() OnlinePanel.tradeModalClose(imp) end })
LV().btn(imp, px + pad + half + gap, cy, innerW - half - gap, btnH,
OnlinePanel.TRADE_MODAL_CONFIRM, Strings("Confirm"),
{ kind = "primary", font = "small",
action = function() OnlinePanel.tradeModalConfirm(imp) end })
return true
end
return TradeScreen
+137
View File
@@ -0,0 +1,137 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local PAL = Theme.PAL
local Ui = {}
local function LV()
return require("src.import.LauncherView")
end
local function OP()
return require("src.import.OnlinePanel")
end
Ui.LV = LV
Ui.OP = OP
function Ui.pads(m)
local s = m.s
return math.floor(14 * s), math.floor(8 * s), math.floor(4 * s)
end
function Ui.label(text, x, y, color)
Kit.text("small", text, x, y, color or PAL.muted)
return Kit.textHeight("small")
end
function Ui.field(imp, x, y, w, h, key, text, placeholder, focused, set)
Kit._audit("control", x, y, w, h, key)
local ring = Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line,
(focused or ring) and Theme.A.focus
or (Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
focused and 2 or 1)
local pad = math.floor(10 * Kit.scale)
local ty = y + (h - Kit.textHeight("button")) / 2
if (text or "") == "" and not focused then
Kit.text("button", Kit.ellipsize("button", placeholder or "", w - 2 * pad),
x + pad, ty, PAL.faint)
else
local shown = Kit.ellipsizeLeft("button", text or "", w - 2 * pad)
local tw = Kit.text("button", shown, x + pad, ty, PAL.heading)
if focused and ((imp.pulse or 0) * 2 % 1) < 0.5 then
Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale),
Kit.textHeight("button"), PAL.ink, 1)
end
end
if Kit.press(x, y, w, h) or Kit._activateId == key then
if Kit.VirtualKeyboard then
Kit.VirtualKeyboard.open({
text = text or "",
targetId = key,
title = placeholder or "Enter Text",
onDone = function(newText, confirmed)
if confirmed then set(newText) end
end,
})
end
LV().queueAction(imp, key, function() imp:_focusOnlineField(key) end)
end
end
function Ui.chooser(imp, x, y, w, h, key, text, onPrev, onNext)
local bw = math.max(Kit.tapMin(), math.floor(28 * Kit.scale))
local gap = math.floor(4 * Kit.scale)
LV().btn(imp, x, y, bw, h, key .. "-prev", "<",
{ kind = "ghost", font = "small", action = onPrev })
LV().btn(imp, x + w - bw, y, bw, h, key .. "-next", ">",
{ kind = "ghost", font = "small", action = onNext })
local inner = math.max(0, w - 2 * bw - 2 * gap)
Kit.textCenter("small", Kit.ellipsize("small", text, inner),
x + bw + gap, y + (h - Kit.textHeight("small")) / 2, inner, PAL.heading)
end
function Ui.indexOf(list, value)
for i = 1, #list do
if list[i] == value or (list[i] == false and value == nil) then return i end
end
return 1
end
function Ui.cycle(list, value, delta)
local at = Ui.indexOf(list, value)
local to = ((at - 1 + delta) % #list) + 1
local picked = list[to]
if picked == false then return nil end
return picked
end
function Ui.header(imp, x, y, w, m, title, pill, pillColor)
local _, gap = Ui.pads(m)
local h = math.max(m.btnH, Kit.tapMin())
local backW = math.floor(82 * m.s)
LV().btn(imp, x, y, backW, h, "online-back", Strings("Back"),
{ kind = "ghost", font = "small",
action = function() OP().back(imp) end })
local right = 0
if pill and pill ~= "" then
right = math.min(math.floor(w * 0.4),
Kit.textWidth("micro", pill) + math.floor(22 * m.s))
Kit.tag(x + w - right, y + (h - math.floor(18 * m.s)) / 2, right,
math.floor(18 * m.s), pill, pillColor or PAL.line)
end
local tx = x + backW + 2 * gap
Kit.textBold("button",
Kit.ellipsize("button", title,
math.max(0, w - backW - 3 * gap - right)),
tx, y + (h - Kit.textHeight("button")) / 2, PAL.heading)
return h + gap
end
function Ui.statusLine(imp, x, y, w, m)
local st = OP().state(imp)
if not st.status then return 0 end
local _, gap = Ui.pads(m)
return Kit.textWrapped("small", tostring(st.status), x, y, w,
st.statusOk and PAL.green or PAL.yellow, 2) + gap
end
function Ui.entryCard(imp, x, y, w, h, key, title, note, enabled, action)
local pad = math.floor(12 * Kit.scale)
local clicked, hot = Kit.row(x, y, w, h, false, enabled and key or nil)
local ink = enabled and (hot or PAL.heading) or PAL.faint
Kit.textBold("button", Kit.ellipsize("button", title, w - 2 * pad),
x + pad, y + pad, ink)
Kit.textWrapped("small", note, x + pad,
y + pad + Kit.textHeight("button") + math.floor(4 * Kit.scale),
w - 2 * pad, enabled and PAL.muted or PAL.faint, 2)
if clicked and enabled and action then
LV().queueAction(imp, key, action)
end
end
return Ui
+98
View File
@@ -0,0 +1,98 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Watch = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
function Watch.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local online = Client().state() == "online"
local cy = y + Ui.header(imp, x, y, w, m, Strings("Watch"),
online and "ONLINE" or "OFFLINE", online and PAL.green or PAL.line)
cy = cy + Ui.label(Strings("Watch by code"), x, cy) + tiny
local codeW = math.floor(w * 0.5)
Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode,
Strings("Six characters"), imp._onlineFocus == "online-code",
function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end)
LV().btn(imp, x + codeW + gap, cy, w - codeW - gap, btnH,
"online-spectate-code", Strings("Spectate"),
{ kind = "primary", font = "small",
enabled = online and #st.joinCode == OnlinePanel.CODE_LEN,
action = function()
if OnlinePanel.joinByCode(imp, st.joinCode, "spectator") then
OnlinePanel.go(imp, "room")
end
end })
cy = cy + btnH + gap
local rows = c.watch
cy = cy + Ui.label(Strings("Live now"), x, cy) + tiny
if #rows == 0 then
Kit.emptyBox(x, cy, w, rowH * 2, online
and Strings("Nothing to watch yet. Paste a code, or check back.")
or Strings("Connect to see who is playing."))
cy = cy + rowH * 2 + gap
else
imp._pages = imp._pages or {}
local pageKey = "online-watch"
local perPage = Kit.rowsThatFit(math.max(rowH * 2,
availH - (cy - y) - btnH), rowH, tiny, 2, 8)
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#rows, perPage)
imp._pages[pageKey] = pageNow
local watchW = math.floor(88 * m.s)
for i = first, last do
local row = rows[i]
local ink = LV().rowHit(imp, x, cy, w, rowH, false,
"online-watch-" .. row.id, nil)
local tx = x + math.floor(10 * m.s)
local textW = w - watchW - math.floor(24 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), tx,
cy + math.floor(5 * m.s), ink or PAL.heading)
local tournament = row.intent == "tournament"
local sub = ("%s %s %s %s"):format(row.game, row.arena, row.rule,
tournament and Strings("tournament")
or Strings("%d watching", row.spectators))
Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx,
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted)
local code = row.code
LV().btn(imp, x + w - watchW - math.floor(6 * m.s),
cy + (rowH - btnH) / 2, watchW, btnH, "online-watch-go-" .. row.id,
tournament and Strings("Watch") or Strings("Spectate"),
{ kind = "accent", font = "small", enabled = online,
action = function()
if tournament then
OnlinePanel.joinTournamentByCode(imp, code, "spectator")
elseif OnlinePanel.joinByCode(imp, code, "spectator") then
OnlinePanel.go(imp, "room")
end
end })
cy = cy + rowH + tiny
end
if #rows > perPage then
local page, ph = Kit.pager(x, cy, w, pageNow, #rows, perPage, pageKey)
imp._pages[pageKey] = page
cy = cy + ph
end
cy = cy + gap
end
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Watch
+519
View File
@@ -0,0 +1,519 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local GameVersion = require("src.core.GameVersion")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Wizard = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Sprites() return require("src.online.OnlineSprites") end
local SPECTATOR_CHOICES = { 0, 4, 8, 16 }
Wizard.SPECTATOR_CHOICES = SPECTATOR_CHOICES
local function chipRow(imp, x, y, w, m, label, id, options, isOn, onPick)
local _, gap, tiny = Ui.pads(m)
local h = math.max(m.btnH, Kit.tapMin())
local cy = y
if label then cy = cy + Ui.label(label, x, cy) + tiny end
local n = math.max(1, #options)
local perRow = math.min(n, (m.twoCol and 4) or 2)
local chipW = math.floor((w - gap * (perRow - 1)) / perRow)
local col = 0
for _, option in ipairs(options) do
local cx = x + (col % perRow) * (chipW + gap)
local key = id .. "-" .. tostring(option.id)
if Kit.chip(cx, cy, chipW, h, Kit.ellipsize("small", option.label, chipW),
isOn(option), PAL.lineStrong, key) then
local pick = option
LV().queueAction(imp, key, function() onPick(pick) end)
end
col = col + 1
if col % perRow == 0 then cy = cy + h + tiny end
end
if col % perRow ~= 0 then cy = cy + h + tiny end
return (cy - y) + gap - tiny
end
Wizard.chipRow = chipRow
-- ------------------------------------------------------------------ steps
local function stepGame(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap = Ui.pads(m)
local cy = y
local versions = OnlinePanel.readyVersions(imp)
if #versions == 0 then
Kit.emptyBox(x, cy, w, math.max(m.rowH, Kit.tapMin()),
Strings("Import a game first."))
return math.max(m.rowH, Kit.tapMin()) + gap
end
local options = {}
for _, id in ipairs(versions) do
local info = GameVersion.info(id)
options[#options + 1] = { id = id, label = (info and info.name) or id }
end
cy = cy + chipRow(imp, x, cy, w, m, Strings("Game"), "online-game", options,
function(option) return option.id == OnlinePanel.selectedVersion(imp) end,
function(option)
st.version, st.slotId, st.cartId, st.team = option.id, nil, nil, {}
st.slotRead, st.ready, st.kind = nil, false, "vanilla"
OnlinePanel.invalidate(imp)
end)
local arena = { { id = "vanilla", label = Strings("Vanilla") } }
for _, row in ipairs(c.carts) do
arena[#arena + 1] = { id = row.id, label = row.title, cart = true }
end
cy = cy + chipRow(imp, x, cy, w, m, Strings("Arena"), "online-arena", arena,
function(option)
if option.cart then return st.kind == "cart" and st.cartId == option.id end
return st.kind ~= "cart"
end,
function(option)
if option.cart then
st.kind, st.cartId = "cart", option.id
else
st.kind, st.cartId = "vanilla", nil
end
st.slotId, st.team, st.slotRead, st.ready = nil, {}, nil, false
OnlinePanel.invalidate(imp)
end)
if #c.carts == 0 then
cy = cy + Kit.textWrapped("micro",
Strings("No sealed carts on this machine."), x, cy, w, PAL.faint, 1)
+ gap
end
return cy - y
end
local function stepSave(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local cy = y + Ui.label(Strings("Save"), x, y) + tiny
if #c.slots == 0 then
Kit.emptyBox(x, cy, w, rowH, Strings("No save in this game yet."))
return (cy - y) + rowH + gap
end
imp._pages = imp._pages or {}
local pageKey = "online-slots"
local perPage = Kit.rowsThatFit(math.max(rowH * 2, availH), rowH, tiny, 2, 6)
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#c.slots, perPage)
imp._pages[pageKey] = pageNow
for i = first, last do
local row = c.slots[i]
local id = row.id
local ink = LV().rowHit(imp, x, cy, w, rowH, st.slotId == id,
"online-slot-" .. id, function()
st.slotId, st.team, st.slotRead, st.ready = id, {}, nil, false
OnlinePanel.invalidate(imp, "party", "summary")
end)
Kit.text("small", Kit.ellipsize("small", row.label,
w - math.floor(20 * m.s)), x + math.floor(10 * m.s),
cy + math.floor(5 * m.s), ink or PAL.heading)
Kit.text("micro", row.sub, x + math.floor(10 * m.s),
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted)
cy = cy + rowH + tiny
end
if #c.slots > perPage then
local page, ph = Kit.pager(x, cy, w, pageNow, #c.slots, perPage, pageKey)
imp._pages[pageKey] = page
cy = cy + ph
end
return (cy - y) + gap
end
function Wizard.monRow(imp, row, x, y, w, m, onPick)
local rowH = math.max(m.rowH, Kit.tapMin())
local sprites = Sprites()
local iconSize = math.max(16, math.floor(rowH * 0.62))
local ink = LV().rowHit(imp, x, y, w, rowH, row.order ~= nil,
"online-mon-" .. row.key, onPick)
local tx = x + math.floor(10 * m.s)
local sprite = sprites.get(row.version, row.mon)
if sprite and sprites.drawIcon(sprite, tx, y + (rowH - iconSize) / 2,
iconSize) then
tx = tx + iconSize + math.floor(6 * m.s)
end
if row.order then
Kit.textBold("small", ("%d."):format(row.order), tx,
y + (rowH - Kit.textHeight("small")) / 2, ink or PAL.heading)
tx = tx + math.floor(20 * m.s)
end
local textW = w - (tx - x) - math.floor(10 * m.s)
Kit.text("small", Kit.ellipsize("small", row.label, textW), tx,
y + math.floor(5 * m.s),
row.refused and PAL.faint or (ink or PAL.text))
local sub = row.note or ((row.where == "box") and row.source or nil)
if sub then
Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx,
y + rowH - Kit.textHeight("micro") - math.floor(5 * m.s),
row.refused and PAL.red or PAL.faint)
end
return rowH
end
local function stepTeam(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local cy = y
local generations = OnlinePanel.installedGenerations(imp)
if #generations > 1 and OnlinePanel.crossGen(imp) then
cy = cy + Kit.textWrapped("small",
Strings("Time Capsule: your team is converted for %s.",
tostring(OnlinePanel.engineVersion(imp) or "?")),
x, cy, w, PAL.yellow, 2) + tiny
end
cy = cy + Ui.label(Strings("Your party"), x, cy) + tiny
if #c.party == 0 then
Kit.emptyBox(x, cy, w, rowH,
c.partyReason or Strings("That save has no POKeMON."))
cy = cy + rowH + tiny
else
local box = math.floor(56 * m.s)
local listW = w - box - gap
local listY = cy
for _, row in ipairs(c.party) do
local ref = row.ref
cy = cy + Wizard.monRow(imp, row, x, cy, listW, m, function()
st.focusMon = row.key
if row.refused then return end
OnlinePanel.toggleTeam(st.team, ref, OnlinePanel.TEAM_MAX)
st.ready = false
OnlinePanel.invalidate(imp, "party", "summary")
end) + tiny
end
local focus
for _, row in ipairs(c.party) do
if row.key == st.focusMon then focus = row end
end
focus = focus or c.team[1] or c.party[1]
if focus then
local sprite = Sprites().get(focus.version, focus.mon)
if sprite then
Sprites().drawFront(sprite, x + listW + gap, listY, box)
Kit.text("micro", Kit.ellipsize("micro",
tostring(focus.mon.species or "?"), box), x + listW + gap,
listY + box, PAL.faint)
end
end
end
LV().btn(imp, x, cy, w, btnH, "online-team-pc", Strings("From PC"),
{ kind = "ghost", font = "small", enabled = st.slotId ~= nil,
action = function() OnlinePanel.pcOpen(imp) end })
cy = cy + btnH + gap
cy = cy + Ui.label(Strings("Picked (%d)", #(st.team or {})), x, cy) + tiny
if #(c.team or {}) == 0 then
Kit.emptyBox(x, cy, w, rowH,
Strings("Tap a POKeMON above, or pull one out of the PC."))
cy = cy + rowH + tiny
else
for _, row in ipairs(c.team) do
if row then
local ref = row.ref
cy = cy + Wizard.monRow(imp, row, x, cy, w, m, function()
OnlinePanel.toggleTeam(st.team, ref, OnlinePanel.TEAM_MAX)
OnlinePanel.invalidate(imp, "party", "summary")
end) + tiny
end
end
end
if c.teamNote and not c.teamOk then
cy = cy + Kit.textWrapped("small", tostring(c.teamNote):gsub("\n", " "),
x, cy, w, PAL.yellow, 2) + tiny
end
return (cy - y) + gap
end
local function stepRules(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y
local rule = OnlinePanel.ruleFor(imp)
local function bump()
OnlinePanel.editRule(imp)
st.ready = false
OnlinePanel.invalidate(imp, "party", "summary")
end
local third = m.twoCol and math.floor((w - 2 * gap) / 3) or w
cy = cy + Ui.label(Strings("How many POKeMON each"), x, cy) + tiny
Ui.chooser(imp, x, cy, third, rowH, "online-size",
("%d v %d"):format(rule.partySize or 1, rule.partySize or 1),
function()
rule.partySize = Ui.cycle(OnlinePanel.SIZES, rule.partySize, -1)
bump()
end,
function()
rule.partySize = Ui.cycle(OnlinePanel.SIZES, rule.partySize, 1)
bump()
end)
cy = cy + rowH + gap
cy = cy + Ui.label(Strings("Levels"), x, cy) + tiny
local col2 = m.twoCol and (x + third + gap) or x
local row2 = m.twoCol and cy or (cy + rowH + tiny)
Ui.chooser(imp, x, cy, third, rowH, "online-min",
rule.minLevel and ("Lv%d+"):format(rule.minLevel) or Strings("Any min"),
function()
rule.minLevel = Ui.cycle(OnlinePanel.LEVELS, rule.minLevel, -1)
bump()
end,
function()
rule.minLevel = Ui.cycle(OnlinePanel.LEVELS, rule.minLevel, 1)
bump()
end)
Ui.chooser(imp, col2, row2, third, rowH, "online-max",
rule.maxLevel and ("Lv%d-"):format(rule.maxLevel) or Strings("Any max"),
function()
rule.maxLevel = Ui.cycle(OnlinePanel.LEVELS, rule.maxLevel, -1)
bump()
end,
function()
rule.maxLevel = Ui.cycle(OnlinePanel.LEVELS, rule.maxLevel, 1)
bump()
end)
cy = row2 + rowH + gap
cy = cy + Ui.label(Strings("Force every level"), x, cy) + tiny
Ui.chooser(imp, x, cy, third, rowH, "online-force",
rule.forceLevel and ("All Lv%d"):format(rule.forceLevel)
or Strings("Own levels"),
function()
rule.forceLevel = Ui.cycle(OnlinePanel.FORCE_LEVELS, rule.forceLevel, -1)
bump()
end,
function()
rule.forceLevel = Ui.cycle(OnlinePanel.FORCE_LEVELS, rule.forceLevel, 1)
bump()
end)
cy = cy + rowH + gap
return cy - y
end
local function stepVisibility(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local cy = y
cy = cy + chipRow(imp, x, cy, w, m, Strings("Visibility"), "online-visible",
{ { id = "public", label = Strings("Public") },
{ id = "code", label = Strings("Code only") } },
function(option)
if option.id == "public" then return st.public ~= false end
return st.public == false
end,
function(option) st.public = option.id == "public" end)
cy = cy + Kit.textWrapped("micro", st.public == false
and Strings("Only trainers you give the code to can join.")
or Strings("Anyone browsing Play sees this lobby."),
x, cy, w, PAL.faint, 2) + gap
cy = cy + Ui.label(Strings("Note (optional)"), x, cy) + tiny
Ui.field(imp, x, cy, w, btnH, "online-note", st.note,
Strings("Say something, like first to three"),
imp._onlineFocus == "online-note", function(text) st.note = text end)
cy = cy + btnH + gap
return cy - y
end
local function stepPlaying(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap = Ui.pads(m)
local cy = y + chipRow(imp, x, y, w, m, Strings("Your seat"),
"online-tour-playing",
{ { id = "play", label = Strings("Play in it") },
{ id = "organize", label = Strings("Organize and watch") } },
function(option)
if option.id == "play" then return st.tourPlaying ~= false end
return st.tourPlaying == false
end,
function(option)
st.tourPlaying = option.id == "play"
OnlinePanel.invalidate(imp, "summary")
end)
cy = cy + Kit.textWrapped("micro", st.tourPlaying == false
and Strings("You run the bracket and never take a seat in it.")
or Strings("You take one of the seats in the bracket."),
x, cy, w, PAL.faint, 2) + gap
return cy - y
end
local function stepShotClock(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap = Ui.pads(m)
local options = {}
for _, seconds in ipairs(OnlinePanel.SHOT_CLOCKS) do
options[#options + 1] = { id = seconds,
label = Strings("%d seconds", seconds) }
end
local cy = y + chipRow(imp, x, y, w, m, Strings("Thinking time a move"),
"online-shot", options,
function(option) return option.id == st.tourShotClock end,
function(option) st.tourShotClock = option.id end)
cy = cy + Kit.textWrapped("micro",
Strings("Thinking time for each move. Run out and the move is picked for you."),
x, cy, w, PAL.faint, 2) + gap
return cy - y
end
local function stepSpectators(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap = Ui.pads(m)
local options = {}
for _, n in ipairs(SPECTATOR_CHOICES) do
options[#options + 1] = { id = n,
label = (n == 0) and Strings("None") or Strings("Up to %d", n) }
end
local cy = y + chipRow(imp, x, y, w, m, Strings("How many can watch"),
"online-spectators", options,
function(option) return option.id == (tonumber(st.tourSpectators) or 0) end,
function(option) st.tourSpectators = option.id end)
cy = cy + Kit.textWrapped("micro",
Strings("How many people can watch the bracket play out."),
x, cy, w, PAL.faint, 2) + gap
return cy - y
end
local function stepRole(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local tr = OnlinePanel.tradeState(imp)
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local cy = y + chipRow(imp, x, y, w, m, Strings("Trade"), "online-trade-role",
{ { id = "host", label = Strings("Host a trade") },
{ id = "join", label = Strings("Join with a code") } },
function(option) return option.id == (st.tradeRole or "host") end,
function(option) st.tradeRole = option.id end)
if (st.tradeRole or "host") == "join" then
cy = cy + Ui.label(Strings("Trade code"), x, cy) + tiny
Ui.field(imp, x, cy, w, btnH, "online-trade-code", tr.code,
Strings("Six characters"), imp._onlineFocus == "online-trade-code",
function(text) tr.code = OnlinePanel.sanitizeCode(text) end)
cy = cy + btnH + gap
else
cy = cy + Kit.textWrapped("micro",
Strings("You get a code to give the other trainer."),
x, cy, w, PAL.faint, 2) + gap
end
local refusal = OnlinePanel.remoteTradeRefusal(imp)
if refusal then
cy = cy + Kit.textWrapped("small", refusal, x, cy, w, PAL.yellow, 2) + gap
end
return cy - y
end
local function stepSummary(imp, x, y, w, m)
local OnlinePanel = OP()
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local changeW = math.floor(84 * m.s)
local cy = y
for _, answer in ipairs(OnlinePanel.wizardAnswers(imp)) do
Kit.card(x, cy, w, rowH, "badge")
local tx = x + math.floor(10 * m.s)
Kit.text("micro", answer.label, tx, cy + math.floor(5 * m.s), PAL.muted)
Kit.text("small", Kit.ellipsize("small", answer.value,
w - changeW - math.floor(28 * m.s)), tx,
cy + rowH - Kit.textHeight("small") - math.floor(5 * m.s), PAL.heading)
local step = answer.step
LV().btn(imp, x + w - changeW - math.floor(8 * m.s),
cy + (rowH - btnH) / 2, changeW, btnH,
"online-change-" .. step, Strings("Change"),
{ kind = "ghost", font = "small",
action = function() OnlinePanel.wizardTo(imp, step) end })
cy = cy + rowH + tiny
end
return (cy - y) + gap
end
local BODIES = {
game = function(imp, x, y, w, availH, m) return stepGame(imp, x, y, w, m) end,
save = stepSave,
team = function(imp, x, y, w, availH, m) return stepTeam(imp, x, y, w, m) end,
rules = function(imp, x, y, w, availH, m) return stepRules(imp, x, y, w, m) end,
visibility = function(imp, x, y, w, availH, m)
return stepVisibility(imp, x, y, w, m)
end,
playing = function(imp, x, y, w, availH, m)
return stepPlaying(imp, x, y, w, m)
end,
shotclock = function(imp, x, y, w, availH, m)
return stepShotClock(imp, x, y, w, m)
end,
spectators = function(imp, x, y, w, availH, m)
return stepSpectators(imp, x, y, w, m)
end,
role = function(imp, x, y, w, availH, m) return stepRole(imp, x, y, w, m) end,
summary = function(imp, x, y, w, availH, m)
return stepSummary(imp, x, y, w, m)
end,
}
Wizard.BODIES = BODIES
function Wizard.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local def = OnlinePanel.wizardDef(imp)
if not def then
local cy = y + Ui.header(imp, x, y, w, m, Strings("Set up"))
Kit.emptyBox(x, cy, w, m.rowH * 2, Strings("Nothing to set up."))
return (cy - y) + m.rowH * 2
end
local id, at, total = OnlinePanel.wizardStep(imp)
local cy = y + Ui.header(imp, x, y, w, m, Strings(def.title),
("%d/%d"):format(at or 1, total or 1), PAL.lineStrong)
Kit.textBold("button", Strings(OnlinePanel.STEP_TITLE[id] or id), x, cy,
PAL.heading)
cy = cy + Kit.textHeight("button") + tiny
local body = math.max(m.rowH * 2, availH - (cy - y) - btnH * 2)
local run = BODIES[id] or BODIES.summary
cy = cy + run(imp, x, cy, w, body, m)
local last = (at or 1) >= (total or 1)
local half = math.floor((w - gap) / 2)
LV().btn(imp, x, cy, half, btnH, "online-wizard-back", Strings("Back"),
{ kind = "ghost", font = "small",
action = function() OnlinePanel.wizardBack(imp) end })
LV().btn(imp, x + half + gap, cy, w - half - gap, btnH,
"online-wizard-next",
last and Strings(def.confirm) or Strings("Next"),
{ kind = "primary", font = "small",
enabled = OnlinePanel.wizardReady(imp),
action = function() OnlinePanel.wizardNext(imp) end })
cy = cy + btnH + tiny
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Wizard
+2 -1
View File
@@ -281,7 +281,8 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
-- WakeUpEntireParty runs on the enemy's bench too
for _, mon in ipairs(battle.enemyParty or {}) do wake(mon) end
if not woke then
return "failed", { romText(data, "_PlayedFluteNoEffectText",
-- engine/items/item_effects.asm:1728, engine/battle/core.asm:2257 (#1882)
return "kept", { romText(data, "_PlayedFluteNoEffectText",
"Played the POKé\nFLUTE.\fNow, that's a\ncatchy tune!") }
end
return "flute", { romText(data, "_PlayedFluteHadEffectText",
+3 -3
View File
@@ -1,5 +1,5 @@
-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState's
-- own `ipDigits`/`addrPos` already uses for IP entry, over the Crockford-32
-- Shared slot-scrub entry widget: the digit-scrub interaction LinkState
-- uses for LAN address entry, over the Crockford-32
-- style alphabet pokeserver room/tournament codes are drawn from
-- (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read aloud or
-- handwritten never has to be checked twice).
@@ -30,7 +30,7 @@ function CodeEntry.new(opts)
end
-- Seed the slots from an existing string: prefilling the LAN address means
-- the player scrubs the last octet instead of all twelve digits. Anything
-- the player scrubs the last octet instead of the whole address. Anything
-- not in the charset lands on slot 1's character.
-- Slots past the end of the seed -- and any character the charset does not
-- carry -- land on the charset's blank where it has one, so seeding a
+14
View File
@@ -315,6 +315,19 @@ local function writeGen2TypeChart(out, data)
end
end
local function writeRulesets(out, data)
local rulesets = data.rulesets
if not rulesets then return end
out[#out + 1] = "[rulesets]"
for _, id in ipairs(sortedIds(rulesets)) do
local record = rulesets[id]
if type(record) == "table" then
out[#out + 1] = "@" .. id
writeValue(out, record)
end
end
end
local function writeConstants(out, data)
if not data.constants then return end
out[#out + 1] = "[constants]"
@@ -365,6 +378,7 @@ local function surfaceGen1(data, mods)
writeTypeChart(out, data)
writeSection(out, data, "statuses")
writeSection(out, data, "move_effects")
writeRulesets(out, data)
writeConstants(out, data)
writeLinkFields(out, data)
out[#out + 1] = "[mods]" .. modKey(mods)
+43 -3
View File
@@ -140,7 +140,7 @@ end
-- the enabled mods that keep this install out of online play, in the
-- id order Handshake.mods sorts: everything except verified translations.
-- LinkState:offerVanillaRestart names these and switches off exactly these.
-- The launcher's ONLINE tab names these and switches off exactly these.
function Handshake.onlineBlockers(game)
local mod = loader(game)
local blockers = {}
@@ -152,8 +152,8 @@ function Handshake.onlineBlockers(game)
return blockers
end
-- online play (the relay-based online match / tournament flows in
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
-- online play (the launcher's relay-based rooms and tournaments) meets
-- strangers, not a coordinating friend, so it
-- skips the LAN path's per-peer compatibility negotiation entirely and
-- just requires a vanilla simulation on both ends: no mod-added Pokemon, no
-- surprises. #501 carves out translations, because a language is not a
@@ -175,6 +175,21 @@ function Handshake.generation(game)
return Fingerprint.generationOf(game and game.data)
end
Handshake.DEFAULT_RULESET = "gen1_faithful"
function Handshake.ruleset(game)
local data = game and game.data
local fallback = (data and data.constants and data.constants.defaultRuleset)
or Handshake.DEFAULT_RULESET
local selected = game and game.save and game.save.options
and game.save.options.ruleset
if selected == nil then return fallback end
selected = tostring(selected)
local rulesets = data and data.rulesets
if rulesets and rulesets[selected] == nil then return fallback end
return selected
end
-- mode is nil on the guest: it pairs and announces itself before the host
-- has picked, and compatibility is decided from the two hellos, not the mode
function Handshake.hello(game, mode)
@@ -193,6 +208,7 @@ function Handshake.hello(game, mode)
generation = generation,
fingerprint = Fingerprint.compute(game and game.data, mods, generation),
linkModified = Handshake.linkModified(game),
ruleset = Handshake.ruleset(game),
mods = mods,
}
end
@@ -247,6 +263,11 @@ function Handshake.checkCompat(localHello, remoteHello)
return "engine_skew", "engine_release_mismatch"
end
if remoteHello.fingerprint == localHello.fingerprint then
local localRuleset = localHello.ruleset or Handshake.DEFAULT_RULESET
local remoteRuleset = remoteHello.ruleset or Handshake.DEFAULT_RULESET
if tostring(localRuleset) ~= tostring(remoteRuleset) then
return "ruleset_skew", "ruleset_mismatch"
end
return "full", nil
end
return "subset", "fingerprint_mismatch"
@@ -256,6 +277,7 @@ end
-- peer keeps the old substitute-a-move behaviour it was built against
function Handshake.strict(verdict)
return verdict == "full" or verdict == "subset" or verdict == "engine_skew"
or verdict == "ruleset_skew"
end
function Handshake.battleAllowed(verdict)
@@ -359,6 +381,24 @@ function Handshake.describe(localHello, remoteHello, verdict, mode)
end
return lines
end
if verdict == "ruleset_skew" then
wrap(lines, "Your battle rules")
wrap(lines, "differ:")
wrap(lines, (" you: %s"):format(
tostring(localHello.ruleset or Handshake.DEFAULT_RULESET):upper():sub(1, 12)))
wrap(lines, (" %s: %s"):format(peer:sub(1, 8),
tostring((remoteHello and remoteHello.ruleset)
or Handshake.DEFAULT_RULESET):upper():sub(1, 12)))
if mode == "battle" then
wrap(lines, "Battle needs the")
wrap(lines, "same RULESET in")
wrap(lines, "OPTIONS.")
else
wrap(lines, "Trading still")
wrap(lines, "works.")
end
return lines
end
if verdict == "engine_skew" then
-- name both releases so two friends can tell WHO updates: this used
-- to surface three turns in as a desync draw blaming mods (#758)
+30 -10
View File
@@ -29,12 +29,14 @@ local LinkBattle = {}
-- Deterministic Park-Miller PRNG: both sides must roll identical
-- streams, so love.math.random can't be used.
local function makeRng(seed)
local function makeRng(seed, owner)
local s = tonumber(seed) or 1
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
s = math.floor(s) % 2147483647
if s <= 0 then s = s + 2147483646 end
if owner then owner.rngDraws = 0 end
return function(a, b)
if owner then owner.rngDraws = (owner.rngDraws or 0) + 1 end
s = (s * 16807) % 2147483647
if a == nil then return s / 2147483647 end
if b == nil then a, b = 1, a end
@@ -42,6 +44,19 @@ local function makeRng(seed)
end
end
local BUILTIN_RULESETS = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
}
local function rulesetFor(game, id)
local rulesets = game.data.rulesets or BUILTIN_RULESETS
local fallback = (game.data.constants and game.data.constants.defaultRuleset)
or "gen1_faithful"
return (id and rulesets[id]) or rulesets[fallback]
or BUILTIN_RULESETS.gen1_faithful
end
-- Battlers go through BattleState.makeBattler so pics get the same
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
-- battles. save=nil skips badge boosts so both machines keep identical
@@ -177,7 +192,8 @@ local function stateSig(self, role, myParty, theirParty)
local hostParty = role == "host" and myParty or theirParty
local guestParty = role == "host" and theirParty or myParty
return {
actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)),
actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)
.. "|r" .. tostring(self.rngDraws or 0)),
volatile = Fingerprint.digest(volStr(host) .. "|" .. volStr(guest)),
bench = Fingerprint.digest(benchStr(hostParty) .. "|" .. benchStr(guestParty)),
}
@@ -197,7 +213,7 @@ local PARTS = { "actives", "volatile", "bench" }
local FATAL_PART = { actives = true, bench = true }
-- opts: { myParty = packed, theirParty = packed, theirName, role =
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
-- "host"/"guest", seed, ruleset, verdict, strict }. Returns nil plus a reason when
-- the handshake says the two link surfaces don't match: a lockstep
-- simulation of two different rulebooks can only end in a bogus draw.
function LinkBattle.new(game, net, opts)
@@ -255,7 +271,9 @@ function LinkBattle.new(game, net, opts)
-- back up and the link can stall or time out. Game:step services
-- game.linkNet unconditionally every frame.
game.linkNet = net
self.rng = makeRng(opts.seed or 1)
self.rng = makeRng(opts.seed or 1, self)
self.rulesetId = opts.ruleset
self.ruleset = rulesetFor(game, opts.ruleset)
self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty
@@ -751,8 +769,8 @@ function LinkBattle.new(game, net, opts)
-- the whole tournament, not just this match.
if not opts.keepNetOpen then
net:close()
if game.linkNet == net then game.linkNet = nil end
end
if game.linkNet == net then game.linkNet = nil end
baseFinish(s)
end
@@ -802,7 +820,9 @@ function LinkBattle.newSpectator(game, net, opts)
self.spectating = true -- Tournament.lua's marker: don't report a result for this one
self.net = net
game.linkNet = net
self.rng = makeRng(opts.seed or 1)
self.rng = makeRng(opts.seed or 1, self)
self.rulesetId = opts.ruleset
self.ruleset = rulesetFor(game, opts.ruleset)
self.player = mkBattler(game.data, hostParty[1], true)
self.enemy = mkBattler(game.data, guestParty[1], false)
self.enemyParty = guestParty
@@ -1011,10 +1031,11 @@ function LinkBattle.newSpectator(game, net, opts)
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
elseif inner.type == "hello" or inner.type == "party" then
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end
-- "hello"/"party"/"hash" ride along too (Tournament.lua already
-- consumed hello/party before building this battle); none of them
-- need any action here
-- a spectated "hash" needs no action here
else
-- same reasoning as the real-participant loop above: don't lose a
-- bracket_update/match_start_spectate that arrives mid-match
@@ -1046,7 +1067,6 @@ function LinkBattle.newSpectator(game, net, opts)
local baseFinish = self.finish
self.finish = function(s)
s.linkEnded = true
if game.linkNet == net then game.linkNet = nil end
baseFinish(s) -- deliberately doesn't touch net: it's the caller's
-- tournament connection, still needed after this match
end
+599
View File
@@ -0,0 +1,599 @@
local Battle = require("src.battle.gen2.Battle")
local BattleState = require("src.ui.gen2.BattleState")
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local LinkBattle2 = {}
local function makeRandom(seed, owner)
local s = tonumber(seed) or 1
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
s = math.floor(s) % 2147483647
if s <= 0 then s = s + 2147483646 end
owner.rngDraws = 0
return function(n)
owner.rngDraws = owner.rngDraws + 1
s = (s * 16807) % 2147483647
n = math.floor(tonumber(n) or 1)
if n < 1 then n = 1 end
return s % n
end
end
local PARTS = { "actives", "volatile", "bench" }
local FATAL_PART = { actives = true, bench = true }
local IDLE_PHASE = {
menu = true, moves = true, ["locked-in"] = true, ["forced-switch"] = true,
["link-wait"] = true, ["link-hold"] = true, ["refuse-menu"] = true,
["refuse-move"] = true, ["refuse-switch"] = true,
}
local function unpackParty(game, packed, unpackOpts, errFmt)
local out = {}
for _, p in ipairs(packed or {}) do
local mon, why = Protocol.unpackMon2(game.data, p, unpackOpts)
if mon then
table.insert(out, mon)
elseif unpackOpts.strict then
return nil, errFmt(p, why)
end
end
return out
end
local function linkSave(game, party)
local player = (game and game.save and game.save.player) or {}
return {
party = party,
player = { name = player.name or "PLAYER", gender = player.gender },
inventory = {},
pokedex = { seen = {}, caught = {} },
options = {},
modData = {},
}
end
local function digestParts(parts)
return {
actives = Fingerprint.digest(parts.actives),
volatile = Fingerprint.digest(parts.volatile),
bench = Fingerprint.digest(parts.bench),
}
end
local function moveSlot(mon, moveId)
for i, mv in ipairs((mon and mon.moves) or {}) do
if mv.id == moveId then return i end
end
return nil
end
local function encodeAction(battle, action)
if action.kind == "switch" then
return { type = "action", kind = "switch", index = action.index }
end
if action.kind == "run" then
return { type = "action", kind = "run" }
end
if action.move == Battle.STRUGGLE then
return { type = "action", kind = "struggle" }
end
local slot = moveSlot(battle.player, action.move)
if not slot then
return { type = "action", kind = "locked" }
end
return { type = "action", kind = "move", slot = slot }
end
local function decodeAction(battle, msg, mon)
local kind = msg.kind
if kind == "switch" then
local index = math.floor(tonumber(msg.index) or 1)
return { kind = "switch", index = index }
end
if kind == "run" then return { kind = "run" } end
if kind == "struggle" then
return { kind = "move", move = Battle.STRUGGLE }
end
if kind == "locked" then
local locked = battle:lockedInMove(mon)
or battle:volatile(mon).chargeMove
or battle:forcedMove(mon)
return { kind = "move", move = locked }
end
local moves = (mon and mon.moves) or {}
local slot = math.max(1, math.min(math.max(1, #moves),
math.floor(tonumber(msg.slot) or 1)))
local mv = moves[slot]
return { kind = "move", move = mv and mv.id or Battle.STRUGGLE }
end
local function outcomeResult(outcome, battle)
if outcome == "win" then
if battle and not Battle.firstHealthy(battle.party) then return "draw" end
return "win"
end
if outcome == "lose" then return "lose" end
return "draw"
end
function LinkBattle2.new(game, net, opts)
local role = opts.role
local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same version\nand mods.")
end
local unpackOpts = { strict = opts.strict or false,
forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
return Strings("Your %s can't\nbattle on the\nother game.",
tostring(p.species))
end)
if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts,
function(p, why)
return Strings("Their %s isn't\nin this game.\n(%s)",
tostring(p.species), tostring(why))
end)
if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then
Logger.warn("link2: empty party on one side")
end
local function announceReceived(party)
for _, mon in ipairs(party) do
Runtime.emit("pokemon.received",
{ mon = mon, from = "link", peerName = theirName })
end
end
announceReceived(role == "host" and myParty or theirParty)
announceReceived(role == "host" and theirParty or myParty)
local owner = {}
local random = makeRandom(opts.seed or 1, owner)
local battle = Battle.new({
data = game.data,
random = random,
party = myParty,
trainer = { name = theirName, party = theirParty },
save = nil,
})
battle.linkBattle = true
battle.mirrored = role == "guest"
theirParty = battle.enemyParty
local hooks = {}
local screen
local ended, byeSent = false, false
local pendingMine, pendingTheirs = nil, nil
local remoteReplace = {}
local turnCount = 0
local localHashes, remoteHashes = {}, {}
local localParts, remoteParts = {}, {}
local checkedTurns = {}
local clock = nil
local function send(msg) net:send(msg) end
local function kick(s)
if IDLE_PHASE[s.phase] then
s.phase = "resolving"
s:advanceQueue()
end
end
local function endWithResult(s, result, text)
if ended then return end
ended = true
s.result = result
if text then s:pushAll({ { kind = "message", text = text } }) end
if not battle.over then
battle:endBattle(result == "win" and "win"
or (result == "lose" and "lose" or "draw"))
end
kick(s)
end
local function endAsDraw(s, text)
endWithResult(s, "draw", text)
end
local function reportDesync(s, turn, component, localH, remoteH)
Logger.warn("link2: desync turn %s component=%s (%s vs %s)",
tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = true })
endAsDraw(s, Strings(
"Link desync! %s differs. Are both games the same version and mods?",
component))
end
local function noteDrift(turn, component, localH, remoteH)
Logger.warn("link2: %s drift on turn %s (%s vs %s) -- match continues",
component, tostring(turn), tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = false })
end
local function checkHashes(s)
for turn, localH in pairs(localHashes) do
local remoteH = remoteHashes[turn]
if remoteH and not checkedTurns[turn] then
checkedTurns[turn] = true
local mine, theirs = localParts[turn], remoteParts[turn]
if mine and theirs then
for _, component in ipairs(PARTS) do
if mine[component] ~= theirs[component] then
if FATAL_PART[component] then
reportDesync(s, turn, component, mine[component],
theirs[component])
return
end
noteDrift(turn, component, mine[component], theirs[component])
end
end
end
if remoteH ~= localH then
reportDesync(s, turn, "state", localH, remoteH)
return
end
end
end
end
local function signTurn(s)
battle.rngDraws = owner.rngDraws
turnCount = turnCount + 1
local raw = battle:linkSignature(role)
local parts = digestParts(raw)
local value = parts.actives .. "|" .. parts.bench
localHashes[turnCount] = value
localParts[turnCount] = parts
if LinkBattle2.keepSignatures then s.linkSignatures[turnCount] = raw end
send({ type = "hash", turn = turnCount, value = value, parts = parts })
checkHashes(s)
end
local function resolveLockstep(s, myMsg, theirMsg)
if myMsg.kind == "run" or theirMsg.kind == "run" then
local who = myMsg.kind == "run"
and ((game.save and game.save.player and game.save.player.name)
or "PLAYER")
or theirName
endAsDraw(s, Strings("%s ran from the battle!", who))
return
end
local myAction = decodeAction(battle, myMsg, battle.player)
local theirAction = decodeAction(battle, theirMsg, battle.enemy)
local events = battle:takeLinkTurn(myAction, theirAction)
signTurn(s)
if ended then return end
s.phase = "resolving"
s:pushAll(events)
s.message = nil
s.messageTimer = 0
s:advanceQueue()
end
local function tryResolve(s)
if not pendingMine or not pendingTheirs then return end
local mine, theirs = pendingMine, pendingTheirs
pendingMine, pendingTheirs = nil, nil
resolveLockstep(s, mine, theirs)
end
local function applyRemoteReplace(s)
if not battle.pendingEnemySwitch or #remoteReplace == 0 then return end
local index = table.remove(remoteReplace, 1)
battle:forcedReplacement("enemy", index)
s:pushAll(battle:takeEvents())
if s.phase == "link-hold" then kick(s) end
end
hooks.submit = function(s, action)
if ended then return end
if action.kind == "item" then
return s:refuseMenu(Strings("Items can't be used in a link battle!"))
end
if action.kind == "run" then
return s:refuseMenu(Strings("No running from a link battle!"))
end
local msg = encodeAction(battle, action)
send(msg)
pendingMine = msg
s.phase = "link-wait"
tryResolve(s)
end
hooks.menuChoice = function(s, choice)
if choice == "item" then
s:refuseMenu(Strings("Items can't be used in a link battle!"))
return true
end
if choice == "run" then
s:refuseMenu(Strings("No running from a link battle!"))
return true
end
return false
end
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after a
hooks.forcedSwitch = function(s, index)
local mon = myParty[index]
if not mon or (mon.hp or 0) <= 0 or mon.isEgg then
return s:refuseSwitch(true)
end
if not battle:forcedReplacement("player", index) then
return s:refuseSwitch(true)
end
send({ type = "replace", index = index })
s.phase = "resolving"
s:pushAll(battle:takeEvents())
s:advanceQueue()
return true
end
screen = BattleState.new(game, {
battle = battle,
save = linkSave(game, myParty),
link = hooks,
onDone = function(outcome)
local result = screen.result or outcomeResult(outcome, battle)
screen.result = result
if not byeSent then
byeSent = true
send({ type = "bye" })
end
if not opts.keepNetOpen then
net:close()
if game.linkNet == net then game.linkNet = nil end
end
if game.stack and game.stack:top() == screen then game.stack:pop() end
if screen.onFinish then screen.onFinish(result) end
end,
})
screen.kind = "link"
screen.linkRole = role
screen.result = nil
screen.playerParty = myParty
screen.enemyParty = theirParty
screen.localHashes = localHashes
screen.remoteHashes = remoteHashes
screen.localParts = localParts
screen.remoteParts = remoteParts
screen.linkSignatures = {}
screen.rngOwner = owner
game.linkNet = net
local baseUpdate = screen.update
screen.update = function(s, dt)
net:update()
for _, msg in ipairs(net:poll()) do
if msg.type == "action" then
pendingTheirs = msg
tryResolve(s)
elseif msg.type == "hash" then
remoteHashes[msg.turn or 0] = msg.value
remoteParts[msg.turn or 0] = msg.parts
checkHashes(s)
elseif msg.type == "replace" then
table.insert(remoteReplace, math.max(1, math.min(#theirParty,
math.floor(tonumber(msg.index) or 1))))
elseif msg.type == "bye" then
if not ended and not battle.over then
endAsDraw(s, Strings("%s left the battle.", theirName))
end
elseif msg.type == "forfeit" then
if not ended and not battle.over then
endWithResult(s, "win", Strings("%s ran out of time!", theirName))
end
else
s.pendingLinkMessages = s.pendingLinkMessages or {}
table.insert(s.pendingLinkMessages, msg)
end
end
if net.closed and not ended and not battle.over then
endAsDraw(s)
end
applyRemoteReplace(s)
if battle.pendingEnemySwitch
and (s.phase == "menu" or s.phase == "moves"
or s.phase == "locked-in") then
s.phase = "link-hold"
end
if opts.turnLimit and (s.phase == "menu" or s.phase == "moves") then
clock = (clock or opts.turnLimit) - (dt or 0)
if clock <= 0 then
clock = nil
send({ type = "forfeit" })
endWithResult(s, "lose",
Strings("Time's up! You forfeit the match."))
end
elseif opts.turnLimit then
clock = nil
end
return baseUpdate(s, dt)
end
return screen
end
function LinkBattle2.newSpectator(game, net, opts)
local hostName = opts.hostName or "HOST"
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same version\nand mods.")
end
local unpackOpts = { strict = opts.strict or false,
forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts,
function(p)
return Strings("%s's %s can't\nbattle on this\ngame.", hostName,
tostring(p.species))
end)
if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts,
function(p, why)
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName,
tostring(p.species), tostring(why))
end)
if not guestParty then return nil, guestErr end
local owner = {}
local random = makeRandom(opts.seed or 1, owner)
local battle = Battle.new({
data = game.data,
random = random,
party = hostParty,
trainer = { name = guestName, party = guestParty },
save = nil,
})
battle.linkBattle = true
guestParty = battle.enemyParty
local hooks = {}
local screen
local hostMsg, guestMsg = nil, nil
local hostReplace, guestReplace = {}, {}
local ended = false
local function endSpectate(s, text)
if ended then return end
ended = true
s.result = s.result or "ended"
if text then s:pushAll({ { kind = "message", text = text } }) end
if not battle.over then battle:endBattle("draw") end
if IDLE_PHASE[s.phase] then
s.phase = "resolving"
s:advanceQueue()
end
end
local function resolveSpecTurn(s, hMsg, gMsg)
if hMsg.kind == "run" or gMsg.kind == "run" then
endSpectate(s, Strings("The match ended."))
return
end
local hostAction = decodeAction(battle, hMsg, battle.player)
local guestAction = decodeAction(battle, gMsg, battle.enemy)
local events = battle:takeLinkTurn(hostAction, guestAction)
if ended then return end
s.phase = "resolving"
s:pushAll(events)
s.message = nil
s.messageTimer = 0
s:advanceQueue()
end
hooks.forcedPrompt = function(s)
local index = table.remove(hostReplace, 1)
if not index then return true end
battle:forcedReplacement("player", index)
s.phase = "resolving"
s:pushAll(battle:takeEvents())
s:advanceQueue()
return true
end
hooks.submit = function() end
hooks.menuChoice = function() return true end
hooks.forcedSwitch = function(s) return s:refuseSwitch(true) end
screen = BattleState.new(game, {
battle = battle,
save = linkSave(game, hostParty),
link = hooks,
onDone = function(outcome)
local result = screen.result or outcomeResult(outcome, battle)
screen.result = result
if game.stack and game.stack:top() == screen then game.stack:pop() end
if screen.onFinish then screen.onFinish(result) end
end,
})
screen.kind = "link"
screen.spectating = true
screen.result = nil
screen.playerParty = hostParty
screen.enemyParty = guestParty
game.linkNet = net
local baseUpdate = screen.update
screen.update = function(s, dt)
net:update()
for _, msg in ipairs(net:poll()) do
if msg.type == "spectate" and type(msg.msg) == "table" then
local inner = msg.msg
if inner.type == "action" then
if msg.side == "host" then hostMsg = inner else guestMsg = inner end
if hostMsg and guestMsg then
local h, g = hostMsg, guestMsg
hostMsg, guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "replace" then
local index = math.floor(tonumber(inner.index) or 1)
if msg.side == "host" then
table.insert(hostReplace,
math.max(1, math.min(#hostParty, index)))
else
table.insert(guestReplace,
math.max(1, math.min(#guestParty, index)))
end
elseif inner.type == "bye" or inner.type == "forfeit" then
endSpectate(s, Strings("The match ended."))
end
else
s.pendingLinkMessages = s.pendingLinkMessages or {}
table.insert(s.pendingLinkMessages, msg)
end
end
if net.closed and not ended and not battle.over then endSpectate(s) end
if battle.pendingEnemySwitch then
local index = table.remove(guestReplace, 1)
if index then
battle:forcedReplacement("enemy", index)
s:pushAll(battle:takeEvents())
if s.phase == "link-hold" then
s.phase = "resolving"
s:advanceQueue()
end
elseif s.phase == "menu" or s.phase == "moves"
or s.phase == "locked-in" then
s.phase = "link-hold"
end
end
if s.phase == "menu" or s.phase == "moves" or s.phase == "locked-in" then
s.phase = "link-wait"
end
return baseUpdate(s, dt)
end
return screen
end
function LinkBattle2.newHost(game, net, opts)
opts.role = "host"
return LinkBattle2.new(game, net, opts)
end
function LinkBattle2.newGuest(game, net, opts)
opts.role = "guest"
return LinkBattle2.new(game, net, opts)
end
return LinkBattle2
+57 -236
View File
@@ -48,26 +48,34 @@ end
-- stages before a successful transport has become this link's Session;
-- terminal checks skip them rather than keying off self.net's presence
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
local PRE_CONNECT_STAGES = { menu = true }
-- how long the host waits for a v2 hello before deciding the peer predates
-- the handshake (a pre-mod guest sends nothing until it hears the mode)
local HELLO_GRACE = 2
-- the joiner edits an IPv4 address as 12 digits (three per octet),
-- prefilled with our own LAN IP so usually only the tail needs changing
local function ipDigits(ip)
local digits = {}
local a, b, c, d = (ip or ""):match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
local octets = { tonumber(a) or 192, tonumber(b) or 168,
tonumber(c) or 0, tonumber(d) or 1 }
LinkState.ADDR_LENGTH = 15
LinkState.ADDR_CHARSET = "0123456789. "
local ADDR_OPTS = { length = LinkState.ADDR_LENGTH,
charset = LinkState.ADDR_CHARSET }
function LinkState.addrEntry(ip)
local seed = ip or "192.168.0.1"
if not seed:match("^%d+%.%d+%.%d+%.%d+$") then seed = "192.168.0.1" end
local state = CodeEntry.fromText(seed, ADDR_OPTS)
state.pos = math.max(1, math.min(ADDR_OPTS.length, #seed))
return state
end
function LinkState.addrText(state)
local text = (CodeEntry.text(state):gsub(" ", ""))
local octets = { text:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") }
if #octets ~= 4 then return nil end
for _, o in ipairs(octets) do
o = math.min(255, o)
table.insert(digits, math.floor(o / 100))
table.insert(digits, math.floor(o / 10) % 10)
table.insert(digits, o % 10)
if #o > 3 or tonumber(o) > 255 then return nil end
end
return digits
return text
end
local function openSession(role, connect)
@@ -88,36 +96,17 @@ function LinkState.new(game)
game.linkSession = true
self.stage = "menu"
self.index = 1
self.addr = ipDigits(Net.lanIP())
self.addrPos = 12 -- the last octet is what usually differs
self.addr = LinkState.addrEntry(Net.lanIP())
self.status = ""
return self
end
-- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua):
-- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to
-- "connecting with this code", same as if the player had typed it in
function LinkState.newJoinOnline(game, code)
local self = LinkState.new(game)
local session, detail = openSession("guest", function(transport)
return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining"
else
self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith(Strings("Link error:\n%s", detail))
end
return self
end
-- Adopt a transport that is ALREADY paired and skip the connect UI: an
-- overworld multiplayer session handing one pair of players off to a battle
-- or a trade. The caller has settled which mode and which side hosts, so
-- all that is left is the hello exchange every link session runs before it
-- commits -- the fingerprint/mod compatibility check still gets its say,
-- exactly as it would have on the LAN or ONLINE path.
-- exactly as it would have on the LAN path.
--
-- `transport` is anything Session accepts (update/poll/send/close plus the
-- .paired/.closed/.error fields), which is what lets a mod route a battle
@@ -150,48 +139,6 @@ function LinkState:exitWith(message, reason)
end
end
-- Online play meets strangers, so it requires a vanilla simulation on both
-- ends (Handshake.onlineAllowed). Mods merge into the shared Data
-- registries at boot and there is no unmerge, so switching them off has to
-- go through a relaunch -- but the player should not have to go find the
-- mod manager and work out which mods count. This turns the blocking mods
-- off, records them so the mod manager can put them back, and relaunches.
-- Verified translations are not blockers (#501), so a player keeps their
-- language across the restart and only the gameplay mods go. The restart
-- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart()
local game = self.game
local loader = game.mods
local mods = Handshake.onlineBlockers(game)
local names = {}
for i, mod in ipairs(mods) do
if i > 2 then break end
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
end
local list = table.concat(names, ", ")
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
local text = Strings(
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
self.game.linkSession = nil
Runtime.emit("link.ended", { reason = "error" })
if self.net then self.net:close() end
game.stack:pop()
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
if not yes then return end
-- setEnabled persists the toggle itself (Loader:_saveState), so the
-- relaunch comes up vanilla and the mod manager lists them as disabled
-- for the player to switch back on afterwards
for _, mod in ipairs(mods) do
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
end
if game.restartWithMods then
game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
end
end }))
end
-- -------------------------------------------------------------------
-- handshake v2 (D8): both peers announce engine version, api version and
-- a fingerprint of their link surface, and the verdict comes from the two
@@ -263,8 +210,7 @@ function LinkState:update(dt)
-- so a final message travelling with the disconnect still counts)
if status == "closed"
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
and self.stage ~= "notice" and self.stage ~= "battleRunning" then
self:exitWith(Strings("The link was\nbroken."))
return
end
@@ -281,38 +227,10 @@ function LinkState:update(dt)
end
if self.stage == "menu" then
if input:wasPressed("down") then
self.index = self.index % 3 + 1
elseif input:wasPressed("up") then
self.index = (self.index - 2) % 3 + 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "lanMenu"
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:offerVanillaRestart()
return
end
if self.index == 2 then
self.stage = "onlineMenu"
else
local Tournament = require("src.link.Tournament")
self.game.stack:pop()
self.game.stack:push(Tournament.new(self.game))
end
self.index = 1
end
end
elseif self.stage == "lanMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self.stage = "menu"
self.index = 1
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
local session, detail = openSession("host", function(transport)
@@ -329,73 +247,6 @@ function LinkState:update(dt)
end
end
elseif self.stage == "onlineMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("a") then
if self.index == 1 then
local session, detail = openSession("host", function(transport)
return transport:hostOnline()
end)
if session then
self.net = session
self.stage = "onlineHosting"
else
self:exitWith(Strings("Link error:\n%s", detail))
end
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "onlineHosting" then
if not self.discordCodeSet and self.net.code then
DiscordPresence.setJoinCode(self.net.code)
self.discordCodeSet = true
end
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising
self.stage = "modeSelect"
self.index = 1
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "onlineMenu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
local code = CodeEntry.text(self.codeEntry)
local session, detail = openSession("guest", function(transport)
return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining"
else
self:exitWith(Strings("Link error:\n%s", detail))
end
end
elseif self.stage == "onlineJoining" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
self.stage = "waitMode"
self:sendHello(nil) -- the host owns the mode; this is just who we are
end
elseif self.stage == "hosting" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
@@ -406,22 +257,20 @@ function LinkState:update(dt)
elseif self.stage == "addrEntry" then
if input:wasPressed("b") then self:exitWith(nil) return end
if input:wasPressed("up") then
self.addr[self.addrPos] = (self.addr[self.addrPos] + 1) % 10
CodeEntry.up(self.addr)
elseif input:wasPressed("down") then
self.addr[self.addrPos] = (self.addr[self.addrPos] - 1) % 10
CodeEntry.down(self.addr)
elseif input:wasPressed("left") then
self.addrPos = math.max(1, self.addrPos - 1)
CodeEntry.left(self.addr)
elseif input:wasPressed("right") then
self.addrPos = math.min(12, self.addrPos + 1)
CodeEntry.right(self.addr)
elseif input:wasPressed("a") then
local octets = {}
for i = 1, 4 do
local base = (i - 1) * 3
octets[i] = math.min(255, self.addr[base + 1] * 100
+ self.addr[base + 2] * 10
+ self.addr[base + 3])
local address = LinkState.addrText(self.addr)
if not address then
self.status = Strings("Not an IP address.")
return
end
local address = table.concat(octets, ".")
self.status = ""
local session, detail = openSession("guest", function(transport)
return transport:join(address)
end)
@@ -511,7 +360,10 @@ function LinkState:update(dt)
if message then
-- the host owns this rule (same as mode); the guest only learns
-- it here, off the host's own party message
if not self.isHost then self.forceLevel = message.forceLevel end
if not self.isHost then
self.forceLevel = message.forceLevel
self.rulesetId = message.ruleset
end
local LinkBattle = require("src.link.LinkBattle")
local opts = {
myParty = Protocol.packParty(self.game.save.party),
@@ -521,6 +373,7 @@ function LinkState:update(dt)
verdict = self.verdict,
strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
ruleset = self.rulesetId,
}
local battle, why
if self.isHost then
@@ -581,11 +434,13 @@ function LinkState:startMode(mode, isHost)
-- the host deals the shared RNG seed for the lockstep simulation
if isHost then
self.linkSeed = love.math.random(1, 2 ^ 30)
self.rulesetId = Handshake.ruleset(self.game)
end
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed,
forceLevel = isHost and self.forceLevel or nil })
forceLevel = isHost and self.forceLevel or nil,
ruleset = isHost and self.rulesetId or nil })
end
end
@@ -734,49 +589,12 @@ end
function LinkState:draw()
if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE")
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
Font.draw(Strings("ONLINE MATCH"), 32, 60)
Font.draw(Strings("TOURNAMENT"), 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)")
Font.draw(Strings("HOST A GAME"), 32, 48)
Font.draw(Strings("JOIN A GAME"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH")
Font.draw(Strings("HOST ONLINE"), 32, 48)
Font.draw(Strings("JOIN ONLINE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE")
Font.draw(Strings("Tell your friend"), 16, 40)
Font.draw(Strings("the code:"), 16, 52)
Font.draw(self.net.code or "??????", 32, 68)
Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end
end
Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...")
Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then
drawTitle("HOSTING")
Font.draw(Strings("Friend joins at:"), 16, 48)
@@ -785,18 +603,16 @@ function LinkState:draw()
elseif self.stage == "addrEntry" then
drawTitle("ENTER HOST ADDRESS")
for i = 1, 12 do
local octet = math.floor((i - 1) / 3) -- 0..3
local x = 16 + (i - 1) * 8 + octet * 8 -- gap for the dots
Font.draw(tostring(self.addr[i]), x, 64)
if i == self.addrPos then
Font.drawCode(0xEE, x, 76) -- ▼ under the active digit
for i = 1, LinkState.ADDR_LENGTH do
local x = 8 + (i - 1) * 8
local ch = CodeEntry.charAt(self.addr, i)
if ch ~= " " then Font.draw(ch, x, 64) end
if i == self.addr.pos then
Font.drawCode(0xEE, x, 76)
end
end
for octet = 1, 3 do
Font.draw(".", 16 + octet * 32 - 8, 64)
end
Font.draw(Strings("Port: %s", Net.defaultPort()), 16, 96)
Font.draw(Strings("Port: %s", Net.defaultPort()), 8, 96)
if self.status ~= "" then Font.draw(self.status, 8, 112) end
Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "joining" then
@@ -828,8 +644,13 @@ function LinkState:draw()
elseif self.stage == "notice" then
-- a version-skew notice has nothing to do with mods (#758)
drawTitle(self.verdict == "engine_skew" and "UPDATE YOUR GAME"
or "CHECK YOUR MODS")
local noticeTitle = "CHECK YOUR MODS"
if self.verdict == "engine_skew" then
noticeTitle = "UPDATE YOUR GAME"
elseif self.verdict == "ruleset_skew" then
noticeTitle = "CHECK YOUR RULES"
end
drawTitle(noticeTitle)
for i, line in ipairs(self.noticeLines or {}) do
if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12)
+133 -14
View File
@@ -38,12 +38,18 @@ local Net = {}
Net.__index = Net
Net.DEFAULT_PORT = 7777
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
Net.DEFAULT_RELAY_ADDRESS = "relay.gen1re.com:7778"
Net.MAX_LINE = 256 * 1024
Net.MAX_RX_PER_FRAME = 512 * 1024
Net.ENET_BANDWIDTH = 256 * 1024
Net.CONNECT_TIMEOUT = 5
Net.PING_INTERVAL = 10
Net.PING_MISSES = 3
Net.CLOSE_FLUSH_TIMEOUT = 0.25
Net.CLOSE_FLUSH_ATTEMPTS = 256
function Net.available()
return enet ~= nil
end
@@ -97,6 +103,11 @@ function Net.new()
rxBuf = "", -- relay backend: bytes read but not yet a full line
txBuf = "", -- relay backend: bytes queued but not yet written
code = nil, -- relay backend, hosting: the room/tournament code
connecting = false, -- relay backend: TCP connect still in flight
selectable = true,
lastSendAt = nil,
pendingPings = 0,
sawPong = false,
}, Net)
end
@@ -160,13 +171,34 @@ function Net:join(address)
return true
end
-- opens a TCP connection to a pokeserver relay (blocking connect with a
-- short timeout -- this runs once, from a single explicit user action, not
-- from a per-frame poll, so blocking briefly is fine). Callers then send
-- opens a TCP connection to a pokeserver relay. The connect is
-- non-blocking: connectTCP returns true once the socket is dialing and
-- updateTCP polls writability until the 5 s deadline. Callers send
-- whatever control message starts their session ({type="host"},
-- {type="join",...}, {type="host_tournament",...}, ...); every reply that
-- isn't one of the four generic ones below lands in the normal inbox for
-- the caller (LinkState, or Tournament.lua) to interpret.
-- {type="join",...}, {type="host_tournament",...}, ...) straight away; it
-- waits in txBuf until the connection lands. Every reply that isn't one
-- of the generic ones below reaches the normal inbox for the caller
-- (LinkState, or the launcher online client) to interpret.
local function connectPending(err)
err = tostring(err or "")
return err == "timeout" or err:find("in progress", 1, true) ~= nil
end
function Net:beginTCP(tcp, host, port, pending)
self.tcpSocket = tcp
self.connectHost, self.connectPort = host, port
self.rxBuf = ""
self.txBuf = ""
self.connecting = pending and true or false
self.connectDeadline = now() + Net.CONNECT_TIMEOUT
self.lastSendAt = now()
if not pending then self:applyKeepalive() end
end
function Net:applyKeepalive()
pcall(function() self.tcpSocket:setoption("keepalive", true) end)
end
function Net:connectTCP(addr)
if not socket then
self.error = "online play needs luasocket (bundled with LOVE)"
@@ -176,19 +208,56 @@ function Net:connectTCP(addr)
host = host or addr
port = tonumber(port) or 7778
local tcp = socket.tcp()
tcp:settimeout(5)
tcp:settimeout(0)
local ok, err = tcp:connect(host, port)
if not ok then
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
if ok then
self:beginTCP(tcp, host, port, false)
return true
end
if connectPending(err) then
self:beginTCP(tcp, host, port, true)
return true
end
pcall(function() tcp:close() end)
local blocking = socket.tcp()
blocking:settimeout(Net.CONNECT_TIMEOUT)
local bok, berr = blocking:connect(host, port)
if not bok then
pcall(function() blocking:close() end)
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(berr))
return false
end
tcp:settimeout(0)
self.tcpSocket = tcp
self.rxBuf = ""
self.txBuf = ""
blocking:settimeout(0)
self:beginTCP(blocking, host, port, false)
return true
end
function Net:pollConnectTCP()
local sock = self.tcpSocket
local okSel, _, writable = pcall(socket.select, nil, { sock }, 0)
if okSel and writable and #writable > 0 then
local ok, err = sock:connect(self.connectHost, self.connectPort)
err = tostring(err or "")
if ok or err:find("already connected", 1, true) then
self.connecting = false
self:applyKeepalive()
return true
end
if not connectPending(err) then
self.error = Strings("can't reach relay %s:%d\n(%s)",
self.connectHost, self.connectPort, err)
self.closed = true
return false
end
end
if now() > (self.connectDeadline or 0) then
self.error = Strings("can't reach relay %s:%d\n(%s)",
self.connectHost, self.connectPort, "timeout")
self.closed = true
end
return false
end
function Net:hostOnline(addr)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineHosting"
@@ -208,6 +277,7 @@ function Net:send(msg)
if self.closed then return end
if self.tcpSocket then
self.txBuf = self.txBuf .. Json.encode(msg) .. "\n"
self.lastSendAt = now()
return
end
if self.peerEnd then -- loopback: re-encode through json like the wire
@@ -242,6 +312,7 @@ local function handleGenericRelayControl(self, msg)
self.paired = true
return true
elseif msg.type == "join_error" then
if self.v2 then return false end
self.error = Strings(({
not_found = Strings.source("That code wasn't\nfound."),
full = Strings.source("That game already\nhas two players."),
@@ -255,6 +326,13 @@ local function handleGenericRelayControl(self, msg)
elseif msg.type == "peer_gone" then
self.closed = true
return true
elseif msg.type == "ping" then
self:send({ type = "pong", t = msg.t })
return true
elseif msg.type == "pong" then
self.sawPong = true
self.pendingPings = 0
return true
end
return false
end
@@ -295,9 +373,24 @@ end
-- non-blocking caller recover the partial line across calls -- a
-- byte-count read hands back whatever's available via the third return
-- value on timeout, which we can buffer ourselves.
function Net:heartbeatTCP()
local t = now()
if not self.lastSendAt then self.lastSendAt = t end
if t - self.lastSendAt < Net.PING_INTERVAL then return end
self:send({ type = "ping", t = math.floor(t * 1000) })
self.pendingPings = (self.pendingPings or 0) + 1
if self.sawPong and self.pendingPings >= Net.PING_MISSES then
self.error = Strings("The relay stopped\nanswering.")
self.closed = true
end
end
function Net:updateTCP()
if self.closed then return end
local sock = self.tcpSocket
if self.connecting then
if not self:pollConnectTCP() then return end
end
if #self.txBuf > 0 then
local sent, err, lastByte = sock:send(self.txBuf)
if sent then
@@ -329,6 +422,31 @@ function Net:updateTCP()
if not data then break end -- nothing more buffered this frame
end
self:drainLines()
if not self.closed then self:heartbeatTCP() end
end
function Net:flushTCP(seconds)
local sock = self.tcpSocket
if not sock or self.connecting then return end
local deadline = now() + (seconds or Net.CLOSE_FLUSH_TIMEOUT)
for _ = 1, Net.CLOSE_FLUSH_ATTEMPTS do
if #self.txBuf == 0 then return end
local sent, err, lastByte = sock:send(self.txBuf)
if sent then
self.txBuf = ""
return
elseif err == "timeout" then
self.txBuf = self.txBuf:sub((lastByte or 0) + 1)
else
return
end
if #self.txBuf == 0 or now() >= deadline then return end
if self.selectable and socket and socket.select then
if not pcall(socket.select, nil, { sock }, 0.01) then
self.selectable = false
end
end
end
end
-- pump enet events; decoded JSON messages are queued for poll()
@@ -400,6 +518,7 @@ function Net:close()
return
end
if self.tcpSocket then
pcall(function() self:flushTCP() end)
pcall(function() self.tcpSocket:close() end)
self.tcpSocket = nil
self.closed = true
+15 -1
View File
@@ -103,7 +103,7 @@ function Protocol.unpackMon(data, packed, opts)
end
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
-- the LinkState/Tournament side (see levelForWire) that must mean "no
-- the LinkState side (see levelForWire) that must mean "no
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
@@ -404,6 +404,20 @@ function Protocol.packParty(party, indices)
return mons
end
function Protocol.packParty2(party, indices)
local mons = {}
if indices then
for _, i in ipairs(indices) do
table.insert(mons, Protocol.packMon2(party[i]))
end
return mons
end
for _, mon in ipairs(party) do
table.insert(mons, Protocol.packMon2(mon))
end
return mons
end
-- ------- subset negotiation
-- every record hash, not just this party's slice: the receiver filters
+16 -4
View File
@@ -4,13 +4,13 @@ local Wire = require("src.link.Wire")
local Session = {}
Session.__index = Session
local VALID_ROLES = { host = true, guest = true }
local VALID_ROLES = { host = true, guest = true, client = true }
local REQUIRED_METHODS = { "update", "poll", "send", "close" }
function Session.new(transport, options)
assert(type(transport) == "table", "Session.new requires a transport")
assert(type(options) == "table", "Session.new requires options")
assert(VALID_ROLES[options.role], "Session role must be host or guest")
assert(VALID_ROLES[options.role], "Session role must be host, guest or client")
assert(type(options.kind) == "string" and options.kind ~= "",
"Session kind must be a non-empty string")
for _, method in ipairs(REQUIRED_METHODS) do
@@ -155,10 +155,11 @@ local function finishRead(self)
self:_refreshStatus()
end
function Session:take(messageType)
function Session:take(messageType, predicate)
assert(type(messageType) == "string", "Session.take requires a message type")
for index, message in ipairs(self._inbox) do
if message.type == messageType then
if message.type == messageType
and (predicate == nil or predicate(message) == true) then
local found = table.remove(self._inbox, index)
finishRead(self)
return found
@@ -181,6 +182,17 @@ function Session:poll()
return messages
end
function Session:unread(messages)
if type(messages) ~= "table" then return end
for index = #messages, 1, -1 do
local message = messages[index]
if type(message) == "table" and type(message.type) == "string" then
table.insert(self._inbox, 1, message)
end
end
finishRead(self)
end
function Session:close()
if self._status == "closed" or self._status == "failed" then return end
if self._terminal then
-651
View File
@@ -1,651 +0,0 @@
-- Tournament play: host or join a bracket over the pokeserver relay.
-- Single-elimination, server-managed (pokeserver's `tournaments` map).
-- Matches run one at a time in bracket order; everyone not currently
-- playing -- still waiting their turn, or already eliminated -- watches
-- the live match play out via LinkBattle.newSpectator, reconstructed from
-- a copy of the real traffic the server fans out. Battle mode only (no
-- trade); vanilla only (Handshake.onlineAllowed already gated entry here
-- from LinkState). Elite Four music (Music_IndigoPlateau) loops the whole
-- time, uninterrupted by individual matches.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local LinkBattle = require("src.link.LinkBattle")
local Net = require("src.link.Net")
local Session = require("src.link.Session")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Tournament = {}
Tournament.__index = Tournament
Tournament.isOpaque = true
local CURSOR = 0xED
local MUSIC = "Music_IndigoPlateau"
local TURN_LIMITS = { 3, 6, 9 }
local PARTY_SIZES = { 1, 2, 3, 4, 5, 6 }
local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs
-- (LuaJIT's # still reports the literal's full size, but
-- ipairs stops dead at the hole), so level bounds use
-- this string in self.settings instead of nil, converted
-- to nil only on the wire
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
70, 75, 80, 85, 90, 95, 100 }
local SETTINGS_ROWS = 6 -- POKEMON, MIN LV, MAX LV, TIMER, LEVELS, PLAYING
-- LEVELS cycles through this: ANY (real levels) or a fixed level every
-- match normalizes to, regardless of each side's real party
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
-- tournaments have no real participant cap (any number can join before
-- start); this is just generous headroom so Discord's party.size never
-- reads as "full" and blocks a real invite click
local TOURNAMENT_PARTY_MAX = 16
local function indexOf(list, value)
for i, v in ipairs(list) do
if v == value then return i end
end
return 1
end
-- accepts either the internal ANY sentinel or a raw wire value (nil means
-- "any" there too, since the server never round-trips the sentinel string)
local function levelLabel(v)
return (v == ANY or v == nil) and "ANY" or tostring(v)
end
local function levelForWire(v)
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
local function partyStats(party)
local size, minLevel, maxLevel = 0, nil, nil
for _, mon in ipairs(party or {}) do
size = size + 1
local lvl = mon.level or 1
minLevel = minLevel and math.min(minLevel, lvl) or lvl
maxLevel = maxLevel and math.max(maxLevel, lvl) or lvl
end
return size, minLevel or 0, maxLevel or 0
end
function Tournament.new(game)
local self = setmetatable({}, Tournament)
self.game = game
self.stage = "menu"
self.index = 1
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
forceLevel = ANY, participating = true }
self.settingsIndex = 1
self.roster = {}
self.spectatorRoster = {}
-- everything from here to exitWith runs at 1X regardless of the GAME
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
-- down on the logic step, so fast-forward would hand one player less
-- real time to choose than the opponent they are racing
game.linkSession = true
Sound.startLoop(game.data, MUSIC)
return self
end
-- entry point for a Discord "Ask to Join" click on a tournament invite
-- (see DiscordPresence.lua): skips the HOST/JOIN menu and code entry,
-- straight to "connecting with this code"
function Tournament.newJoinOnline(game, code)
local self = Tournament.new(game)
self:startJoining(code)
return self
end
-- headcount for Discord's party.size: the roster only ever lists
-- competing players, so a non-participating (organizer-only) host isn't
-- in it even though they're right here running the thing
function Tournament:discordPartySize()
return #self.roster + (self.participating == false and 1 or 0)
end
function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end
self.game.stack:pop()
if message then
self.game.stack:push(TextBox.new(self.game, message))
end
end
-- -------------------------------------------------------------------
-- host / join
-- -------------------------------------------------------------------
local function openSession(role)
local transport = Net.new()
if transport:connectTCP(Net.defaultRelayAddress()) then
return Session.new(transport, { role = role, kind = "tournament" })
end
local detail = transport.error or "?"
transport:close()
return nil, detail
end
function Tournament:startHosting()
local session, detail = openSession("host")
if not session then
self:exitWith(Strings("Link error:\n%s", detail))
return
end
self.net = session
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = true
self.participating = self.settings.participating
self.net:send({
type = "host_tournament",
turnLimit = self.settings.turnLimit,
requiredPartySize = self.settings.requiredPartySize,
minLevel = levelForWire(self.settings.minLevel),
maxLevel = levelForWire(self.settings.maxLevel),
forceLevel = levelForWire(self.settings.forceLevel),
participating = self.settings.participating,
name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
end
function Tournament:startJoining(code)
local session, detail = openSession("guest")
if not session then
self:exitWith(Strings("Link error:\n%s", detail))
return
end
self.net = session
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = false
self.participating = true -- joining is always to compete; only hosting can opt out
self.code = code
self.net:send({
type = "join_tournament", code = code, name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
end
-- -------------------------------------------------------------------
-- message handling (shared between "registering"/"bracket" and drained
-- again from a just-finished match's pendingTournamentMessages)
-- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = {
not_found = Strings.source("That code wasn't\nfound."),
already_started = Strings.source("That tournament\nhas already begun."),
expired = Strings.source("That code has\nexpired."),
}
function Tournament:handleMessage(msg)
if msg.type == "tournament_hosted" then
self.code = msg.code
self.settings.turnLimit = msg.turnLimit
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.participating = msg.participating
self.stage = "bracket"
-- the server already seeded t.players/spectators with the creator at
-- creation time; mirror that here so the Discord party size (and the
-- "waiting for players" list) show the host from the very first frame,
-- not just once someone else joins and a real roster broadcast arrives
if self.participating then
self.roster = { self.game.save.player.name }
else
self.spectatorRoster = { self.game.save.player.name }
end
if self.isCreator then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
end
elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith(Strings("Couldn't host\nthat tournament."))
end
elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith(JOIN_ERROR_TEXT[msg.reason]
and Strings(JOIN_ERROR_TEXT[msg.reason])
or Strings("Couldn't join\nthat tournament."))
end
elseif msg.type == "tournament_roster" then
self.roster = msg.players
self.spectatorRoster = msg.spectators or {}
self.settings.turnLimit = msg.turnLimit
self.settings.requiredPartySize = msg.requiredPartySize
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.stage = "bracket"
if self.isCreator and self.code then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
end
elseif msg.type == "bracket_update" then
self.bracket = msg.tournament
self.code = self.bracket.code
if self.stage == "registering" then self.stage = "bracket" end
elseif msg.type == "match_start" then
self:enterMatch(msg)
elseif msg.type == "match_start_spectate" then
self:enterSpectate(msg)
elseif msg.type == "tournament_bye" then
self.byeRound = msg.round
elseif msg.type == "tournament_over" then
self.champion = msg.champion
self.stage = "done"
end
end
-- -------------------------------------------------------------------
-- entering a match: real participant
-- -------------------------------------------------------------------
function Tournament:sendHello(mode)
self.myHello = Handshake.hello(self.game, mode)
self.net:send(self.myHello)
end
function Tournament:pollHello()
local message
if not self.peerHello then message = self.net:take("hello") end
if message then self.peerHello = message end
return message ~= nil
end
-- The relay assigns a side for each match; this can differ from the immutable
-- tournament creator/joiner role held by Session.
function Tournament:enterMatch(msg)
self.isHost = (msg.role == "host")
self.opponentName = msg.opponent
self.matchRound = msg.round
self.matchTurnLimit = msg.turnLimit
self.peerHello = nil
self:sendHello(self.isHost and "battle" or nil)
self.stage = "matchHello"
end
function Tournament:beginMatchBattle()
local verdict = Handshake.checkCompat(self.myHello, self.peerHello)
if not (verdict == "full" or verdict == "vanilla_peer") then
-- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament
self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
return
end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
local opts = {
myParty = Protocol.packParty(self.game.save.party),
theirName = self.opponentName or "FOE",
seed = self.isHost and self.linkSeed or nil,
verdict = verdict,
strict = Handshake.strict(verdict),
turnLimit = self.matchTurnLimit,
forceLevel = levelForWire(self.settings.forceLevel),
keepNetOpen = true, -- this is the tournament's own connection, not a
-- dedicated match socket -- don't let finish() close it
}
self.stage = "matchWaitParty"
self.pendingBattleOpts = opts
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed })
end
-- -------------------------------------------------------------------
-- entering a match: spectator
-- -------------------------------------------------------------------
function Tournament:enterSpectate(msg)
self.matchRound = msg.round
self.spectate = {
hostName = msg.playerHost, guestName = msg.playerGuest,
hostParty = nil, guestParty = nil, seed = nil,
}
self.stage = "spectateWait"
end
-- -------------------------------------------------------------------
-- update
-- -------------------------------------------------------------------
function Tournament:update(dt)
local input = self.game.input
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
if self.game.stack:top() == self then
-- the battle popped; drain anything the network handed to it but it
-- didn't itself understand (a bracket_update, the next match...)
local battle = self.activeBattle
self.activeBattle = nil
if self.stage == "matchRunning" and battle and battle.result and self.net
and not self.net.closed then
-- a real participant reports its own outcome; the server resolves
-- the match once both sides have (or one disconnects)
self.net:send({ type = "tournament_result", result = battle.result })
end
if battle and battle.pendingTournamentMessages then
for _, msg in ipairs(battle.pendingTournamentMessages) do
self:handleMessage(msg)
end
end
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
self.stage = "bracket"
end
end
return
end
if self.net then
self.net:update()
local status = self.net:getStatus()
if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then
self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60)))
return
end
if status == "closed" and self.stage ~= "done" then
self:exitWith(Strings("The tournament\nconnection was\nlost."))
return
end
end
if self.stage == "matchHello" then
if input:wasPressed("b") then self:exitWith(nil) return end
self:pollHello()
if self.peerHello then
self:beginMatchBattle()
return
end
for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end
return
elseif self.stage == "matchWaitParty" then
if input:wasPressed("b") then self:exitWith(nil) return end
while self.net:hasPending() do
local message = self.net:pollOne()
if message.type == "party" then
self.pendingBattleOpts.theirParty = message.mons
if self.isHost then
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
else
self.pendingBattleOpts.seed = message.seed
end
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
end
if not battle then
self:exitWith(why or Strings("Link battle\ncan't start."))
return
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "matchRunning"
return
else
self:handleMessage(message)
end
end
return
elseif self.stage == "spectateWait" then
if input:wasPressed("b") then self:exitWith(nil) return end
while self.net:hasPending() do
local message = self.net:pollOne()
if message.type == "spectate" and message.msg.type == "party" then
local inner = message.msg
if message.side == "host" then
self.spectate.hostParty = inner.mons
self.spectate.seed = inner.seed
else
self.spectate.guestParty = inner.mons
end
if self.spectate.hostParty and self.spectate.guestParty then
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
hostParty = self.spectate.hostParty,
guestParty = self.spectate.guestParty,
hostName = self.spectate.hostName,
guestName = self.spectate.guestName,
seed = self.spectate.seed,
forceLevel = levelForWire(self.settings.forceLevel),
})
if not battle then
self:exitWith(why or Strings("Can't watch this\nmatch."))
return
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "spectateRunning"
return
end
else
self:handleMessage(message)
end
end
return
end
if self.net then
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
end
if self.stage == "menu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "hostSettings"
self.settingsIndex = 1
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "hostSettings" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 1
elseif input:wasPressed("up") then
self.settingsIndex = self.settingsIndex == 1 and SETTINGS_ROWS or self.settingsIndex - 1
elseif input:wasPressed("down") then
self.settingsIndex = self.settingsIndex % SETTINGS_ROWS + 1
elseif input:wasPressed("left") or input:wasPressed("right") then
local delta = input:wasPressed("right") and 1 or -1
if self.settingsIndex == 1 then
local i = indexOf(PARTY_SIZES, self.settings.requiredPartySize)
i = ((i - 1 + delta) % #PARTY_SIZES) + 1
self.settings.requiredPartySize = PARTY_SIZES[i]
elseif self.settingsIndex == 2 then
local i = indexOf(LEVEL_STEPS, self.settings.minLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.minLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 3 then
local i = indexOf(LEVEL_STEPS, self.settings.maxLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.maxLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 4 then
local i = indexOf(TURN_LIMITS, self.settings.turnLimit)
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
self.settings.turnLimit = TURN_LIMITS[i]
elseif self.settingsIndex == 5 then
local i = indexOf(FORCE_LEVEL_STEPS, self.settings.forceLevel)
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
self.settings.forceLevel = FORCE_LEVEL_STEPS[i]
elseif self.settingsIndex == 6 then
self.settings.participating = not self.settings.participating
end
elseif input:wasPressed("a") or input:wasPressed("start") then
self:startHosting()
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
self:startJoining(CodeEntry.text(self.codeEntry))
end
elseif self.stage == "registering" then
if input:wasPressed("b") then self:exitWith(nil) end
elseif self.stage == "bracket" then
if input:wasPressed("b") then self:exitWith(nil) return end
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
DiscordPresence.setJoinCode(nil) -- roster's locking in; stop advertising
self.net:send({ type = "start_tournament" })
end
elseif self.stage == "done" then
if input:wasPressed("a") or input:wasPressed("b") then
self:exitWith(nil)
end
end
end
-- -------------------------------------------------------------------
-- draw
-- -------------------------------------------------------------------
local function drawTitle(text)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(text, 8, 6)
end
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLAYING" }
function Tournament:draw()
if self.stage == "menu" then
drawTitle("TOURNAMENT")
Font.draw(Strings("HOST"), 32, 48)
Font.draw(Strings("JOIN"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then
drawTitle("TOURNAMENT RULES")
local values = {
tostring(self.settings.requiredPartySize),
levelLabel(self.settings.minLevel),
levelLabel(self.settings.maxLevel),
self.settings.turnLimit .. "s",
forceLevelLabel(self.settings.forceLevel),
self.settings.participating and "YES" or "NO",
}
for i, label in ipairs(SETTINGS_LABELS) do
local y = 32 + (i - 1) * 16
Font.draw(label, 16, y)
Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end
Font.draw(Strings("START: create"), 8, 128)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76)
end
end
Font.draw(Strings("A: join B: back"), 8, 128)
elseif self.stage == "registering" then
drawTitle("CONNECTING...")
Font.draw(Strings("B: cancel"), 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
if self.bracket then
local y = 20
for _, round in ipairs(self.bracket.rounds) do
Font.draw(Strings("ROUND %d", round.round), 8, y)
y = y + 10
for _, m in ipairs(round.matches) do
local line
if m.bye then
line = Strings("%s (bye)", m.a or m.b or "?")
else
local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end
Font.draw(line, 12, y)
y = y + 10
if y > 120 then break end
end
end
else
if self.participating == false then
Font.draw(Strings("(organizing --"), 16, 32)
Font.draw(Strings("not playing)"), 16, 42)
end
Font.draw(Strings("Waiting for"), 16, 48)
Font.draw(Strings("players to join:"), 16, 60)
local y = 60
for i, name in ipairs(self.roster) do
y = 60 + i * 10
Font.draw(name, 24, y)
end
for _, name in ipairs(self.spectatorRoster) do
y = y + 10
Font.draw(name .. " (watch)", 24, y)
end
end
if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw(Strings("A: START B: cancel"), 8, 132)
else
Font.draw(Strings("B: cancel"), 8, 132)
end
elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER")
if self.champion then
Font.draw(Strings("%s is the", self.champion), 16, 56)
Font.draw(Strings("champion!"), 16, 68)
end
Font.draw(Strings("A: continue"), 8, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
return Tournament
+496 -5
View File
@@ -137,6 +137,7 @@ local function name(v)
end
local sanitize
local arenaStart
local SCHEMAS = {}
@@ -150,6 +151,7 @@ SCHEMAS.hello = function(m)
generation = Wire.num(m.generation, nil, 0, 255),
fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING),
linkModified = Wire.bool(m.linkModified, nil),
ruleset = Wire.str(m.ruleset, nil, MAX_STRING),
mods = Wire.list(m.mods, MAX_MODS, modEntry),
}
end
@@ -167,6 +169,7 @@ SCHEMAS.party = function(m)
mons = Wire.list(m.mons, MAX_PARTY, mon),
seed = Wire.num(m.seed, nil, 0, MAX_INT),
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
ruleset = Wire.str(m.ruleset, nil, MAX_STRING),
}
end
@@ -207,15 +210,24 @@ SCHEMAS.replace = function(m)
end
SCHEMAS.bye = function() return {} end
SCHEMAS.forfeit = function() return {} end
SCHEMAS.forfeit = function(m)
return { match = Wire.str(m.match, nil, MAX_NAME) }
end
SCHEMAS.hosted = function(m)
return { code = Wire.str(m.code, nil, MAX_NAME) }
end
SCHEMAS.paired = function() return {} end
SCHEMAS.ping = function(m)
return { t = Wire.num(m.t, 0, 0) }
end
SCHEMAS.pong = SCHEMAS.ping
SCHEMAS.peer_gone = function() return {} end
SCHEMAS.join_error = function(m)
return { reason = Wire.str(m.reason, "", MAX_STRING) }
return { reason = Wire.str(m.reason, "", MAX_STRING),
field = Wire.str(m.field, nil, MAX_STRING),
detail = Wire.str(m.detail, nil, MAX_STRING),
code = Wire.str(m.code, nil, MAX_NAME) }
end
local function rule(m)
@@ -247,6 +259,7 @@ SCHEMAS.tournament_roster = function(m)
local out = rule(m)
out.players = Wire.list(m.players, MAX_MATCHES, name)
out.spectators = Wire.list(m.spectators, MAX_MATCHES, name)
out.creator = name(m.creator)
return out
end
@@ -256,6 +269,8 @@ local function match(v)
a = name(v.a), b = name(v.b), winner = name(v.winner),
bye = Wire.bool(v.bye, false),
state = Wire.str(v.state, nil, MAX_STRING),
match = Wire.str(v.match, nil, MAX_NAME),
how = Wire.str(v.how, nil, MAX_STRING),
}
end
@@ -279,24 +294,32 @@ SCHEMAS.bracket_update = function(m)
end
SCHEMAS.match_start = function(m)
return {
local out = {
opponent = name(m.opponent),
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
role = Wire.str(m.role, "", MAX_STRING),
match = Wire.str(m.match, nil, MAX_NAME),
}
arenaStart(m, out)
return out
end
SCHEMAS.match_start_spectate = function(m)
return {
local out = {
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
playerHost = name(m.playerHost),
playerGuest = name(m.playerGuest),
match = Wire.str(m.match, nil, MAX_NAME),
role = Wire.str(m.role, nil, MAX_STRING),
}
arenaStart(m, out)
return out
end
SCHEMAS.tournament_bye = function(m)
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) }
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
match = Wire.str(m.match, nil, MAX_NAME) }
end
SCHEMAS.tournament_over = function(m)
@@ -315,6 +338,474 @@ SCHEMAS.spectate = function(m)
return { side = Wire.str(m.side, "", MAX_STRING), msg = inner }
end
local CodeEntry = require("src.link.CodeEntry")
local MAX_DISPLAY_NAME = 16
local MAX_TICKET = 128
local MAX_SESSION = 64
local MAX_PROFILES = 16
local MAX_LOBBY_ENTRIES = 200
local MAX_NOTE = 40
local MAX_SPECTATORS = 64
local MAX_REPLAY = 512
local MAX_ROOM_PLAYERS = 4
local MAX_ENTRY_PLAYERS = 64
local MAX_TEAM = 6
local MAX_DEADLINES = 8
local function displayName(v)
return Wire.str(v, nil, MAX_DISPLAY_NAME)
end
local function codeStr(v)
if type(v) ~= "string" then return nil end
local s = v:upper()
if #s ~= CodeEntry.LENGTH then return nil end
for i = 1, #s do
if not CodeEntry.CHARSET:find(s:sub(i, i), 1, true) then return nil end
end
return s
end
Wire.code = codeStr
local function cartRef(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
version = Wire.str(v.version, nil, MAX_NAME),
hash = Wire.str(v.hash, nil, MAX_STRING),
}
end
local function arenaRule(v)
if type(v) ~= "table" then v = {} end
return {
partySize = Wire.num(v.partySize, nil, 1, MAX_TEAM),
minLevel = Wire.num(v.minLevel, nil, 1, 100),
maxLevel = Wire.num(v.maxLevel, nil, 1, 100),
forceLevel = Wire.num(v.forceLevel, nil, 1, 100),
}
end
local function profile(v)
if type(v) ~= "table" then return nil end
return {
engine = Wire.num(v.engine, nil, 1, 2),
version = Wire.str(v.version, nil, MAX_NAME),
engineVersion = Wire.str(v.engineVersion, nil, MAX_STRING),
apiVersion = Wire.num(v.apiVersion, nil, 0, MAX_INT),
fingerprint = Wire.str(v.fingerprint, nil, MAX_STRING),
rulesetId = Wire.str(v.rulesetId, nil, MAX_NAME),
kind = Wire.str(v.kind, nil, MAX_NAME),
cart = cartRef(v.cart),
rule = arenaRule(v.rule),
}
end
Wire.profile = profile
arenaStart = function(m, out)
out.seed = Wire.num(m.seed, nil, 0, MAX_INT)
out.ruleset = Wire.str(m.ruleset, nil, MAX_STRING)
out.rule = m.rule ~= nil and arenaRule(m.rule) or nil
out.code = codeStr(m.code) or out.code
out.peerName = displayName(m.peerName)
out.hostName = displayName(m.hostName)
out.guestName = displayName(m.guestName)
out.theirParty = m.theirParty ~= nil and Wire.list(m.theirParty, MAX_TEAM, mon) or nil
out.hostParty = m.hostParty ~= nil and Wire.list(m.hostParty, MAX_TEAM, mon) or nil
out.guestParty = m.guestParty ~= nil and Wire.list(m.guestParty, MAX_TEAM, mon) or nil
return out
end
local function youEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
account = Wire.str(v.account, nil, MAX_NAME),
}
end
local function lobbyEntry(v)
if type(v) ~= "table" then return nil end
local id = Wire.str(v.id, nil, MAX_NAME)
if not id then return nil end
return {
id = id,
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
intent = Wire.str(v.intent, nil, MAX_NAME),
profile = profile(v.profile),
since = Wire.num(v.since, nil, 0),
note = Wire.str(v.note, nil, MAX_STRING),
code = codeStr(v.code),
open = Wire.bool(v.open, nil),
stage = Wire.str(v.stage, nil, MAX_NAME),
players = Wire.num(v.players, nil, 0, MAX_ENTRY_PLAYERS),
spectators = Wire.num(v.spectators, nil, 0, MAX_SPECTATORS),
maxSpectators = Wire.num(v.maxSpectators, nil, 0, MAX_SPECTATORS),
}
end
local function playerEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
role = Wire.str(v.role, nil, MAX_NAME),
ready = Wire.bool(v.ready, false),
online = Wire.bool(v.online, true),
party = Wire.list(v.party, MAX_TEAM, mon),
partyDigest = Wire.str(v.partyDigest, nil, MAX_STRING),
}
end
local function spectatorEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
}
end
local function deadlineEntry(v)
if type(v) ~= "table" then return nil end
return {
kind = Wire.str(v.kind, nil, MAX_NAME),
at = Wire.num(v.at, nil, 0),
}
end
local function innerMsg(v)
if type(v) ~= "table" then return nil end
return sanitize(v)
end
local function replayEntry(v)
if type(v) ~= "table" then return nil end
local inner = innerMsg(v.msg)
if not inner then
inner = innerMsg(v)
if not inner then return nil end
return { seq = Wire.num(v.seq, nil, 0, MAX_INT), msg = inner }
end
return {
seq = Wire.num(v.seq, nil, 0, MAX_INT),
clientSeq = Wire.num(v.clientSeq, nil, 0, MAX_INT),
side = Wire.str(v.side, nil, MAX_NAME),
msg = inner,
}
end
SCHEMAS.lobby_hello = function(m)
return {
ticket = Wire.str(m.ticket, nil, MAX_TICKET),
name = displayName(m.name),
engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING),
platform = Wire.str(m.platform, nil, MAX_NAME),
profiles = Wire.list(m.profiles, MAX_PROFILES, profile),
}
end
SCHEMAS.lobby_welcome = function(m)
return {
session = Wire.str(m.session, nil, MAX_SESSION),
you = youEntry(m.you),
serverTime = Wire.num(m.serverTime, nil, 0),
heartbeatMs = Wire.num(m.heartbeatMs, nil, 0, 600000),
resumed = Wire.bool(m.resumed, false),
}
end
SCHEMAS.resume = function(m)
return {
session = Wire.str(m.session, nil, MAX_SESSION),
ack = Wire.num(m.ack, 0, 0, MAX_INT),
}
end
SCHEMAS.advertise = function(m)
return {
intent = Wire.str(m.intent, nil, MAX_NAME),
profile = profile(m.profile),
note = Wire.str(m.note, nil, MAX_STRING),
}
end
SCHEMAS.unadvertise = function() return {} end
SCHEMAS.room_create = function(m)
return {
intent = Wire.str(m.intent, nil, MAX_NAME),
profile = profile(m.profile),
playing = Wire.bool(m.playing, true),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS),
public = Wire.bool(m.public, true),
note = Wire.str(m.note, nil, MAX_NOTE),
}
end
SCHEMAS.room_join = function(m)
return {
code = codeStr(m.code),
as = Wire.str(m.as, nil, MAX_NAME),
profile = profile(m.profile),
}
end
SCHEMAS.room_leave = function() return {} end
SCHEMAS.room_ready = function(m)
return {
party = Wire.list(m.party, MAX_TEAM, mon),
partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING),
}
end
SCHEMAS.room_msg = function(m)
local inner = innerMsg(m.msg)
if not inner then return nil end
return {
seq = Wire.num(m.seq, 0, 0, MAX_INT),
clientSeq = Wire.num(m.clientSeq, nil, 0, MAX_INT),
side = Wire.str(m.side, nil, MAX_NAME),
msg = inner,
}
end
SCHEMAS.room_report = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
result = Wire.str(m.result, nil, MAX_NAME),
}
end
SCHEMAS.room_kick = function(m)
return { id = Wire.str(m.id, nil, MAX_NAME) }
end
SCHEMAS.room_close = function() return {} end
SCHEMAS.lobby_query = function() return {} end
SCHEMAS.room_ack = function(m)
return { seq = Wire.num(m.seq, 0, 0, MAX_INT) }
end
SCHEMAS.lobby_list = function(m)
return {
entries = Wire.list(m.entries, MAX_LOBBY_ENTRIES, lobbyEntry),
online = Wire.num(m.online, nil, 0, MAX_INT),
}
end
SCHEMAS.lobby_delta = function(m)
local ids = function(v)
return Wire.list(v, MAX_LOBBY_ENTRIES, function(x)
return Wire.str(x, nil, MAX_NAME)
end)
end
return {
added = Wire.list(m.added, MAX_LOBBY_ENTRIES, lobbyEntry),
changed = Wire.list(m.changed, MAX_LOBBY_ENTRIES, lobbyEntry),
removed = ids(m.removed),
add = Wire.list(m.add, MAX_LOBBY_ENTRIES, lobbyEntry),
update = Wire.list(m.update, MAX_LOBBY_ENTRIES, lobbyEntry),
remove = Wire.list(m.remove, MAX_LOBBY_ENTRIES, function(v)
return Wire.str(v, nil, MAX_NAME)
end),
op = Wire.str(m.op, nil, MAX_NAME),
entry = lobbyEntry(m.entry),
id = Wire.str(m.id, nil, MAX_NAME),
}
end
SCHEMAS.room_state = function(m)
return {
code = codeStr(m.code),
players = Wire.list(m.players, MAX_ROOM_PLAYERS, playerEntry),
spectators = Wire.list(m.spectators, MAX_SPECTATORS, spectatorEntry),
stage = Wire.str(m.stage, nil, MAX_NAME),
profile = profile(m.profile),
host = Wire.str(m.host, nil, MAX_NAME),
seed = Wire.num(m.seed, nil, 0, MAX_INT),
intent = Wire.str(m.intent, nil, MAX_NAME),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS),
rule = m.rule ~= nil and arenaRule(m.rule) or nil,
match = Wire.str(m.match, nil, MAX_NAME),
deadlines = Wire.list(m.deadlines, MAX_DEADLINES, deadlineEntry),
}
end
SCHEMAS.room_replay = function(m)
return {
from = Wire.num(m.from, 0, 0, MAX_INT),
yourSeq = Wire.num(m.yourSeq, nil, 0, MAX_INT),
msgs = Wire.list(m.msgs, MAX_REPLAY, replayEntry),
}
end
SCHEMAS.room_deadline = function(m)
return {
kind = Wire.str(m.kind, nil, MAX_NAME),
at = Wire.num(m.at, nil, 0),
}
end
SCHEMAS.room_result = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
code = codeStr(m.code),
winner = displayName(m.winner),
winnerId = Wire.str(m.winnerId, nil, MAX_NAME),
how = Wire.str(m.how, nil, MAX_NAME),
}
end
SCHEMAS.room_closed = function(m)
return {
reason = Wire.str(m.reason, nil, MAX_NAME),
code = codeStr(m.code),
}
end
local MAX_TOUR_PLAYERS = 64
local MAX_TOUR_SPECTATORS = 64
local MAX_TOUR_ROUNDS = 7
local MAX_TOUR_MATCHES = 32
local TOUR_STAGES = { registering = true, running = true, finished = true }
local TOUR_MATCH_STATES = { pending = true, live = true, done = true,
bye = true }
local function tourPlayerEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
online = Wire.bool(v.online, true),
eliminated = Wire.bool(v.eliminated, false),
}
end
local function tourMatchEntry(v)
if type(v) ~= "table" then return nil end
local state = Wire.str(v.state, nil, MAX_NAME)
return {
match = Wire.str(v.match, nil, MAX_NAME),
a = Wire.str(v.a, nil, MAX_NAME),
b = Wire.str(v.b, nil, MAX_NAME),
winner = Wire.str(v.winner, nil, MAX_NAME),
how = Wire.str(v.how, nil, MAX_NAME),
state = TOUR_MATCH_STATES[state or ""] and state or nil,
}
end
local function tourRoundEntry(v)
if type(v) ~= "table" then return nil end
return {
round = Wire.num(v.round, 0, 0, MAX_TOUR_ROUNDS),
matches = Wire.list(v.matches, MAX_TOUR_MATCHES, tourMatchEntry),
}
end
SCHEMAS.tour_create = function(m)
return {
profile = profile(m.profile),
rule = m.rule ~= nil and arenaRule(m.rule) or nil,
playing = Wire.bool(m.playing, true),
shotClock = Wire.num(m.shotClock, nil, 0, 3600),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_TOUR_SPECTATORS),
party = m.party ~= nil and Wire.list(m.party, MAX_TEAM, mon) or nil,
partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING),
public = Wire.bool(m.public, true),
note = Wire.str(m.note, nil, MAX_NOTE),
}
end
SCHEMAS.tour_join = function(m)
return {
code = codeStr(m.code),
as = Wire.str(m.as, nil, MAX_NAME),
profile = profile(m.profile),
party = Wire.list(m.party, MAX_TEAM, mon),
partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING),
}
end
SCHEMAS.tour_leave = function() return {} end
SCHEMAS.tour_start = function() return {} end
SCHEMAS.tour_close = function() return {} end
SCHEMAS.tour_kick = function(m)
return { id = Wire.str(m.id, nil, MAX_NAME) }
end
SCHEMAS.tour_state = function(m)
local stage = Wire.str(m.stage, nil, MAX_NAME)
return {
code = codeStr(m.code),
creator = Wire.str(m.creator, nil, MAX_NAME),
stage = TOUR_STAGES[stage or ""] and stage or "registering",
players = Wire.list(m.players, MAX_TOUR_PLAYERS, tourPlayerEntry),
spectators = Wire.list(m.spectators, MAX_TOUR_SPECTATORS, spectatorEntry),
profile = profile(m.profile),
rule = m.rule ~= nil and arenaRule(m.rule) or nil,
shotClock = Wire.num(m.shotClock, nil, 0, 3600),
round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS),
bracket = Wire.list(m.bracket, MAX_TOUR_ROUNDS, tourRoundEntry),
live = Wire.str(m.live, nil, MAX_NAME),
champion = Wire.str(m.champion, nil, MAX_NAME),
championId = Wire.str(m.championId, nil, MAX_NAME),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_TOUR_SPECTATORS),
}
end
SCHEMAS.tour_match = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS),
code = codeStr(m.code),
}
end
SCHEMAS.tour_match_spectate = SCHEMAS.tour_match
SCHEMAS.tour_bye = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS),
}
end
SCHEMAS.tour_deadline = function(m)
return {
kind = Wire.str(m.kind, nil, MAX_NAME),
at = Wire.num(m.at, nil, 0),
match = Wire.str(m.match, nil, MAX_NAME),
}
end
SCHEMAS.tour_closed = function(m)
return {
code = codeStr(m.code),
reason = Wire.str(m.reason, nil, MAX_NAME),
}
end
SCHEMAS.tour_over = function(m)
return {
code = codeStr(m.code),
champion = displayName(m.champion),
championId = Wire.str(m.championId, nil, MAX_NAME),
}
end
Wire.SCHEMAS = SCHEMAS
local function passthrough(m)
+3 -2
View File
@@ -1942,8 +1942,9 @@ COVERAGE["src.battle.BattleState"] = {
.. "takes its native safari path when it finds one",
openParty = "Gold's takes a `forced` argument Gen 1's does not; a wrap "
.. "must forward ... faithfully rather than normalising it away",
wantsFillScale = "returns TRUE unconditionally on Gold, which reads as "
.. "\"the player chose FILL\" and is not a choice at all",
wantsFillScale = "reads OPTION -> BATTLE SIZE (#1709), same fixed/fill "
.. "key as Gen 1; Gold has no WIDE layout, so FILL is the scale of the "
.. "160x144 panel and nothing more",
swapMoves = "no disabled-slot migration and no sfx on Gold",
sides = "self.battle.sides is the same { index, battlers, screens, "
.. "hazards, tokens } shape, with the same index-1-is-player rule",
+3 -2
View File
@@ -1230,8 +1230,9 @@ local ModProfile = require("src.mods.ModProfile")
function LauncherMods.getProfiles(options)
options = options or SaveData.loadOptions()
local manifests = discover()
ModProfile.ensureFirst(options, manifests, options.modOptions)
if not options.modProfilesSeeded then
ModProfile.ensureFirst(options, discover(), options.modOptions)
end
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
end
+112 -6
View File
@@ -327,6 +327,13 @@ end
function Loader:_loadState()
self.disabled = {}
if self.arenaMode then
self.safeMode = false
Runtime.safeMode = false
self.gen2Forced = {}
self.modOptions = {}
return
end
local options = SaveData.loadOptions(self.fs)
self.safeMode = SaveData.isSafeMode(options)
Runtime.safeMode = self.safeMode
@@ -608,6 +615,88 @@ function Loader:cartStatus()
return self.cartReport
end
Loader.ARENA_MODES = { normal = true, disableAll = true, cartOnly = true }
local function translationCandidate(manifest)
if type(manifest) ~= "table" then return false end
if manifest.language ~= true then return false end
if manifest.affects_link ~= false then return false end
return #(manifest.permissions or {}) == 0
end
function Loader:_arenaDisableAll()
for id, mod in pairs(self.mods) do
if not translationCandidate(mod.manifest) then self.disabled[id] = true end
end
end
function Loader:_arenaVerifyTranslations()
local ok, Handshake = pcall(require, "src.link.Handshake")
if not ok or not Handshake or not Handshake.onlineBlockers then return end
local blocked = {}
for _, entry in ipairs(Handshake.onlineBlockers({ mods = self })) do
blocked[entry.id] = true
end
if next(blocked) == nil then return end
for id in pairs(blocked) do
local mod = self.mods[id]
if mod then
self:_rollback(id)
mod.enabled, mod.state = false, "disabled"
mod.skipReason = "not a verified translation"
self.disabled[id] = true
end
end
for i = #self.loaded, 1, -1 do
if blocked[self.loaded[i].manifest.id] then table.remove(self.loaded, i) end
end
for i = #self.order, 1, -1 do
if blocked[self.order[i]] then table.remove(self.order, i) end
end
end
function Loader:_arenaCart()
local cartId = self.arenaCartId
if type(cartId) ~= "string" or cartId == "" then
return false, "no cart chosen"
end
local cart, err = self.cart
if not cart then cart, err = CartStore.get(cartId, self.fs) end
if not cart then
return false, tostring(err or "this cart is not installed")
end
local report = Loader.planCart(cart, self.mods, self.arenaSealBroken)
report.id = cartId
self.cartReport = report
if self.arenaSealBroken then return false, "this save's seal is broken" end
if cart.seal ~= "sealed" then
return false, ("%s is not a sealed cart"):format(cart.title or cartId)
end
if report.refused or not report.enforced then
return false, report.message or ("%s cannot be enforced"):format(cartId)
end
self.cartSwitches = {}
for id, mod in pairs(self.mods) do
local pin = report.pins[id]
if pin then
local on = CartManifest.modEnabled(pin)
mod.enabled, mod.state = on, on and "pending" or "disabled"
self.disabled[id] = not on or nil
else
mod.enabled, mod.state = false, "disabled"
self.disabled[id] = true
end
end
local merged = {}
for id, pin in pairs(report.pins) do
local bucket = {}
for key, value in pairs(pin.options or {}) do bucket[key] = value end
merged[id] = bucket
end
self.modOptions = merged
return true
end
function Loader:_applyCart()
local cartId = SaveData.getCart()
if not cartId or self.safeMode then return end
@@ -1737,7 +1826,15 @@ local function stampAudioOwners(data, name, registry)
owners[key] = map
end
function Loader:load(data)
function Loader:load(data, opts)
opts = opts or {}
local mode = opts.mode or "normal"
if not Loader.ARENA_MODES[mode] then
return false, ("unknown loader mode %q"):format(tostring(mode))
end
self.arenaMode = mode ~= "normal" and mode or nil
self.arenaCartId = mode == "cartOnly" and opts.cartId or nil
self.arenaSealBroken = opts.sealBroken == true
self.baseData = data
-- every registry folds against the pristine view of its Data target;
-- resolution is lazy so optional namespaces may appear later
@@ -1760,11 +1857,17 @@ function Loader:load(data)
if self.safeMode then
for id in pairs(self.mods) do self.disabled[id] = true end
end
if self.arenaMode == "disableAll" then
self:_arenaDisableAll()
elseif self.arenaMode == "cartOnly" then
local ok, reason = self:_arenaCart()
if not ok then return false, reason end
end
-- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same
-- fallback, so this write cannot change the current boot's result.
do
if not self.arenaMode then
local options = SaveData.loadOptions(self.fs)
local installed = {}
for id, mod in pairs(self.mods) do
@@ -1779,7 +1882,7 @@ function Loader:load(data)
end
-- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that.
do
if not self.arenaMode then
local options = SaveData.loadOptions(self.fs)
local scope = self:_enableScope()
for id, mod in pairs(self.mods) do
@@ -1795,7 +1898,8 @@ function Loader:load(data)
-- the one build where its env var is set.
for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env
if not self.safeMode and envName and os.getenv(envName) == "1" then
if not self.safeMode and not self.arenaMode and envName
and os.getenv(envName) == "1" then
self.disabled[id] = nil
end
end
@@ -1803,7 +1907,7 @@ function Loader:load(data)
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
end
self:_applyCart()
if not self.arenaMode then self:_applyCart() end
-- engine call sites reach these buses -- and this error feed, for failures
-- that only surface at play time -- through Runtime from here on
Runtime.install(self.events, self.hooks, self.errors)
@@ -1856,6 +1960,7 @@ function Loader:load(data)
-- the commands registry is final once every entry chunk has run, so
-- each map_scripts contribution's rows can be judged before they merge
self:_validateScripts()
if self.arenaMode == "disableAll" then self:_arenaVerifyTranslations() end
-- merge: fold every touched id from its pristine base value and write it
-- home, creating the Data namespace when the base modules never shipped
-- one. A registry nobody wrote to -- engine included -- is skipped, so
@@ -1977,7 +2082,8 @@ function Loader:status()
table.sort(available, function(a, b) return a.id < b.id end)
table.sort(loaded, function(a, b) return a.id < b.id end)
return { available = available, loaded = loaded, errors = self.errors,
order = self.order, cart = self.cartReport }
order = self.order, cart = self.cartReport,
arenaMode = self.arenaMode or "normal" }
end
return Loader
+36
View File
@@ -880,6 +880,42 @@ function ModIndex.fetchText(url)
return ModIndex.httpGet(url)
end
function ModIndex.beginFetchText(url)
local h = { url = url, stage = "start" }
if type(url) ~= "string" or url == "" then
h.stage, h.err = "done", "no description"
end
return h
end
function ModIndex.pumpFetchText(h)
if not h then return true, nil, "no handle" end
if h.stage == "done" then return true, h.body, h.err end
local Fetch = require("src.net.Fetch")
if h.stage == "start" then
h.job = Fetch.get(h.url, { userAgent = "gen1recomp-mod-index" })
h.stage = "fetching"
return false
end
local st = Fetch.poll(h.job)
if st.status == "pending" then return false end
Fetch.release(h.job)
h.job, h.stage = nil, "done"
if st.status == "ok" and type(st.body) == "string" and st.body ~= "" then
h.body = st.body
return true, h.body
end
h.err = st.err or "description fetch failed"
return true, nil, h.err
end
function ModIndex.cancelFetchText(h)
if not h or not h.job then return end
local Fetch = require("src.net.Fetch")
pcall(Fetch.cancel, h.job)
h.job, h.stage = nil, "done"
end
-- Download a thumbnail into the save directory and return the love.filesystem
-- relative path. Reuses ModUpdate.downloadZip, which is a plain curl -o with
-- a non-empty-file check -- nothing in it is zip-specific.
+2 -1
View File
@@ -2045,7 +2045,8 @@ R.field = {
cursorOrder = f.opt(f.list(f.str)),
locations = f.opt(f.map(f.str, f.rec{ x = f.int(0), y = f.int(0),
name = f.opt(f.str) })),
nest = f.opt(f.any) },
nest = f.opt(f.any),
upArrow = f.opt(f.any) },
flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
+250
View File
@@ -0,0 +1,250 @@
local Protocol = require("src.link.Protocol")
local ArenaBoot = {}
local VERSIONS = {
red = true, blue = true, yellow = true,
gold = true, silver = true, crystal = true,
}
local ROLES = { host = true, guest = true, spectator = true }
local KINDS = { vanilla = true, cart = true }
local function isCallable(v)
if type(v) == "function" then return true end
if type(v) ~= "table" then return false end
local mt = getmetatable(v)
return type(mt) == "table" and type(mt.__call) == "function"
end
local function hasMethod(obj, name)
if type(obj) ~= "table" then return false end
return isCallable(obj[name])
end
local function isPackedParty(v)
if type(v) ~= "table" then return false end
for _, mon in ipairs(v) do
if type(mon) ~= "table" or type(mon.species) ~= "string" then return false end
end
return true
end
local function level(v)
return type(v) == "number" and v >= 1 and v <= 100 and math.floor(v) == v
end
local function normaliseRule(rule)
if rule == nil then rule = {} end
if type(rule) ~= "table" then return nil, "rule must be a table" end
local size = rule.partySize
if size == nil then size = 6 end
if type(size) ~= "number" or size < 1 or size > 6 or math.floor(size) ~= size then
return nil, "rule.partySize must be 1..6"
end
if rule.minLevel ~= nil and not level(rule.minLevel) then
return nil, "rule.minLevel must be 1..100"
end
if rule.maxLevel ~= nil and not level(rule.maxLevel) then
return nil, "rule.maxLevel must be 1..100"
end
if rule.minLevel and rule.maxLevel and rule.minLevel > rule.maxLevel then
return nil, "rule.minLevel is above rule.maxLevel"
end
if rule.forceLevel ~= nil and not level(rule.forceLevel) then
return nil, "rule.forceLevel must be 1..100"
end
return {
partySize = size,
minLevel = rule.minLevel,
maxLevel = rule.maxLevel,
forceLevel = rule.forceLevel,
}
end
function ArenaBoot.profile(fields)
if type(fields) ~= "table" then return nil, "profile must be a table" end
local engine = fields.engine
if engine ~= 1 and engine ~= 2 then return nil, "profile.engine must be 1 or 2" end
if type(fields.version) ~= "string" or not VERSIONS[fields.version] then
return nil, "profile.version is not a known game"
end
local kind = fields.kind or "vanilla"
if not KINDS[kind] then return nil, "profile.kind must be vanilla or cart" end
if kind == "cart" then
local cart = fields.cart
if type(cart) ~= "table" or type(cart.id) ~= "string" or cart.id == ""
or type(cart.hash) ~= "string" or cart.hash == "" then
return nil, "a cart profile needs cart.id and cart.hash"
end
end
if fields.engineVersion ~= nil and type(fields.engineVersion) ~= "string" then
return nil, "profile.engineVersion must be a string"
end
if fields.rulesetId ~= nil and type(fields.rulesetId) ~= "string" then
return nil, "profile.rulesetId must be a string"
end
local rule, ruleErr = normaliseRule(fields.rule)
if not rule then return nil, ruleErr end
local cart = nil
if kind == "cart" then
cart = {
id = fields.cart.id,
version = fields.cart.version,
hash = fields.cart.hash,
}
end
return {
engine = engine,
version = fields.version,
engineVersion = fields.engineVersion,
apiVersion = fields.apiVersion,
fingerprint = fields.fingerprint,
rulesetId = fields.rulesetId,
kind = kind,
cart = cart,
rule = rule,
}
end
local function normaliseTeam(team, size)
if team == nil then return nil end
if type(team) ~= "table" then return nil, "team must be an array of party indices" end
local seen, out = {}, {}
for _, index in ipairs(team) do
if type(index) ~= "number" or index < 1 or index > 6 or math.floor(index) ~= index then
return nil, "team holds a party index outside 1..6"
end
if seen[index] then return nil, "team repeats a party index" end
seen[index] = true
out[#out + 1] = index
end
if #out == 0 then return nil, "team is empty" end
if #out > size then return nil, "team is longer than the rule allows" end
return out
end
function ArenaBoot.spec(fields)
if type(fields) ~= "table" then return nil, "spec must be a table" end
local profile, profileErr = ArenaBoot.profile(fields.profile)
if not profile then return nil, profileErr end
local role = fields.role
if type(role) ~= "string" or not ROLES[role] then
return nil, "role must be host, guest or spectator"
end
local spectating = role == "spectator"
if not spectating then
if type(fields.slotId) ~= "string" or fields.slotId == "" then
return nil, "slotId is required"
end
elseif fields.slotId ~= nil and type(fields.slotId) ~= "string" then
return nil, "slotId must be a string"
end
local team, teamErr = normaliseTeam(fields.team, profile.rule.partySize)
if fields.team ~= nil and not team then return nil, teamErr end
if type(fields.seed) ~= "number" then return nil, "seed must be a number" end
local session = fields.session
if not (hasMethod(session, "send") and hasMethod(session, "poll")
and hasMethod(session, "close")) then
return nil, "session must provide send, poll and close"
end
if fields.onDone ~= nil and not isCallable(fields.onDone) then
return nil, "onDone must be a function"
end
if spectating then
if not isPackedParty(fields.hostParty) or not isPackedParty(fields.guestParty) then
return nil, "a spectator spec needs hostParty and guestParty"
end
else
if not isPackedParty(fields.theirParty) then
return nil, "theirParty must be a packed party"
end
if fields.myParty ~= nil and not isPackedParty(fields.myParty) then
return nil, "myParty must be a packed party"
end
end
local onDone = fields.onDone
return {
profile = profile,
role = role,
slotId = fields.slotId,
team = team,
seed = fields.seed,
peerName = fields.peerName or "FOE",
hostName = fields.hostName or "HOST",
guestName = fields.guestName or "GUEST",
myParty = fields.myParty,
theirParty = fields.theirParty,
hostParty = fields.hostParty,
guestParty = fields.guestParty,
session = session,
onDone = function(result)
if onDone then onDone(result) end
end,
}
end
function ArenaBoot.battleOpts(spec)
if type(spec) ~= "table" or type(spec.profile) ~= "table" then
return nil, "spec must carry a profile"
end
local rule = spec.profile.rule or {}
if spec.role == "spectator" then
return {
hostParty = spec.hostParty,
guestParty = spec.guestParty,
hostName = spec.hostName,
guestName = spec.guestName,
seed = spec.seed,
ruleset = spec.profile.rulesetId,
verdict = "full",
strict = true,
forceLevel = rule.forceLevel,
keepNetOpen = true,
}
end
return {
myParty = spec.myParty,
theirParty = spec.theirParty,
theirName = spec.peerName,
role = spec.role,
seed = spec.seed,
ruleset = spec.profile.rulesetId,
verdict = "full",
strict = true,
forceLevel = rule.forceLevel,
keepNetOpen = true,
}
end
function ArenaBoot.packOwnParty(game, spec)
if type(spec) ~= "table" then return nil, "spec must be a table" end
if spec.role == "spectator" then return nil end
if isPackedParty(spec.myParty) and #spec.myParty > 0 then return spec.myParty end
local party = game and game.save and game.save.party
if type(party) ~= "table" then return nil, "no party to send" end
local size = (spec.profile and spec.profile.rule and spec.profile.rule.partySize) or 6
local indices = {}
for _, index in ipairs(spec.team or {}) do
if party[index] then indices[#indices + 1] = index end
end
if #indices == 0 then
for index = 1, math.min(#party, size) do indices[index] = index end
end
if #indices == 0 then return nil, "no party to send" end
spec.myParty = (spec.profile and spec.profile.engine == 2)
and Protocol.packParty2(party, indices)
or Protocol.packParty(party, indices)
return spec.myParty
end
return ArenaBoot
+260
View File
@@ -0,0 +1,260 @@
local CacheFs = require("src.import.CacheFs")
local CartStore = require("src.carts.CartStore")
local Fingerprint = require("src.link.Fingerprint")
local GameVersion = require("src.core.GameVersion")
local Handshake = require("src.link.Handshake")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local ArenaData = {}
local rulesetMemo = {}
local FIELDS = {
{ key = "engine", text = "engine differs" },
{ key = "version", text = "game differs" },
{ key = "engineVersion", text = "engine version differs" },
{ key = "apiVersion", text = "mod api differs" },
{ key = "kind", text = "arena kind differs" },
{ key = "rulesetId", text = "ruleset differs" },
{ key = "fingerprint", text = "data differs" },
}
local CART_FIELDS = {
{ key = "id", text = "cart differs" },
{ key = "version", text = "cart version differs" },
{ key = "hash", text = "cart hash differs" },
}
function ArenaData.cacheKey(version, kind, cartHash)
return tostring(version) .. "|" .. tostring(kind) .. "|"
.. (cartHash or "-") .. "|" .. tostring(Version.engine)
end
local function copyRule(rule)
rule = type(rule) == "table" and rule or {}
local size = tonumber(rule.partySize) or 3
size = math.max(1, math.min(6, math.floor(size)))
return {
partySize = size,
minLevel = tonumber(rule.minLevel) or nil,
maxLevel = tonumber(rule.maxLevel) or nil,
forceLevel = tonumber(rule.forceLevel) or nil,
}
end
local function copyProfile(entry, rule)
local out = {
engine = entry.engine,
version = entry.version,
engineVersion = entry.engineVersion,
apiVersion = entry.apiVersion,
fingerprint = entry.fingerprint,
rulesetId = entry.rulesetId,
kind = entry.kind,
rule = copyRule(rule),
}
if type(entry.cart) == "table" then
out.cart = { id = entry.cart.id, version = entry.cart.version,
hash = entry.cart.hash }
end
return out
end
function ArenaData.equal(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return false end
for _, field in ipairs(FIELDS) do
if a[field.key] ~= b[field.key] then return false end
end
local ca, cb = a.cart, b.cart
if (ca == nil) ~= (cb == nil) then return false end
if ca then
for _, field in ipairs(CART_FIELDS) do
if ca[field.key] ~= cb[field.key] then return false end
end
end
return true
end
function ArenaData.describeMismatch(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return "no profile" end
for _, field in ipairs(FIELDS) do
if a[field.key] ~= b[field.key] then return field.text end
end
local ca, cb = a.cart, b.cart
if (ca == nil) ~= (cb == nil) then return "cart differs" end
if ca then
for _, field in ipairs(CART_FIELDS) do
if ca[field.key] ~= cb[field.key] then return field.text end
end
end
return nil
end
local function cachedProfiles()
local options = SaveData.loadOptions()
local bucket = options.arenaProfiles
return type(bucket) == "table" and bucket or {}, options
end
local function storeProfile(key, entry)
local bucket = cachedProfiles()
bucket[key] = entry
pcall(SaveData.saveOptions, { arenaProfiles = bucket })
end
function ArenaData.forget(version)
local bucket = cachedProfiles()
local prefix = version and (tostring(version) .. "|") or nil
for key in pairs(bucket) do
if not prefix or key:sub(1, #prefix) == prefix then bucket[key] = nil end
end
pcall(SaveData.saveOptions, { arenaProfiles = bucket })
if version then rulesetMemo[version] = nil else rulesetMemo = {} end
end
local function gen2Dataset()
local data = {}
local function load(name)
return CacheFs.loadActive("data/generated/" .. name .. ".lua")
end
data.pokemon = load("pokemon") or {}
data.items = load("items") or {}
data.moves = load("moves") or {}
data.type_chart = load("type_chart") or {}
data.gen2Constants = load("constants")
data.text = load("rom_text") or {}
data.font = load("font")
local chart = data.type_chart
chart.matchups = chart.matchups or {}
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
local ItemEffects = require("src.core.gen2.ItemEffects")
data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items)
return data, ItemEffects.heldSnapshot(data.gen2HeldItems)
end
local function loadDataset(version, generation)
if generation == 2 then
local data, held = gen2Dataset()
return data, function()
require("src.core.gen2.ItemEffects").applyHeldItems(data, held)
end
end
local Data = require("src.core.Data")
Data:load()
return Data, nil
end
local function busy()
return require("src.online.Trade").gameIsLive()
end
local function compute(version, kind, cartId, cart, cartHash)
local generation = GameVersion.generation(version)
local prevVersion = GameVersion.get()
local prevPrefix = CacheFs.prefix
local saved = { events = Runtime.events, hooks = Runtime.hooks,
errors = Runtime.errors, safeMode = Runtime.safeMode }
local function restore()
pcall(function() require("src.core.Data"):unloadGenerated() end)
pcall(CacheFs.unmountVersion, version)
if Loader.endSession then pcall(Loader.endSession) end
if Runtime.reset then pcall(Runtime.reset) end
Runtime.events, Runtime.hooks = saved.events, saved.hooks
Runtime.errors, Runtime.safeMode = saved.errors, saved.safeMode
Runtime.currentMod = nil
GameVersion.set(prevVersion)
CacheFs.prefix = prevPrefix
end
local ok, result = pcall(function()
GameVersion.set(version)
CacheFs.prefix = GameVersion.cachePrefix(version)
CacheFs.mountVersion(version)
if not CacheFs.readActive("data/generated/pokemon.lua") then
return { error = ("%s is not imported"):format(version) }
end
local data, after = loadDataset(version, generation)
local loader = Loader.new({ generation = generation, cart = cart })
local opts = kind == "cart"
and { mode = "cartOnly", cartId = cartId }
or { mode = "disableAll" }
local loaded, reason = loader:load(data, opts)
if loaded == false and reason then return { error = reason } end
if after then after() end
local mods = Handshake.mods({ mods = loader })
local rulesetId = "gen2"
if generation ~= 2 then
rulesetId = Handshake.ruleset({ data = data })
local ids = {}
for id in pairs(data.rulesets or {}) do ids[#ids + 1] = id end
table.sort(ids)
rulesetMemo[version] = ids
else
rulesetMemo[version] = { "gen2" }
end
return {
engine = generation,
version = version,
engineVersion = Version.engine,
apiVersion = Handshake.apiVersion or Version.modApi,
fingerprint = Fingerprint.compute(data, mods, generation),
rulesetId = rulesetId,
kind = kind,
cart = cart and { id = cartId, version = cart.version, hash = cartHash }
or nil,
}
end)
restore()
if not ok then return nil, tostring(result) end
if type(result) ~= "table" then return nil, "could not read that game" end
if result.error then return nil, result.error end
return result
end
function ArenaData.profile(version, kind, cartId, rule)
kind = kind or "vanilla"
if not GameVersion.VERSIONS[version] then return nil, "unknown game" end
if kind ~= "vanilla" and kind ~= "cart" then return nil, "unknown arena kind" end
local cart, cartHash
if kind == "cart" then
local got, hash = CartStore.get(cartId)
if not got then return nil, tostring(hash or "this cart is not installed") end
cart, cartHash = got, hash
if cart.base ~= version then return nil, "that cart is for another game" end
if cart.seal ~= "sealed" then return nil, "that cart is not sealed" end
end
local key = ArenaData.cacheKey(version, kind, cartHash)
local bucket = cachedProfiles()
local hit = bucket[key]
if type(hit) == "table" and hit.fingerprint then
return copyProfile(hit, rule)
end
if busy() then return nil, "close the game first" end
local entry, reason = compute(version, kind, cartId, cart, cartHash)
if not entry then return nil, reason end
storeProfile(key, entry)
return copyProfile(entry, rule)
end
function ArenaData.rulesetIds(version)
if rulesetMemo[version] then return rulesetMemo[version] end
if GameVersion.generation(version) == 2 then
rulesetMemo[version] = { "gen2" }
return rulesetMemo[version]
end
local _, reason = ArenaData.profile(version, "vanilla", nil, nil)
if rulesetMemo[version] then return rulesetMemo[version] end
return { Handshake.DEFAULT_RULESET }, reason
end
return ArenaData
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
-- engine/link/link.asm:1970 CheckTimeCapsuleCompatibility
-- engine/link/link.asm:640 Link_PrepPartyData_Gen1, :930 Link_ConvertPartyStruct1to2
local Mon = require("src.battle.gen2.Mon")
local Mail = require("src.core.gen2.Mail")
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local Convert = {}
Convert.STATUS_1TO2 = {
SLP = "sleep", PSN = "poison", BRN = "burn",
PAR = "paralyze", FRZ = "freeze",
}
-- engine/link/link.asm:980
Convert.STATUS_2TO1 = {
sleep = "SLP", poison = "PSN", toxic = "PSN", burn = "BRN",
paralyze = "PAR", freeze = "FRZ",
}
-- engine/link/time_capsule.asm:41
Convert.TYPE_EXEMPT = { MAGNEMITE = true, MAGNETON = true }
Convert.DEFAULT_HAPPINESS = 70 -- engine/link/link.asm:1067
local function displayName(registry, id)
local def = registry and registry[id]
local name = def and def.name
if type(name) == "string" and name ~= "" then return name end
return tostring(id):gsub("_", " ")
end
local function baseStatsOf(def)
return (def and def.baseStats) or {}
end
local function copyStatExp(statExp)
statExp = statExp or {}
local special = statExp.special
if special == nil then
special = statExp.specialAttack or statExp.specialDefense
end
return {
hp = statExp.hp or 0,
attack = statExp.attack or 0,
defense = statExp.defense or 0,
speed = statExp.speed or 0,
special = special or 0,
}
end
local function copyDVs(dvs)
dvs = dvs or {}
local special = dvs.special
if special == nil then
special = dvs.specialAttack or dvs.specialDefense
end
local out = {
attack = dvs.attack or 0,
defense = dvs.defense or 0,
speed = dvs.speed or 0,
special = special or 0,
}
out.hp = Mon.hpDV(out)
return out
end
local function scaleHp(hp, oldMax, newMax)
hp = tonumber(hp)
if hp == nil then return newMax end
if not oldMax or oldMax <= 0 then return newMax end
if hp <= 0 then return 0 end
if hp >= oldMax then return newMax end
local scaled = math.floor(hp * newMax / oldMax + 0.5)
return math.max(1, math.min(newMax, scaled))
end
local function ppBonus(base, ups)
return (base or 0) + (ups or 0) * math.floor((base or 0) / 5)
end
-- engine/items/item_effects.asm:2736 ComputeMaxPP
local function ppUpsFrom(base, maxPp)
base = base or 0
maxPp = tonumber(maxPp)
if not maxPp or base < 5 then return 0 end
local step = math.floor(base / 5)
if step <= 0 then return 0 end
local ups = math.floor((maxPp - base) / step + 0.5)
return math.max(0, math.min(3, ups))
end
local function entry(list, kind, text, extra)
local row = extra or {}
row.kind = kind
row.text = text
list[#list + 1] = row
return row
end
local function newReport()
return { lost = {}, changed = {} }
end
local function levelOf(mon)
return math.max(1, math.min(100, math.floor(tonumber(mon.level) or 1)))
end
-- engine/link/link.asm:930 Link_ConvertPartyStruct1to2
function Convert.toGen2(mon, gen1Data, gen2Data)
if type(mon) ~= "table" then return nil, "not_a_mon" end
local def2 = gen2Data and gen2Data.pokemon and gen2Data.pokemon[mon.species]
if not def2 then return nil, "species_unknown" end
local def1 = gen1Data and gen1Data.pokemon and gen1Data.pokemon[mon.species]
local report = newReport()
local level = levelOf(mon)
local dvs = copyDVs(mon.dvs)
local statExp = copyStatExp(mon.statExp)
local stats = Mon.stats(baseStatsOf(def2), dvs, level, statExp)
local oldStats = mon.stats
if not (oldStats and oldStats.hp) then
oldStats = Stats.calc(def1 or { baseStats = baseStatsOf(def2) }, level,
dvs, statExp)
end
local oldSpecial = oldStats.special
if oldSpecial and (oldSpecial ~= stats.specialAttack
or oldSpecial ~= stats.specialDefense) then
-- engine/link/link.asm:1042
entry(report.changed, "special_split",
("SPECIAL SPLIT %d -> %d/%d"):format(oldSpecial, stats.specialAttack,
stats.specialDefense),
{ from = oldSpecial, specialAttack = stats.specialAttack,
specialDefense = stats.specialDefense })
end
local hp = scaleHp(mon.hp, oldStats.hp, stats.hp)
if oldStats.hp ~= stats.hp then
entry(report.changed, "hp",
("HP %d/%d -> %d/%d"):format(math.min(tonumber(mon.hp) or 0,
oldStats.hp), oldStats.hp, hp, stats.hp),
{ from = mon.hp, fromMax = oldStats.hp, to = hp, toMax = stats.hp })
end
local growth = Mon.growthFor(gen2Data, def2.growthRate)
local experience = Mon.experienceForLevel(growth, level)
if tonumber(mon.exp) and math.floor(mon.exp) ~= experience then
entry(report.changed, "experience",
("EXP %d -> %d"):format(math.floor(mon.exp), experience),
{ from = math.floor(mon.exp), to = experience })
end
local moves = {}
for _, mv in ipairs(mon.moves or {}) do
local mdef = gen2Data.moves and gen2Data.moves[mv.id]
local base = (mdef and mdef.pp) or 0
local maxPp = ppBonus(base, mv.ppUps)
moves[#moves + 1] = {
id = mv.id,
pp = math.max(0, math.min(tonumber(mv.pp) or maxPp, maxPp)),
maxPp = maxPp,
}
end
local status = mon.status and Convert.STATUS_1TO2[mon.status] or nil
if mon.status and status ~= mon.status then
entry(report.changed, "status",
("STATUS %s -> %s"):format(tostring(mon.status), tostring(status)),
{ from = mon.status, to = status })
end
local out = {
species = mon.species,
name = def2.name or mon.species,
nickname = mon.nickname,
level = level,
experience = experience,
dvs = dvs,
statExp = statExp,
stats = stats,
hp = hp,
maxHp = stats.hp,
types = def2.types,
moves = moves,
item = nil,
status = status,
-- engine/link/link.asm:1067
happiness = Convert.DEFAULT_HAPPINESS,
pokerus = 0,
caughtLevel = level,
isEgg = false,
ot = mon.ot,
otName = mon.ot,
otId = mon.otId,
traded = mon.traded,
}
out.shiny = Mon.isShiny(dvs,
{ species = mon.species, def = def2, level = level })
out.gender = Mon.gender(def2, dvs, { species = mon.species, level = level })
entry(report.changed, "happiness",
("FRIENDSHIP SET TO %d"):format(Convert.DEFAULT_HAPPINESS),
{ to = Convert.DEFAULT_HAPPINESS })
entry(report.changed, "caught_level",
("MET AT LEVEL %d"):format(level), { to = level })
return out, report
end
-- engine/link/link.asm:1985 species, :1999 mail, :2016 move
function Convert.refusalFor(mon, gen2Data, gen1Data)
if type(mon) ~= "table" then return "not_a_mon", {} end
if mon.isEgg then return "is_egg", {} end
local species1 = gen1Data and gen1Data.pokemon and gen1Data.pokemon[mon.species]
if not species1 then
return "species_too_new", { species = mon.species }
end
if mon.mail ~= nil or Mail.monHoldsMail(mon) then
return "has_mail", { item = mon.item }
end
for _, mv in ipairs(mon.moves or {}) do
if not (gen1Data.moves and gen1Data.moves[mv.id]) then
return "move_too_new", { move = mv.id }
end
end
return nil
end
function Convert.toGen1(mon, gen2Data, gen1Data)
local reason, info = Convert.refusalFor(mon, gen2Data, gen1Data)
if reason then return nil, reason, info end
local def1 = gen1Data.pokemon[mon.species]
local def2 = gen2Data and gen2Data.pokemon and gen2Data.pokemon[mon.species]
local report = newReport()
local level = levelOf(mon)
local dvs = copyDVs(mon.dvs)
local statExp = copyStatExp(mon.statExp)
-- engine/link/link.asm:784, data/pokemon/gen1_base_special.asm:3
local stats = Stats.calc(def1, level, dvs, statExp)
local oldMax = tonumber(mon.maxHp)
if not oldMax then
local old = mon.stats
or Mon.stats(baseStatsOf(def2), dvs, level, statExp)
oldMax = old.hp
end
local oldSpA = mon.stats and mon.stats.specialAttack
local oldSpD = mon.stats and mon.stats.specialDefense
if oldSpA or oldSpD then
entry(report.changed, "special_fold",
("SPECIAL FOLDED %s/%s -> %d"):format(tostring(oldSpA or "?"),
tostring(oldSpD or "?"), stats.special),
{ specialAttack = oldSpA, specialDefense = oldSpD, to = stats.special })
end
local hp = scaleHp(mon.hp, oldMax, stats.hp)
if oldMax ~= stats.hp then
entry(report.changed, "hp",
("HP %d/%d -> %d/%d"):format(math.min(tonumber(mon.hp) or 0, oldMax),
oldMax, hp, stats.hp),
{ from = mon.hp, fromMax = oldMax, to = hp, toMax = stats.hp })
end
local exp = Growth.expForLevel(def1.growthRate, level, gen1Data.growth_rates)
if tonumber(mon.experience) and math.floor(mon.experience) ~= exp then
entry(report.changed, "experience",
("EXP %d -> %d"):format(math.floor(mon.experience), exp),
{ from = math.floor(mon.experience), to = exp })
end
local moves = {}
for _, mv in ipairs(mon.moves or {}) do
local base2 = gen2Data and gen2Data.moves and gen2Data.moves[mv.id]
and gen2Data.moves[mv.id].pp
local ups = ppUpsFrom(base2, mv.maxPp)
local base1 = (gen1Data.moves[mv.id] and gen1Data.moves[mv.id].pp) or 0
local maxPp = ppBonus(base1, ups)
moves[#moves + 1] = {
id = mv.id,
pp = math.max(0, math.min(tonumber(mv.pp) or maxPp, maxPp)),
ppUps = ups,
}
end
local status = mon.status and Convert.STATUS_2TO1[mon.status] or nil
if mon.status and status ~= mon.status then
entry(report.changed, "status",
("STATUS %s -> %s"):format(tostring(mon.status), tostring(status)),
{ from = mon.status, to = status })
end
if mon.item then
-- engine/link/link.asm:756, :1078 TimeCapsule_ReplaceTeruSama
entry(report.lost, "item",
("HELD ITEM LOST: %s"):format(displayName(gen2Data and gen2Data.items,
mon.item)), { item = mon.item })
end
if tonumber(mon.happiness) then
entry(report.lost, "happiness", "FRIENDSHIP LOST",
{ from = math.floor(mon.happiness) })
end
if tonumber(mon.pokerus) and mon.pokerus ~= 0 then
entry(report.lost, "pokerus", "POKERUS LOST", { from = mon.pokerus })
end
if tonumber(mon.caughtLevel) then
entry(report.lost, "caught_level", "MET DATA LOST",
{ from = math.floor(mon.caughtLevel) })
end
local out = {
species = mon.species,
level = level,
exp = exp,
dvs = dvs,
statExp = statExp,
stats = stats,
hp = hp,
catchRate = def1.catchRate,
status = status,
moves = moves,
nickname = mon.nickname,
ot = mon.ot or mon.otName,
otId = mon.otId,
traded = mon.traded,
}
return out, report
end
-- engine/link/time_capsule.asm:3 ValidateOTTrademon, :41 the type carve-out
function Convert.validateArrival(mon, destData)
if type(mon) ~= "table" then return false, "not_a_mon" end
local def = destData and destData.pokemon and destData.pokemon[mon.species]
if not def then return false, "species_unknown" end
-- engine/link/time_capsule.asm:27
local level = tonumber(mon.level)
if not level or level < 1 or level > 100 then return false, "level" end
if Convert.TYPE_EXEMPT[mon.species] then return true end
local claimed = mon.types
if type(claimed) ~= "table" then return true end
local want = def.types or {}
local w1, w2 = want[1], want[2] or want[1]
local c1, c2 = claimed[1], claimed[2] or claimed[1]
if c1 ~= w1 or c2 ~= w2 then return false, "types" end
return true
end
local REFUSAL_TEXT = {
is_egg = function() return "EGGS CANNOT TRAVEL" end,
has_mail = function() return "MON IS HOLDING MAIL" end,
not_a_mon = function() return "NO MON" end,
species_unknown = function(_, info)
return ("SPECIES UNKNOWN: %s"):format(tostring(info.species or "?"))
end,
species_too_new = function(_, info)
return ("SPECIES NOT IN GEN 1: %s"):format(
tostring(info.species or "?"):gsub("_", " "))
end,
move_too_new = function(_, info)
return ("MOVE ILLEGAL IN GEN 1: %s"):format(
tostring(info.move or "?"):gsub("_", " "))
end,
}
local function previewLines(mon, report, reason, info)
local lines = {}
if reason then
local fn = REFUSAL_TEXT[reason]
lines[1] = fn and fn(mon, info or {}) or reason:upper()
return lines, false
end
for _, row in ipairs(report.lost) do lines[#lines + 1] = row.text end
for _, row in ipairs(report.changed) do lines[#lines + 1] = row.text end
if #lines == 0 then lines[1] = "NOTHING IS LOST" end
return lines, true
end
local function convertOne(mon, toGen, fromData, toData)
if toGen == 1 then
local out, second, info = Convert.toGen1(mon, fromData, toData)
if out then return out, second end
return nil, second, info or {}
end
local out, second = Convert.toGen2(mon, fromData, toData)
if out then return out, second end
return nil, second, { species = mon and mon.species }
end
function Convert.preview(mon, fromGen, toGen, fromData, toData)
local out, second, info = convertOne(mon, toGen, fromData, toData)
if out then return previewLines(mon, second) end
return previewLines(mon, nil, second, info)
end
-- engine/link/link.asm:2048 refuses the whole party; per-slot here instead
local function mapParty(party, toGen, fromData, toData)
local converted, results = {}, {}
for i, mon in ipairs(party or {}) do
local out, second, info = convertOne(mon, toGen, fromData, toData)
if out then
converted[#converted + 1] = out
results[i] = { index = i, ok = true, mon = out, report = second,
species = mon.species,
preview = previewLines(mon, second) }
else
results[i] = { index = i, ok = false, reason = second, info = info,
species = mon.species,
preview = previewLines(mon, nil, second, info) }
end
end
return converted, results
end
function Convert.partyToGen1(party, gen2Data, gen1Data)
return mapParty(party, 1, gen2Data, gen1Data)
end
function Convert.partyToGen2(party, gen1Data, gen2Data)
return mapParty(party, 2, gen1Data, gen2Data)
end
function Convert.refusals(results)
local out = {}
for _, row in ipairs(results or {}) do
if not row.ok then out[#out + 1] = row end
end
return out
end
return Convert
+234
View File
@@ -0,0 +1,234 @@
-- engine/gfx/mon_icons.asm WriteSymmetricMonPartySpriteOAM
-- engine/gfx/load_pics.asm
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local OnlineSprites = {}
local catalogs = {}
local cache = {}
-- engine/gfx/mon_icons.asm:246
local function mirrors(name)
return name ~= nil and name ~= "HELIX"
end
local SHADES = { { 255, 255, 255 }, { 170, 170, 170 },
{ 85, 85, 85 }, { 0, 0, 0 } }
function OnlineSprites.key(version, species, shiny)
return ("%s|%s|%s"):format(tostring(version), tostring(species),
shiny and "shiny" or "normal")
end
function OnlineSprites.reset()
catalogs, cache = {}, {}
end
function OnlineSprites.keepOnly(versions)
local keep = {}
for _, version in ipairs(versions or {}) do keep[tostring(version)] = true end
for version in pairs(catalogs) do
if not keep[version] then catalogs[version] = nil end
end
for key in pairs(cache) do
local version = key:match("^([^|]*)|")
if version and not keep[version] then cache[key] = nil end
end
end
function OnlineSprites.readBytes(version, path)
local prefix = GameVersion.cachePrefix(version) or ""
local bytes = CacheFs.readAt(prefix .. path)
if type(bytes) == "string" and bytes ~= "" then return bytes end
return nil
end
local function loadTable(version, path)
local bytes = OnlineSprites.readBytes(version, path)
if not bytes then return nil end
local loader = loadstring or load
local chunk = loader(bytes, "@" .. path)
if not chunk then return nil end
local ok, value = pcall(chunk)
if not ok or type(value) ~= "table" then return nil end
return value
end
local function catalog(version)
local hit = catalogs[version]
if hit then return hit end
hit = {
pokemon = loadTable(version, "data/generated/pokemon.lua") or {},
icons = loadTable(version, "data/generated/icons.lua") or {},
palettes = loadTable(version, "data/generated/palettes.lua") or {},
generation = GameVersion.generation(version),
}
catalogs[version] = hit
return hit
end
local function paletteFor(cat, species, shiny)
local entry = cat.palettes and cat.palettes.pokemon
and cat.palettes.pokemon[species]
if type(entry) == "string" then
local ramp = cat.palettes.palettes and cat.palettes.palettes[entry]
if type(ramp) == "table" and #ramp == 4 then return ramp end
return nil
end
if type(entry) ~= "table" then return nil end
local pair = entry[shiny and "shiny" or "normal"] or entry.normal
if type(pair) ~= "table" or #pair < 2 then return nil end
return { { 255, 255, 255 }, pair[1], pair[2], { 0, 0, 0 } }
end
local function shadeIndex(r)
if r > 0.75 then return 1 end
if r > 0.5 then return 2 end
if r > 0.25 then return 3 end
return 4
end
function OnlineSprites.makeImage(bytes, path, palette)
if not (love and love.graphics and love.graphics.newImage) then return nil end
local ok, image = pcall(function()
local data = love.filesystem.newFileData(bytes, path)
if not (palette and love.image and love.image.newImageData) then
return love.graphics.newImage(data)
end
local pixels = love.image.newImageData(data)
if type(pixels.mapPixel) ~= "function" then
return love.graphics.newImage(data)
end
pixels:mapPixel(function(_, _, r, _, _, a)
local col = palette[shadeIndex(r)] or SHADES[shadeIndex(r)]
return (col[1] or 0) / 255, (col[2] or 0) / 255, (col[3] or 0) / 255, a
end)
return love.graphics.newImage(pixels)
end)
if not ok then return nil end
return image
end
local function image(version, path, palette)
local bytes = OnlineSprites.readBytes(version, path)
if not bytes then return false end
return OnlineSprites.makeImage(bytes, path, palette) or false
end
local function iconPath(cat, mon)
local icons = cat.icons or {}
if cat.generation == 2 then
local id = mon.isEgg and "ICON_EGG"
or (icons.species and icons.species[mon.species])
local entry = id and icons.icons and icons.icons[id]
if type(entry) == "table" then return entry.image, id end
return nil, id
end
local def = cat.pokemon and cat.pokemon[mon.species]
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
or (def and def.icon)
local name, path
if type(entry) == "string" then
name = entry
path = icons.icons and icons.icons[entry]
elseif type(entry) == "table" then
path = entry.image
end
if not path then
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
local hit = name and icons.icons and icons.icons[name]
path = (type(hit) == "table") and hit.image or hit
end
return path, name
end
function OnlineSprites.get(version, mon)
if type(mon) ~= "table" then return nil end
return cache[OnlineSprites.key(version, mon.species, mon.shiny == true)]
end
function OnlineSprites.ensure(version, mon)
if type(mon) ~= "table" or not version then return nil end
local key = OnlineSprites.key(version, mon.species, mon.shiny == true)
local hit = cache[key]
if hit then return hit end
local cat = catalog(version)
local def = cat.pokemon and cat.pokemon[mon.species]
local palette = paletteFor(cat, mon.species, mon.shiny == true)
local path, name = iconPath(cat, mon)
local entry = { key = key, mirror = false }
if path then
entry.icon = image(version, path, nil)
entry.mirror = (cat.generation ~= 2) and mirrors(name) or false
else
entry.icon = false
end
entry.front = def and def.spriteFront
and image(version, def.spriteFront, palette) or false
cache[key] = entry
return entry
end
function OnlineSprites.prime(version, party)
if not version or type(party) ~= "table" then return 0 end
local n = 0
for _, mon in ipairs(party) do
if OnlineSprites.ensure(version, mon) then n = n + 1 end
end
return n
end
local function paintWhite()
if not (love.graphics and love.graphics.getColor) then return nil end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(1, 1, 1, 1)
return { r, g, b, a }
end
local function restore(saved)
if saved and love.graphics and love.graphics.setColor then
love.graphics.setColor(saved[1], saved[2], saved[3], saved[4])
end
end
function OnlineSprites.drawIcon(entry, x, y, size)
if type(entry) ~= "table" or not entry.icon then return false end
local img = entry.icon
if type(img.getDimensions) ~= "function" then return false end
local iw, ih = img:getDimensions()
if not iw or iw <= 0 or ih <= 0 then return false end
local scale = (size or 16) / 16
local saved = paintWhite()
if ih > 16 and entry.mirror then
local half = love.graphics.newQuad(0, 0, 8, 16, iw, ih)
love.graphics.draw(img, half, x, y, 0, scale, scale)
love.graphics.draw(img, half, x + 16 * scale, y, 0, -scale, scale)
elseif ih > 16 then
local frame = love.graphics.newQuad(0, 0, 16, 16, iw, ih)
love.graphics.draw(img, frame, x, y, 0, scale, scale)
else
love.graphics.draw(img, x, y, 0, scale, scale)
end
restore(saved)
return true
end
function OnlineSprites.drawFront(entry, x, y, box)
if type(entry) ~= "table" or not entry.front then return false end
local img = entry.front
if type(img.getDimensions) ~= "function" then return false end
local iw, ih = img:getDimensions()
if not iw or iw <= 0 or ih <= 0 then return false end
box = box or 56
local scale = box / math.max(iw, ih)
if scale >= 1 then scale = math.floor(scale) end
local saved = paintWhite()
love.graphics.draw(img, math.floor(x + (box - iw * scale) / 2),
math.floor(y + (box - ih * scale) / 2), 0, scale, scale)
restore(saved)
return true
end
return OnlineSprites
+381
View File
@@ -0,0 +1,381 @@
local Wire = require("src.link.Wire")
local Protocol2 = {}
Protocol2.VERSION = 2
Protocol2.INTENTS = { battle = true, trade = true, tournament = true }
Protocol2.JOIN_AS = { player = true, spectator = true }
local function build(msg)
local out = Wire.sanitize(msg)
return out
end
function Protocol2.lobbyHello(opts)
opts = opts or {}
return build({
type = "lobby_hello",
ticket = opts.ticket,
name = opts.name,
engineVersion = opts.engineVersion,
platform = opts.platform,
profiles = opts.profiles or {},
})
end
function Protocol2.resume(session, ack)
return build({ type = "resume", session = session, ack = ack or 0 })
end
function Protocol2.advertise(intent, profile, note)
return build({ type = "advertise", intent = intent, profile = profile,
note = note })
end
function Protocol2.unadvertise()
return build({ type = "unadvertise" })
end
function Protocol2.roomCreate(opts)
opts = opts or {}
return build({
type = "room_create",
intent = opts.intent or "battle",
profile = opts.profile,
playing = opts.playing ~= false,
maxSpectators = opts.maxSpectators,
public = opts.public ~= false,
note = opts.note,
})
end
function Protocol2.roomJoin(code, as, profile)
return build({ type = "room_join", code = code, as = as or "player",
profile = profile })
end
function Protocol2.roomLeave()
return build({ type = "room_leave" })
end
function Protocol2.roomReady(party, partyDigest)
return build({ type = "room_ready", party = party or {},
partyDigest = partyDigest })
end
function Protocol2.roomMsg(seq, msg)
return build({ type = "room_msg", seq = seq, msg = msg })
end
function Protocol2.roomAck(seq)
return build({ type = "room_ack", seq = seq })
end
function Protocol2.roomReport(match, result)
return build({ type = "room_report", match = match, result = result })
end
function Protocol2.forfeit(match)
return build({ type = "forfeit", match = match })
end
function Protocol2.roomKick(id)
return build({ type = "room_kick", id = id })
end
function Protocol2.roomClose()
return build({ type = "room_close" })
end
function Protocol2.lobbyQuery()
return build({ type = "lobby_query" })
end
Protocol2.SHOT_CLOCKS = { 3, 6, 9 }
Protocol2.TOUR_STAGES = { registering = true, running = true, finished = true }
Protocol2.TOUR_MATCH_STATES = { pending = true, live = true, done = true,
bye = true }
function Protocol2.shotClock(value)
local want = tonumber(value)
if not want then return Protocol2.SHOT_CLOCKS[2] end
local best, bestGap = Protocol2.SHOT_CLOCKS[2], math.huge
for _, allowed in ipairs(Protocol2.SHOT_CLOCKS) do
local gap = math.abs(allowed - want)
if gap < bestGap then best, bestGap = allowed, gap end
end
return best
end
function Protocol2.tourCreate(opts)
opts = opts or {}
return build({
type = "tour_create",
profile = opts.profile,
rule = opts.rule,
playing = opts.playing ~= false,
shotClock = Protocol2.shotClock(opts.shotClock),
maxSpectators = opts.maxSpectators,
party = opts.party,
partyDigest = opts.partyDigest,
public = opts.public ~= false,
note = opts.note,
})
end
function Protocol2.tourJoin(code, as, profile, party, partyDigest)
return build({
type = "tour_join",
code = code,
as = as or "player",
profile = profile,
party = party,
partyDigest = partyDigest,
})
end
function Protocol2.tourLeave()
return build({ type = "tour_leave" })
end
function Protocol2.tourStart()
return build({ type = "tour_start" })
end
function Protocol2.tourKick(id)
return build({ type = "tour_kick", id = id })
end
function Protocol2.tourClose()
return build({ type = "tour_close" })
end
Protocol2.CLIENT_TYPES = {
lobby_hello = true, ping = true, pong = true, resume = true,
advertise = true, unadvertise = true, room_create = true,
room_join = true, room_leave = true, room_ready = true,
room_msg = true, room_ack = true, room_report = true, forfeit = true,
room_kick = true, room_close = true, lobby_query = true,
tour_create = true, tour_join = true, tour_leave = true,
tour_start = true, tour_kick = true, tour_close = true,
}
Protocol2.SERVER_TYPES = {
lobby_welcome = true, lobby_list = true, lobby_delta = true,
room_state = true, room_replay = true, room_msg = true,
room_deadline = true, room_result = true, room_closed = true,
match_start = true, match_start_spectate = true, join_error = true,
ping = true, pong = true,
tour_state = true, tour_match = true, tour_match_spectate = true,
tour_bye = true, tour_deadline = true, tour_over = true,
tour_closed = true,
}
Protocol2.RESULTS = { win = true, lose = true, draw = true }
local VALIDATORS = {}
VALIDATORS.lobby_welcome = function(m)
if type(m.session) ~= "string" or m.session == "" then
return nil, "lobby_welcome without a session id"
end
if type(m.you) ~= "table" then return nil, "lobby_welcome without you" end
return m
end
VALIDATORS.lobby_list = function(m)
if type(m.entries) ~= "table" then return nil, "lobby_list without entries" end
return m
end
local DELTA_LISTS = { "added", "removed", "changed", "add", "update", "remove" }
VALIDATORS.lobby_delta = function(m)
for _, key in ipairs(DELTA_LISTS) do
if type(m[key]) == "table" and #m[key] > 0 then return m end
end
if type(m.op) == "string"
and (type(m.entry) == "table" or type(m.id) == "string") then
return m
end
return nil, "empty lobby_delta"
end
VALIDATORS.room_state = function(m)
if type(m.code) ~= "string" then return nil, "room_state without a code" end
if type(m.players) ~= "table" then return nil, "room_state without players" end
return m
end
VALIDATORS.room_replay = function(m)
if type(m.msgs) ~= "table" then return nil, "room_replay without msgs" end
return m
end
VALIDATORS.room_msg = function(m)
if type(m.msg) ~= "table" or type(m.msg.type) ~= "string" then
return nil, "room_msg without an inner message"
end
if type(m.seq) ~= "number" then return nil, "room_msg without a seq" end
return m
end
VALIDATORS.room_deadline = function(m)
if type(m.kind) ~= "string" then return nil, "room_deadline without a kind" end
if type(m.at) ~= "number" then return nil, "room_deadline without a time" end
return m
end
VALIDATORS.match_start = function(m)
if type(m.role) ~= "string" or m.role == "" then
return nil, "match_start without a role"
end
if type(m.match) ~= "string" or m.match == "" then
return nil, "match_start without a match token"
end
return m
end
VALIDATORS.match_start_spectate = VALIDATORS.match_start
VALIDATORS.room_closed = function(m)
if type(m.reason) ~= "string" or m.reason == "" then
return nil, "room_closed without a reason"
end
return m
end
VALIDATORS.room_result = function(m)
if type(m.how) ~= "string" then return nil, "room_result without how" end
return m
end
VALIDATORS.tour_state = function(m)
if type(m.code) ~= "string" or m.code == "" then
return nil, "tour_state without a code"
end
if type(m.players) ~= "table" then return nil, "tour_state without players" end
if type(m.bracket) ~= "table" then return nil, "tour_state without a bracket" end
if not Protocol2.TOUR_STAGES[m.stage or ""] then
return nil, "tour_state without a stage"
end
return m
end
VALIDATORS.tour_match = function(m)
if type(m.match) ~= "string" or m.match == "" then
return nil, "tour_match without a match token"
end
if type(m.code) ~= "string" or m.code == "" then
return nil, "tour_match without a child room code"
end
return m
end
VALIDATORS.tour_match_spectate = VALIDATORS.tour_match
VALIDATORS.tour_bye = function(m)
if type(m.match) ~= "string" or m.match == "" then
return nil, "tour_bye without a match token"
end
return m
end
VALIDATORS.tour_deadline = function(m)
if type(m.kind) ~= "string" or m.kind == "" then
return nil, "tour_deadline without a kind"
end
if type(m.at) ~= "number" then return nil, "tour_deadline without a time" end
return m
end
VALIDATORS.tour_closed = function(m)
if type(m.reason) ~= "string" or m.reason == "" then
return nil, "tour_closed without a reason"
end
return m
end
VALIDATORS.tour_over = function(m)
if type(m.code) ~= "string" or m.code == "" then
return nil, "tour_over without a code"
end
return m
end
VALIDATORS.join_error = function(m)
if type(m.reason) ~= "string" or m.reason == "" then
return nil, "join_error without a reason"
end
return m
end
Protocol2.VALIDATORS = VALIDATORS
function Protocol2.validate(raw)
if type(raw) ~= "table" then return nil, "not a table" end
local msg = Wire.sanitize(raw)
if not msg then return nil, "failed sanitize" end
local validator = VALIDATORS[msg.type]
if not validator then return msg end
local ok, reason = validator(msg)
if not ok then return nil, reason end
return ok
end
local REASONS = {
not_found = "That room code wasn't found.",
full = "That room is full.",
expired = "That room code has expired.",
profile_mismatch = "Your game doesn't match the room.",
rule_violation = "Your team doesn't meet the room's rule.",
spectate_late = "That match is too far along to watch.",
bad_ticket = "The relay didn't accept your sign-in.",
lobby_disabled = "This relay isn't running online play.",
resume_unknown = "The relay forgot your session.",
resume_expired = "You were away too long to rejoin.",
already_in_room = "You're already in a room.",
spectators_full = "That room has all the spectators it can take.",
bad_profile = "The relay couldn't read your game's profile.",
bad_party = "The relay couldn't read your team.",
party_ineligible = "Your team doesn't meet the room's rule.",
tour_not_found = "That tournament code wasn't found.",
tour_started = "That tournament has already started.",
tour_full = "That tournament is full.",
not_creator = "Only the tournament's creator can do that.",
}
local CLOSED_REASONS = {
kicked = "The host removed you from the room.",
closed = "The host closed the room.",
idle = "The room was closed for being idle.",
backlog = "You fell too far behind to keep watching.",
}
local TOUR_CLOSED_REASONS = {
kicked = "The creator removed you from the tournament.",
closed = "The creator closed the tournament.",
idle = "The tournament was closed for being idle.",
}
function Protocol2.tourClosedText(msg)
local reason = type(msg) == "table" and msg.reason or tostring(msg)
return TOUR_CLOSED_REASONS[reason]
or ("The tournament closed: " .. tostring(reason))
end
function Protocol2.roomClosedText(msg)
local reason = type(msg) == "table" and msg.reason or tostring(msg)
return CLOSED_REASONS[reason] or ("The room closed: " .. tostring(reason))
end
function Protocol2.joinErrorText(msg)
local reason = type(msg) == "table" and msg.reason or tostring(msg)
local text = REASONS[reason] or ("Couldn't join: " .. tostring(reason))
local detail = type(msg) == "table" and msg.detail or nil
if detail and detail ~= "" then return text .. " (" .. detail .. ")" end
return text
end
return Protocol2
+241
View File
@@ -0,0 +1,241 @@
local GameVersion = require("src.core.GameVersion")
local Protocol = require("src.link.Protocol")
local SaveData = require("src.core.SaveData")
local TeamPick = {}
local function generationOf(version, save)
if type(save) == "table" and tonumber(save.generation) then
return tonumber(save.generation)
end
return GameVersion.generation(version)
end
local function readCartSource(cartId, slotId)
local registered = false
for _, row in ipairs(SaveData.listCartSlots(cartId)) do
if row.id == slotId then registered = row.exists end
end
if not registered then return nil, "no save in that slot" end
local fs = SaveData.persistenceFs()
local main = "saves/cart_" .. cartId .. "/" .. slotId .. ".lua"
for _, name in ipairs({ main, main .. ".tmp", main .. ".bak" }) do
if fs.getInfo(name) then
local body = fs.read(name)
if type(body) == "string" and body ~= "" and SaveData.decode(body) then
return body
end
end
end
return nil, "no save in that slot"
end
local function readSource(version, slotId, cartId)
if cartId then return readCartSource(cartId, slotId) end
return SaveData.readSlotSource(version, slotId)
end
function TeamPick.readSlot(version, slotId, cartId)
if not GameVersion.VERSIONS[version] then return nil, "unknown game" end
if type(slotId) ~= "string" or slotId == "" then return nil, "no slot chosen" end
if cartId and SaveData.slotSealBroken(cartId, slotId) then
return nil, "this save's seal is broken"
end
local ok, source, err = pcall(readSource, version, slotId, cartId)
if not ok then return nil, tostring(source) end
if type(source) ~= "string" then
return nil, tostring(err or "no save in that slot")
end
local decoded, save = pcall(SaveData.decode, source)
if not decoded or type(save) ~= "table" then
return nil, "that save can't be read"
end
local name, meta = SaveData.slotSummary(save)
return {
save = save,
party = type(save.party) == "table" and save.party or {},
trainerName = name,
badges = (meta and meta.badges) or 0,
generation = generationOf(version, save),
}
end
-- engine/pokemon/bills_pc.asm
local GEN1_BOXES, GEN2_BOXES = 12, 14
local function boxName(save, generation, index)
if generation == 2 then
local names = type(save) == "table" and save.boxNames or nil
local given = type(names) == "table" and names[index] or nil
if type(given) == "string" and given ~= "" then return given end
return "BOX" .. tostring(index)
end
return "BOX " .. tostring(index)
end
TeamPick.boxName = boxName
function TeamPick.refKey(ref)
if type(ref) == "number" then return "party|" .. tostring(ref) end
if type(ref) ~= "table" then return "?" end
if ref.where == "box" then
return ("box|%s|%s"):format(tostring(ref.box), tostring(ref.index))
end
return "party|" .. tostring(ref.index)
end
function TeamPick.sameRef(a, b)
return TeamPick.refKey(a) == TeamPick.refKey(b)
end
local function slotOf(source)
if type(source) ~= "table" then return { party = {} } end
if type(source.party) == "table" then return source end
return { party = source }
end
TeamPick.slotOf = slotOf
local function boxesOf(slot)
local save = type(slot.save) == "table" and slot.save or nil
if save and type(save.boxes) == "table" then return save.boxes end
if type(slot.boxes) == "table" then return slot.boxes end
return nil
end
function TeamPick.monAt(source, ref)
local slot = slotOf(source)
if type(ref) == "number" then ref = { where = "party", index = ref } end
if type(ref) ~= "table" then return nil end
local index = tonumber(ref.index)
if not index or index < 1 or index ~= math.floor(index) then return nil end
if ref.where == "box" then
local boxes = boxesOf(slot)
if type(boxes) ~= "table" then return nil end
local list = boxes[tonumber(ref.box) or 0]
if type(list) ~= "table" then return nil end
local mon = list[index]
return type(mon) == "table" and mon or nil
end
local mon = (slot.party or {})[index]
return type(mon) == "table" and mon or nil
end
function TeamPick.candidates(source)
local slot = slotOf(source)
local out = {}
for index, mon in ipairs(slot.party or {}) do
out[#out + 1] = { where = "party", index = index, mon = mon,
source = "Party" }
end
local boxes = boxesOf(slot)
if type(boxes) ~= "table" then return out end
local generation = tonumber(slot.generation) or 1
local last = (generation == 2) and GEN2_BOXES or GEN1_BOXES
for index in pairs(boxes) do
local n = tonumber(index)
if n and n > last then last = n end
end
for box = 1, last do
local list = boxes[box]
if type(list) == "table" then
local name = boxName(slot.save, generation, box)
for index, mon in ipairs(list) do
if type(mon) == "table" then
out[#out + 1] = { where = "box", box = box, index = index,
mon = mon, source = name }
end
end
end
end
return out
end
function TeamPick.validate(source, team, rule)
local slot = slotOf(source)
rule = type(rule) == "table" and rule or {}
if type(team) ~= "table" then return false, "pick a team first" end
local want = tonumber(rule.partySize) or #team
if #team ~= want then
return false, ("this arena needs %d Pokemon."):format(want)
end
local seen = {}
for _, ref in ipairs(team) do
local key = TeamPick.refKey(ref)
local mon = TeamPick.monAt(slot, ref)
if not mon then return false, "that's not in this save." end
if seen[key] then return false, "no doubles allowed." end
seen[key] = true
if mon.isEgg then return false, "an EGG can't battle." end
if not rule.forceLevel then
local level = tonumber(mon.level) or 0
local min, max = tonumber(rule.minLevel), tonumber(rule.maxLevel)
if min and level < min then
return false, ("every Pokemon must be\nLv%d or higher."):format(min)
end
if max and level > max then
return false, ("every Pokemon must be\nLv%d or lower."):format(max)
end
end
end
return true
end
function TeamPick.pack(source, team, generation)
local slot = slotOf(source)
team = type(team) == "table" and team or {}
local gen2 = tonumber(generation) == 2
local mons = {}
for _, ref in ipairs(team) do
local mon = TeamPick.monAt(slot, ref)
if mon then
mons[#mons + 1] = gen2 and Protocol.packMon2(mon) or Protocol.packMon(mon)
end
end
return mons
end
-- engine/link/time_capsule.asm:41
function TeamPick.convert(source, toGeneration, fromData, toData)
local Convert = require("src.online.Convert")
toGeneration = tonumber(toGeneration) or 1
local list = TeamPick.candidates(source)
local mons = {}
for i, row in ipairs(list) do mons[i] = row.mon end
local _, results
if toGeneration == 1 then
_, results = Convert.partyToGen1(mons, fromData, toData)
else
_, results = Convert.partyToGen2(mons, fromData, toData)
end
local byKey, rows, refusals = {}, {}, {}
for index, row in ipairs(results or {}) do
local ref = list[index]
local key = ref and TeamPick.refKey(ref) or tostring(index)
row.ref, row.key = ref, key
rows[key] = row
if row.ok then
byKey[key] = row.mon
else
refusals[key] = row.reason or "refused"
end
end
return byKey, rows, refusals, list
end
function TeamPick.packConverted(converted, team, generation)
converted = type(converted) == "table" and converted or {}
team = type(team) == "table" and team or {}
local mons = {}
for _, ref in ipairs(team) do
local mon = converted[TeamPick.refKey(ref)]
if type(mon) ~= "table" then
return nil, "that Pokemon cannot cross generations."
end
mons[#mons + 1] = (tonumber(generation) == 2)
and Protocol.packMon2(mon) or Protocol.packMon(mon)
end
return mons
end
return TeamPick
+681
View File
@@ -0,0 +1,681 @@
local GameVersion = require("src.core.GameVersion")
local Protocol = require("src.link.Protocol")
local SaveData = require("src.core.SaveData")
local TeamPick = require("src.online.TeamPick")
local Trade = {}
local function disk()
return SaveData.persistenceFs()
end
local function scopeKey(version, cartId)
if cartId then return "cart_" .. cartId end
return version
end
function Trade.slotPath(version, slotId, cartId)
return "saves/" .. scopeKey(version, cartId) .. "/" .. slotId .. ".lua"
end
Trade.mountDepth = 0
function Trade.mounted()
return (Trade.mountDepth or 0) > 0
end
Trade.hostIsLive = nil
function Trade.gameIsLive()
if type(Trade.hostIsLive) == "function" then
return Trade.hostIsLive() == true
end
if Trade.mounted() then return false end
local Data = package.loaded["src.core.Data"]
return Data ~= nil and Data._pristineKeys ~= nil
end
local function deepCopy(value, seen)
if type(value) ~= "table" then return value end
seen = seen or {}
if seen[value] then return seen[value] end
local out = {}
seen[value] = out
for k, v in pairs(value) do out[deepCopy(k, seen)] = deepCopy(v, seen) end
return out
end
-- ------- dataset mounting (no Game)
local function gen2Dataset()
local CacheFs = require("src.import.CacheFs")
local data = {}
local function load(name)
return CacheFs.loadActive("data/generated/" .. name .. ".lua")
end
data.pokemon = load("pokemon") or {}
data.items = load("items") or {}
data.moves = load("moves") or {}
data.type_chart = load("type_chart") or {}
data.gen2Constants = load("constants")
local ItemEffects = require("src.core.gen2.ItemEffects")
data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items)
return data
end
local function mountDataset(version)
local CacheFs = require("src.import.CacheFs")
local generation = GameVersion.generation(version)
local prevVersion, prevPrefix = GameVersion.get(), CacheFs.prefix
local released = false
Trade.mountDepth = (Trade.mountDepth or 0) + 1
local function release()
if released then return end
released = true
Trade.mountDepth = math.max(0, (Trade.mountDepth or 1) - 1)
if generation ~= 2 then
pcall(function() require("src.core.Data"):unloadGenerated() end)
end
pcall(CacheFs.unmountVersion, version)
GameVersion.set(prevVersion)
CacheFs.prefix = prevPrefix
end
local ok, data = pcall(function()
GameVersion.set(version)
CacheFs.prefix = GameVersion.cachePrefix(version)
CacheFs.mountVersion(version)
if not CacheFs.readActive("data/generated/pokemon.lua") then
error(("%s is not imported"):format(tostring(version)), 0)
end
if generation == 2 then return gen2Dataset() end
local Data = require("src.core.Data")
Data:load()
return Data
end)
if not ok then
release()
return nil, tostring(data)
end
return data, release
end
function Trade.withDataset(version, fn)
local data, release = mountDataset(version)
if not data then return nil, release end
local ok, result = pcall(fn, data)
release()
if not ok then return nil, tostring(result) end
return result
end
function Trade.withMountedData(data, fn)
Trade.mountDepth = (Trade.mountDepth or 0) + 1
local ok, result = pcall(fn, data)
Trade.mountDepth = math.max(0, (Trade.mountDepth or 1) - 1)
if not ok then return nil, tostring(result) end
return result
end
local function withData(handle, fn)
if handle.data then
local ok, result = pcall(fn, handle.data)
if not ok then return nil, tostring(result) end
return result
end
if Trade.gameIsLive() then return nil, "close the game first" end
return Trade.withDataset(handle.version, fn)
end
-- ------- handles
function Trade.openSlot(version, slotId, cartId, opts)
opts = type(opts) == "table" and opts or {}
if Trade.gameIsLive() then return nil, "close the game first" end
local slot, reason = TeamPick.readSlot(version, slotId, cartId)
if not slot then return nil, reason end
local save = slot.save
return {
version = version,
generation = slot.generation,
slotId = slotId,
cartId = cartId,
save = save,
path = Trade.slotPath(version, slotId, cartId),
party = slot.party,
boxes = type(save.boxes) == "table" and save.boxes or nil,
trainerName = slot.trainerName,
data = opts.data,
}
end
local function refOf(index)
local where, box, at = "party", nil, index
if type(index) == "table" then
at = index.index
if index.where == "box" then
where = "box"
box = tonumber(index.box)
if not box or box < 1 or box ~= math.floor(box) then return nil end
end
end
at = tonumber(at)
if not at or at < 1 or at ~= math.floor(at) then return nil end
return { where = where, box = box, index = at }
end
Trade.refOf = refOf
-- engine/link/link.asm:810
function Trade.holdsMail(handle, index)
if type(handle) ~= "table" or handle.generation ~= 2 then return false end
local ref = refOf(index)
if not ref or ref.where == "box" then return false end
local mon = (handle.party or {})[ref.index]
local Mail = require("src.core.gen2.Mail")
if Mail.monHoldsMail(mon) then return true end
local state = type(handle.save) == "table" and handle.save.mail or nil
local party = type(state) == "table" and state.party or nil
return type(party) == "table" and party[ref.index] ~= nil
end
local function pickable(handle, index)
if type(handle) ~= "table" or type(handle.party) ~= "table" then
return nil, "that save can't be read"
end
local ref = refOf(index)
if not ref then
return nil, (type(index) == "table" and index.where == "box")
and "that's not in the PC" or "that's not in the party"
end
local mon = TeamPick.monAt(handle, ref)
if type(mon) ~= "table" then
return nil, (ref.where == "box") and "that's not in the PC"
or "that's not in the party"
end
if mon.isEgg then return nil, "an EGG can't be traded" end
if Trade.holdsMail(handle, ref) then return nil, "take the MAIL first" end
return mon, nil, ref
end
-- ------- rebuild, evolve, dex
local function packFor(generation, mon)
if generation == 2 then return Protocol.packMon2(mon) end
return Protocol.packMon(mon)
end
local function packPartyFor(generation, party, indices)
if generation ~= 2 then return Protocol.packParty(party, indices) end
if Protocol.packParty2 then return Protocol.packParty2(party, indices) end
local mons = {}
if indices then
for _, i in ipairs(indices) do
mons[#mons + 1] = Protocol.packMon2(party[i])
end
return mons
end
for _, mon in ipairs(party) do mons[#mons + 1] = Protocol.packMon2(mon) end
return mons
end
-- engine/pokemon/evos_moves.asm
local function evolveGen1(data, record)
local def = data.pokemon and data.pokemon[record.species]
local into
for _, evo in ipairs((def and def.evolutions) or {}) do
if evo.method == "TRADE" then
into = evo.species
break
end
end
local newDef = into and data.pokemon and data.pokemon[into]
if not newDef then return nil end
local Stats = require("src.pokemon.Stats")
local evolved = deepCopy(record)
local previousMax = (record.stats and record.stats.hp) or record.hp or 0
local lost = previousMax - (record.hp or previousMax)
evolved.species = into
evolved.stats = Stats.calc(newDef, evolved.level, evolved.dvs, evolved.statExp)
evolved.hp = math.max(1, evolved.stats.hp - lost)
if evolved.nickname and evolved.nickname == (def.name or record.species) then
evolved.nickname = nil
end
return into, evolved, false
end
-- engine/pokemon/evolve.asm
local function evolveGen2(data, record)
local Evolution = require("src.core.gen2.Evolution")
local entry, consumes = Evolution.checkMon(data, record, { link = true })
if not entry then return nil end
local evolved = Evolution.apply(data, record, entry)
if not evolved then return nil end
return entry.into, evolved, consumes == true
end
local function markDex(save, generation, species)
local dex = save.pokedex
if type(dex) ~= "table" then
dex = {}
save.pokedex = dex
end
local ownedKey = generation == 2 and "caught" or "owned"
if type(dex.seen) ~= "table" then dex.seen = {} end
if type(dex[ownedKey]) ~= "table" then dex[ownedKey] = {} end
for _, id in ipairs(species) do
dex.seen[id] = true
dex[ownedKey][id] = true
end
end
local function sideFor(handle, ref, packed, record, warnings)
return withData(handle, function(data)
local built, why = record, nil
if not built then
local unpacker = handle.generation == 2
and Protocol.unpackMon2 or Protocol.unpackMon
built, why = unpacker(data, packed, { strict = true })
if not built then return { error = why or "unknown POKéMON" } end
end
-- engine/battle/experience.asm:69
built.traded = true
local into, evolved, consumed
if handle.generation == 2 then
into, evolved, consumed = evolveGen2(data, built)
else
into, evolved, consumed = evolveGen1(data, built)
end
local species = { built.species }
if into then species[#species + 1] = into end
if into then
warnings[#warnings + 1] =
{ code = "evolve", slot = handle.slotId, species = into }
end
if consumed then
warnings[#warnings + 1] =
{ code = "item_used", slot = handle.slotId, item = built.item }
end
return {
handle = handle,
ref = ref,
index = ref.index,
received = built,
record = evolved or built,
evolveTo = into,
dex = species,
sent = TeamPick.monAt(handle, ref),
}
end)
end
-- ------- plan
local function planFrom(sides, warnings)
local plan = { sides = sides, warnings = warnings }
for _, side in ipairs(sides) do
if side.role == "a" then
plan.get = side.record
plan.evolveA = side.evolveTo
plan.give = plan.give or side.sent
elseif side.role == "b" then
plan.give = side.record
plan.evolveB = side.evolveTo
end
end
return plan
end
function Trade.plan(req)
req = type(req) == "table" and req or {}
local from, to = req.from, req.to
if type(from) ~= "table" or type(to) ~= "table" then
return nil, "two saves are needed"
end
if from.path == to.path then return nil, "that's the same save" end
local monA, reasonA, refA = pickable(from, req.fromIndex)
if not monA then return nil, reasonA end
local monB, reasonB, refB = pickable(to, req.toIndex)
if not monB then return nil, reasonB end
local packA = packFor(from.generation, monA)
local packB = packFor(to.generation, monB)
if from.generation ~= to.generation then
if type(req.convert) ~= "function" then return nil, "needs_conversion" end
local intoB, whyB = req.convert(packA, from.generation, to.generation)
if not intoB then return nil, tostring(whyB or "needs_conversion") end
local intoA, whyA = req.convert(packB, to.generation, from.generation)
if not intoA then return nil, tostring(whyA or "needs_conversion") end
packA, packB = intoB, intoA
end
local warnings = {}
local sideB, whyB = sideFor(to, refB, packA, nil, warnings)
if not sideB then return nil, tostring(whyB) end
if sideB.error then return nil, sideB.error end
local sideA, whyA = sideFor(from, refA, packB, nil, warnings)
if not sideA then return nil, tostring(whyA) end
if sideA.error then return nil, sideA.error end
sideA.role, sideB.role = "a", "b"
return planFrom({ sideA, sideB }, warnings)
end
function Trade.planIncoming(req)
req = type(req) == "table" and req or {}
local to = req.to
if type(to) ~= "table" then return nil, "no save" end
local mon, reason, ref = pickable(to, req.toIndex)
if not mon then return nil, reason end
local warnings = {}
local side, why = sideFor(to, ref, req.mon, req.record, warnings)
if not side then return nil, tostring(why) end
if side.error then return nil, side.error end
side.role = "a"
return planFrom({ side }, warnings)
end
-- ------- commit
local function validateSave(save, generation, data)
if generation == 2 then
local Save2 = require("src.core.gen2.Save")
local report = Save2.validate(save)
if not Save2.emptyReport(report) then return false, "that save didn't validate" end
return true
end
local report = SaveData.validate(save, data)
if not SaveData.emptyReport(report) then return false, "that save didn't validate" end
return true
end
local function buildSave(side)
local handle = side.handle
local save = deepCopy(handle.save)
local ref = side.ref or { where = "party", index = side.index }
if ref.where == "box" then
if type(save.boxes) ~= "table" then save.boxes = {} end
if type(save.boxes[ref.box]) ~= "table" then save.boxes[ref.box] = {} end
save.boxes[ref.box][ref.index] = side.record
else
if type(save.party) ~= "table" then save.party = {} end
save.party[ref.index] = side.record
end
markDex(save, handle.generation, side.dex)
if handle.generation == 2 then
local state = ref.where ~= "box" and type(save.mail) == "table"
and save.mail or nil
if state and type(state.party) == "table" then
state.party[ref.index] = nil
end
elseif handle.version == "yellow" and side.sent then
-- engine/link/cable_club.asm:801
local previous = GameVersion.get()
GameVersion.set("yellow")
pcall(function()
require("src.world.PikachuFollower")
.modifyHappiness(save, "TRADE", side.sent)
end)
GameVersion.set(previous)
end
return save
end
function Trade.commit(plan)
if type(plan) ~= "table" or type(plan.sides) ~= "table"
or #plan.sides == 0 then
return false, "no trade to make"
end
if Trade.gameIsLive() then return false, "close the game first" end
local jobs = {}
for _, side in ipairs(plan.sides) do
local built, why = withData(side.handle, function(data)
local save = buildSave(side)
local encoded = SaveData.encode(save)
local decoded = SaveData.decode(encoded)
if type(decoded) ~= "table" then
return { error = "that save didn't encode" }
end
local ok, reason = validateSave(decoded, side.handle.generation, data)
if not ok then return { error = reason } end
return { save = save, encoded = encoded }
end)
if not built then return false, tostring(why) end
if built.error then return false, built.error end
jobs[#jobs + 1] = { side = side, path = side.handle.path,
save = built.save, encoded = built.encoded }
end
local fs = disk()
if not fs then return false, "no filesystem" end
local stamp = tostring(os.time())
local backups = {}
for _, job in ipairs(jobs) do
job.prevMain = fs.getInfo(job.path) and fs.read(job.path) or nil
job.prevBak = fs.getInfo(job.path .. ".bak") and fs.read(job.path .. ".bak")
or nil
job.backup = job.path .. ".trade-bak-" .. stamp
if job.prevMain then
local ok = fs.write(job.backup, job.prevMain)
if not ok then return false, "couldn't back that save up" end
backups[#backups + 1] = job.backup
end
end
local function restore()
for _, job in ipairs(jobs) do
if job.prevMain then
fs.write(job.path, job.prevMain)
elseif fs.remove then
fs.remove(job.path)
end
if job.prevBak then
fs.write(job.path .. ".bak", job.prevBak)
elseif fs.remove then
fs.remove(job.path .. ".bak")
end
if fs.remove then fs.remove(job.path .. ".tmp") end
end
end
for _, job in ipairs(jobs) do
local dir = job.path:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
local ok, err = fs.write(job.path .. ".tmp", job.encoded)
if not ok then
restore()
return false, tostring(err or "couldn't write that save")
end
local body = fs.read(job.path .. ".tmp")
if body ~= job.encoded or type(SaveData.decode(body or "")) ~= "table" then
restore()
return false, "that save didn't write"
end
end
for _, job in ipairs(jobs) do
if job.prevMain then fs.write(job.path .. ".bak", job.prevMain) end
if fs.remove then fs.remove(job.path) end
local ok, err = fs.write(job.path, job.encoded)
if not ok then
restore()
return false, tostring(err or "couldn't write that save")
end
end
local handles = {}
for i, job in ipairs(jobs) do
if fs.remove then fs.remove(job.path .. ".tmp") end
local handle = job.side.handle
handle.save = job.save
handle.party = job.save.party
handle.boxes = type(job.save.boxes) == "table" and job.save.boxes or nil
handles[i] = handle
end
return true, handles, backups
end
function Trade.pruneBackups(path, keep)
keep = math.max(0, tonumber(keep) or 3)
if type(path) ~= "string" or path == "" then return 0 end
local fs = disk()
if not fs or type(fs.getDirectoryItems) ~= "function"
or type(fs.remove) ~= "function" then
return 0
end
local dir, base = path:match("^(.*)/([^/]+)$")
if not dir then dir, base = "", path end
local ok, items = pcall(fs.getDirectoryItems, dir)
if not ok or type(items) ~= "table" then return 0 end
local prefix = base .. ".trade-bak-"
local found = {}
for _, name in ipairs(items) do
if type(name) == "string" and name:sub(1, #prefix) == prefix then
local stamp = tonumber(name:sub(#prefix + 1))
if stamp then found[#found + 1] = { name = name, stamp = stamp } end
end
end
table.sort(found, function(a, b)
if a.stamp ~= b.stamp then return a.stamp > b.stamp end
return a.name > b.name
end)
local removed = 0
for i = keep + 1, #found do
local full = (dir == "") and found[i].name or (dir .. "/" .. found[i].name)
if fs.remove(full) then removed = removed + 1 end
end
return removed
end
-- ------- remote
local Remote = {}
Remote.__index = Remote
function Trade.remote(handle, link, opts)
if type(handle) ~= "table" then return nil, "no save" end
if type(link) ~= "table" or type(link.send) ~= "function" then
return nil, "no room"
end
opts = type(opts) == "table" and opts or {}
local data, release = handle.data, nil
if not data then
if Trade.gameIsLive() then return nil, "close the game first" end
local mounted, freeOrReason = mountDataset(handle.version)
if not mounted then return nil, tostring(freeOrReason) end
data, release = mounted, freeOrReason
end
local session = Protocol.TradeSession.new(data, handle.party, {
subset = opts.subset, strict = opts.strict, peerName = opts.peerName,
})
return setmetatable({
handle = handle,
link = link,
data = data,
session = session,
release = release,
game = { data = data, save = handle.save },
}, Remote)
end
function Remote:_send(msg)
if type(msg) ~= "table" then return end
if msg.type == "party" and self.handle.generation == 2 then
msg = { type = "party",
mons = packPartyFor(2, self.session.party,
self.session.sendIndices) }
end
self.link:send(msg)
end
function Remote:start()
self:_send(self.session:opening())
end
function Remote:_theirParty(msg)
local session = self.session
session.theirParty = {}
for _, packed in ipairs(msg.mons or {}) do
local mon, why = Protocol.unpackMon2(self.data, packed,
{ strict = session.strict })
if mon then
session.theirParty[#session.theirParty + 1] = mon
elseif session.strict then
session.stage = "cancelled"
session.error = why or "the other game sent an unknown POKéMON"
return
end
end
if session.stage == "waitParty" then session.stage = "picking" end
end
function Remote:update()
if self.link.update then self.link:update() end
local messages = self.link:poll() or {}
for _, msg in ipairs(messages) do
if type(msg) == "table" and type(msg.type) == "string" then
if msg.type == "party" and self.handle.generation == 2 then
self:_theirParty(msg)
else
local reply = self.session:handle(msg)
if reply then self:_send(reply) end
end
end
end
return self.session.stage
end
function Remote:stage()
return self.session.stage
end
function Remote:canPick(index)
return self.session:canPick(index)
end
function Remote:pick(index)
local mon, reason = pickable(self.handle, index)
if not mon then return false, reason end
self:_send(self.session:pick(index))
return true
end
function Remote:confirm(ok)
self:_send(self.session:confirm(ok and true or false))
return true
end
function Remote:plan()
local session = self.session
if session.stage ~= "done" then return nil, "the trade isn't finished" end
local record = session.theirParty[session.theirPick]
if type(record) ~= "table" then return nil, "the other game sent nothing" end
local handle = self.handle
local injected = handle.data
handle.data = self.data
local plan, reason = Trade.planIncoming({
to = handle, toIndex = session.myPick, record = record,
})
handle.data = injected
return plan, reason
end
function Remote:commit()
local plan, reason = self:plan()
if not plan then return false, reason end
local handle = self.handle
local injected = handle.data
handle.data = self.data
local ok, result, backups = Trade.commit(plan)
handle.data = injected
return ok, result, backups
end
function Remote:close()
if self.release then
self.release()
self.release = nil
end
if self.link.close then pcall(function() self.link:close() end) end
end
return Trade
+3
View File
@@ -787,6 +787,9 @@ end
-- leaves the original.
PaletteFX.DARK_BGP = { [0] = 2, [1] = 3, [2] = 3, [3] = 3 }
-- engine/gfx/screen_effects.asm:1-12
PaletteFX.POISON_BGP = { [0] = 2, [1] = 1, [2] = 2, [3] = 3 }
local shadeMap = nil
function PaletteFX.setShadeMap(map)
+18 -1
View File
@@ -394,6 +394,13 @@ function TextBox:update(dt)
return
end
if self.auto then
-- engine/items/item_effects.asm:1794 (#1880)
if self.auto.promptFirst and not self.autoPrompted then
if not (input:wasPressed("a") or input:wasPressed("b")) then return end
require("src.core.Sound").play(self.game.data, "Press_AB")
self.autoPrompted = true
return
end
if not self.autoStarted then
self.autoStarted = true
self.autoSrc = self.auto.sound and self.auto.sound() or nil
@@ -608,7 +615,15 @@ function TextBox:draw()
Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE)
else
Font.drawBox(11, 0, 9, 3)
-- data/text_boxes.asm:35
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8)
love.graphics.setColor(0, 0, 0, 1)
local cap = 13 * 8
for _, code in ipairs(Font.encode("MONEY")) do
drawGlyph(code, cap, 0)
cap = cap + Font.advanceOf(code)
end
end
local money = ("¥%d"):format(self.money() or 0)
local pen = 152 - Font.width(money)
@@ -617,7 +632,9 @@ function TextBox:draw()
pen = pen + Font.advanceOf(code)
end
end
if (self.waiting or (self.done and not self.choice and not self.auto
if (self.waiting or (self.done and not self.choice
and (not self.auto
or (self.auto.promptFirst and not self.autoPrompted))
and (not self.stay
or (self.stay.prompt and not self.stayShown))))
and self.blink < 30 then
+9
View File
@@ -39,6 +39,9 @@ Gen2Layout.goldSilver = {
wYCoord = 0x286A,
wEventFlags = 0x261F,
wPlayerState = 0x24EA,
wStatusFlags = 0x23D9,
wStatusFlags2 = 0x23DA,
wPokegearFlags = 0x24E4,
wGameTimeHours = 0x2053,
wGameTimeMinutes = 0x2055,
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
@@ -81,6 +84,9 @@ Gen2Layout.crystal = {
wYCoord = 0x2845,
wEventFlags = 0x2600,
wPlayerState = 0x24EB,
wStatusFlags = 0x23DA,
wStatusFlags2 = 0x23DB,
wPokegearFlags = 0x24E5,
wGameTimeHours = 0x2052,
wGameTimeMinutes = 0x2054,
-- The 14 archived boxes, listed rather than strided (see BOX_COUNT).
@@ -123,6 +129,9 @@ Gen2Layout.crystal = {
wYCoord = 0x1A45,
wEventFlags = 0x1800,
wPlayerState = 0x16EB,
wStatusFlags = 0x15DA,
wStatusFlags2 = 0x15DB,
wPokegearFlags = 0x16E5,
wGameTimeHours = 0x1252,
wGameTimeMinutes = 0x1254,
boxes = { 0x4000, 0x4450, 0x48A0, 0x4CF0, 0x5140, 0x5590, 0x59E0, 0x6000, 0x6450, 0x68A0, 0x6CF0, 0x7140, 0x7590, 0x79E0 },
+75 -6
View File
@@ -95,6 +95,7 @@ function Gen2Save.crosswalks(data)
itemIndex = toIndex(data.items),
mapIds = mapIds,
itemDefs = data.items or {},
moveDefs = data.moves or {},
}
end
@@ -147,18 +148,36 @@ local function decodeStatExp(bytes, o)
}
end
-- constants/pokemon_data_constants.asm:216, :218
local function decodePPByte(b) return b % 64, math.floor(b / 64) % 4 end
local function encodePPByte(pp, ppUps)
return ((ppUps or 0) % 4) * 64 + ((pp or 0) % 64)
end
-- engine/items/item_effects.asm:2736
local function maxPPOf(base, ppUps)
if not base then return nil end
return base + (ppUps or 0) * math.min(math.floor(base / 5), 7)
end
-- The first 32 bytes, which a box mon and a party mon share.
local function decodeSharedMon(bytes, o, x)
local moves, pp = {}, {}
local moves = {}
for i = 0, 3 do
local id = named(x.moves, u8(bytes, o + 2 + i))
if id then moves[#moves + 1] = id end
if id then
local pp, ppUps = decodePPByte(u8(bytes, o + 0x17 + i))
local def = x.moveDefs[id]
local base = type(def) == "table" and def.pp or nil
moves[#moves + 1] = {
id = id, pp = pp, ppUps = ppUps, maxPp = maxPPOf(base, ppUps),
}
end
end
for i = 0, 3 do pp[i + 1] = u8(bytes, o + 0x17 + i) % 64 end
return {
species = named(x.pokemon, u8(bytes, o)),
item = named(x.items, u8(bytes, o + 1)),
moves = moves, pp = pp,
moves = moves,
otId = be(bytes, o + 6, 2),
experience = be(bytes, o + 8, 3),
statExp = decodeStatExp(bytes, o + 0x0B),
@@ -299,6 +318,29 @@ local function decodeBadges(byte, order)
return out
end
-- data/events/engine_flags.asm:11-40
local ENGINE_FLAG_BITS = {
wPokegearFlags = { { 1, 0 }, { 0, 1 }, { 2, 2 }, { 3, 3 }, { 4, 7 } },
wStatusFlags = { { 11, 0 }, { 12, 1 }, { 13, 3 }, { 14, 4 }, { 15, 6 } },
wStatusFlags2 = {
{ 18, 0 }, { 17, 1 }, { 19, 4 }, { 20, 5 }, { 21, 6 }, { 22, 7 },
},
}
local function decodeEngineFlags(bytes, L)
local out = {}
for field, rows in pairs(ENGINE_FLAG_BITS) do
local at = L[field]
if at then
local byte = u8(bytes, at)
for _, row in ipairs(rows) do
if math.floor(byte / 2 ^ row[2]) % 2 == 1 then out[row[1]] = true end
end
end
end
return out
end
Gen2Save.NUM_SPECIES = 251
Gen2Save.EVENT_BYTES = 256
@@ -369,6 +411,7 @@ function Gen2Save.decode(bytes, gameVersion, data)
seen = decodeDex(bytes, L.wPokedexSeen, x),
},
events = decodeFlagBytes(bytes, L.wEventFlags, Gen2Save.EVENT_BYTES),
engineFlags = decodeEngineFlags(bytes, L),
-- Save.summary does `save.position.map or save.spawn`.
position = {
map = x.maps[u8(bytes, L.wMapGroup) * 256 + u8(bytes, L.wMapNumber)],
@@ -479,7 +522,9 @@ local function putSharedMon(t, o, mon, x)
putU8(t, o, indexOf(x.pokemonIndex, mon.species))
putU8(t, o + 1, indexOf(x.itemIndex, mon.item))
for i = 0, 3 do
putU8(t, o + 2 + i, indexOf(x.moveIndex, (mon.moves or {})[i + 1]))
local mv = (mon.moves or {})[i + 1]
putU8(t, o + 2 + i,
indexOf(x.moveIndex, type(mv) == "table" and mv.id or mv))
end
putBE(t, o + 6, mon.otId or 0, 2)
putBE(t, o + 8, mon.experience or 0, 3)
@@ -491,7 +536,12 @@ local function putSharedMon(t, o, mon, x)
putU8(t, o + 0x15, (d.attack or 0) * 16 + (d.defense or 0))
putU8(t, o + 0x16, (d.speed or 0) * 16 + (d.special or 0))
for i = 0, 3 do
putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0)
local mv = (mon.moves or {})[i + 1]
if type(mv) == "table" then
putU8(t, o + 0x17 + i, encodePPByte(mv.pp, mv.ppUps))
else
putU8(t, o + 0x17 + i, (mon.ppRaw or {})[i + 1] or (mon.pp or {})[i + 1] or 0)
end
end
putU8(t, o + 0x1B, mon.happiness or 0)
-- 0x1C-0x1E belong to the mon, not to the slot: leaving them to the template
@@ -551,6 +601,22 @@ local function putBag(t, L, inventory, x)
return overflow
end
local function putEngineFlags(t, L, flags)
for field, rows in pairs(ENGINE_FLAG_BITS) do
local at = L[field]
if at then
local byte = t[at] or 0
for _, row in ipairs(rows) do
local mask = 2 ^ row[2]
local on = math.floor(byte / mask) % 2 == 1
local want = flags[row[1]] == true
if on ~= want then byte = want and (byte + mask) or (byte - mask) end
end
t[at] = byte
end
end
end
local function putFlagSet(t, at, set, count, indexFor)
for i = 0, count - 1 do
local byteAt = at + math.floor(i / 8)
@@ -657,6 +723,9 @@ function Gen2Save.encode(save, gameVersion, template, data)
local byte = (save.events or {})[i]
if type(byte) == "number" then putU8(t, L.wEventFlags + i, byte) end
end
if type(save.engineFlags) == "table" then
putEngineFlags(t, L, save.engineFlags)
end
local pos = save.position
if pos then
+14 -14
View File
@@ -23,8 +23,7 @@
-- ORIGINAL imported bytes as a template when available (GenSave.decode
-- stashes them) so that scratch state round-trips untouched instead of
-- being invented; with no template (a save that originated in this
-- project) those bytes stay zero-filled, which is safe because the real
-- game regenerates all of it from wCurMap on the next map load anyway.
-- project) src/save_convert/MapContext.lua rebuilds it (home/overworld.asm:2016).
local bit = require("bit")
local MapContext = require("src.save_convert.MapContext")
@@ -1069,20 +1068,21 @@ function GenSave.encode(save, data, template)
rebuild = index == nil or u8(src, O.curMap) ~= bit.band(index, 0xFF)
end
if rebuild then
local ctx = MapContext.build(data, mapId,
local ctx, why = MapContext.build(data, mapId,
(save.player and save.player.x) or 0, (save.player and save.player.y) or 0)
if ctx then
for offset, values in pairs(ctx.writes) do
for i, value in ipairs(values) do
setByte(buf, O.mainData + offset + i - 1, value)
end
end
for i, value in ipairs(ctx.spriteData) do
setByte(buf, O.spriteData + i - 1, value)
end
-- sTileAnimations, the byte between sCurBoxData and the checksum
setByte(buf, O.checksumEnd - 1, ctx.tileAnimations)
-- home/overworld.asm:2016 (#1691)
if not ctx then
error(("this save cannot be exported: %s"):format(tostring(why)), 0)
end
for offset, values in pairs(ctx.writes) do
for i, value in ipairs(values) do
setByte(buf, O.mainData + offset + i - 1, value)
end
end
for i, value in ipairs(ctx.spriteData) do
setByte(buf, O.spriteData + i - 1, value)
end
setByte(buf, O.checksumEnd - 1, ctx.tileAnimations)
end
end
+10 -9
View File
@@ -207,13 +207,13 @@ function MapContext.build(data, mapId, x, y)
-- The tileset header, as predef LoadTilesetHeader would have copied it.
local tilesets = data.tilesets or {}
local tilesetDef = tilesets[map.tileset]
local tileAnimations = 0
if tilesetDef and tilesetDef.header then
local row = {}
for i = 1, 11 do row[i] = tilesetDef.header[i] end
writes[O.tilesetHeader] = row
tileAnimations = tilesetDef.header[12] or 0
if not (tilesetDef and tilesetDef.header) then
return nil, ("no tileset bytes for %s (re-import the ROM)"):format(tostring(mapId))
end
local row = {}
for i = 1, 11 do row[i] = tilesetDef.header[i] end
writes[O.tilesetHeader] = row
local tileAnimations = tilesetDef.header[12] or 0
-- MapSongBanks: without it the game continues with sound id 0 and audio
-- bank 0, which is what actually hangs a Continue on a white screen.
@@ -221,10 +221,11 @@ function MapContext.build(data, mapId, x, y)
local songLabel = audio and audio.mapSongs and audio.mapSongs[mapId]
local song = songLabel and audio.songs and audio.songs[songLabel]
local id = song and soundId(song.address)
if id and song.bank then
writes[O.mapMusicSoundID] = { id }
writes[O.mapMusicROMBank] = { song.bank % 256 }
if not (id and song.bank) then
return nil, ("no map music for %s (re-import the ROM)"):format(tostring(mapId))
end
writes[O.mapMusicSoundID] = { id }
writes[O.mapMusicROMBank] = { song.bank % 256 }
-- Player position within its block, and the upper-left corner of the view.
-- The pointer is the same expression the warp_to tables are assembled with
+11
View File
@@ -190,6 +190,17 @@ function SyncClient:fetchShare(code)
{ noAuth = true, params = { code = trimmed } })
end
function SyncClient:lobbyTicket()
return self:send("POST", "/lobby/ticket", {})
end
function SyncClient:setDisplayName(name)
if type(name) ~= "string" or name == "" then
return nil, "pick a display name first"
end
return self:send("POST", "/sync/displayname", { displayName = name })
end
function SyncClient:unlink(device)
return self:send("POST", "/sync/unlink", { device = device })
end
+58 -21
View File
@@ -13,6 +13,16 @@ SyncEngine.MAX_STEPS_PER_UPDATE = 8
local IDLE_STATUS = "Ready"
local UNLINKED_STATUS = "Not set up"
local function unixSeconds(v)
local n = tonumber(v)
if not n or n ~= n or n <= 0 or n == math.huge or n == -math.huge then
return nil
end
return n
end
SyncEngine.unixSeconds = unixSeconds
local function saveApi()
return require("src.core.SaveData")
end
@@ -56,8 +66,9 @@ function SyncEngine.defaultSaves()
playthroughId = id,
blob = source,
meta = {
savedAt = tonumber(meta.savedAt),
sessionStart = tonumber(meta.sessionStart),
savedAt = unixSeconds(meta.savedAt)
or unixSeconds(save.savedAt),
sessionStart = unixSeconds(meta.sessionStart),
playthroughId = id,
format = meta.format,
engine = meta.engine,
@@ -110,8 +121,8 @@ end
function SyncEngine.overlaps(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return false end
local aStart, aEnd = tonumber(a.sessionStart), tonumber(a.savedAt)
local bStart, bEnd = tonumber(b.sessionStart), tonumber(b.savedAt)
local aStart, aEnd = unixSeconds(a.sessionStart), unixSeconds(a.savedAt)
local bStart, bEnd = unixSeconds(b.sessionStart), unixSeconds(b.savedAt)
if not (aStart and aEnd and bStart and bEnd) then return false end
return aStart <= bEnd and bStart <= aEnd
end
@@ -135,7 +146,7 @@ local function playedMinutes(meta)
if hours then return tonumber(hours) * 60 + tonumber(minutes) end
end
local seconds = tonumber(meta.playTime)
if seconds then return math.floor(seconds / 60) end
if seconds and seconds > 0 then return math.floor(seconds / 60) end
return nil
end
@@ -146,6 +157,26 @@ function SyncEngine.samePlaytime(a, b)
return left ~= nil and left == right
end
function SyncEngine.sameProgress(a, b)
if SyncEngine.samePlaytime(a, b) then return true end
if type(a) ~= "table" or type(b) ~= "table" then return false end
local left = type(a.summary) == "table" and a.summary or nil
local right = type(b.summary) == "table" and b.summary or nil
if not (left and right) then return false end
if type(left.name) ~= "string" or left.name == "" then return false end
return left.name == right.name and left.badges == right.badges
and left.timeText == right.timeText and left.dexCount == right.dexCount
end
function SyncEngine.displayMeta(meta)
if type(meta) ~= "table" then return meta end
local out = {}
for k, v in pairs(meta) do out[k] = v end
out.savedAt = unixSeconds(meta.savedAt)
out.sessionStart = unixSeconds(meta.sessionStart)
return out
end
function SyncEngine.new(opts)
opts = opts or {}
local eng = setmetatable({}, SyncEngine)
@@ -383,19 +414,20 @@ function SyncEngine:unlink()
self:_forgetLocal()
return true
end
if self:busy() then return false, "sync is busy" end
if self:busy() then self:cancel() end
self.phase = "checking"
self.status = "Unlinking this device..."
self.error = nil
local handle, err = self.client:unlink(self.state.deviceId)
if not handle then
self:_forgetLocal()
return true
end
return self:_request(handle, err, function(eng)
eng:_forgetLocal()
end, function(eng, res)
if res.code == 401 or res.code == 404 then
eng:_forgetLocal()
return true
end
return false
end, function(eng)
eng:_forgetLocal()
return true
end)
end
@@ -479,15 +511,20 @@ function SyncEngine:_planFrom(remoteState)
seen[key] = true
local row = remote[key]
local knownRev = SyncState.rev(self.state, key)
local stamp = SyncState.stamp(self.state, key)
local localChanged = stamp == nil
or tonumber(entry.meta and entry.meta.savedAt) ~= stamp
local stamp = unixSeconds(SyncState.stamp(self.state, key))
local liveStamp = unixSeconds(entry.meta and entry.meta.savedAt)
local localChanged
if liveStamp == nil and stamp == nil then
localChanged = knownRev == nil
else
localChanged = liveStamp ~= stamp
end
local remoteRev = row and tonumber(row.rev)
local remoteChanged = row ~= nil and remoteRev ~= knownRev
if not row then
self:_queueUpload(entry, key, false)
elseif localChanged and remoteChanged then
if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
if SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then
self:_queueUpload(entry, key, true)
else
self:_addConflict(entry, key, row)
@@ -511,14 +548,14 @@ function SyncEngine:_planFrom(remoteState)
end
function SyncEngine:_addConflict(entry, key, row)
local remoteMeta = SyncEngine.metaOf(row)
local remoteMeta = SyncEngine.displayMeta(SyncEngine.metaOf(row))
self.conflicts[#self.conflicts + 1] = {
key = key,
version = entry.version,
playthroughId = entry.playthroughId,
slot = entry.slot,
entry = entry,
localMeta = entry.meta,
localMeta = SyncEngine.displayMeta(entry.meta),
remoteMeta = remoteMeta,
remoteRev = tonumber(row.rev),
overlap = SyncEngine.overlaps(entry.meta, remoteMeta),
@@ -551,7 +588,7 @@ function SyncEngine:_queueUpload(entry, key, force)
eng:_request(handle, err, function(e, res)
local data = res.data or {}
SyncState.setRev(e.state, key, tonumber(data.rev),
entry.meta and entry.meta.savedAt)
unixSeconds(entry.meta and entry.meta.savedAt))
e:_persist()
if not e:busy() then e:_finish() end
end, function(e, res)
@@ -560,7 +597,7 @@ function SyncEngine:_queueUpload(entry, key, force)
-- Retried with force only once: a forced write that still 409s is a
-- real refusal, and retrying it would spin.
if not force
and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then
and SyncEngine.sameProgress(entry.meta, SyncEngine.metaOf(row)) then
e:_queueUpload(entry, key, true)
else
e:_addConflict(entry, key, row)
@@ -592,7 +629,7 @@ function SyncEngine:_queueDownload(key, version, playthroughId, mode, knownRev)
if mode ~= "new" then
local meta = type(data.meta) == "table" and data.meta or {}
SyncState.setRev(e.state, key, tonumber(data.rev) or knownRev,
tonumber(meta.savedAt))
unixSeconds(meta.savedAt))
end
e:_persist()
if not e:busy() then e:_finish() end
+1
View File
@@ -35,6 +35,7 @@ function SyncState.sanitize(raw)
out.deviceToken = str(raw.deviceToken)
out.deviceId = str(raw.deviceId)
out.deviceLabel = str(raw.deviceLabel)
out.displayName = str(raw.displayName)
out.lastSyncAt = num(raw.lastSyncAt) or 0
if type(raw.revs) == "table" then
for key, rev in pairs(raw.revs) do
+100
View File
@@ -0,0 +1,100 @@
local ArenaBoot = require("src.online.ArenaBoot")
local LinkBattle = require("src.link.LinkBattle")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local ArenaState = {}
ArenaState.__index = ArenaState
ArenaState.isOpaque = true
ArenaState.screenId = "ArenaState"
function ArenaState.new(game, spec)
local self = setmetatable({}, ArenaState)
self.game = game
self.spec = spec
self.stage = "boot"
self.battle = nil
self.finished = false
return self
end
function ArenaState:report(result)
if self.finished then return end
self.finished = true
local onDone = self.spec and self.spec.onDone
if onDone then pcall(onDone, result) end
end
function ArenaState:leave()
local game = self.game
game.linkNet = nil
game.linkSession = nil
if game.stack and game.stack:top() == self then game.stack:pop() end
if type(game.returnToLauncher) == "function" then
game.returnToLauncher({ tab = "online" })
end
end
function ArenaState:fail(reason)
Logger.error("arena: battle could not start (%s)", tostring(reason))
self.stage = "done"
self:report("error")
self:leave()
end
function ArenaState:enter()
local game, spec = self.game, self.spec
game.linkNet = spec.session
game.linkSession = true
if spec.role ~= "spectator" then
local packed, packErr = ArenaBoot.packOwnParty(game, spec)
if not packed then return self:fail(packErr) end
end
local opts, optsErr = ArenaBoot.battleOpts(spec)
if not opts then return self:fail(optsErr) end
local battle, why
if spec.role == "spectator" then
battle, why = LinkBattle.newSpectator(game, spec.session, opts)
elseif spec.role == "guest" then
battle, why = LinkBattle.newGuest(game, spec.session, opts)
else
battle, why = LinkBattle.newHost(game, spec.session, opts)
end
if not battle then return self:fail(why) end
self.battle = battle
self.stage = "running"
game.stack:push(battle)
end
function ArenaState:update(_dt)
if self.stage ~= "running" then return end
if self.game.stack:top() ~= self then return end
self.stage = "done"
local battle = self.battle
local result = (battle and battle.result) or "ended"
if battle and Runtime.wants("link.battle_ended") then
Runtime.emit("link.battle_ended", {
result = result,
myParty = battle.playerParty,
theirParty = battle.enemyParty,
peerName = self.spec.peerName,
role = self.spec.role,
})
end
self.battle = nil
self:report(result)
self:leave()
end
function ArenaState:draw()
local G = love.graphics
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, 160, 144)
G.setColor(1, 1, 1, 1)
end
return ArenaState
+4 -2
View File
@@ -116,14 +116,16 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
-- then the woke-up/battle sequence (data/scripts/story.lua snorlaxWake)
if result == "flute_wake" then
closeBag()
require("src.core.Sound").play(game.data, "Pokeflute")
-- engine/items/item_effects.asm:1794 (#1880)
local opts = TextBox.soundOpts(game, "Pokeflute",
{ auto = { wait = false, delay = 0, promptFirst = true } })
showMessages(game, payload, function()
local ow = game.overworld
local mod = ow and require("data.scripts.init").get(extra.mapId)
if ow and mod and mod.snorlaxWake then
ow.runner:run(mod.snorlaxWake.script, { npc = extra.npc })
end
end)
end, opts)
return
end
+15 -30
View File
@@ -5,6 +5,7 @@
local Font = require("src.render.Font")
local Runtime = require("src.mods.Runtime")
local Theme = require("src.ui.Theme")
local MenuRepeat = require("src.ui.MenuRepeat")
local Strings = require("src.core.Strings")
local ListMenu = {}
@@ -54,14 +55,18 @@ local ITEM_CURSOR_X = 40
local ITEM_QTY_X, ITEM_QTY_END = 112, 136
local ITEM_MORE_X, ITEM_MORE_Y = 144, 88
-- frames to wait before key-repeat kicks in, then between repeats
local REPEAT_DELAY = 16
local REPEAT_RATE = 4
local REPEAT_DELAY = MenuRepeat.GEN1_DELAY
local REPEAT_RATE = MenuRepeat.GEN1_RATE
local UPDOWN_DIRS = { "up", "down" }
local NAV_DIRS = { "up", "down", "left", "right" }
-- ui.list_menu identity: unhooked opts pass through unchanged
local function sameOpts(opts) return opts end
function ListMenu.new(game, title, items, opts)
opts = opts or {}
-- home/list_menu.asm:8
opts.keyRepeat = opts.keyRepeat ~= false
-- bag / shop / dex / generic: mods may enable wrap, pageJump, keyRepeat
if Runtime.wantsHook("ui.list_menu") then
local hooked = Runtime.call("ui.list_menu", sameOpts, {
@@ -99,8 +104,7 @@ function ListMenu.new(game, title, items, opts)
self.keyRepeat = opts.keyRepeat -- hold Up/Down (and pageJump L/R) to scroll
self.repeatDelay = opts.repeatDelay or REPEAT_DELAY
self.repeatRate = opts.repeatRate or REPEAT_RATE
self.holdDir = nil
self.holdFrames = 0
self.hold = MenuRepeat.new(self.repeatDelay, self.repeatRate, self.keyRepeat)
self.onSelectKey = opts.onSelectKey -- SELECT pressed on an item
-- scripted mode (the old man tutorial): update() runs the script
-- every frame INSTEAD of reading input -- DisplayListMenuID's old-man
@@ -201,18 +205,10 @@ function ListMenu:update(dt)
end
local moved = false
if input:wasPressed("up") then
moved = navPressed(self, "up")
self.holdDir, self.holdFrames = "up", 0
elseif input:wasPressed("down") then
moved = navPressed(self, "down")
self.holdDir, self.holdFrames = "down", 0
elseif self.pageJump and input:wasPressed("left") then
moved = navPressed(self, "left")
self.holdDir, self.holdFrames = "left", 0
elseif self.pageJump and input:wasPressed("right") then
moved = navPressed(self, "right")
self.holdDir, self.holdFrames = "right", 0
local dir = MenuRepeat.direction(self.hold, input,
self.pageJump and NAV_DIRS or UPDOWN_DIRS)
if dir then
moved = navPressed(self, dir)
elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then
@@ -229,20 +225,6 @@ function ListMenu:update(dt)
return
end
-- hold-to-scroll (opt-in via ui.list_menu keyRepeat)
if self.keyRepeat then
local dir = self.holdDir
if dir and input:isDown(dir) then
self.holdFrames = self.holdFrames + 1
local afterDelay = self.holdFrames - self.repeatDelay
if afterDelay >= 0 and afterDelay % self.repeatRate == 0 then
navPressed(self, dir)
end
else
self.holdDir, self.holdFrames = nil, 0
end
end
if not moved then syncScroll(self) end
end
@@ -306,6 +288,9 @@ function ListMenu:drawItemBox()
if item.sub then
-- PrintLevel, one row down and 8 columns right (home/list_menu.asm:459-461)
Font.draw(item.sub, ITEM_QTY_X, y + 8)
elseif item.price then
-- home/list_menu.asm:410-424
Font.draw(item.price, ITEM_QTY_END - Font.width(item.price), y + 8)
elseif item.right then
-- '×' at column 14, PrintNumber's two right-aligned digits after it
-- (home/list_menu.asm:479-490)
+6 -2
View File
@@ -34,6 +34,8 @@ function Menu.new(game, items, opts)
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
end
self.rowStep = opts.rowStep or 2
-- home/window.asm:56-83
self.noWrap = opts.noWrap or false
-- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox)
self.title = opts.title
self.itemY = opts.itemY
@@ -82,9 +84,11 @@ end
function Menu:update(dt)
local input = self.game.input
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or #self.items
self.index = self.index > 1 and self.index - 1
or (self.noWrap and 1 or #self.items)
elseif input:wasPressed("down") then
self.index = self.index < #self.items and self.index + 1 or 1
self.index = self.index < #self.items and self.index + 1
or (self.noWrap and #self.items or 1)
elseif input:wasPressed("a") then
-- HandleMenuInput_ (home/window.asm): SFX_PRESS_AB on every A press
if not self.noSound then
+43
View File
@@ -0,0 +1,43 @@
-- home/joypad2.asm:16-53 JoypadLowSensitivity (hJoy7 = 1, 30 then 5)
-- pokegold home/joypad.asm:313-340 JoyTextDelay (hInMenu, 15 then 5)
local MenuRepeat = {}
MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE = 30, 5
MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE = 15, 5
local ALL_DIRS = { "up", "down", "left", "right" }
function MenuRepeat.new(delay, rate, enabled)
return {
delay = tonumber(delay) or MenuRepeat.GEN1_DELAY,
rate = math.max(1, tonumber(rate) or MenuRepeat.GEN1_RATE),
enabled = enabled ~= false,
dir = nil,
frames = 0,
}
end
function MenuRepeat.reset(state)
state.dir, state.frames = nil, 0
end
function MenuRepeat.direction(state, input, dirs)
for _, dir in ipairs(dirs or ALL_DIRS) do
if input:wasPressed(dir) then
state.dir, state.frames = dir, 0
return dir, true
end
end
local dir = state.dir
if not (dir and state.enabled and input.isDown and input:isDown(dir)) then
MenuRepeat.reset(state)
return nil, false
end
state.frames = state.frames + 1
local afterDelay = state.frames - state.delay
if afterDelay >= 0 and afterDelay % state.rate == 0 then return dir, false end
return nil, false
end
return MenuRepeat
+2 -9
View File
@@ -731,14 +731,7 @@ function PartyMenu:update(dt)
end
end
-- The bottom-of-screen context message for the current menu state
-- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_):
-- the party menu always prints a message in the bottom text box. With the
-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?")
-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap /
-- item / TM-HM ids print their own strings, and EVO_STONE shares
-- PartyMenuItemUseText (party_menu.asm:229 PartyMenuMessagePointers).
-- Pure (no side effects) so drivers can assert it. #147 #1610
-- engine/menus/party_menu.asm:229 (#147 #1610 #1901)
function PartyMenu:bottomMessage()
if self.swapFrom then
return self.game.data.text._PartyMenuSwapMonText
@@ -749,7 +742,7 @@ function PartyMenu:bottomMessage()
elseif self.softboiledFrom or self.itemUse then
return self.game.data.text._PartyMenuItemUseText
or Strings("Use item on which\nPOKéMON?")
elseif self.battle then
elseif self.forceSwitch then
return self.game.data.text._PartyMenuBattleText
or Strings("Bring out which\nPOKéMON?")
else
+8 -4
View File
@@ -4,6 +4,7 @@
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local MenuRepeat = require("src.ui.MenuRepeat")
local PokedexMenu = {}
PokedexMenu.__index = PokedexMenu
@@ -46,6 +47,8 @@ function PokedexMenu.new(game, opts)
self.onCancel = opts.onCancel -- B returns to the start menu when opened from it
self.index = 1
self.scroll = 0
-- engine/menus/pokedex.asm:13
self.hold = MenuRepeat.new(MenuRepeat.GEN1_DELAY, MenuRepeat.GEN1_RATE)
self.rowsAt = require("src.core.GameVersion").isYellow()
and YELLOW_ROWS or RED_ROWS
local dex = game.save.pokedex or { seen = {}, owned = {} }
@@ -153,13 +156,14 @@ function PokedexMenu:update(dt)
end
return
end
if input:wasPressed("up") then
local dir = MenuRepeat.direction(self.hold, input)
if dir == "up" then
self.index = self.index - 1
elseif input:wasPressed("down") then
elseif dir == "down" then
self.index = self.index + 1
elseif input:wasPressed("left") then
elseif dir == "left" then
self:pageScroll(-1)
elseif input:wasPressed("right") then
elseif dir == "right" then
self:pageScroll(1)
elseif input:wasPressed("b") then
beep(self)
+66
View File
@@ -0,0 +1,66 @@
-- engine/events/prize_menu.asm CeladonPrizeMenu
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local PrizeCounter = {}
PrizeCounter.__index = PrizeCounter
local NAME_X, TOP_Y, ROW_STEP = 16, 32, 16
local CURSOR_X = 8
local FIELD_END = 136
local ROWS = 4
function PrizeCounter.new(game, prizes, opts)
opts = opts or {}
return setmetatable({
game = game,
prizes = prizes,
index = 1,
onPick = opts.onPick,
onCancel = opts.onCancel,
}, PrizeCounter)
end
function PrizeCounter:update(dt)
local input = self.game.input
-- home/window.asm:56-83
if input:wasPressed("up") then
if self.index > 1 then self.index = self.index - 1 end
elseif input:wasPressed("down") then
if self.index < ROWS then self.index = self.index + 1 end
elseif input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB")
if input:wasPressed("b") or self.index >= ROWS then
if self.onCancel then self.onCancel() end
elseif self.onPick then
self.onPick(self.prizes[self.index], self.index)
end
end
end
function PrizeCounter:draw()
local coins = tostring((self.game.save and self.game.save.coins) or 0)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(11, 0, 9, 3)
love.graphics.rectangle("fill", 12 * 8, 0, 4 * 8, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("COIN"), 12 * 8, 0)
Font.draw(coins, FIELD_END - Font.width(coins), 8)
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(0, 2, 18, 10)
love.graphics.setColor(0, 0, 0, 1)
for i, p in ipairs(self.prizes) do
local y = TOP_Y + (i - 1) * ROW_STEP
Font.draw(p.name, NAME_X, y)
local cost = tostring(p.cost)
Font.draw(cost, FIELD_END - Font.width(cost), y + 8)
end
-- data/events/prizes.asm
Font.draw(Strings("NO THANKS"), NAME_X, TOP_Y + 3 * ROW_STEP)
Font.drawCode(Theme.cursor, CURSOR_X, TOP_Y + (self.index - 1) * ROW_STEP)
love.graphics.setColor(1, 1, 1, 1)
end
return PrizeCounter
+46 -25
View File
@@ -45,22 +45,31 @@ local function buy(game, stock, menu)
table.insert(items, {
value = id,
label = def.name,
right = ("¥%d"):format(def.price),
price = ("¥%d"):format(def.price),
})
end
end
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.")
local notEnough = txt(game, "_PokemartNotEnoughMoneyText",
Strings("You don't have\nenough money."))
local bagFull = txt(game, "_PokemartItemBagFullText",
Strings("You can't carry\nany more items."))
local list
list = ListMenu.new(game, "BUY", items, {
list = ListMenu.new(game, nil, items, {
dialogue = true,
-- home/list_menu.asm:29-31
itemBox = true,
money = function() return game.save.money end,
footer = greet,
onCancel = function() menu.footer = anythingElse(game) end,
onChoose = function(item)
-- home/list_menu.asm:105-110, 523-528
if item.cancel then
list:close()
menu.footer = anythingElse(game)
return
end
local def = game.data.items[item.value]
if game.save.money < def.price then
list.footer = notEnough
@@ -109,24 +118,32 @@ local function buy(game, stock, menu)
game.stack:push(list)
end
-- home/list_menu.asm:472-477
local function sellItems(game)
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
local keyed = (def and def.keyItem) or id:find("^HM_") ~= nil
table.insert(items, {
value = id,
label = def and def.name or id,
right = (not keyed) and ("x" .. game.save.inventory[id]) or nil,
})
end
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
return items
end
local function sell(game, menu)
-- Sell list is ITEMLISTMENU with wPrintItemPrices cleared
-- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows
-- in the quantity chooser. Stuffing "xN" into the label next to a
-- right-aligned ¥ price made long names overlap (issue #116).
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
table.insert(items, {
value = id,
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
})
end
local items = sellItems(game)
-- engine/events/pokemart.asm:50
local greet = txt(game, "_PokemonSellingGreetingText",
Strings("What would you\nlike to sell?"))
if #items == 0 then
if #Bag.order(game.save) == 0 then
refuse(game, menu, nil, txt(game, "_PokemartItemBagEmptyText",
Strings("You don't have\nanything to sell.")))
return
@@ -134,13 +151,16 @@ local function sell(game, menu)
local unsellable = txt(game, "_PokemartUnsellableItemText",
Strings("I can't put a\nprice on that."))
local list
list = ListMenu.new(game, "SELL", items, {
list = ListMenu.new(game, nil, items, {
dialogue = true,
-- home/list_menu.asm:29-31
itemBox = true,
money = function() return game.save.money end,
footer = greet,
onCancel = function() menu.footer = anythingElse(game) end,
onSelectKey = function(item, l)
if not item then return end
-- swap_items.asm:19-22
if not item or item.cancel then return end
if not l.swapIndex then
l.swapIndex = l.index
return
@@ -149,18 +169,15 @@ local function sell(game, menu)
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
l.swapIndex = nil
require("src.core.Sound").play(game.data, "Swap")
local rebuilt = {}
for _, id in ipairs(order) do
local def = game.data.items[id]
rebuilt[#rebuilt + 1] = {
value = id,
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
}
end
l.items = rebuilt
l.items = sellItems(game)
end,
onChoose = function(item)
-- home/list_menu.asm:105-110, 523-528
if item.cancel then
list:close()
menu.footer = anythingElse(game)
return
end
local def = game.data.items[item.value]
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
-- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id
@@ -214,7 +231,10 @@ local function drawClerk(menu)
local game = menu.game
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(11, 0, 9, 3)
-- data/text_boxes.asm:35
love.graphics.rectangle("fill", 13 * 8, 0, 5 * 8, 8)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("MONEY"), 13 * 8, 0)
local money = ("¥%d"):format((game.save and game.save.money) or 0)
Font.draw(money, 152 - Font.width(money), 8)
love.graphics.setColor(1, 1, 1, 1)
@@ -247,7 +267,8 @@ function ShopMenu.new(game, stock, onQuit)
{ label = Strings("BUY"), keepOpen = true, onSelect = function() buy(game, stock, menu) end },
{ label = Strings("SELL"), keepOpen = true, onSelect = function() sell(game, menu) end },
{ label = Strings("QUIT"), onSelect = farewell },
}, { tx = 0, ty = 0, tw = 8, th = 8 })
-- data/text_boxes.asm:34
}, { tx = 0, ty = 0, tw = 11, th = 7 })
menu.onCancel = farewell
menu.footer = txt(game, "_PokemartGreetingText",
Strings("Hi there!\nMay I help you?"))
+2 -10
View File
@@ -1,6 +1,6 @@
-- The START menu (engine/menus/start_menu.asm): entries appear as they
-- become usable -- POKéDEX once Oak gives it, POKéMON once you have any,
-- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT. The built
-- SAVE with a confirmation, plus ITEM / OPTION / QUIT. The built
-- item list runs through the ui.start_menu.items hook before the menu
-- opens, so mods insert or remove rows without patching this file.
@@ -142,14 +142,6 @@ function StartMenu.new(game)
Screens.push(game, "OptionsMenu", { onCancel = reopen })
end })
-- LINK needs a party
if #game.save.party > 0 then
table.insert(items, { label = Strings("LINK"), onSelect = function()
local LinkState = require("src.link.LinkState")
game.stack:push(LinkState.new(game))
end })
end
-- the manager's pause-menu entry (18-mod-manager-ux): gated on at least
-- one discovered mod so a vanilla install's menu is unchanged
local status = game.modStatus
@@ -184,7 +176,7 @@ function StartMenu.new(game)
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START.
--
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
-- item count isn't fixed: POKéDEX/MODS come and go with save state,
-- and mods can append their own rows through the hook above, so the
-- double-spaced box (the original's style) can grow past the 18-tile
-- canvas. Cap it at however many rows actually fit and scroll the rest,
+92 -35
View File
@@ -18,11 +18,15 @@ local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local SpriteRenderer = require("src.render.SpriteRenderer")
local Theme = require("src.ui.Theme")
local TownMap = {}
TownMap.__index = TownMap
TownMap.isOpaque = true
-- engine/items/town_map.asm:183
local ARROW_DELAY = 15
-- SGB: PalPacket_TownMap, whole screen
function TownMap:sgbPalettes(game)
return require("src.render.PaletteFX").wholeNamed(game.data, "TOWNMAP")
@@ -47,12 +51,27 @@ local function isRoute(loc)
return loc.name:find("ROUTE", 1, true) ~= nil
end
-- data/maps/town_map_order.asm:1
local function orderByCursorOrder(byMap, order)
if type(order) ~= "table" then return nil end
local out, seen = {}, {}
for _, mapId in ipairs(order) do
local loc = byMap[mapId]
if loc and not seen[loc] then
seen[loc] = true
out[#out + 1] = loc
end
end
return #out >= 2 and out or nil
end
-- Build the ordered location list. Grid mode dedupes shared entries
-- (interior maps point at their town's square); list mode falls back to
-- the fly towns so the screen still works without townMap data.
local function buildLocations(game)
local field = game.data.field or {}
local townMap = field.townMap
local cursorOrder = type(townMap) == "table" and townMap.cursorOrder or nil
-- the extractor nests the per-map entries under .locations
if type(townMap) == "table" and type(townMap.locations) == "table" then
townMap = townMap.locations
@@ -80,7 +99,7 @@ local function buildLocations(game)
if a.x ~= b.x then return a.x < b.x end
return a.name < b.name
end)
return locs, byMap, "grid"
return orderByCursorOrder(byMap, cursorOrder) or locs, byMap, "grid", locs
end
end
-- fallback: towns from the fly order (deduped, outdoor maps only)
@@ -193,7 +212,7 @@ function TownMap.new(game, opts)
local self = setmetatable({}, TownMap)
self.game = game
self.bg = loadBackground(game)
self.locs, self.byMap, self.mode = buildLocations(game)
self.locs, self.byMap, self.mode, self.allLocs = buildLocations(game)
if opts.nestSpecies then
self.nestSpecies = opts.nestSpecies
self.nests = {}
@@ -207,7 +226,8 @@ function TownMap.new(game, opts)
if found then break end
end
local loc = found and self.byMap[mapId]
if loc and not seen[loc] then
-- engine/items/town_map.asm:388
if loc and not seen[loc] and not (loc.x == 9 and loc.y == 1) then
seen[loc] = true
table.insert(self.nests, loc)
end
@@ -255,41 +275,34 @@ function TownMap.new(game, opts)
self.birdSheet, self.birdQuad =
markerSheet(sprites[playerSprites.fly or "SPRITE_BIRD"]
or sprites.SPRITE_BIRD, "bird")
-- engine/items/town_map.asm:150
local art = ((game.data.field or {}).townMap or {}).upArrow
local okArrow, arrow = pcall(love.graphics.newImage,
(art and art.path)
or "assets/generated/townmap/up_arrow.png")
self.upArrow = okArrow and arrow or nil
-- engine/items/town_map.asm:170, 183
self.arrowHide, self.arrowDelay = "up", ARROW_DELAY
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).
-- Only the plain viewer snaps the cursor to where the player stands.
if not self.fly then
local found = false
for i, loc in ipairs(self.locs) do
if loc == self.playerLoc then self.sel = i break end
if loc == self.playerLoc then self.sel = i found = true break end
end
-- engine/items/town_map.asm:29
if self.playerLoc and not found then
table.insert(self.locs, self.playerLoc)
self.sel = #self.locs
end
end
self.blink = 0
return self
end
-- snap the cursor to the nearest location in the pressed direction
function TownMap:moveGrid(dx, dy)
local cur = self.locs[self.sel]
local best, bestScore
for i, loc in ipairs(self.locs) do
if i ~= self.sel then
local ddx, ddy = loc.x - cur.x, loc.y - cur.y
local fwd = ddx * dx + ddy * dy -- progress along the d-pad axis
local side = math.abs(ddx * dy) + math.abs(ddy * dx)
if fwd > 0 then
local score = fwd + side * 3 -- prefer staying on-axis
if not best or score < bestScore then best, bestScore = i, score end
end
end
end
if best then
self.sel = best
Sound.play(self.game.data, "Tink")
end
end
function TownMap:moveList(step)
local n = #self.locs
if n < 2 then return end
@@ -300,6 +313,10 @@ end
function TownMap:update(dt)
local cycle = GameVersion.generation() == 2 and 32 or 50
self.blink = (self.blink + 1) % cycle
if self.arrowDelay and self.arrowDelay > 0 then
self.arrowDelay = self.arrowDelay - 1
if self.arrowDelay == 0 then self.arrowHide = nil end
end
local input = self.game.input
if input:wasPressed("b") then
Sound.play(self.game.data, "Press_AB")
@@ -318,8 +335,12 @@ function TownMap:update(dt)
self.game.stack:pop()
if mapId and self.onFly then self.onFly(mapId) end
return
elseif input:wasPressed("up") then self:moveList(1)
elseif input:wasPressed("down") then self:moveList(-1)
elseif input:wasPressed("up") then
self:moveList(1)
self.arrowHide, self.arrowDelay = "up", ARROW_DELAY
elseif input:wasPressed("down") then
self:moveList(-1)
self.arrowHide, self.arrowDelay = "down", ARROW_DELAY
end
elseif self.nestSpecies then
if input:wasPressed("a") then
@@ -327,10 +348,9 @@ function TownMap:update(dt)
self.game.stack:pop()
end
elseif self.mode == "grid" then
if input:wasPressed("up") then self:moveGrid(0, -1)
elseif input:wasPressed("down") then self:moveGrid(0, 1)
elseif input:wasPressed("left") then self:moveGrid(-1, 0)
elseif input:wasPressed("right") then self:moveGrid(1, 0)
-- engine/items/town_map.asm:74
if input:wasPressed("up") then self:moveList(1)
elseif input:wasPressed("down") then self:moveList(-1)
end
else
if input:wasPressed("up") then self:moveList(-1)
@@ -345,6 +365,22 @@ function TownMap:markPlayerRedraw(x, y)
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
end
-- engine/items/town_map.asm:170, 185
function TownMap:drawFlyArrows()
if self.arrowHide ~= "up" then
if self.upArrow then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.upArrow, 144, 0)
love.graphics.setColor(0, 0, 0, 1)
else
love.graphics.polygon("fill", 148, 1, 152, 7, 144, 7)
end
end
if self.arrowHide ~= "down" then
Font.drawCode(Theme.moreArrow, 152, 0)
end
end
local function drawSquare(loc)
if isRoute(loc) then
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
@@ -385,12 +421,26 @@ function TownMap:draw()
end
end
end
if #self.nests > 0 then
-- engine/items/town_map.asm:399
if self.playerLoc and self.playerSheet then
local x, y = markerXY(self.playerLoc)
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
self:markPlayerRedraw(x - 4, y - 3)
end
else
-- engine/items/town_map.asm:403
Font.drawBox(1, 7, 17, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(" AREA UNKNOWN", 16, 72)
love.graphics.setColor(1, 1, 1, 1)
end
love.graphics.rectangle("fill", 0, 0, 160, 8)
love.graphics.setColor(0, 0, 0, 1)
local def = self.game.data.pokemon[self.nestSpecies]
local name = def and def.name or self.nestSpecies
Font.draw(#self.nests > 0 and (name .. "'s NEST")
or (name .. " AREA UNKNOWN"), 8, 0)
-- engine/items/town_map.asm:124
Font.draw(name .. "'s NEST", 8, 0)
love.graphics.setColor(1, 1, 1, 1)
return
end
@@ -437,7 +487,14 @@ function TownMap:draw()
-- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name)
love.graphics.rectangle("fill", 0, 0, 160, 8)
love.graphics.setColor(0, 0, 0, 1)
if selected then Font.draw(self:bannerText(selected), 8, 0) end
if self.fly then
-- engine/items/town_map.asm:167, 176, 185
Font.draw("To", 0, 0)
if selected then Font.draw(selected.name, 24, 0) end
self:drawFlyArrows()
elseif selected then
Font.draw(self:bannerText(selected), 8, 0)
end
love.graphics.setColor(1, 1, 1, 1)
return
end
@@ -446,7 +503,7 @@ function TownMap:draw()
Font.drawBox(0, 0, 20, 18)
if self.mode == "grid" then
-- stale assets (no background art): the old abstract squares
for _, loc in ipairs(self.locs) do
for _, loc in ipairs(self.allLocs or self.locs) do
drawSquare(loc)
end
-- player marker is static in both Gen 1 and 2
+137
View File
@@ -0,0 +1,137 @@
local ArenaBoot = require("src.online.ArenaBoot")
local Chrome = require("src.ui.gen2.Chrome")
local LinkBattle2 = require("src.link.LinkBattle2")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local ArenaState = {}
ArenaState.__index = ArenaState
ArenaState.isOpaque = true
ArenaState.screenId = "Gen2ArenaState"
local HOLD_FRAMES = 300
function ArenaState:wantsFillScale() return true end
function ArenaState:drawsWidescreen() return true end
function ArenaState.new(game, spec)
local self = setmetatable({}, ArenaState)
self.game = game
self.spec = spec
self.stage = "boot"
self.battle = nil
self.frames = 0
self.finished = false
self.message = nil
return self
end
function ArenaState:report(result)
if self.finished then return end
self.finished = true
local onDone = self.spec and self.spec.onDone
if onDone then pcall(onDone, result) end
end
function ArenaState:leave()
local game = self.game
game.linkNet = nil
game.linkSession = nil
if game.stack and game.stack:top() == self then game.stack:pop() end
if type(game.returnToLauncher) == "function" then
game.returnToLauncher({ tab = "online" })
end
end
function ArenaState:fail(reason)
Logger.error("arena2: battle could not start (%s)", tostring(reason))
self.stage = "failed"
self.frames = 0
self.message = tostring(reason or Strings("The battle could not start."))
end
function ArenaState:enter()
local game, spec = self.game, self.spec
game.linkNet = spec.session
game.linkSession = true
if spec.role ~= "spectator" then
local packed, packErr = ArenaBoot.packOwnParty(game, spec)
if not packed then return self:fail(packErr) end
end
local opts, optsErr = ArenaBoot.battleOpts(spec)
if not opts then return self:fail(optsErr) end
local battle, why
if spec.role == "spectator" then
battle, why = LinkBattle2.newSpectator(game, spec.session, opts)
elseif spec.role == "guest" then
battle, why = LinkBattle2.newGuest(game, spec.session, opts)
else
battle, why = LinkBattle2.newHost(game, spec.session, opts)
end
if not battle then return self:fail(why) end
self.battle = battle
self.stage = "running"
game.stack:push(battle)
end
function ArenaState:update(_dt)
if self.stage == "failed" then
self.frames = self.frames + 1
local input = self.game.input
local dismissed = input and (input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start"))
if dismissed or self.frames >= HOLD_FRAMES then
self.stage = "done"
self:report("error")
self:leave()
end
return
end
if self.stage ~= "running" then return end
if self.game.stack:top() ~= self then return end
self.stage = "done"
local battle = self.battle
local result = (battle and battle.result) or "ended"
if battle and Runtime.wants("link.battle_ended") then
Runtime.emit("link.battle_ended", {
result = result,
myParty = battle.playerParty,
theirParty = battle.enemyParty,
peerName = self.spec.peerName,
role = self.spec.role,
})
end
self.battle = nil
self:report(result)
self:leave()
end
function ArenaState:drawPanel()
Chrome.clear()
if self.message then
Chrome.textbox(0, 12, 18, 4)
Chrome.printWrapped(self.message, 1, 13, 18, 4)
end
end
function ArenaState:draw()
self:drawPanel()
end
function ArenaState:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return ArenaState
+34 -17
View File
@@ -172,7 +172,7 @@ end
-- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn
-- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge:
-- the cart's BG map wraps, so a scrolled scanline never spills past the LCD.
function BattleAnimView:blitRow(row, dx, dy)
function BattleAnimView:blitRowAt(srcRow, destRow, dx)
local canvas = self.canvas
if not self.blitQuad then
self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H)
@@ -184,8 +184,12 @@ function BattleAnimView:blitRow(row, dx, dy)
srcX, width, destX = -dx, SCREEN_W + dx, 0
end
if width <= 0 then return end
self.blitQuad:setViewport(srcX, row, width, 1, SCREEN_W, SCREEN_H)
love.graphics.draw(canvas, self.blitQuad, destX, row + dy)
self.blitQuad:setViewport(srcX, srcRow, width, 1, SCREEN_W, SCREEN_H)
love.graphics.draw(canvas, self.blitQuad, destX, destRow)
end
function BattleAnimView:blitRow(row, dx, dy)
self:blitRowAt(row, row + dy, dx)
end
-- Draw the battle panel into the blit canvas, optionally with an rBGP byte
@@ -207,7 +211,10 @@ function BattleAnimView:bake(drawBg, palByte)
end
local previousCanvas = G.getCanvas()
local previousBgp = GbcPalette.setBgp(palByte)
local sx, sy, sw, sh
if G.getScissor then sx, sy, sw, sh = G.getScissor() end
G.setCanvas(self.canvas)
G.setScissor()
G.clear(0, 0, 0, 0)
-- A love canvas does NOT reset the transform: without this the panel is
-- drawn at whatever scale and offset the caller was already under, and then
@@ -217,6 +224,7 @@ function BattleAnimView:bake(drawBg, palByte)
local ok, err = pcall(drawBg)
G.pop()
G.setCanvas(previousCanvas)
if sx then G.setScissor(sx, sy, sw, sh) end
GbcPalette.setBgp(previousBgp)
if not ok then error(err, 0) end
end
@@ -286,6 +294,26 @@ function BattleAnimView:panelPalettes(battle)
return list
end
-- engine/battle_anims/bg_effects.asm:2638
function BattleAnimView.scanlines(bg)
local lines = {}
local scy = signed(bg.scy)
for row = 0, SCREEN_H - 1 do
local dx, src = 0, row + scy
local inWindow = bg.lcdc and bg.lcdc ~= "BGP"
and row >= bg.lyStart and row < bg.lyEnd
local byte = bg.lyBackup[row] or 0
if inWindow then
local value = signed(byte)
if bg.lcdc == "SCX" then dx = -value else src = src + value end
end
if (not inWindow or byte ~= 0x90) and src >= 0 and src < SCREEN_H then
lines[#lines + 1] = { src = src, dest = row, dx = dx }
end
end
return lines
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)
@@ -332,20 +360,9 @@ function BattleAnimView:present(runner, drawBg, battle)
G.setColor(1, 1, 1, 1)
-- hSCX / hSCY move the whole background; the per-scanline overrides only
-- apply inside the effect's own window.
local baseX, baseY = -signed(bg.scx), -signed(bg.scy)
for row = 0, SCREEN_H - 1 do
local dx, dy = baseX, baseY
local inWindow = bg.lcdc and row >= bg.lyStart and row < bg.lyEnd
if inWindow and bg.lcdc ~= "BGP" then
local value = signed(bg.lyBackup[row] or 0)
if bg.lcdc == "SCX" then dx = -value else dy = -value end
end
-- A row scrolled to $90 is showing a blank part of the map: skip it, which
-- is what makes Withdraw and Dig look like the mon sinking out of sight.
if (bg.lyBackup[row] or 0) ~= 0x90 or not bg.lcdc or bg.lcdc == "BGP"
or not inWindow then
self:blitRow(row, dx, dy)
end
local baseX = -signed(bg.scx)
for _, line in ipairs(BattleAnimView.scanlines(bg)) do
self:blitRowAt(line.src, line.dest, baseX + line.dx)
end
if remapped then GbcPalette.clear() end
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to
+104 -11
View File
@@ -206,7 +206,33 @@ local function paginate(text)
return pages
end
function BattleState:wantsFillScale() return true end
function BattleState.fillScale(winW, winH)
local w, h = winW or 0, winH or 0
local ok, Playfield = pcall(require, "src.render.Playfield")
if ok and Playfield.rect then
local okv, _, _, pw, ph = pcall(Playfield.rect, winW, winH)
if okv and pw and pw >= 1 and ph and ph >= 1 then
w, h = pw, ph
end
end
return math.max(1, math.min(w / (Chrome.SCREEN_W * 8),
h / (Chrome.SCREEN_H * 8)))
end
function BattleState.panelScale(winW, winH, fill)
if not fill then return Chrome.fitScale(winW, winH) end
return BattleState.fillScale(winW, winH)
end
function BattleState:wantsFillScale()
local options = self.game and self.game.options
return (options and options.battleFit) == "fill"
end
function BattleState:battlePanelScale(winW, winH)
return BattleState.panelScale(winW, winH, self:wantsFillScale())
end
function BattleState:drawsWidescreen() return true end
-- BATTLE BG (#1709): WHITE is the cart's paper surround, BLACK plain bars.
@@ -251,6 +277,7 @@ function BattleState.new(game, opts)
self.pokemon = data.pokemon
self.battle = opts.battle
self.onDone = opts.onDone
self.link = opts.link
-- What PlayVictoryMusic needs to know about the opponent (the class the
-- trainer belongs to); nil for a wild battle.
self.music = opts.music
@@ -462,6 +489,15 @@ function BattleState.new(game, opts)
self.shownLevel = (player and player.level) or 1
self.shownExp = player
and self:expPixels(player, player.level, player.experience) or 0
local enemy = self.battle and self.battle.enemy
Runtime.emit("battle.started", {
battle = self,
kind = (self.battle and self.battle.wild and "wild")
or (self.link and "link") or "trainer",
trainerId = self.battle and self.battle.trainer and self.battle.trainer.id,
species = enemy and enemy.species,
level = enemy and enemy.level,
})
return self
end
@@ -1952,6 +1988,9 @@ function BattleState:finishBattle()
-- The party tables it wrote them on are the save's own, so this has to run
-- before the overworld (and the next save write) sees them again.
if self.battle then self.battle:clearAllVolatiles() end
Runtime.emit("battle.ended", {
battle = self, result = self.battle and self.battle.outcome,
})
if self.onDone then
self.onDone(self.battle and self.battle.outcome, self.battle)
end
@@ -2004,6 +2043,9 @@ function BattleState:playVictoryMusic()
end
function BattleState:submit(action)
if self.link and self.link.submit then
return self.link.submit(self, action)
end
self.phase = "resolving"
self:pushAll(self.battle:takeTurn(action))
self.message = nil
@@ -2018,6 +2060,9 @@ end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if self.link and self.link.menuChoice and self.link.menuChoice(self, choice) then
return true
end
if choice == "fight" then
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
local fighter = self.battle and self.battle.player
@@ -2415,6 +2460,9 @@ function BattleState:update(_dt)
end
if self.phase == "forced-switch" then
if self.link and self.link.forcedPrompt and self.link.forcedPrompt(self) then
return
end
-- Reuse the party list so the layout and controls match the start menu's.
self:openParty(true)
return
@@ -2454,6 +2502,18 @@ function BattleState:update(_dt)
return
end
if self.phase == "refuse-menu" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self.message = nil
self.phase = "menu"
return
end
if self.phase == "learn-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
@@ -2541,6 +2601,8 @@ function BattleState:openParty(forced)
prompt = forced and "which" or "choose",
battle = true,
battleSubmenu = not forced,
party = (self.battle and self.battle.party) or nil,
save = self.save,
onCancel = function()
stack:pop()
-- A forced switch cannot be cancelled.
@@ -2577,6 +2639,9 @@ function BattleState:openParty(forced)
-- ForcePickPartyMonInBattle loops on carry: a pick the engine will not
-- take has to come back as the list again, never as the battle menu
-- with a fainted mon standing on the field.
if self.link and self.link.forcedSwitch then
return self.link.forcedSwitch(self, index)
end
if not self.battle:switch(index) then
return self:refuseSwitch(true)
end
@@ -2607,6 +2672,12 @@ function BattleState:refuseMove(text)
self.messageTimer = MESSAGE_FRAMES
end
function BattleState:refuseMenu(text)
self.phase = "refuse-menu"
self.message = text
self.messageTimer = MESSAGE_FRAMES
end
-- BattleMenu_Pack: `farcall BattlePack`, which is a different jumptable from
-- the field PACK's -- it dispatches on the item's BATTLE menu nibble and never
-- reaches a field effect. The empty world is the same guard MartMenu:enterSell
@@ -2828,6 +2899,26 @@ function BattleState:hasPokedex()
or save.pokedexReceived == true
end
-- caught_data.asm:168-199
function BattleState:stampCaughtData(mon, bugContest)
local save = self.save
local world = self.game and self.game.world
local map = world and world.map
local battle = self.battle
Catching.stampCaughtData(mon, {
version = save and save.version,
save = save,
data = self.game and self.game.data,
bugContest = bugContest,
timeOfDay = (battle and battle.timeOfDay)
or (world and world.timeOfDayId and world:timeOfDayId()),
map = map and map.def,
backupMap = world and world.backupMapId and world.maps
and world.maps[world.backupMapId],
playerGender = save and save.player and save.player.gender,
})
end
-- PokeBallEffect's caught tail, in the cart's order (item_effects.asm:514-676):
-- Text_GotchaMonWasCaught, CheckCaughtMon / SetSeenAndCaughtMon, the new-entry
-- line and NewPokedexEntry, the party add or .SendToPC, then
@@ -2879,6 +2970,8 @@ function BattleState:pushCaught(enemy, itemId)
return self:pushPayDay()
end
save.party = save.party or {}
-- item_effects.asm:556-558, :612-614
self:stampCaughtData(enemy)
local toPc = #save.party >= Boxes.PARTY_SIZE
if toPc then
-- `.SendToPC` / `predef SendMonIntoBox` (item_effects.asm:548-550, 604):
@@ -2956,6 +3049,7 @@ end
-- (engine/battle/core.asm:3269-3295, engine/menus/options_menu.asm:249-256).
function BattleState:shiftOfferAllowed()
local battle = self.battle
if self.link then return false end
if not (battle and battle.player and battle.trainer) then return false end
if #(battle.party or {}) < 2 then return false end
if (battle.player.hp or 0) <= 0 then return false end
@@ -3156,6 +3250,8 @@ end
-- already in stock the player is shown the comparison and asked, and the NO arm
-- -- which is also what B does -- keeps the mon they already had.
function BattleState:contestCatch(mon)
-- engine/pokemon/caught_data.asm:72-81
self:stampCaughtData(mon, true)
local kind, stock, fresh = BugContest.catch(self.save, mon)
if kind ~= BugContest.ASK_SWITCH then
self:push({ kind = "message", text = "Caught " .. self:name(mon) .. "!" })
@@ -3902,7 +3998,10 @@ function BattleState:drawLiftedRows()
self.liftCanvas:setFilter("nearest", "nearest")
end
local previous = G.getCanvas()
local sx, sy, sw, sh
if G.getScissor then sx, sy, sw, sh = G.getScissor() end
G.setCanvas(self.liftCanvas)
G.setScissor()
G.clear(0, 0, 0, 0)
G.push()
G.origin()
@@ -3912,6 +4011,7 @@ function BattleState:drawLiftedRows()
self.liftedPass = nil
G.pop()
G.setCanvas(previous)
if sx then G.setScissor(sx, sy, sw, sh) end
G.setColor(1, 1, 1, 1)
G.draw(self.liftCanvas, 0, 0)
end
@@ -3945,19 +4045,12 @@ function BattleState:drawSceneBody()
end
function BattleState:draw()
self:drawScene()
Chrome.withClip(function() self:drawScene() end)
end
function BattleState:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 1, 1, 1)
local scale = Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push()
G.translate(ox, oy)
G.scale(scale, scale)
self:drawScene()
G.pop()
Chrome.withPanel(winW, winH, 1, 1, 1, function() self:drawScene() end,
self:battlePanelScale(winW, winH))
end
BattleState.MENU = MENU
+28
View File
@@ -103,6 +103,34 @@ function Chrome.positionLift(winW, winH, scale)
or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop())
end
-- pokegold engine/battle/core.asm:8646, engine/events/halloffame.asm:270
local function clipTo(x, y, w, h)
local G = love.graphics
if G.intersectScissor then G.intersectScissor(x, y, w, h)
else G.setScissor(x, y, w, h) end
end
function Chrome.withPanel(winW, winH, r, g, b, drawFn, scale)
local G = love.graphics
Chrome.letterbox(winW, winH, r, g, b)
scale = scale or Chrome.fitScale(winW, winH)
local ox, oy = Chrome.fitOrigin(winW, winH, scale)
G.push("all")
clipTo(ox, oy, Chrome.SCREEN_W * 8 * scale, Chrome.SCREEN_H * 8 * scale)
G.translate(ox, oy)
G.scale(scale, scale)
drawFn()
G.pop()
end
function Chrome.withClip(drawFn)
local G = love.graphics
G.push("all")
clipTo(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
drawFn()
G.pop()
end
Chrome.DEFAULT_BOX_PALETTE = {
{ 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 },
}
+2 -9
View File
@@ -836,18 +836,11 @@ function Credits:drawPanel()
end
function Credits:draw()
self:drawPanel()
Chrome.withClip(function() self:drawPanel() end)
end
function Credits:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 0, 0, 0)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
G.scale(scale, scale)
self:drawPanel()
G.pop()
Chrome.withPanel(winW, winH, 0, 0, 0, function() self:drawPanel() end)
end
Credits.TILES_W = TILES_W
+2 -9
View File
@@ -663,18 +663,11 @@ function HallOfFame:drawPanel()
end
function HallOfFame:draw()
self:drawPanel()
Chrome.withClip(function() self:drawPanel() end)
end
function HallOfFame:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH, 0, 0, 0)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
G.scale(scale, scale)
self:drawPanel()
G.pop()
Chrome.withPanel(winW, winH, 0, 0, 0, function() self:drawPanel() end)
end
HallOfFame.WHITE = WHITE
+4 -1
View File
@@ -336,6 +336,9 @@ local ROWS = {
text = function(options)
return require("src.core.FrameCap").label(options.fpsCap)
end },
{ label = Strings.source("BATTLE SIZE"), key = "battleFit", port = true,
values = { "fixed", "fill" },
display = { fixed = "FIXED", fill = "FILL " } },
-- BATTLE BG (#1709): the void around the battle screen. Gold has no WIDE
-- layout and no WORLD backdrop, so the ladder is the WHITE/BLACK pair only.
{ label = Strings.source("BATTLE BG"), key = "battleBg", port = true,
@@ -364,7 +367,7 @@ local GROUPS = {
{ id = "group.audio", label = Strings.source("AUDIO"),
members = { "sound", "musicVol", "sfxVol", "musicFilter" } },
{ id = "group.battle", label = Strings.source("BATTLE OPTIONS"),
members = { "battleScene", "battleStyle", "battleBg" } },
members = { "battleScene", "battleStyle", "battleFit", "battleBg" } },
{ id = "group.extras", label = Strings.source("EXTRAS"),
members = { "zoom", "voidFill", "tilt" } },
}
+50 -12
View File
@@ -15,11 +15,15 @@ local Chrome = require("src.ui.gen2.Chrome")
local Gen2Save = require("src.core.gen2.Save")
local PackGfx = require("src.ui.gen2.PackGfx")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local MenuRepeat = require("src.ui.MenuRepeat")
-- constants/sfx_constants.asm:3, :28
local SFX_DEX_FANFARE_50_79, SFX_WRONG = 0, 25
local LIST_DIRS = { "up", "down" }
local PackMenu = {}
PackMenu.__index = PackMenu
PackMenu.isOpaque = true
@@ -161,6 +165,8 @@ function PackMenu.new(game, opts)
self.give = opts.give and true or false
self.battle = opts.battle and true or false
self.cursorStore = cursorStore(game)
-- engine/menus/scrolling_menu.asm:6
self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE)
self.pocketIndex = 1
-- wLastPocket, unless the caller names one: DepositSellInitPackBuffers writes
-- ITEM_POCKET over it, so an explicit pocket still wins.
@@ -301,6 +307,13 @@ function PackMenu:isCancel()
return self.index > #self.rows
end
-- home/menu.asm:746, :758
function PackMenu:playSfx(name)
local data = self.game and self.game.data
local sfx = data and data.audio and data.audio.sfx
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
end
function PackMenu:ensureVisible()
if self.index <= self.scroll then
self.scroll = self.index - 1
@@ -319,6 +332,8 @@ function PackMenu:switchPocket(delta)
self:restoreCursor()
self:rebuild()
self:storeCursor()
-- engine/items/pack.asm:1268
self:playSfx("Sfx_SwitchPockets")
end
-- The player name OakThisIsntTheTimeText addresses, same fallback the SAVE
@@ -759,25 +774,26 @@ function PackMenu:update(_dt)
self:updateSubmenu(input)
return
end
local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS)
if input:wasPressed("left") then
self:switchPocket(-1)
return
elseif input:wasPressed("right") then
self:switchPocket(1)
return
elseif input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or self:total()
self:ensureVisible()
elseif dir == "up" then
self:stepCursor(-1, edge)
return
elseif input:wasPressed("down") then
self.index = self.index < self:total() and self.index + 1 or 1
self:ensureVisible()
elseif dir == "down" then
self:stepCursor(1, edge)
return
elseif input:wasPressed("b") then
self:playSfx("Sfx_ReadText2")
self:storeCursor()
if self.onClose then self.onClose() end
return
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
if self:isCancel() then
self:storeCursor()
if self.onClose then self.onClose() end
@@ -795,6 +811,19 @@ function PackMenu:update(_dt)
end
end
-- engine/menus/scrolling_menu.asm
function PackMenu:stepCursor(delta, edge)
local total = self:total()
local next = self.index + delta
if next < 1 then
next = edge and total or 1
elseif next > total then
next = edge and 1 or total
end
self.index = next
self:ensureVisible()
end
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT.
function PackMenu:armSwitch()
@@ -808,12 +837,11 @@ end
-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs
-- out, and left/right cannot leave the pocket mid-move.
function PackMenu:updateSwitch(input)
if input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or self:total()
self:ensureVisible()
elseif input:wasPressed("down") then
self.index = self.index < self:total() and self.index + 1 or 1
self:ensureVisible()
local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS)
if dir == "up" then
self:stepCursor(-1, edge)
elseif dir == "down" then
self:stepCursor(1, edge)
elseif input:wasPressed("a") or input:wasPressed("select") then
self:placeSwitch()
elseif input:wasPressed("b") then
@@ -830,6 +858,8 @@ function PackMenu:placeSwitch()
self:rebuild()
self:storeCursor()
end
-- engine/items/pack.asm:1309
self:playSfx("Sfx_SwitchPokemon")
self:endSwitch()
end
@@ -848,8 +878,10 @@ function PackMenu:updateSubmenu(input)
elseif input:wasPressed("down") then
menu.index = menu.index < total and menu.index + 1 or 1
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
self:chooseSubmenu()
elseif input:wasPressed("b") then
self:playSfx("Sfx_ReadText2")
self:closeSubmenu()
end
end
@@ -867,9 +899,11 @@ function PackMenu:updateQuantity(input)
elseif input:wasPressed("left") then
state.qty = qtyStep(state.qty, state.max, -10)
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
self.message = nil
self:confirmToss()
elseif input:wasPressed("b") then
self:playSfx("Sfx_ReadText2")
self.qtyState = nil
self.message = nil
end
@@ -881,9 +915,11 @@ function PackMenu:updateConfirm(input)
if input:wasPressed("up") or input:wasPressed("down") then
confirm.choice = confirm.choice == 1 and 2 or 1
elseif input:wasPressed("b") then
self:playSfx("Sfx_ReadText2")
self.confirm = nil
if confirm.onNo then confirm.onNo() end
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
local yes = confirm.choice == 1
self.confirm = nil
if yes then
@@ -908,6 +944,8 @@ function PackMenu:registerSelected()
local world = not self:inBattle() and self.world or nil
local ok = world and world.registerItem and world:registerItem(row.id)
if ok then
-- engine/items/pack.asm:551
self:playSfx("Sfx_FullHeal")
-- RegisteredItemText: "Registered the\n<item>."
self.message = { Strings("Registered the"), row.name .. "." }
else
+92 -62
View File
@@ -32,12 +32,14 @@
local Assets = require("src.render.Assets")
local Chrome = require("src.ui.gen2.Chrome")
local GbcPalette = require("src.render.GbcPalette")
local HallOfFame = require("src.core.gen2.HallOfFame")
local Palettes = require("src.world.gen2.Palettes")
local TileSheet = require("src.ui.gen2.TileSheet")
local Nests = require("src.core.gen2.Nests")
local Sound = require("src.core.Sound")
local Unown = require("src.core.gen2.Unown")
local Strings = require("src.core.Strings")
local MenuRepeat = require("src.ui.MenuRepeat")
-- `db $3b, " OPTION ", $3c` / `db $3b, " SEARCH ", $3c"`: the panel titles
-- drawn by drawOption/drawSearch below, declared here (rather than inline)
@@ -45,6 +47,8 @@ local Strings = require("src.core.Strings")
local OPTION_LABEL = Strings.source(" OPTION ")
local SEARCH_LABEL = Strings.source(" SEARCH ")
local LIST_DIRS = { "up", "down" }
local PokedexMenu = {}
PokedexMenu.__index = PokedexMenu
PokedexMenu.isOpaque = true
@@ -164,6 +168,8 @@ function PokedexMenu.new(game, opts)
end
self.index = 1
self.scroll = 0
-- engine/pokedex/pokedex.asm:36-39
self.hold = MenuRepeat.new(MenuRepeat.GEN2_DELAY, MenuRepeat.GEN2_RATE)
self.view = "list" -- list | entry | area | option | search | results | unown
self.page = 1
self.entryAction = 1
@@ -384,6 +390,7 @@ function PokedexMenu:update(_dt)
if self.view == "option" then return self:updateOption(input) end
if self.view == "search" then return self:updateSearch(input) end
if self.view == "unown" then return self:updateUnown(input) end
local dir, edge = MenuRepeat.direction(self.hold, input, LIST_DIRS)
if input:wasPressed("b") then
self:close()
return
@@ -399,12 +406,21 @@ function PokedexMenu:update(_dt)
self.searchType = self.searchType or { 1, 0 }
self.searchResults = nil
return
elseif input:wasPressed("up") then
self.index = self.index > 1 and self.index - 1 or #self.rows
elseif dir == "up" then
-- pokedex.asm:982-1011
if self.index > 1 then
self.index = self.index - 1
elseif edge then
self.index = #self.rows
end
self:ensureVisible()
return
elseif input:wasPressed("down") then
self.index = self.index < #self.rows and self.index + 1 or 1
elseif dir == "down" then
if self.index < #self.rows then
self.index = self.index + 1
elseif edge then
self.index = 1
end
self:ensureVisible()
return
elseif input:wasPressed("a") then
@@ -807,22 +823,9 @@ end
-- ------------------------------------------------------------------- AREA
--
-- Pokedex_GetArea (engine/pokegear/pokegear.asm) borrows the Pokegear's town
-- map and overlays FindNest's landmarks. The region shown starts as the one the
-- player is standing in; LEFT/RIGHT swap it, which is how you see a Kanto mon's
-- nests from Johto. B goes back to the entry.
-- engine/pokegear/pokegear.asm:2285, :2322
function PokedexMenu:areaRegionName()
if self.areaRegion then return self.areaRegion end
local landmark = self:playerLandmark()
return Nests.regionOf(landmark) or "johto"
end
-- The player's landmark index, which is what decides the starting region.
function PokedexMenu:playerLandmark()
local save = self.game and self.game.save
local mapId = save and save.position and save.position.map
local def = mapId and self.data and self.data.gen2Maps and self.data.gen2Maps[mapId]
return def and def.landmark
return self.areaRegion or "johto"
end
-- The Pokegear's own tilemap blit: a flat list of tile ids, row-major over the
@@ -849,11 +852,70 @@ function PokedexMenu:updateArea(input)
self.view = "entry"
return
end
if input:wasPressed("left") or input:wasPressed("right") then
self.areaRegion = (self:areaRegionName() == "johto") and "kanto" or "johto"
if input:wasPressed("left") then
self.areaRegion = "johto"
elseif input:wasPressed("right") then
-- pokegear.asm:2373
if HallOfFame.hasEntered(self.game and self.game.save) then
self.areaRegion = "kanto"
end
end
end
-- engine/pokegear/pokegear.asm:2451
function PokedexMenu:nestIconColors()
local set = self.palettes and Palettes.objectSet(self.palettes, "DAY")
return (set and set[1])
or (self.mapGfx and self.mapGfx.palettes and self.mapGfx.palettes[1])
end
-- engine/pokegear/pokegear.asm:2298
function PokedexMenu:drawNestIcon(x, y)
if self.nestIcon == nil then
self.nestIcon = false
local path = self.mapGfx and self.mapGfx.nestIcon
if path then
local ok, image = pcall(Assets.image, path)
if ok and image then self.nestIcon = image end
end
end
local G = love.graphics
G.setColor(1, 1, 1, 1)
if not self.nestIcon then
local ink = self:nestIconColors()
local dark = ink and GbcPalette.color(ink, 4) or { 0, 0, 0 }
G.setColor(dark[1] / 255, dark[2] / 255, dark[3] / 255, 1)
G.rectangle("fill", x + 1, y + 1, 6, 6)
G.setColor(1, 1, 1, 1)
return
end
local function body() G.draw(self.nestIcon, x, y) end
local colors = self:nestIconColors()
if colors and GbcPalette.available() then
GbcPalette.withRaw(colors, body)
else
body()
end
end
-- engine/pokegear/pokegear.asm:2403
function PokedexMenu:drawAreaHeader(title)
local pals = self.mapGfx and self.mapGfx.palettes
local pal = pals and pals[1]
local paper = pal and GbcPalette.color(pal, 1) or { 255, 255, 255 }
local G = love.graphics
G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1)
G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, 8)
G.setColor(1, 1, 1, 1)
local sheet = self.mapSheet
if sheet then
sheet:draw(0x06, 0, 1)
for x = 1, Chrome.SCREEN_W - 2 do sheet:draw(0x07, x, 1) end
sheet:draw(0x17, Chrome.SCREEN_W - 1, 1)
end
Chrome.printThrough(title, 2, 0, pal, false, true)
end
function PokedexMenu:drawArea()
local row = self:current()
if not row then return end
@@ -861,54 +923,22 @@ function PokedexMenu:drawArea()
local save = self.game and self.game.save
local nests = Nests.find(self.data, row.species, region, save)
self:fill(TILE_BG, 0, 0, Chrome.SCREEN_W + 1, Chrome.SCREEN_H)
-- The map itself is the Pokegear's, drawn through the same gfx the MAP card
-- uses. Without it (a cache imported before the town map was extracted) the
-- page still lists the landmark NAMES, which is the information the screen
-- exists to convey.
local maps = self.mapGfx and self.mapGfx.maps
local cells = maps and maps[region]
if cells then
self:drawTilemap(cells)
end
self:blank(0, 0, Chrome.SCREEN_W, 2)
self:text(self:monName(row.species) .. "'S NEST", 1, 0)
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
self:drawAreaHeader(self:monName(row.species) .. "'S NEST")
local G = love.graphics
if #nests == 0 then
-- A species with no grass, water or roamer entry in this region. The cart
-- simply shows the map with nothing blinking on it.
self:text("AREA UNKNOWN", 4, 16)
return
end
-- 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 = 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)
G.setColor(1, 1, 1, 1)
G.rectangle("fill", mark.x - 1, mark.y - 1, 3, 3)
end
end
end
-- 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 = Nests.landmark(self.data, nests[1])
if first and first.name then
local name = tostring(first.name):gsub("\n", " ")
self:text(name, 1, 16)
if #nests > 1 then
self:text(("+%d"):format(#nests - 1), 17, 16)
-- engine/pokegear/pokegear.asm:2385
local on = ((self.areaBlink or 0) % 32) < 16
if not on then return end
for _, index in ipairs(nests) do
local mark = Nests.landmark(self.data, index)
if mark and mark.x and mark.y then
-- engine/pokegear/pokegear.asm:2444
self:drawNestIcon(mark.x - 4, mark.y - 4)
end
end
end
+50 -18
View File
@@ -22,6 +22,7 @@
local Chrome = require("src.ui.gen2.Chrome")
local FieldMoves = require("src.world.gen2.FieldMoves")
local FlagNames = require("src.core.gen2.FlagNames")
local GbcPalette = require("src.render.GbcPalette")
local Gen2Save = require("src.core.gen2.Save")
local Clock = require("src.core.gen2.Clock")
@@ -1004,6 +1005,11 @@ function Pokegear:text(str, tx, ty)
return Chrome.printThrough(str, tx, ty, pals and pals[1], false, true)
end
function Pokegear:cursor(tx, ty)
local pals = self:pals()
return Chrome.cursorThrough(tx, ty, pals and pals[1], false, false, true)
end
-- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are
-- POKEGEAR_RADIO/MAP/PHONE/EXPN_CARD_F (pokegold data/events/engine_flags.asm,
-- constants/engine_flags.asm const order), so the scripts' `setflag` -- the
@@ -1014,6 +1020,14 @@ end
-- a world.
local CARD_ENGINE_FLAGS = { radio = 0, map = 1, phone = 2, expn = 3 }
-- ../pokecrystal/constants/engine_flags.asm:25
function Pokegear:engineFlag(name, goldId)
local world = self.game and self.game.world
local id = goldId
if world and world.engineFlagId then id = world:engineFlagId(name, goldId) end
return ((self.save or {}).engineFlags or {})[id] == true
end
function Pokegear:flags()
local save = self.save or {}
local flags = {}
@@ -1164,7 +1178,18 @@ function Pokegear:update(_dt)
self.phoneSubmenu = nil
return
end
-- engine/pokegear/pokegear.asm:454
if card and card.id == "clock" then
if input:wasPressed("right") then self:switchCard("map", "phone", "radio") end
return
end
if card and card.id == "radio" then
-- engine/pokegear/pokegear.asm:740
if input:wasPressed("left") then
self:stopRadio()
self:switchCard("phone", "map", "clock")
return
end
self:ensureTuned()
-- AnimateTuningKnob.TuningKnob: up winds the knob towards 80 and down
-- back towards 0, and it stops dead at either end rather than wrapping.
@@ -1220,7 +1245,8 @@ function Pokegear:radioContext()
-- POKEGEAR_EXPN_CARD_F, the Kanto radio upgrade.
expnCard = flags.expn or false,
-- STATUSFLAGS_ROCKET_SIGNAL_F, set while Team Rocket holds Mahogany.
rocketSignal = (save.flags or {}).ROCKET_SIGNAL or false,
rocketSignal = self:engineFlag("ENGINE_ROCKET_SIGNAL_ON_CH20",
FlagNames.engine.ENGINE_ROCKET_SIGNAL_ON_CH20),
}
end
@@ -1386,7 +1412,10 @@ function Pokegear:radioData()
-- Number Man in Radio Tower has never rolled one, so 00000 is the honest
-- reading, not a stand-in for unfinished work.
out.luckyNumber = save.luckyNumber or 0
out.rocketsInRadioTower = (save.flags or {}).ROCKETS_IN_RADIO_TOWER or false
-- ../pokegold/engine/events/std_scripts.asm:255
out.rocketsInRadioTower =
self:engineFlag("ENGINE_ROCKETS_IN_RADIO_TOWER",
FlagNames.engine.ENGINE_ROCKETS_IN_RADIO_TOWER)
self.radioDataCache = out
return out
end
@@ -1949,8 +1978,8 @@ function Pokegear:drawClock()
local hour, minute, weekday = self:clockParts()
self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.clock)
self:drawStrip()
self:text("SWITCH", 13, 1)
Chrome.cursor(19, 1)
self:text(" SWITCH", 12, 1)
self:cursor(19, 1)
-- Pokegear_UpdateClock: ClearBox(3,5) 5x14, the day at (6,6) and
-- PrintHoursMins at (6,8) -- two digits, ':', two more, then AM/PM at
@@ -1981,7 +2010,7 @@ end
function Pokegear:printBoxText(text)
local lines = Chrome.wrap(text, 18)
for i = 1, math.min(#lines, 2) do
Chrome.print(lines[i], 1, 14 + (i - 1) * 2)
self:text(lines[i], 1, 14 + (i - 1) * 2)
end
end
@@ -2056,17 +2085,19 @@ end
-- TownMapBubble: the plate the fly screen wears instead of the card strip.
-- Three rows from (1,0) to (18,2), "Where?" at (2,0), the flypoint's landmark
-- name at (2,1) and the up/down scroller at (18,1). The four rounded corners
-- come from FlyMapLabelBorderGFX, a six-tile 1bpp set loaded over vTiles2 tile
-- $30 for this screen only -- the extractor carries the town map's own $30-$33
-- instead, so the plate is drawn square rather than with the wrong art in its
-- corners.
-- name at (2,1) and the up/down scroller at (18,1).
function Pokegear:drawFlyBubble()
self:drawPlate(1, 0, 18, 3)
self:tile(0x30, 1, 0)
for x = 2, 17 do self:tile(SPACE_TILE, x, 0) end
self:tile(0x31, 18, 0)
for x = 1, 18 do self:tile(SPACE_TILE, x, 1) end
self:tile(0x32, 1, 2)
for x = 2, 17 do self:tile(SPACE_TILE, x, 2) end
self:tile(0x33, 18, 2)
self:text("Where?", 2, 0)
local row = self:flyRow()
self:text(flatName(row and row.name), 2, 1)
Chrome.cursor(18, 1)
self:tile(0x34, 18, 1)
end
-- _TownMap.InitTilemap (../pokecrystal/engine/pokegear/pokegear.asm:1891-1918):
@@ -2273,8 +2304,8 @@ function Pokegear:drawRadio()
self:textbox(0, 12, 18, 4)
local radio = self.radio
if not (station and station.station and radio) then return end
if radio.top ~= "" then Chrome.print(radio.top, 1, 14) end
if radio.bottom ~= "" then Chrome.print(radio.bottom, 1, 16) end
if radio.top ~= "" then self:text(radio.top, 1, 14) end
if radio.bottom ~= "" then self:text(radio.bottom, 1, 16) end
end
function Pokegear:drawPhone()
@@ -2294,8 +2325,9 @@ function Pokegear:drawPhone()
-- A call in progress replaces the prompt with what the caller is saying;
-- otherwise the box holds PokegearAskWhoCallText the whole time.
if self.call then
Chrome.printWrapped(self.call.text or self:phoneText("GearEllipse"),
1, 14, 18, 3)
local lines = Chrome.wrap(self.call.text
or self:phoneText("GearEllipse"), 18)
for i = 1, math.min(#lines, 3) do self:text(lines[i], 1, 13 + i) end
else
self:printBoxText(self:phoneText("AskWhoCall"))
end
@@ -2312,7 +2344,7 @@ function Pokegear:drawPhone()
if className then self:text(className, 5, ty + 1) end
end
-- PokegearPhone_UpdateCursor draws the cursor at (1, 4 + 2 * cursor).
Chrome.cursor(1, 4 + self.phoneCursor * 2)
self:cursor(1, 4 + self.phoneCursor * 2)
self:drawPhoneSubmenu()
end
@@ -2328,7 +2360,7 @@ function Pokegear:drawPhoneSubmenu()
local ty = menu.textY + (index - 1) * 2
self:text(label, menu.textX, ty)
end
Chrome.cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2)
self:cursor(menu.textX - 1, menu.textY + self.phoneSubmenuCursor * 2)
end
-- ------------------------------------------------------------------ fallback

Some files were not shown because too many files have changed in this diff Show More