mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-07 09:27:38 -04:00
Merge pull request #1904 from bryanthaboi/dev
big freaky style with a twist of skrrrrrrrrrrrt
This commit is contained in:
@@ -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
@@ -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`)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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/
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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` /
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
@@ -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),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user