mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-14 01:11:07 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c40dcd6159 | |||
| 35f5c9513c | |||
| 5a0f9ab06d | |||
| 13b58dc51e | |||
| d872010209 | |||
| 6306975352 | |||
| 819150f50f |
@@ -33,8 +33,32 @@ audio channel programs copied out of the verified ROM.
|
||||
|
||||
## Controls
|
||||
|
||||
arrow keys or WASD move; Z, Enter, or Space is A; X or Backspace is
|
||||
B; Escape opens START. F1 saves and F2 loads. Controllers are supported.
|
||||
| Action | Keyboard | Controller |
|
||||
|--------|----------|------------|
|
||||
| Move | Arrow keys / WASD | D-pad / left stick |
|
||||
| A | Z / Enter / Space | A |
|
||||
| B | X / Backspace | B |
|
||||
| Start | Escape | Start |
|
||||
| Select | Tab / Shift | Back / Select |
|
||||
|
||||
Rebind any of these in-game under **OPTIONS → CONTROLS**. Controllers are
|
||||
supported out of the box.
|
||||
|
||||
### Hotkeys
|
||||
|
||||
| Key | What it does |
|
||||
|-----|----------------|
|
||||
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
|
||||
| `2` | Cycle COLORS |
|
||||
| `3` | Cycle TILT (free-roam overworld) |
|
||||
| `4` | Cycle ZOOM through every level (free-roam overworld) |
|
||||
| `5` | Cycle GBC FX |
|
||||
| `F1` | Save |
|
||||
| `F2` | Load |
|
||||
| `F10` | Open / close the mod manager |
|
||||
|
||||
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
|
||||
and persist in `options.lua`.
|
||||
|
||||
## Running From Source
|
||||
|
||||
|
||||
@@ -61,6 +61,50 @@ M.ROUTE_12_SUPER_ROD_HOUSE = {
|
||||
M.ROUTE_12_SUPER_ROD_HOUSE.talk.TEXT_ROUTE12SUPERRODHOUSE_FISHING_GURU[9] =
|
||||
{ "show_text", "_Route12SuperRodHouseFishingGuruTryFishingText" }
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- Pokemon Tower 5F purified zone (scripts/PokemonTower5F.asm
|
||||
-- PokemonTower5FDefaultScript): the 2x2 center pad heals the party once
|
||||
-- per visit. EVENT_IN_PURIFIED_ZONE latches until the player steps off;
|
||||
-- while on the pad the map script also sets BIT_NO_BATTLES (we return
|
||||
-- true from onStep so wild encounters are skipped the same way).
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local TOWER_5F_PURIFIED = {
|
||||
[10 * 256 + 8] = true, [11 * 256 + 8] = true,
|
||||
[10 * 256 + 9] = true, [11 * 256 + 9] = true,
|
||||
}
|
||||
|
||||
-- HealParty -> GBFadeOutToWhite -> Delay3 -> Delay3 -> GBFadeInFromWhite
|
||||
-- -> TEXT_POKEMONTOWER5F_PURIFIEDZONE (no Music_PkmnHealed).
|
||||
local TOWER_5F_HEAL = {
|
||||
{ "heal_party" },
|
||||
{ "fade", "out", "white" },
|
||||
{ "wait", 3 },
|
||||
{ "wait", 3 },
|
||||
{ "fade", "in", "white" },
|
||||
{ "show_text", "_PokemonTower5FPurifiedZoneText" },
|
||||
}
|
||||
|
||||
M.POKEMON_TOWER_5F = {
|
||||
onStep = function(game, ow, x, y)
|
||||
if not TOWER_5F_PURIFIED[x * 256 + y] then
|
||||
game.save.flags.EVENT_IN_PURIFIED_ZONE = nil
|
||||
return false
|
||||
end
|
||||
if game.save.flags.EVENT_IN_PURIFIED_ZONE then
|
||||
return true
|
||||
end
|
||||
if ow.runner and ow.runner:isRunning() then return false end
|
||||
game.save.flags.EVENT_IN_PURIFIED_ZONE = true
|
||||
if ow.runner then
|
||||
ow.runner:run(TOWER_5F_HEAL)
|
||||
elseif ow.queueScript then
|
||||
ow:queueScript(TOWER_5F_HEAL)
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
-- The ghost Marowak (scripts/PokemonTower6F.asm): blocks the stairs at
|
||||
-- (10,16) until defeated.
|
||||
|
||||
+38
-11
@@ -8,27 +8,42 @@ behavior is in docs/behavior-porting-notes.md.
|
||||
|
||||
## Survey zoom
|
||||
|
||||
The mouse wheel (or `-`/`=`) zooms the overworld between 1 pixel per world
|
||||
pixel (full survey) and 2× the window fit scale (close-up), in crisp
|
||||
integer steps. This has no Game Boy equivalent:
|
||||
The mouse wheel (or `-`/`=`), the Options **ZOOM** row, or hotkey `4`
|
||||
zooms the overworld between 1 pixel per world pixel (full survey) and 2×
|
||||
the window fit scale (close-up), in crisp integer steps. This has no Game
|
||||
Boy equivalent:
|
||||
|
||||
- Connected maps render their full bodies, and their NPCs appear as
|
||||
visual-only "ghosts", they wander but have no sight lines, triggers,
|
||||
dialogue, or collision until the map is actually entered.
|
||||
- Menus, text boxes, and battles draw at normal scale on top of the
|
||||
zoomed world. Zoom input is ignored while a script, menu, or battle is
|
||||
active; the zoom level persists across warps and is never saved.
|
||||
- Beyond the border ring the border block repeats indefinitely (interiors
|
||||
stay black, seaside towns stay water, except OVERWORLD-tileset maps,
|
||||
whose beyond-edge space fills with the solid tree wall instead of the
|
||||
per-map border block), and each visible map area is colorized with its
|
||||
own SGB palette (the original recolored the whole screen per map).
|
||||
active; the zoom offset is persisted as `save.options.zoom` (default
|
||||
`0` = FIT) and survives New Game via `options.lua`.
|
||||
- Hotkey `4` ticks through every integer zoom level (survey → FIT →
|
||||
close-up → wrap). The Options row shows `FIT` / `OUTn` / `INn`.
|
||||
- Beyond the border ring the void fill repeats indefinitely (see VOID
|
||||
FILL below); interiors keep their own border block. Each visible map
|
||||
area is colorized with its own SGB palette (the original recolored the
|
||||
whole screen per map).
|
||||
- Neighbor maps load two connection hops out so corner-adjacent maps
|
||||
don't pop in and out, and ghost NPCs share instances with the real ones
|
||||
so their wander positions persist across seamless connection crossings
|
||||
(a warp or fresh map entry still respawns everything at its script
|
||||
position, like the original's per-entry sprite init).
|
||||
|
||||
## VOID FILL
|
||||
|
||||
The Options **VOID FILL** row picks what paints the infinite beyond-edge
|
||||
space on OVERWORLD-tileset maps during survey zoom:
|
||||
|
||||
- **TREES** (default): solid tree wall block `$0F`.
|
||||
- **WATER**: animated water tile `$14` (same hshift cycle as on-map water).
|
||||
- **BLACK**: solid black.
|
||||
|
||||
Other tilesets are unchanged (house/cave borders stay as authored).
|
||||
Persisted as `save.options.voidFill`.
|
||||
|
||||
## Tilt mode
|
||||
|
||||
The `3` key (and the Options menu TILT row) cycles a visual-only perspective
|
||||
@@ -139,7 +154,7 @@ Nidorino-vs-Gengar attract scene) mirror the original.
|
||||
Options persist in a standalone `options.lua` (separate from the game
|
||||
progress `save.lua`), so audio/display/battle preferences survive New Game
|
||||
and aren't wiped when a save slot is cleared. Changing a row in the Options
|
||||
menu or cycling hotkeys `2`/`3`/`5` writes immediately; an in-game save also
|
||||
menu or cycling hotkeys `2`/`3`/`4`/`5` writes immediately; an in-game save also
|
||||
flushes the live options. Old saves that still embed an `options` table are
|
||||
migrated once into `options.lua` on load.
|
||||
|
||||
@@ -150,6 +165,18 @@ migrated once into `options.lua` on load.
|
||||
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
|
||||
SuperPalettes + per-species mon colors)
|
||||
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
|
||||
- ZOOM (FIT / OUTn / INn), also hotkey `4` while free-roaming; wheel and
|
||||
`-`/`=` step one level and save
|
||||
- VOID FILL (TREES / WATER / BLACK) for OVERWORLD beyond-edge space
|
||||
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
|
||||
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
a hard render frame-rate cap (`save.options.fpsCap`).
|
||||
|
||||
## Battle transition cascade + white battle letterbox
|
||||
|
||||
Into-battle wipes still run the original eight styles inside the classic
|
||||
160×144 letterbox. On wide/tall windows (survey zoom), matching black 8×8
|
||||
blocks cascade outward from that square into the surrounding world so the
|
||||
void outside the OG wipe fills in lockstep. Once the battle state is up,
|
||||
letterbox voids around the battle canvas fill **white** instead of black
|
||||
so the whole window reads as one continuous battle screen.
|
||||
@@ -30,6 +30,9 @@ local TypeChart = require("src.battle.TypeChart")
|
||||
local BattleState = {}
|
||||
BattleState.__index = BattleState
|
||||
BattleState.isOpaque = true
|
||||
-- Letterbox voids around the 160x144 battle canvas fill white so the
|
||||
-- window reads as one continuous battle screen (no black bars).
|
||||
BattleState.letterboxWhite = true
|
||||
|
||||
-- Battle colors itself per-pixel (species pics + HP bar tints), so the
|
||||
-- SGB whole-screen remap must not run over it.
|
||||
@@ -2390,10 +2393,17 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
-- PP: not for continuations, struggle, called moves, or (under
|
||||
-- gen1_faithful) wild/trainer enemies — pokered DecrementPP only ever
|
||||
-- mutates wBattleMonPP / party PP (engine/battle/decrement_pp.asm).
|
||||
-- That rule doesn't apply in a link battle: "the enemy" there is a real
|
||||
-- human peer independently tracking their own PP the normal way, not an
|
||||
-- AI Gen 1 never bothered to decrement -- applying it made each side
|
||||
-- silently skip decrementing the OTHER side's PP for the move it just
|
||||
-- used, so both simulations' party state diverged by exactly 1 PP on
|
||||
-- the very first move either side made, failing the lockstep hash
|
||||
-- check turn 1 of literally every link battle.
|
||||
local isContinuation = releasing
|
||||
or (user.thrashTurns and user.thrashTurns > 0 and moveInst == user.thrashMove)
|
||||
or moveInst == user.rageMove
|
||||
local enemyUnlimited = not user.isPlayer
|
||||
local enemyUnlimited = not user.isPlayer and self.kind ~= "link"
|
||||
and self.ruleset and self.ruleset.enemyUnlimitedPP
|
||||
if not isContinuation and not moveInst.struggle and not isCalled
|
||||
and not enemyUnlimited then
|
||||
|
||||
@@ -176,25 +176,51 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
battle.lastDamage = dmg -- wDamage (shared by both sides, read by Counter)
|
||||
|
||||
-- the hit blink + damage sound ride the queue behind the animation:
|
||||
-- on the move's anim row when one was announced, else on a bare hit
|
||||
-- row (thrash/rage continuations), placed BEFORE the drain rows the
|
||||
-- hits loop inserts so the blink precedes the bar drain
|
||||
local hitRow = battle.moveAnimRow
|
||||
if not hitRow then
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { hitRow = true }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
-- the hit blink + damage sound ride each animation row, placed BEFORE
|
||||
-- that hit's drain so the blink precedes the bar. Multi-hit moves
|
||||
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
|
||||
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
|
||||
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
|
||||
-- Thrash/rage continuations have no announcement anim -- a bare
|
||||
-- hitRow carries the blink instead.
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
local hitFx = { sfx = hitSfx,
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hitRow
|
||||
if h == 1 then
|
||||
hitRow = battle.moveAnimRow
|
||||
if not hitRow then
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { hitRow = true }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
else
|
||||
battle.nextInsert = (battle.nextInsert or 0) + 1
|
||||
hitRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
||||
table.insert(battle.queue, battle.nextInsert, hitRow)
|
||||
end
|
||||
local hadSub = target.substituteHP ~= nil
|
||||
local dealt = battle:applyDamage(target, dmg)
|
||||
totalDealt = totalDealt + dealt
|
||||
landed = h
|
||||
if dealt > 0 then hitRow.hit = hitFx end
|
||||
-- PrintCriticalOHKOText + DisplayEffectiveness run inside the
|
||||
-- multi-hit loop (core.asm .moveDidNotMiss before the jump back
|
||||
-- to GetPlayerAnimationType), so crit/effectiveness reprint on
|
||||
-- every strike -- damage was only rolled once
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if Runtime.wants("battle.damage_dealt") then
|
||||
Runtime.emit("battle.damage_dealt", {
|
||||
battle = battle, user = user, target = target, move = move,
|
||||
@@ -208,23 +234,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
end
|
||||
hits = landed > 0 and landed or hits
|
||||
if totalDealt > 0 then
|
||||
-- the original's per-hit sound: normal / super / not-very-effective
|
||||
local hitSfx = info.typeMult > 10 and "Super_Effective"
|
||||
or info.typeMult < 10 and "Not_Very_Effective" or "Damage"
|
||||
hitRow.hit = { sfx = hitSfx,
|
||||
blink = battle:animationsOn() and target or nil }
|
||||
end
|
||||
-- PrintCriticalOHKOText prints "Critical hit!"/"One-hit KO!" right
|
||||
-- after the damage lands, BEFORE DisplayEffectiveness (core.asm
|
||||
-- .moveDidNotMiss); the multi-hit count follows the last hit
|
||||
if info.crit then battle:sayNext("Critical hit!") end
|
||||
if info.ohko then battle:sayNext("One-hit KO!") end
|
||||
if info.typeMult > 10 then
|
||||
battle:sayNext("It's super\neffective!")
|
||||
elseif info.typeMult < 10 then
|
||||
battle:sayNext("It's not very\neffective...")
|
||||
end
|
||||
if hits > 1 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
if user.isPlayer then
|
||||
|
||||
+21
-2
@@ -246,7 +246,11 @@ end
|
||||
function Game:zoomStep(delta)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
if not Zoom.gateOK(self.stack:top(), self.overworld) then return end
|
||||
Zoom.step(delta, Renderer:fitScale())
|
||||
local offset = Zoom.step(delta, Renderer:fitScale())
|
||||
if self.save and self.save.options then
|
||||
self.save.options.zoom = offset
|
||||
self:writeOptions()
|
||||
end
|
||||
end
|
||||
|
||||
function Game:wheelmoved(_, dy)
|
||||
@@ -320,9 +324,19 @@ function Game:keypressed(key)
|
||||
self:writeOptions()
|
||||
end
|
||||
return
|
||||
elseif key == "4" then
|
||||
-- cycle ZOOM through every integer level (survey → FIT → close-up → wrap)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
if Zoom.gateOK(self.stack:top(), self.overworld) then
|
||||
self.save.options.zoom = Zoom.cycle(Renderer:fitScale())
|
||||
self:writeOptions()
|
||||
end
|
||||
return
|
||||
elseif key == "5" then
|
||||
-- cycle GBC FX OFF → 1 → 2 → 3 → 4 (unlit-GBC ladder); always on
|
||||
-- desktop. Mobile refuses the present shader (issue #136).
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if not GBCFX.isSupported() then return end
|
||||
self.save.options.gbcfx = GBCFX.cycle()
|
||||
self:writeOptions()
|
||||
return
|
||||
@@ -443,12 +457,17 @@ function Game:applyOptions(opts)
|
||||
if Sound.applyOptions then Sound.applyOptions(opts) end
|
||||
require("src.render.PaletteFX").applyOptions(opts)
|
||||
require("src.render.Tilt").applyOptions(opts)
|
||||
require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.render.Zoom").applyOptions(opts)
|
||||
require("src.render.TileRenderer").applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
Input:applyBindings(opts.bindings)
|
||||
-- heal soft-bricked APK installs that already saved gbcfx > 0 (#136)
|
||||
if gbcCleared then self:writeOptions() end
|
||||
end
|
||||
|
||||
function Game:restoreSave(loaded, recovered)
|
||||
|
||||
@@ -11,7 +11,12 @@ local DEFAULT_BINDINGS = {
|
||||
z = "a", ["return"] = "a", space = "a",
|
||||
x = "b", backspace = "b",
|
||||
["kpenter"] = "start", escape = "start",
|
||||
-- Select: fight-menu move reorder + bag item reorder. Tab is the
|
||||
-- discoverable default (shown in CONTROLS); both shifts stay as
|
||||
-- aliases so Right-Shift muscle memory from older builds still works.
|
||||
tab = "select",
|
||||
rshift = "select",
|
||||
lshift = "select",
|
||||
}
|
||||
|
||||
-- keys that map to "start" but also to "a" would conflict; keep Enter = a,
|
||||
|
||||
@@ -186,10 +186,14 @@ function SaveData.defaultOptions()
|
||||
musicFilter = 0,
|
||||
-- logic fast-forward multiplier; audio is unaffected (GameSpeed.lua)
|
||||
speed = 1,
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/5)
|
||||
-- port display options (OptionsMenu / hotkeys 2/3/4/5)
|
||||
colors = "gbc",
|
||||
tilt = 0,
|
||||
gbcfx = 0,
|
||||
-- survey zoom offset from window fit scale (0 = FIT); see Zoom.lua
|
||||
zoom = 0,
|
||||
-- OVERWORLD beyond-edge fill: trees | water | black
|
||||
voidFill = "trees",
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
|
||||
@@ -48,7 +48,7 @@ local KEY = {
|
||||
a = "z",
|
||||
b = "x",
|
||||
start = "escape",
|
||||
select = "rshift",
|
||||
select = "tab",
|
||||
}
|
||||
|
||||
local function nowMs()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
-- Shared 6-slot room-code entry widget: the digit-scrub interaction
|
||||
-- LinkState's own `ipDigits`/`addrPos` already uses for IP 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).
|
||||
|
||||
local CodeEntry = {}
|
||||
|
||||
CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
CodeEntry.LENGTH = 6
|
||||
|
||||
function CodeEntry.new()
|
||||
local chars = {}
|
||||
for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based
|
||||
return { chars = chars, pos = 1 }
|
||||
end
|
||||
|
||||
local N = #CodeEntry.CHARSET
|
||||
|
||||
function CodeEntry.up(state)
|
||||
state.chars[state.pos] = state.chars[state.pos] % N + 1
|
||||
end
|
||||
|
||||
function CodeEntry.down(state)
|
||||
state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1
|
||||
end
|
||||
|
||||
function CodeEntry.left(state)
|
||||
state.pos = math.max(1, state.pos - 1)
|
||||
end
|
||||
|
||||
function CodeEntry.right(state)
|
||||
state.pos = math.min(CodeEntry.LENGTH, state.pos + 1)
|
||||
end
|
||||
|
||||
function CodeEntry.text(state)
|
||||
local out = {}
|
||||
for i = 1, CodeEntry.LENGTH do
|
||||
out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i])
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
return CodeEntry
|
||||
@@ -67,6 +67,17 @@ function Handshake.linkModified(game)
|
||||
return false
|
||||
end
|
||||
|
||||
-- online play (the relay-based online match / tournament flows in
|
||||
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
|
||||
-- skips the LAN path's per-peer compatibility negotiation entirely and
|
||||
-- just requires vanilla on both ends: no mod-added Pokemon, no surprises.
|
||||
-- Mods only ever get baked in at boot (Loader:load), so this is a gate on
|
||||
-- attempting to go online, not a live mod toggle -- the player disables
|
||||
-- mods via the mod manager and relaunches.
|
||||
function Handshake.onlineAllowed(game)
|
||||
return #Handshake.mods(game) == 0
|
||||
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)
|
||||
|
||||
+337
-29
@@ -15,6 +15,7 @@
|
||||
-- them in link battles).
|
||||
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
@@ -45,6 +46,38 @@ local function mkBattler(data, mon, isPlayer)
|
||||
return BattleState.makeBattler(data, mon, isPlayer, nil)
|
||||
end
|
||||
|
||||
-- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator
|
||||
-- (hostParty/guestParty): pack->unpack clamp so every perspective watching
|
||||
-- a given match holds identical mon copies. errFmt(packedMon, why) builds
|
||||
-- the message shown when strict mode refuses an unrebuildable mon.
|
||||
local function unpackParty(game, packed, unpackOpts, errFmt)
|
||||
local out = {}
|
||||
for _, p in ipairs(packed or {}) do
|
||||
local mon, why = Protocol.unpackMon(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
|
||||
|
||||
-- decode a wire action message against whichever battler it belongs to
|
||||
-- (the opponent's, from a real participant's perspective; either side's,
|
||||
-- from a spectator's)
|
||||
local function decodeWireAction(s, msg, battler)
|
||||
if msg.kind == "move" then
|
||||
local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1)))
|
||||
return battler.curMoves[slot]
|
||||
elseif msg.kind == "struggle" then
|
||||
return { id = "STRUGGLE", pp = 1, struggle = true }
|
||||
elseif msg.kind == "locked" then
|
||||
return s:lockedAction(battler)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- canonical (host-side-first) state hash, unchanged since v1: it stays on
|
||||
-- the wire as `value` so a pre-mod peer still compares something it agrees
|
||||
-- with, while the components below carry the real coverage
|
||||
@@ -150,25 +183,14 @@ function LinkBattle.new(game, net, opts)
|
||||
-- both parties pass through the same pack->unpack clamp on both
|
||||
-- machines, so the copies are identical everywhere
|
||||
local unpackOpts = { strict = opts.strict or false }
|
||||
local myParty, theirParty = {}, {}
|
||||
for _, p in ipairs(opts.myParty or {}) do
|
||||
local mon = Protocol.unpackMon(game.data, p, unpackOpts)
|
||||
if mon then
|
||||
table.insert(myParty, mon)
|
||||
elseif unpackOpts.strict then
|
||||
return nil, ("Your %s can't\nbattle on the\nother game."):format(
|
||||
tostring(p.species))
|
||||
end
|
||||
end
|
||||
for _, p in ipairs(opts.theirParty or {}) do
|
||||
local mon, why = Protocol.unpackMon(game.data, p, unpackOpts)
|
||||
if mon then
|
||||
table.insert(theirParty, mon)
|
||||
elseif unpackOpts.strict then
|
||||
return nil, ("Their %s isn't\nin this game.\n(%s)"):format(
|
||||
tostring(p.species), tostring(why))
|
||||
end
|
||||
end
|
||||
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
|
||||
return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species))
|
||||
end)
|
||||
if not myParty then return nil, myErr end
|
||||
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why)
|
||||
return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not theirParty then return nil, theirErr end
|
||||
if #myParty == 0 or #theirParty == 0 then
|
||||
Logger.warn("link: empty party on one side")
|
||||
end
|
||||
@@ -227,6 +249,17 @@ function LinkBattle.new(game, net, opts)
|
||||
if text then s:say(text) end
|
||||
end
|
||||
|
||||
-- a tournament shot clock (opts.turnLimit) costs the slow player
|
||||
-- specifically, unlike RUN or a desync -- both of which stay a draw --
|
||||
-- so it needs its own result rather than reusing endAsDraw
|
||||
local function endWithResult(s, result, text)
|
||||
if s.linkEnded then return end
|
||||
s.result = result
|
||||
s.afterQueue = "finish"
|
||||
s.phase = "messages"
|
||||
if text then s:say(text) end
|
||||
end
|
||||
|
||||
local function orderMove(action)
|
||||
if action and action.id then return game.data.moves[action.id] end
|
||||
return nil
|
||||
@@ -271,15 +304,7 @@ function LinkBattle.new(game, net, opts)
|
||||
|
||||
-- decode a remote action message against the enemy battler
|
||||
local function decodeTheirAction(s, msg)
|
||||
if msg.kind == "move" then
|
||||
local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1)))
|
||||
return s.enemy.curMoves[slot]
|
||||
elseif msg.kind == "struggle" then
|
||||
return { id = "STRUGGLE", pp = 1, struggle = true }
|
||||
elseif msg.kind == "locked" then
|
||||
return s:lockedAction(s.enemy)
|
||||
end
|
||||
return nil
|
||||
return decodeWireAction(s, msg, s.enemy)
|
||||
end
|
||||
|
||||
-- with the handshake guaranteeing both games share a link surface, a
|
||||
@@ -520,6 +545,19 @@ function LinkBattle.new(game, net, opts)
|
||||
if not s.result then
|
||||
endAsDraw(s, ("%s left the\nbattle."):format(theirName))
|
||||
end
|
||||
elseif msg.type == "forfeit" then
|
||||
-- the peer's own shot clock ran out; unlike a mutual RUN/desync
|
||||
-- draw, this has a definite winner (us)
|
||||
if not s.result then
|
||||
endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName))
|
||||
end
|
||||
else
|
||||
-- a tournament control message (bracket_update, the next
|
||||
-- match_start, ...) can arrive while this match is still
|
||||
-- finishing up; Tournament.lua drains this once it regains the
|
||||
-- stack top rather than losing it to this poll loop
|
||||
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
|
||||
table.insert(s.pendingTournamentMessages, msg)
|
||||
end
|
||||
end
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
@@ -535,16 +573,48 @@ function LinkBattle.new(game, net, opts)
|
||||
end
|
||||
return
|
||||
end
|
||||
if opts.turnLimit and s.phase == "menu" then
|
||||
if not s.turnClockActive then
|
||||
s.turnClockActive = true
|
||||
s.turnClock = opts.turnLimit
|
||||
end
|
||||
s.turnClock = s.turnClock - dt
|
||||
if s.turnClock <= 0 then
|
||||
s.turnClockActive = false
|
||||
send({ type = "forfeit" })
|
||||
endWithResult(s, "lose", "Time's up! You\nforfeit the match.")
|
||||
end
|
||||
elseif opts.turnLimit then
|
||||
s.turnClockActive = false
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
end
|
||||
|
||||
if opts.turnLimit then
|
||||
local baseDraw = self.draw
|
||||
self.draw = function(s, ...)
|
||||
baseDraw(s, ...)
|
||||
if s.phase == "menu" and s.turnClockActive then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.draw(tostring(math.max(0, math.ceil(s.turnClock))), 144, 4)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local baseFinish = self.finish
|
||||
self.finish = function(s)
|
||||
if not s.linkEnded then
|
||||
s.linkEnded = true
|
||||
send({ type = "bye" })
|
||||
end
|
||||
net:close()
|
||||
-- opts.keepNetOpen: a tournament match's `net` is the caller's
|
||||
-- long-lived tournament connection (still needed for the next round,
|
||||
-- spectating, bracket updates, ...), not a dedicated match socket --
|
||||
-- closing it here the way a plain 1v1 link battle does would sever
|
||||
-- the whole tournament, not just this match.
|
||||
if not opts.keepNetOpen then
|
||||
net:close()
|
||||
end
|
||||
if game.linkNet == net then game.linkNet = nil end
|
||||
baseFinish(s)
|
||||
end
|
||||
@@ -552,6 +622,244 @@ function LinkBattle.new(game, net, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
-- A tournament spectator: reconstructs the exact same lockstep battle a
|
||||
-- live match's two real participants are playing, from a copy of the
|
||||
-- traffic the relay fans out to onlookers (see pokeserver's `spectate`
|
||||
-- envelope). No local input drives anything here -- both sides' actions
|
||||
-- arrive over the wire, tagged by which real player sent them -- so it's
|
||||
-- a read-only replay, not a third participant: no hash/desync checking
|
||||
-- (a spectator has nothing to verify against), no shot clock (nothing to
|
||||
-- act on), and `finish` must NOT close `net`, since that's the caller's
|
||||
-- long-lived tournament connection, not a dedicated match socket.
|
||||
--
|
||||
-- opts: { hostParty = packed, guestParty = packed, hostName, guestName,
|
||||
-- seed, verdict, strict }. `self.player` is always the host's battler and
|
||||
-- `self.enemy` the guest's, which is what makes TurnOrder.firstMover's
|
||||
-- tie-break (below) land on the same result the host's own instance
|
||||
-- already computed with invertTie=false.
|
||||
function LinkBattle.newSpectator(game, net, opts)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local hostName = opts.hostName or "HOST"
|
||||
local guestName = opts.guestName or "GUEST"
|
||||
|
||||
if not Handshake.battleAllowed(opts.verdict) then
|
||||
return nil, "Link battle needs\nthe same mods on\nboth games."
|
||||
end
|
||||
|
||||
local unpackOpts = { strict = opts.strict or false }
|
||||
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p)
|
||||
return ("%s's %s can't\nbattle on this\ngame."):format(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 ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format(
|
||||
guestName, tostring(p.species), tostring(why))
|
||||
end)
|
||||
if not guestParty then return nil, guestErr end
|
||||
if #hostParty == 0 or #guestParty == 0 then
|
||||
Logger.warn("link: empty party on one side (spectator)")
|
||||
end
|
||||
|
||||
local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species
|
||||
or "RATTATA", 5)
|
||||
self.kind = "link" -- exact same visual treatment as a real link battle
|
||||
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.player = mkBattler(game.data, hostParty[1], true)
|
||||
self.enemy = mkBattler(game.data, guestParty[1], false)
|
||||
self.enemyParty = guestParty
|
||||
self.playerParty = hostParty
|
||||
self.opponentName = guestName
|
||||
self.introText = ("%s vs %s!"):format(hostName, guestName)
|
||||
|
||||
local function orderMove(action)
|
||||
if action and action.id then return game.data.moves[action.id] end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function endSpectate(s, text)
|
||||
if s.linkEnded then return end
|
||||
s.result = s.result or "ended"
|
||||
s.afterQueue = "finish"
|
||||
s.phase = "messages"
|
||||
if text then s:say(text) end
|
||||
end
|
||||
|
||||
local function sendOutHost(s, mon)
|
||||
local previous = s.player
|
||||
s.player = mkBattler(game.data, mon, true)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[1], battler = s.player, previous = previous,
|
||||
})
|
||||
s.sendingOut = true
|
||||
s:sayNext(s:sendOutText(s.player.name))
|
||||
s:animNext("POOF_ANIM", false)
|
||||
s:actNext(function()
|
||||
s.sendingOut = false
|
||||
s:startGrowIn(s.player)
|
||||
require("src.core.Sound").playCry(s.data, s.player.mon.species)
|
||||
end)
|
||||
end
|
||||
|
||||
local function sendOutGuest(s, mon)
|
||||
local previous = s.enemy
|
||||
s.enemy = mkBattler(game.data, mon, false)
|
||||
s:syncSides()
|
||||
Runtime.emit("battle.battler_switched", {
|
||||
battle = s, side = s.sides[2], battler = s.enemy, previous = previous,
|
||||
})
|
||||
s.enemySendingOut = true
|
||||
s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name))
|
||||
s:actNext(function()
|
||||
s.enemySendingOut = false
|
||||
s:startGrowIn(s.enemy)
|
||||
s:actNext(function()
|
||||
require("src.core.Sound").playCry(s.data, s.enemy.mon.species)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function resolveSpecTurn(s, hostMsg, guestMsg)
|
||||
if hostMsg.kind == "run" or guestMsg.kind == "run" then
|
||||
endSpectate(s, "The match ended.")
|
||||
return
|
||||
end
|
||||
s.phase = "messages"
|
||||
s.afterQueue = "linkNext"
|
||||
s.turnCount = (s.turnCount or 0) + 1
|
||||
|
||||
if hostMsg.kind == "switch" then
|
||||
local idx = hostMsg.index
|
||||
s:act(function() sendOutHost(s, hostParty[idx]) end)
|
||||
end
|
||||
if guestMsg.kind == "switch" then
|
||||
local idx = guestMsg.index
|
||||
s:act(function() sendOutGuest(s, guestParty[idx]) end)
|
||||
end
|
||||
|
||||
s:act(function()
|
||||
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
|
||||
and decodeWireAction(s, hostMsg, s.player) or nil
|
||||
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
|
||||
and decodeWireAction(s, guestMsg, s.enemy) or nil
|
||||
Runtime.emit("battle.turn_started", {
|
||||
battle = s, turn = s.turnCount,
|
||||
playerAction = hostAction, enemyAction = guestAction,
|
||||
})
|
||||
if hostAction and guestAction then
|
||||
local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction)
|
||||
local first
|
||||
if Runtime.wantsHook("battle.turn_order") then
|
||||
first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c)
|
||||
return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie)
|
||||
end, s.player, hostMove, s.enemy, guestMove, { rng = s.rng, invertTie = false })
|
||||
else
|
||||
first = TurnOrder.firstMover(s.player, hostMove, s.enemy, guestMove, s.rng, false)
|
||||
end
|
||||
local order
|
||||
if first then
|
||||
order = { { s.player, s.enemy, hostAction }, { s.enemy, s.player, guestAction } }
|
||||
else
|
||||
order = { { s.enemy, s.player, guestAction }, { s.player, s.enemy, hostAction } }
|
||||
end
|
||||
for _, entry in ipairs(order) do
|
||||
s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end)
|
||||
end
|
||||
elseif hostAction then
|
||||
s:act(function() s:executeAction(s.player, s.enemy, hostAction) end)
|
||||
elseif guestAction then
|
||||
s:act(function() s:executeAction(s.enemy, s.player, guestAction) end)
|
||||
end
|
||||
s:act(function() s:endOfTurn() end)
|
||||
end)
|
||||
end
|
||||
|
||||
self.resolveTurn = function() end -- a spectator's own input never drives anything
|
||||
self.resolveSwitch = function() end
|
||||
self.tryRun = function() end
|
||||
self.openParty = function(s) s.phase = "waitBoth" end
|
||||
|
||||
self.playerMonFainted = function(s)
|
||||
for _, mon in ipairs(hostParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutHost(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName))
|
||||
s.result = "guestWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
|
||||
self.enemyMonFainted = function(s)
|
||||
for _, mon in ipairs(guestParty) do
|
||||
if mon.hp > 0 then
|
||||
s:act(function() sendOutGuest(s, mon) end)
|
||||
return
|
||||
end
|
||||
end
|
||||
s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName))
|
||||
s.result = "hostWin"
|
||||
s.afterQueue = "finish"
|
||||
end
|
||||
|
||||
self.hostMsg, self.guestMsg = nil, nil
|
||||
local baseUpdate = self.update
|
||||
self.update = function(s, dt)
|
||||
net:update()
|
||||
for _, msg in ipairs(net:poll()) do
|
||||
if msg.type == "spectate" then
|
||||
local inner = msg.msg
|
||||
if inner.type == "action" then
|
||||
if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end
|
||||
if s.hostMsg and s.guestMsg then
|
||||
local h, g = s.hostMsg, s.guestMsg
|
||||
s.hostMsg, s.guestMsg = nil, nil
|
||||
resolveSpecTurn(s, h, g)
|
||||
end
|
||||
elseif inner.type == "bye" or inner.type == "forfeit" then
|
||||
if not s.result then endSpectate(s, "The match ended.") end
|
||||
end
|
||||
-- "hello"/"party"/"hash" ride along too (Tournament.lua already
|
||||
-- consumed hello/party before building this battle); none of them
|
||||
-- need any action here
|
||||
else
|
||||
-- same reasoning as the real-participant loop above: don't lose a
|
||||
-- bracket_update/match_start_spectate that arrives mid-match
|
||||
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
|
||||
table.insert(s.pendingTournamentMessages, msg)
|
||||
end
|
||||
end
|
||||
if net.closed and not s.linkEnded and not s.result then
|
||||
endSpectate(s)
|
||||
end
|
||||
if s.phase == "messages" and s.afterQueue == "linkNext" then
|
||||
if not s:updateQueue() then
|
||||
s.afterQueue = "waitBoth"
|
||||
s.phase = "waitBoth"
|
||||
end
|
||||
return
|
||||
end
|
||||
if s.phase == "menu" or s.phase == "waitBoth" then
|
||||
return -- frozen between resolved turns; never a real decision here
|
||||
end
|
||||
baseUpdate(s, dt)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- backwards-compatible entry points (LinkState passes role explicitly)
|
||||
function LinkBattle.newHost(game, net, opts)
|
||||
opts.role = "host"
|
||||
|
||||
+134
-5
@@ -2,6 +2,7 @@
|
||||
-- the other joins by typing that address in. Direct peer-to-peer over
|
||||
-- lua-enet (bundled with LÖVE), no relay server.
|
||||
|
||||
local CodeEntry = require("src.link.CodeEntry")
|
||||
local Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local Net = require("src.link.Net")
|
||||
@@ -16,6 +17,12 @@ LinkState.isOpaque = true
|
||||
|
||||
local CURSOR = 0xED
|
||||
|
||||
-- stages before any Net object is meaningfully "this session's link" --
|
||||
-- .net can still be a leftover failed attempt sitting on self, so error/
|
||||
-- closed checks below skip these rather than keying off self.net's
|
||||
-- presence alone
|
||||
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = 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
|
||||
@@ -119,25 +126,54 @@ function LinkState:update(dt)
|
||||
local input = self.game.input
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" then
|
||||
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
return
|
||||
end
|
||||
-- the peer vanished without a bye (only once the inbox is drained,
|
||||
-- so a final message travelling with the disconnect still counts)
|
||||
if self.net.closed and #self.net.inbox == 0
|
||||
and self.stage ~= "menu" and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "notice" and self.stage ~= "battleRunning" then
|
||||
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
|
||||
and self.stage ~= "codeEntry" and self.stage ~= "notice"
|
||||
and self.stage ~= "battleRunning" then
|
||||
self:exitWith("The link was\nbroken.")
|
||||
return
|
||||
end
|
||||
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:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.")
|
||||
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:exitWith(nil)
|
||||
self.stage = "menu"
|
||||
self.index = 1
|
||||
elseif input:wasPressed("a") then
|
||||
self.net = Net.new()
|
||||
if self.index == 1 then
|
||||
@@ -151,6 +187,62 @@ 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
|
||||
self.net = Net.new()
|
||||
if self.net:hostOnline() then
|
||||
self.stage = "onlineHosting"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
end
|
||||
else
|
||||
self.stage = "codeEntry"
|
||||
self.codeEntry = CodeEntry.new()
|
||||
end
|
||||
end
|
||||
|
||||
elseif self.stage == "onlineHosting" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
if self.net.paired then
|
||||
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)
|
||||
self.net = Net.new()
|
||||
if self.net:joinOnline(nil, code) then
|
||||
self.stage = "onlineJoining"
|
||||
else
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
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
|
||||
@@ -389,12 +481,49 @@ end
|
||||
|
||||
function LinkState:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("LINK CABLE CLUB")
|
||||
drawTitle("BOIS CLUB LIVE")
|
||||
Font.draw("LINK CABLE (LAN)", 32, 44)
|
||||
Font.draw("ONLINE MATCH", 32, 60)
|
||||
Font.draw("TOURNAMENT", 32, 76)
|
||||
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
|
||||
|
||||
elseif self.stage == "lanMenu" then
|
||||
drawTitle("LINK CABLE (LAN)")
|
||||
Font.draw("HOST A GAME", 32, 48)
|
||||
Font.draw("JOIN A GAME", 32, 68)
|
||||
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
|
||||
Font.draw("UDP port " .. Net.defaultPort(), 8, 128)
|
||||
|
||||
elseif self.stage == "onlineMenu" then
|
||||
drawTitle("ONLINE MATCH")
|
||||
Font.draw("HOST ONLINE", 32, 48)
|
||||
Font.draw("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("Tell your friend", 16, 40)
|
||||
Font.draw("the code:", 16, 52)
|
||||
Font.draw(self.net.code or "??????", 32, 68)
|
||||
Font.draw("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("A: connect B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "onlineJoining" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw("Calling...", 8, 56)
|
||||
Font.draw(self.net.target or "", 8, 72)
|
||||
|
||||
elseif self.stage == "hosting" then
|
||||
drawTitle("HOSTING")
|
||||
Font.draw("Friend joins at:", 16, 48)
|
||||
|
||||
+170
-1
@@ -14,6 +14,15 @@
|
||||
-- Plain luajit (headless tests) has no enet; Net.available() reports
|
||||
-- that, and Net.loopbackPair() returns two in-memory ends with the
|
||||
-- same API so the protocol/battle logic stays testable offline.
|
||||
--
|
||||
-- A second backend, alongside the ENet one above, talks plain TCP to a
|
||||
-- pokeserver relay instead of direct peer-to-peer: both sides dial out
|
||||
-- to a public server (works through NAT with no hole-punching), the host
|
||||
-- gets a 6-character room code instead of an IP, and the server forwards
|
||||
-- messages between them. Same newline-delimited JSON on the wire, same
|
||||
-- Net API (host/join/send/update/poll/.paired/.closed/.error) -- see
|
||||
-- Net:hostOnline/Net:joinOnline below. LinkState/LinkBattle/Protocol don't
|
||||
-- know or care which backend is in play.
|
||||
|
||||
local Json = require("src.link.Json")
|
||||
local Logger = require("src.core.Logger")
|
||||
@@ -21,10 +30,14 @@ local Logger = require("src.core.Logger")
|
||||
local hasEnet, enet = pcall(require, "enet")
|
||||
if not hasEnet then enet = nil end
|
||||
|
||||
local hasSocket, socket = pcall(require, "socket")
|
||||
if not hasSocket then socket = nil end
|
||||
|
||||
local Net = {}
|
||||
Net.__index = Net
|
||||
|
||||
Net.DEFAULT_PORT = 7777
|
||||
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
||||
|
||||
function Net.available()
|
||||
return enet ~= nil
|
||||
@@ -34,6 +47,10 @@ function Net.defaultPort()
|
||||
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
|
||||
end
|
||||
|
||||
function Net.defaultRelayAddress()
|
||||
return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS
|
||||
end
|
||||
|
||||
-- monotonic-ish clock for the join timeout
|
||||
local function now()
|
||||
if love and love.timer and love.timer.getTime then
|
||||
@@ -64,13 +81,17 @@ function Net.new()
|
||||
enetHost = nil, -- our enet host object (both ends have one)
|
||||
peer = nil, -- the connected remote peer
|
||||
inbox = {},
|
||||
outbox = {}, -- messages queued before pairing completes
|
||||
outbox = {}, -- messages queued before pairing completes (enet only)
|
||||
paired = false,
|
||||
address = nil, -- host: "ip:port" the other player types in
|
||||
error = nil,
|
||||
closed = false,
|
||||
mode = nil,
|
||||
joinTimeout = 10,
|
||||
tcpSocket = nil, -- relay backend: the luasocket TCP connection
|
||||
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
|
||||
}, Net)
|
||||
end
|
||||
|
||||
@@ -133,8 +154,56 @@ 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
|
||||
-- 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.
|
||||
function Net:connectTCP(addr)
|
||||
if not socket then
|
||||
self.error = "online play needs luasocket (bundled with LOVE)"
|
||||
return false
|
||||
end
|
||||
local host, port = addr:match("^(.-):(%d+)$")
|
||||
host = host or addr
|
||||
port = tonumber(port) or 7778
|
||||
local tcp = socket.tcp()
|
||||
tcp:settimeout(5)
|
||||
local ok, err = tcp:connect(host, port)
|
||||
if not ok then
|
||||
self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err))
|
||||
return false
|
||||
end
|
||||
tcp:settimeout(0)
|
||||
self.tcpSocket = tcp
|
||||
self.rxBuf = ""
|
||||
self.txBuf = ""
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:hostOnline(addr)
|
||||
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
|
||||
self.mode = "onlineHosting"
|
||||
self:send({ type = "host" })
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:joinOnline(addr, code)
|
||||
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
|
||||
self.mode = "onlineJoining"
|
||||
self.target = code
|
||||
self:send({ type = "join", code = code })
|
||||
return true
|
||||
end
|
||||
|
||||
function Net:send(msg)
|
||||
if self.closed then return end
|
||||
if self.tcpSocket then
|
||||
self.txBuf = self.txBuf .. Json.encode(msg) .. "\n"
|
||||
return
|
||||
end
|
||||
if self.peerEnd then -- loopback: re-encode through json like the wire
|
||||
local decoded = Json.decode(Json.encode(msg))
|
||||
if decoded and not self.peerEnd.closed then
|
||||
@@ -155,8 +224,102 @@ function Net:send(msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- one control message recognized on every relay connection, regardless of
|
||||
-- what it's being used for (a 1v1 room or a tournament): "peer_gone" only
|
||||
-- ever fires for a paired 1v1 room (tournaments signal disconnects through
|
||||
-- bracket_update/tournament_over instead), so it's unambiguous here.
|
||||
local function handleGenericRelayControl(self, msg)
|
||||
if msg.type == "hosted" then
|
||||
self.code = msg.code
|
||||
return true
|
||||
elseif msg.type == "paired" then
|
||||
self.paired = true
|
||||
return true
|
||||
elseif msg.type == "join_error" then
|
||||
self.error = ({
|
||||
not_found = "That code wasn't\nfound.",
|
||||
full = "That game already\nhas two players.",
|
||||
expired = "That code has\nexpired.",
|
||||
})[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason))
|
||||
self.closed = true
|
||||
return true
|
||||
elseif msg.type == "peer_gone" then
|
||||
self.closed = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Net:handleTCPLine(line)
|
||||
local msg = Json.decode(line)
|
||||
if not msg then
|
||||
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
||||
return
|
||||
end
|
||||
if not handleGenericRelayControl(self, msg) then
|
||||
table.insert(self.inbox, msg)
|
||||
end
|
||||
end
|
||||
|
||||
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
|
||||
-- trailing partial line, if any, for the next call to complete) and hands
|
||||
-- each to handleTCPLine. Pure buffer manipulation, no socket -- factored
|
||||
-- out of updateTCP so the framing logic is testable without a real
|
||||
-- connection (see the relay-path tests in tests/run_link_tests.lua).
|
||||
function Net:drainLines()
|
||||
while true do
|
||||
local nl = self.rxBuf:find("\n", 1, true)
|
||||
if not nl then break end
|
||||
local line = self.rxBuf:sub(1, nl - 1)
|
||||
self.rxBuf = self.rxBuf:sub(nl + 1)
|
||||
if #line > 0 then self:handleTCPLine(line) end
|
||||
end
|
||||
end
|
||||
|
||||
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
||||
-- whatever's arrived into complete lines. Uses a byte-count receive
|
||||
-- (rather than the "*l" pattern) because luasocket's "*l" doesn't let a
|
||||
-- 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:updateTCP()
|
||||
if self.closed then return end
|
||||
local sock = self.tcpSocket
|
||||
if #self.txBuf > 0 then
|
||||
local sent, err, lastByte = sock:send(self.txBuf)
|
||||
if sent then
|
||||
self.txBuf = ""
|
||||
elseif err == "timeout" then
|
||||
self.txBuf = self.txBuf:sub((lastByte or 0) + 1)
|
||||
else
|
||||
self.error = "send failed: " .. tostring(err)
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
end
|
||||
while true do
|
||||
local data, err, partial = sock:receive(8192)
|
||||
local chunk = data or partial or ""
|
||||
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
|
||||
if err == "closed" then
|
||||
self.closed = true
|
||||
break
|
||||
elseif err and err ~= "timeout" then
|
||||
self.error = tostring(err)
|
||||
self.closed = true
|
||||
break
|
||||
end
|
||||
if not data then break end -- nothing more buffered this frame
|
||||
end
|
||||
self:drainLines()
|
||||
end
|
||||
|
||||
-- pump enet events; decoded JSON messages are queued for poll()
|
||||
function Net:update()
|
||||
if self.tcpSocket then
|
||||
self:updateTCP()
|
||||
return
|
||||
end
|
||||
if self.peerEnd then return end -- loopback needs no pumping
|
||||
if not self.enetHost or self.closed then return end
|
||||
while true do
|
||||
@@ -219,6 +382,12 @@ function Net:close()
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
if self.tcpSocket then
|
||||
pcall(function() self.tcpSocket:close() end)
|
||||
self.tcpSocket = nil
|
||||
self.closed = true
|
||||
return
|
||||
end
|
||||
if self.enetHost then
|
||||
if self.peer and self.paired and not self.closed then
|
||||
-- graceful goodbye: disconnect_later delivers the queued
|
||||
|
||||
@@ -0,0 +1,577 @@
|
||||
-- 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 Font = require("src.render.Font")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local LinkBattle = require("src.link.LinkBattle")
|
||||
local Net = require("src.link.Net")
|
||||
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 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 = 5 -- POKEMON, MIN LV, MAX LV, TIMER, PLAYING
|
||||
|
||||
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)
|
||||
return v == ANY and nil or 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,
|
||||
participating = true }
|
||||
self.settingsIndex = 1
|
||||
self.roster = {}
|
||||
self.spectatorRoster = {}
|
||||
Sound.startLoop(game.data, MUSIC)
|
||||
return self
|
||||
end
|
||||
|
||||
function Tournament:exitWith(message)
|
||||
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
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
function Tournament:startHosting()
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
return
|
||||
end
|
||||
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),
|
||||
participating = self.settings.participating,
|
||||
name = self.game.save.player.name,
|
||||
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
|
||||
})
|
||||
self.stage = "registering"
|
||||
end
|
||||
|
||||
function Tournament:startJoining(code)
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith("Link error:\n" .. (self.net.error or "?"))
|
||||
return
|
||||
end
|
||||
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 = "That code wasn't\nfound.",
|
||||
already_started = "That tournament\nhas already begun.",
|
||||
expired = "That code has\nexpired.",
|
||||
}
|
||||
|
||||
function Tournament:handleMessage(msg)
|
||||
if msg.type == "tournament_hosted" then
|
||||
self.code = msg.code
|
||||
self.settings.turnLimit = msg.turnLimit
|
||||
self.participating = msg.participating
|
||||
self.stage = "bracket"
|
||||
elseif msg.type == "tournament_host_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
else
|
||||
self:exitWith("Couldn't host\nthat tournament.")
|
||||
end
|
||||
elseif msg.type == "tournament_join_error" then
|
||||
if msg.reason == "party_ineligible" then
|
||||
self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format(
|
||||
msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
|
||||
else
|
||||
self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "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.stage = "bracket"
|
||||
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 msgs = self.net:poll()
|
||||
local keep, got = {}, false
|
||||
for _, msg in ipairs(msgs) do
|
||||
if msg.type == "hello" and not self.peerHello then
|
||||
self.peerHello = msg
|
||||
got = true
|
||||
else
|
||||
keep[#keep + 1] = msg
|
||||
end
|
||||
end
|
||||
for i = #keep, 1, -1 do
|
||||
table.insert(self.net.inbox, 1, keep[i])
|
||||
end
|
||||
return got
|
||||
end
|
||||
|
||||
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("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,
|
||||
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()
|
||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
and self.stage ~= "codeEntry" then
|
||||
self:exitWith("Link error:\n" .. self.net.error:sub(1, 60))
|
||||
return
|
||||
end
|
||||
if self.net.closed and self.stage ~= "done" then
|
||||
self:exitWith("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() end
|
||||
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
|
||||
return
|
||||
elseif self.stage == "matchWaitParty" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
local msgs = self.net:poll()
|
||||
for i, msg in ipairs(msgs) do
|
||||
if msg.type == "party" then
|
||||
self.pendingBattleOpts.theirParty = msg.mons
|
||||
if self.isHost then
|
||||
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
|
||||
else
|
||||
self.pendingBattleOpts.seed = msg.seed
|
||||
end
|
||||
local battle, why = self.isHost
|
||||
and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
|
||||
if not battle then
|
||||
self:exitWith(why or "Link battle\ncan't start.")
|
||||
return
|
||||
end
|
||||
-- anything after `party` in this same batch belongs to the
|
||||
-- battle now, not to Tournament -- put it back for its own poll()
|
||||
for j = #msgs, i + 1, -1 do
|
||||
table.insert(self.net.inbox, 1, msgs[j])
|
||||
end
|
||||
self.activeBattle = battle
|
||||
self.game.stack:push(battle)
|
||||
self.stage = "matchRunning"
|
||||
return
|
||||
else
|
||||
self:handleMessage(msg)
|
||||
end
|
||||
end
|
||||
return
|
||||
elseif self.stage == "spectateWait" then
|
||||
if input:wasPressed("b") then self:exitWith(nil) return end
|
||||
local msgs = self.net:poll()
|
||||
for i, msg in ipairs(msgs) do
|
||||
if msg.type == "spectate" and msg.msg.type == "party" then
|
||||
local inner = msg.msg
|
||||
if msg.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,
|
||||
})
|
||||
if not battle then
|
||||
self:exitWith(why or "Can't watch this\nmatch.")
|
||||
return
|
||||
end
|
||||
for j = #msgs, i + 1, -1 do
|
||||
table.insert(self.net.inbox, 1, msgs[j])
|
||||
end
|
||||
self.activeBattle = battle
|
||||
self.game.stack:push(battle)
|
||||
self.stage = "spectateRunning"
|
||||
return
|
||||
end
|
||||
else
|
||||
self:handleMessage(msg)
|
||||
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
|
||||
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
|
||||
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", "PLAYING" }
|
||||
|
||||
function Tournament:draw()
|
||||
if self.stage == "menu" then
|
||||
drawTitle("TOURNAMENT")
|
||||
Font.draw("HOST", 32, 48)
|
||||
Font.draw("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",
|
||||
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("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("A: join B: back", 8, 128)
|
||||
|
||||
elseif self.stage == "registering" then
|
||||
drawTitle("CONNECTING...")
|
||||
Font.draw("B: cancel", 8, 128)
|
||||
|
||||
elseif self.stage == "bracket" or self.stage == "matchHello"
|
||||
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
|
||||
drawTitle(("TOURNAMENT %s"):format(self.code or "??????"))
|
||||
if self.bracket then
|
||||
local y = 20
|
||||
for _, round in ipairs(self.bracket.rounds) do
|
||||
Font.draw(("ROUND %d"):format(round.round), 8, y)
|
||||
y = y + 10
|
||||
for _, m in ipairs(round.matches) do
|
||||
local line
|
||||
if m.bye then
|
||||
line = ("%s (bye)"):format(m.a or m.b or "?")
|
||||
else
|
||||
local mark = m.state == "live" and "*" or (m.winner and "" or "")
|
||||
line = ("%s%s vs %s%s"):format(
|
||||
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("(organizing --", 16, 32)
|
||||
Font.draw("not playing)", 16, 42)
|
||||
end
|
||||
Font.draw("Waiting for", 16, 48)
|
||||
Font.draw("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("A: START B: cancel", 8, 132)
|
||||
else
|
||||
Font.draw("B: cancel", 8, 132)
|
||||
end
|
||||
|
||||
elseif self.stage == "done" then
|
||||
drawTitle("TOURNAMENT OVER")
|
||||
if self.champion then
|
||||
Font.draw(("%s is the"):format(self.champion), 16, 56)
|
||||
Font.draw("champion!", 16, 68)
|
||||
end
|
||||
Font.draw("A: continue", 8, 128)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Tournament
|
||||
@@ -230,6 +230,11 @@ function BattleTransition:draw()
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
local prog = math.min(1, self.t / self.wipeLen)
|
||||
-- Cascade black 8x8 blocks across the window area *outside* the classic
|
||||
-- 160x144 wipe square, in lockstep with the OG wipe progress. Renderer
|
||||
-- paints them in screen space after the world blit (see endFrame).
|
||||
local renderer = self.game and self.game.renderer
|
||||
if renderer then renderer.battleCascadeProg = prog end
|
||||
local style = self.style
|
||||
|
||||
-- a registered style may draw itself; the eight built-ins do not
|
||||
|
||||
+29
-1
@@ -22,6 +22,15 @@ GBCFX.level = 0
|
||||
|
||||
local shader -- false = unavailable (headless / no shader support)
|
||||
|
||||
-- Mobile GPUs often compile this pass but present a black frame, and the
|
||||
-- level persists in options.lua -- soft-bricking the APK until a manual
|
||||
-- edit (issue #136). Desktop is unchanged; Android/iOS refuse the effect.
|
||||
function GBCFX.isSupported()
|
||||
if not love or not love.system or not love.system.getOS then return true end
|
||||
local osName = love.system.getOS()
|
||||
return osName ~= "Android" and osName ~= "iOS"
|
||||
end
|
||||
|
||||
-- GLSL 1.20-compatible (no array initializers; wavelength terms and the
|
||||
-- shadow blur are unrolled by hand).
|
||||
local SHADER_SRC = [[
|
||||
@@ -214,6 +223,7 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 pc)
|
||||
GBCFX.SHADER_SRC = SHADER_SRC -- exposed for the standalone compile check
|
||||
|
||||
function GBCFX.shader()
|
||||
if not GBCFX.isSupported() then return nil end
|
||||
if shader == nil then
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
shader = ok and sh or false
|
||||
@@ -222,6 +232,10 @@ function GBCFX.shader()
|
||||
end
|
||||
|
||||
function GBCFX.setLevel(level)
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return
|
||||
end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
if level > 4 then level = 4 end
|
||||
@@ -230,12 +244,26 @@ end
|
||||
|
||||
-- Advance OFF → 1 → 2 → 3 → 4 → OFF. Returns the new level.
|
||||
function GBCFX.cycle()
|
||||
if not GBCFX.isSupported() then
|
||||
GBCFX.level = 0
|
||||
return 0
|
||||
end
|
||||
GBCFX.setLevel((GBCFX.level + 1) % 5)
|
||||
return GBCFX.level
|
||||
end
|
||||
|
||||
-- Apply opts.gbcfx. On unsupported platforms force OFF and clear a
|
||||
-- persisted non-zero value so boot recovers from a soft-brick. Returns
|
||||
-- true when opts was sanitized (caller should persist options.lua).
|
||||
function GBCFX.applyOptions(opts)
|
||||
if not GBCFX.isSupported() then
|
||||
local had = opts and (tonumber(opts.gbcfx) or 0) ~= 0
|
||||
if opts then opts.gbcfx = 0 end
|
||||
GBCFX.level = 0
|
||||
return had and true or false
|
||||
end
|
||||
GBCFX.setLevel(opts and opts.gbcfx or 0)
|
||||
return false
|
||||
end
|
||||
|
||||
function GBCFX.levelLabel(level)
|
||||
@@ -243,7 +271,7 @@ function GBCFX.levelLabel(level)
|
||||
end
|
||||
|
||||
function GBCFX.active()
|
||||
return GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
return GBCFX.isSupported() and GBCFX.level > 0 and GBCFX.shader() ~= nil
|
||||
end
|
||||
|
||||
-- Draw `canvas` fullscreen through the GBC FX shader into the current
|
||||
|
||||
@@ -295,6 +295,16 @@ function PaletteFX.monPal(data, species, transformed)
|
||||
return p.palettes.GRAYMON
|
||||
or (data and data.palettes and data.palettes.palettes.GRAYMON)
|
||||
end
|
||||
-- a species' own `palette` field (per-record override) wins over the
|
||||
-- vanilla species->name map. Mod-registered palettes live in
|
||||
-- data.palettes.palettes even when the active pack is the RED++ gbc pack,
|
||||
-- so fall back to it when the pack itself doesn't carry the name.
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if def and def.palette then
|
||||
local pal = p.palettes[def.palette]
|
||||
or (data and data.palettes and data.palettes.palettes[def.palette])
|
||||
if pal then return pal end
|
||||
end
|
||||
local name = p.pokemon[species] or "MEWMON"
|
||||
local c = p.palettes[name]
|
||||
if c then return c end
|
||||
@@ -308,6 +318,12 @@ end
|
||||
-- palette name a species currently resolves to (for image-cache keys)
|
||||
function PaletteFX.monPalName(data, species, transformed)
|
||||
if transformed then return "GRAYMON" end
|
||||
-- honor the per-record palette override, matching monPal
|
||||
local def = data and data.pokemon and data.pokemon[species]
|
||||
if def and def.palette and data.palettes
|
||||
and data.palettes.palettes[def.palette] then
|
||||
return def.palette
|
||||
end
|
||||
local p = PaletteFX.pack(data)
|
||||
if p and p.pokemon[species] then return p.pokemon[species] end
|
||||
if data and data.palettes and data.palettes.pokemon[species] then
|
||||
@@ -353,18 +369,21 @@ local TILESET_GROUP_EXCEPTIONS = {
|
||||
CEMETERY = { tiles = { [0x22] = true }, group = 0 },
|
||||
}
|
||||
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon roof table's flat top
|
||||
-- pokered-gbc's lobby.bst repoints the Celadon LOBBY table's flat top
|
||||
-- (block 29, cells 5/6/9/10) at a duplicate tile ($5a, BROWN) so the
|
||||
-- tabletop and the checkerboard floor -- both raw tile $37 -- can take
|
||||
-- different palettes; the vanilla-derived blockset shares the one tile
|
||||
-- id, so the RED++ atlas path re-creates the duplicate: the alias slot
|
||||
-- is baked as a copy of `tile` in `group`'s colors, and the listed
|
||||
-- 0-based block cells draw the alias instead of the shared tile.
|
||||
-- Same block appears on CELADON_MART_ROOF (#52) and CELADON_DINER (#84).
|
||||
local LOBBY_TABLE_TOP_ALIAS = {
|
||||
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
}
|
||||
PaletteFX.TILE_ALIASES = {
|
||||
CELADON_MART_ROOF = {
|
||||
{ block = 29, cells = { [5] = true, [6] = true, [9] = true, [10] = true },
|
||||
tile = 0x37, alias = 0x5a, group = 5 },
|
||||
},
|
||||
CELADON_MART_ROOF = LOBBY_TABLE_TOP_ALIAS,
|
||||
CELADON_DINER = LOBBY_TABLE_TOP_ALIAS,
|
||||
}
|
||||
local ROOF_GROUP = 6
|
||||
local ROUTE_6_SAFFRON = { mapId = "ROUTE_6", useMapId = "SAFFRON_CITY", cellYBelow = 2 }
|
||||
|
||||
+61
-1
@@ -94,6 +94,8 @@ function Renderer:beginFrame(transparent)
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
-- battle-transition cascade outside the 160x144 wipe (BattleTransition)
|
||||
self.battleCascadeProg = nil
|
||||
-- last frame's trueColor rects and sprite redraws go before anything
|
||||
-- draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
@@ -107,6 +109,46 @@ function Renderer:beginFrame(transparent)
|
||||
end
|
||||
end
|
||||
|
||||
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox
|
||||
-- into the surrounding window, matching BattleTransition wipe progress.
|
||||
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe.
|
||||
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, S)
|
||||
if not prog or prog <= 0 then return end
|
||||
local TILE = 8 * S
|
||||
if TILE < 1 then TILE = 1 end
|
||||
local cols = math.ceil(ww / TILE)
|
||||
local rows = math.ceil(wh / TILE)
|
||||
local cx, cy = ox + vpw / 2, oy + vph / 2
|
||||
local order = {}
|
||||
for row = 0, rows - 1 do
|
||||
for col = 0, cols - 1 do
|
||||
local x, y = col * TILE, row * TILE
|
||||
-- any tile with area outside the letterbox participates
|
||||
if x < ox or y < oy or x + TILE > ox + vpw or y + TILE > oy + vph then
|
||||
local dist = math.max(math.abs(x + TILE / 2 - cx),
|
||||
math.abs(y + TILE / 2 - cy))
|
||||
order[#order + 1] = { x, y, dist }
|
||||
end
|
||||
end
|
||||
end
|
||||
if #order == 0 then return end
|
||||
table.sort(order, function(a, b)
|
||||
if a[3] ~= b[3] then return a[3] < b[3] end
|
||||
if a[2] ~= b[2] then return a[2] < b[2] end
|
||||
return a[1] < b[1]
|
||||
end)
|
||||
local n = math.floor(#order * math.min(1, prog) + 1e-6)
|
||||
if prog >= 1 then n = #order end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
for i = 1, n do
|
||||
local t = order[i]
|
||||
love.graphics.rectangle("fill", t[1], t[2], TILE, TILE)
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Renderer:beginWorldPass()
|
||||
local vw, vh = self:worldViewSize()
|
||||
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
||||
@@ -349,7 +391,20 @@ function Renderer:endFrame(zones, worldZones)
|
||||
present = self.presentCanvas
|
||||
love.graphics.setCanvas(present)
|
||||
end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- Default letterbox is black. Battle (and any state that opts in via
|
||||
-- letterboxWhite) fills the voids white so the window matches the
|
||||
-- white battle canvas instead of showing black bars.
|
||||
local clearR, clearG, clearB = 0, 0, 0
|
||||
if not self.worldActive then
|
||||
local ok, Game = pcall(require, "src.core.Game")
|
||||
local stack = ok and Game and Game.stack
|
||||
local base = stack and stack.visibleBase and stack:visibleBase()
|
||||
local state = base and stack.states and stack.states[base]
|
||||
if state and state.letterboxWhite then
|
||||
clearR, clearG, clearB = 1, 1, 1
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -460,6 +515,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
-- Battle transition: cascade black blocks into the area outside the
|
||||
-- classic 160x144 wipe square (world still shows through until filled).
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
|
||||
end
|
||||
end
|
||||
-- UI stays in the classic centered GB letterbox
|
||||
blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph)
|
||||
|
||||
+171
-33
@@ -10,19 +10,57 @@ TileRenderer.__index = TileRenderer
|
||||
|
||||
local BORDER_BLOCKS = 3 -- ring width; > half a screen (2.5 blocks)
|
||||
|
||||
-- OVERWORLD maps fill beyond-edge space with the solid tree wall
|
||||
-- (blockset $0F: four regular-tree metatiles, tiles $40/$41/$50/$51,
|
||||
-- the border block of ViridianCity/CeruleanCity/CeladonCity et al.),
|
||||
-- not each map's own border_block, which can be grass ($0B, the
|
||||
-- CutTreeBlockSwaps $0B->$0A cut-grass block) or water; other
|
||||
-- tilesets keep their designated border (interiors stay black/void)
|
||||
-- OVERWORLD maps fill beyond-edge space from save.options.voidFill:
|
||||
-- trees (default) — solid tree wall $0F (Viridian/Cerulean/Celadon)
|
||||
-- water — solid water $43 (Cinnabar/Route 19 border block)
|
||||
-- black — solid black (no tiled metatile)
|
||||
-- Other tilesets keep their designated border (interiors stay black/void).
|
||||
local TREE_WALL_BLOCK = 0x0F
|
||||
local WATER_BORDER_BLOCK = 0x43
|
||||
TileRenderer.VOID_FILLS = { "trees", "water", "black" }
|
||||
TileRenderer.voidFill = "trees"
|
||||
|
||||
local function borderBlockFor(map)
|
||||
if map.def.tileset == "OVERWORLD" then return TREE_WALL_BLOCK end
|
||||
if map.def.tileset == "OVERWORLD" then
|
||||
local mode = TileRenderer.voidFill or "trees"
|
||||
if mode == "water" then return WATER_BORDER_BLOCK end
|
||||
if mode == "black" then return false end
|
||||
return TREE_WALL_BLOCK
|
||||
end
|
||||
return map.def.borderBlock
|
||||
end
|
||||
TileRenderer.borderBlockFor = borderBlockFor
|
||||
|
||||
function TileRenderer.setVoidFill(mode)
|
||||
local ok = false
|
||||
for _, m in ipairs(TileRenderer.VOID_FILLS) do
|
||||
if m == mode then ok = true; break end
|
||||
end
|
||||
TileRenderer.voidFill = ok and mode or "trees"
|
||||
end
|
||||
|
||||
function TileRenderer.cycleVoidFill()
|
||||
local cur = TileRenderer.voidFill or "trees"
|
||||
local idx = 1
|
||||
for i, m in ipairs(TileRenderer.VOID_FILLS) do
|
||||
if m == cur then idx = i; break end
|
||||
end
|
||||
TileRenderer.setVoidFill(
|
||||
TileRenderer.VOID_FILLS[idx % #TileRenderer.VOID_FILLS + 1])
|
||||
return TileRenderer.voidFill
|
||||
end
|
||||
|
||||
function TileRenderer.applyOptions(opts)
|
||||
TileRenderer.setVoidFill(opts and opts.voidFill or "trees")
|
||||
end
|
||||
|
||||
function TileRenderer.voidFillLabel(mode)
|
||||
mode = mode or TileRenderer.voidFill or "trees"
|
||||
if mode == "water" then return "WATER" end
|
||||
if mode == "black" then return "BLACK" end
|
||||
return "TREES"
|
||||
end
|
||||
|
||||
local imageCache = {}
|
||||
|
||||
local function getImage(path)
|
||||
@@ -55,8 +93,29 @@ local FLOWER_IMAGES = {
|
||||
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
|
||||
|
||||
local animFrame = 0
|
||||
function TileRenderer.tick()
|
||||
animFrame = animFrame + 1
|
||||
local animAccum = 0
|
||||
local ANIM_STEP = 1 / 60 -- Game Boy logic rate (matches FixedStep.STEP)
|
||||
|
||||
-- Advance water/flower/spinner tile animation. Called from the overworld
|
||||
-- draw path so it keeps running under dialogs (overworld update does not),
|
||||
-- but consumes wall-clock dt into 60Hz steps so high/low display refresh
|
||||
-- no longer speeds up or slows the cycle (issue #4).
|
||||
-- tick() / tick(nil) with no love.timer.getDelta (headless tests) still
|
||||
-- advances exactly one frame per call.
|
||||
function TileRenderer.tick(dt)
|
||||
if dt == nil and love and love.timer and love.timer.getDelta then
|
||||
dt = love.timer.getDelta()
|
||||
end
|
||||
if dt == nil then
|
||||
animFrame = animFrame + 1
|
||||
return
|
||||
end
|
||||
-- Cap catch-up so a long stall cannot jump many water/flower periods
|
||||
animAccum = math.min(animAccum + dt, 0.25)
|
||||
while animAccum >= ANIM_STEP do
|
||||
animAccum = animAccum - ANIM_STEP
|
||||
animFrame = animFrame + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------
|
||||
@@ -466,42 +525,118 @@ function TileRenderer.new(map, data)
|
||||
self.anims = anims
|
||||
self.claimedBy = claimedBy
|
||||
|
||||
-- a repeating 32x32 image of the border block, tiled behind
|
||||
-- everything the 3-block ring doesn't cover (the survey zoom sees
|
||||
-- far past the ring; interiors keep their black border this way)
|
||||
pcall(function()
|
||||
local border = map.tileset.blocks[borderBlockFor(map) + 1]
|
||||
if not border then return end
|
||||
local canvas = love.graphics.newCanvas(32, 32)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
for ty = 0, 3 do
|
||||
for tx = 0, 3 do
|
||||
local quad = self.quads[border[ty * 4 + tx + 1]]
|
||||
if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end
|
||||
end
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.pop()
|
||||
local img = love.graphics.newImage(canvas:newImageData())
|
||||
img:setWrap("repeat", "repeat")
|
||||
img:setFilter("nearest", "nearest")
|
||||
self.borderFill = img
|
||||
end)
|
||||
-- border-fill image is built lazily in :ensureBorderFill so a VOID FILL
|
||||
-- option change can swap trees/water/black without reloading the map
|
||||
self.borderFillMode = nil
|
||||
self.borderFill = nil
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- Bake a static repeating 32x32 of `block` into self.borderFill.
|
||||
local function bakeBorderFill(self, block)
|
||||
local border = self.map.tileset.blocks[block + 1]
|
||||
if not border then return end
|
||||
local canvas = love.graphics.newCanvas(32, 32)
|
||||
love.graphics.push("all")
|
||||
love.graphics.setCanvas(canvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
for ty = 0, 3 do
|
||||
for tx = 0, 3 do
|
||||
local quad = self.quads[border[ty * 4 + tx + 1]]
|
||||
if quad then love.graphics.draw(self.image, quad, tx * 8, ty * 8) end
|
||||
end
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.pop()
|
||||
local img = love.graphics.newImage(canvas:newImageData())
|
||||
img:setWrap("repeat", "repeat")
|
||||
img:setFilter("nearest", "nearest")
|
||||
self.borderFill = img
|
||||
end
|
||||
|
||||
-- WATER void fill: the eight hshift frames of tile $14 (same cycle as map
|
||||
-- water), wrap-tiled so the void scrolls in lockstep with on-map water.
|
||||
local function ensureWaterBorderFill(self)
|
||||
if self.borderWaterTextures then return true end
|
||||
local map = self.map
|
||||
local perRow = map.tileset.tilesPerRow
|
||||
local colors, gbcKey
|
||||
if self.gbcAtlas and self.data then
|
||||
local group = PaletteFX.worldGroupAt(map.tileset.id, map.id, WATER_TILE)
|
||||
local groupColors = PaletteFX.worldGroupColors(
|
||||
self.data, map.tileset.id, map.id, nil)
|
||||
colors = group and groupColors and groupColors[group + 1] or nil
|
||||
gbcKey = "#gbc:" .. map.id
|
||||
end
|
||||
local textures = getShiftVariants(map.tileset.image, perRow, WATER_TILE,
|
||||
colors, gbcKey)
|
||||
if not textures then return false end
|
||||
for _, img in ipairs(textures) do
|
||||
img:setWrap("repeat", "repeat")
|
||||
img:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.borderWaterTextures = textures
|
||||
return true
|
||||
end
|
||||
|
||||
-- (re)build the repeating border image when the VOID FILL mode or tileset
|
||||
-- choice changes. OVERWORLD "black" leaves borderFill nil and draws a
|
||||
-- solid clear; "water" keeps the live hshift textures instead of a bake.
|
||||
function TileRenderer:ensureBorderFill()
|
||||
local block = borderBlockFor(self.map)
|
||||
local mode = block == false and "black"
|
||||
or ((self.map.def.tileset == "OVERWORLD")
|
||||
and (TileRenderer.voidFill or "trees")
|
||||
or "map")
|
||||
local ready = (mode == "black")
|
||||
or (mode == "water" and self.borderWaterTextures)
|
||||
or (mode ~= "water" and mode ~= "black" and self.borderFill)
|
||||
if self.borderFillMode == mode and ready then return end
|
||||
if self.borderFill and self.borderFill.release then
|
||||
pcall(self.borderFill.release, self.borderFill)
|
||||
end
|
||||
self.borderFill = nil
|
||||
-- shared shift-variant cache: drop the reference only, never release
|
||||
self.borderWaterTextures = nil
|
||||
self.borderFillMode = mode
|
||||
if mode == "black" or block == false or block == nil then return end
|
||||
if mode == "water" then
|
||||
if ensureWaterBorderFill(self) then return end
|
||||
-- headless / missing pixels: fall back to the static water block bake
|
||||
end
|
||||
pcall(bakeBorderFill, self, block)
|
||||
end
|
||||
|
||||
-- tile the border block across the whole view (world-aligned so it
|
||||
-- meshes seamlessly with the ring batch)
|
||||
function TileRenderer:drawBorderFill(camX, camY, vw, vh)
|
||||
if not self.borderFill then return end
|
||||
self:ensureBorderFill()
|
||||
if self.borderFillMode == "black" then
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, vw, vh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
|
||||
local x, y = math.floor(camX), math.floor(camY)
|
||||
-- one reused Quad per renderer, mutated in place: this runs every
|
||||
-- overworld frame, so allocating a fresh Quad here churned the GC
|
||||
local q = self.borderQuad
|
||||
if self.borderFillMode == "water" and self.borderWaterTextures then
|
||||
local step = math.floor(animFrame / ANIM_PERIOD) % #WATER_OFFSETS + 1
|
||||
local tex = self.borderWaterTextures[WATER_OFFSETS[step] + 1]
|
||||
if not tex then return end
|
||||
if q then
|
||||
q:setViewport(x, y, vw, vh, 8, 8)
|
||||
else
|
||||
q = love.graphics.newQuad(x, y, vw, vh, 8, 8)
|
||||
self.borderQuad = q
|
||||
end
|
||||
love.graphics.draw(tex, q, 0, 0)
|
||||
return
|
||||
end
|
||||
if not self.borderFill then return end
|
||||
if q then
|
||||
q:setViewport(x, y, vw, vh, 32, 32)
|
||||
else
|
||||
@@ -709,6 +844,9 @@ function TileRenderer:releaseBatches()
|
||||
safeRelease(self.winBatch); self.winBatch = nil
|
||||
safeRelease(self.borderFill); self.borderFill = nil
|
||||
safeRelease(self.borderQuad); self.borderQuad = nil
|
||||
-- shared shift-variant cache; only drop the reference
|
||||
self.borderWaterTextures = nil
|
||||
self.borderFillMode = nil
|
||||
self.win = nil
|
||||
if self.quads then
|
||||
for _, q in pairs(self.quads) do safeRelease(q) end
|
||||
|
||||
+41
-5
@@ -1,6 +1,7 @@
|
||||
-- Overworld survey zoom: integer pixels-per-world-pixel scales stepped
|
||||
-- by the mouse wheel. Stored as an offset from the window fit scale S
|
||||
-- so a resize keeps the relative zoom. Session-only; never saved.
|
||||
-- by the mouse wheel, Options ZOOM row, or hotkey `4`. Stored as an
|
||||
-- offset from the window fit scale S so a resize keeps the relative
|
||||
-- zoom. Persisted as save.options.zoom (default 0 = FIT).
|
||||
-- Spec: docs/new-features.md (survey zoom)
|
||||
|
||||
local Zoom = {}
|
||||
@@ -12,16 +13,51 @@ function Zoom.scale(S)
|
||||
return math.max(1, math.min(2 * S, S + Zoom.offset))
|
||||
end
|
||||
|
||||
-- legal offset range for a given fit scale
|
||||
function Zoom.offsetRange(S)
|
||||
S = math.max(1, math.floor(tonumber(S) or 1))
|
||||
return 1 - S, S
|
||||
end
|
||||
|
||||
function Zoom.clampOffset(offset, S)
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
offset = math.floor(tonumber(offset) or 0)
|
||||
if offset < lo then return lo end
|
||||
if offset > hi then return hi end
|
||||
return offset
|
||||
end
|
||||
|
||||
function Zoom.step(delta, S)
|
||||
Zoom.offset = Zoom.offset + delta
|
||||
if S + Zoom.offset < 1 then Zoom.offset = 1 - S end
|
||||
if S + Zoom.offset > 2 * S then Zoom.offset = S end
|
||||
Zoom.offset = Zoom.clampOffset(Zoom.offset + delta, S)
|
||||
return Zoom.offset
|
||||
end
|
||||
|
||||
-- Advance one zoom level toward max close-up, then wrap to full survey.
|
||||
-- Returns the new offset.
|
||||
function Zoom.cycle(S)
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local next = Zoom.offset + 1
|
||||
if next > hi then next = lo end
|
||||
Zoom.offset = next
|
||||
return Zoom.offset
|
||||
end
|
||||
|
||||
function Zoom.reset()
|
||||
Zoom.offset = 0
|
||||
end
|
||||
|
||||
function Zoom.applyOptions(opts)
|
||||
Zoom.offset = math.floor(tonumber(opts and opts.zoom) or 0)
|
||||
end
|
||||
|
||||
-- FIT / OUT1 / OUT2 / … / IN1 / IN2 / …
|
||||
function Zoom.offsetLabel(offset)
|
||||
offset = math.floor(tonumber(offset) or 0)
|
||||
if offset == 0 then return "FIT" end
|
||||
if offset < 0 then return "OUT" .. tostring(-offset) end
|
||||
return "IN" .. tostring(offset)
|
||||
end
|
||||
|
||||
-- world pixels covered by a w x h letterbox viewport at fit scale S
|
||||
-- (legacy GB-framed size; prefer fillViewSize for the live world pass)
|
||||
function Zoom.viewSize(S, w, h)
|
||||
|
||||
+22
-4
@@ -11,7 +11,9 @@ local Input = require("src.core.Input")
|
||||
local BindingsMenu = setmetatable({}, { __index = ListMenu })
|
||||
BindingsMenu.__index = BindingsMenu
|
||||
|
||||
-- Input.lua's map, primary key first where several keys share a button
|
||||
-- Input.lua's map, primary key first where several keys share a button.
|
||||
-- `pad` is the default SDL gamecontroller button (see Input.lua); shown
|
||||
-- on the SELECT row so controller Back/View is discoverable (#73).
|
||||
local BUTTONS = {
|
||||
{ id = "up", label = "UP", key = "up" },
|
||||
{ id = "down", label = "DOWN", key = "down" },
|
||||
@@ -20,7 +22,7 @@ local BUTTONS = {
|
||||
{ id = "a", label = "A", key = "z" },
|
||||
{ id = "b", label = "B", key = "x" },
|
||||
{ id = "start", label = "START", key = "escape" },
|
||||
{ id = "select", label = "SELECT", key = "rshift" },
|
||||
{ id = "select", label = "SELECT", key = "tab", pad = "back" },
|
||||
}
|
||||
|
||||
-- a binding is a plain key string or { key, pad }; absent = the fixed
|
||||
@@ -32,13 +34,29 @@ local function boundKey(overlay, def)
|
||||
return def.key
|
||||
end
|
||||
|
||||
local function boundPad(overlay, def)
|
||||
local b = overlay and overlay[def.id]
|
||||
if type(b) == "table" and b.pad then return b.pad end
|
||||
return def.pad
|
||||
end
|
||||
|
||||
-- Key column for every row. SELECT also appends "/PAD" (default BACK)
|
||||
-- so controller Select/View is visible without opening a second legend.
|
||||
local function boundRight(overlay, def)
|
||||
local key = boundKey(overlay, def)
|
||||
if def.id ~= "select" then return key:upper() end
|
||||
local pad = boundPad(overlay, def)
|
||||
if pad then return (key .. "/" .. pad):upper() end
|
||||
return key:upper()
|
||||
end
|
||||
|
||||
function BindingsMenu.new(game)
|
||||
local overlay = game.save and game.save.options
|
||||
and game.save.options.bindings
|
||||
local items = {}
|
||||
for i, def in ipairs(BUTTONS) do
|
||||
items[i] = { label = def.label,
|
||||
right = boundKey(overlay, def):upper(), button = def }
|
||||
right = boundRight(overlay, def), button = def }
|
||||
end
|
||||
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}),
|
||||
BindingsMenu)
|
||||
@@ -78,7 +96,7 @@ function BindingsMenu:storeBinding(slot, value)
|
||||
end
|
||||
b[slot] = value
|
||||
opts.bindings[item.button.id] = b
|
||||
item.right = boundKey(opts.bindings, item.button):upper()
|
||||
item.right = boundRight(opts.bindings, item.button)
|
||||
Input:applyBindings(opts.bindings)
|
||||
if game.writeOptions then game:writeOptions() end
|
||||
end
|
||||
|
||||
+46
-2
@@ -3,7 +3,8 @@
|
||||
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
||||
-- quirks), plus the port's audio rows and display rows: music/SFX
|
||||
-- volume (0-7), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
||||
-- GBC FX / VIDEO MODE, and the MODS row that opens the mod manager.
|
||||
-- GBC FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
||||
-- the mod manager.
|
||||
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
||||
-- add their own; CANCEL is appended after the hook and stays fixed on the
|
||||
-- bottom line like pokered's.
|
||||
@@ -11,12 +12,15 @@
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local OptionRows = require("src.ui.OptionRows")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
|
||||
local OptionsMenu = {}
|
||||
OptionsMenu.__index = OptionsMenu
|
||||
@@ -110,7 +114,7 @@ local function sameRows(_, rows) return rows end
|
||||
-- the vanilla rows as descriptors; each step body is the old per-index
|
||||
-- ladder's, so the save.options mutations are unchanged
|
||||
local function buildRows(game)
|
||||
return {
|
||||
local rows = {
|
||||
{ id = "textSpeed", label = "TEXT SPEED",
|
||||
value = function(g) return SPEEDS[speedIndex(g)][2] end,
|
||||
step = function(g)
|
||||
@@ -201,6 +205,37 @@ local function buildRows(game)
|
||||
GBCFX.setLevel(o.gbcfx)
|
||||
return true
|
||||
end },
|
||||
{ id = "zoom", label = "ZOOM",
|
||||
value = function(g)
|
||||
return Zoom.offsetLabel(g.save.options.zoom or 0)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local S = Renderer:fitScale()
|
||||
local lo, hi = Zoom.offsetRange(S)
|
||||
local off = (o.zoom or 0) + dir
|
||||
if off > hi then off = lo
|
||||
elseif off < lo then off = hi end
|
||||
o.zoom = off
|
||||
Zoom.offset = off
|
||||
return true
|
||||
end },
|
||||
{ id = "voidFill", label = "VOID FILL",
|
||||
value = function(g)
|
||||
return TileRenderer.voidFillLabel(g.save.options.voidFill)
|
||||
end,
|
||||
step = function(g, dir)
|
||||
local o = g.save.options
|
||||
local modes = TileRenderer.VOID_FILLS
|
||||
local cur = o.voidFill or "trees"
|
||||
local i = 1
|
||||
for idx, m in ipairs(modes) do
|
||||
if m == cur then i = idx; break end
|
||||
end
|
||||
o.voidFill = modes[wrapIndex(i - 1 + dir, #modes) + 1]
|
||||
TileRenderer.setVoidFill(o.voidFill)
|
||||
return true
|
||||
end },
|
||||
{ id = "videoMode", label = "VIDEO MODE",
|
||||
value = function(g)
|
||||
return VideoMode.modeLabel(g.save.options.videoMode)
|
||||
@@ -252,6 +287,15 @@ local function buildRows(game)
|
||||
require("src.ui.Screens").push(g, "BindingsMenu")
|
||||
end },
|
||||
}
|
||||
-- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks
|
||||
if not GBCFX.isSupported() then
|
||||
local filtered = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.id ~= "gbcfx" then filtered[#filtered + 1] = row end
|
||||
end
|
||||
rows = filtered
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function OptionsMenu.new(game)
|
||||
|
||||
@@ -11,6 +11,8 @@ local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local FieldDefaults = require("src.world.FieldDefaults")
|
||||
local Map = require("src.world.Map")
|
||||
|
||||
local PartyMenu = {}
|
||||
PartyMenu.__index = PartyMenu
|
||||
@@ -355,8 +357,12 @@ function PartyMenu:update(dt)
|
||||
-- Battle still excludes this list via `not self.battle`. Softboiled
|
||||
-- can appear for a fainted user; its heal transfer then no-ops.
|
||||
if not self.battle and ow then
|
||||
-- FLY/TELEPORT: CheckIfInOutsideMap (OVERWORLD + PLATEAU —
|
||||
-- Route 23 / Indigo Plateau outdoor), not OVERWORLD alone (#83)
|
||||
local outside = Map.isOutside(ow.map.def,
|
||||
FieldDefaults.field(self.game.data, "outsideTilesets"))
|
||||
for _, mv in ipairs(mon.moves) do
|
||||
if mv.id == "FLY" and ow.map.def.tileset == "OVERWORLD"
|
||||
if mv.id == "FLY" and outside
|
||||
and self.game.save.inventory.THUNDERBADGE then
|
||||
table.insert(items, { label = "FLY", action = "fly" })
|
||||
elseif mv.id == "FLASH" and ow.dark
|
||||
@@ -375,7 +381,7 @@ function PartyMenu:update(dt)
|
||||
table.insert(items, { label = "STRENGTH", action = "strength" })
|
||||
elseif mv.id == "SOFTBOILED" then
|
||||
table.insert(items, { label = "SOFTBOILED", action = "softboiled" })
|
||||
elseif mv.id == "TELEPORT" and ow.map.def.tileset == "OVERWORLD" then
|
||||
elseif mv.id == "TELEPORT" and outside then
|
||||
-- TELEPORT works only OUTDOORS (start_sub_menus.asm
|
||||
-- .teleport -> CheckIfInOutsideMap); dark maps don't
|
||||
-- block it
|
||||
|
||||
@@ -2431,6 +2431,27 @@ function OverworldState:runVictoryHook()
|
||||
if hooks and hooks.onVictory then hooks.onVictory(Game, self) end
|
||||
end
|
||||
|
||||
-- pokered player sprite is fixed at screen ($40, $3c). TrainerEngage reads
|
||||
-- the NPC's 8-bit SPRITESTATEDATA1 X/Y pixels and CalcDifference; engage
|
||||
-- distance is stored as range<<4 (pixels). There is no tile LOS check for
|
||||
-- interposed NPCs / walls -- but unsigned 8-bit Y makes a sprite exactly 4
|
||||
-- tiles north of the player sit at Y=$fc, so |$3c-$fc|=$c0 and a range-4
|
||||
-- DOWN trainer does not engage that tile (Route 9 Bug Catcher / issue #76).
|
||||
local PLAYER_SCREEN_X, PLAYER_SCREEN_Y = 0x40, 0x3c
|
||||
local function u8(n) return n % 256 end
|
||||
local function calcDiff(a, b)
|
||||
a, b = u8(a), u8(b)
|
||||
return a >= b and a - b or b - a
|
||||
end
|
||||
local function trainerSightPixelDist(npc, player, horizontal)
|
||||
if horizontal then
|
||||
return calcDiff(PLAYER_SCREEN_X,
|
||||
u8(PLAYER_SCREEN_X + (npc.cellX - player.cellX) * 16))
|
||||
end
|
||||
return calcDiff(PLAYER_SCREEN_Y,
|
||||
u8(PLAYER_SCREEN_Y + (npc.cellY - player.cellY) * 16))
|
||||
end
|
||||
|
||||
-- STAY trainers with a facing spot the player crossing their line of
|
||||
-- sight (range from the extracted trainer headers), walk up and battle.
|
||||
function OverworldState:checkTrainerSight()
|
||||
@@ -2448,22 +2469,23 @@ function OverworldState:checkTrainerSight()
|
||||
local range = header and header.range or 0
|
||||
local vec = DIRVEC[npc.facing]
|
||||
if range > 0 and vec then
|
||||
local dist
|
||||
local dist, horizontal
|
||||
if vec[1] ~= 0 and npc.cellY == p.cellY then
|
||||
dist = (p.cellX - npc.cellX) * vec[1]
|
||||
horizontal = true
|
||||
elseif vec[2] ~= 0 and npc.cellX == p.cellX then
|
||||
dist = (p.cellY - npc.cellY) * vec[2]
|
||||
horizontal = false
|
||||
end
|
||||
-- pokered's TrainerEngage / CheckSpriteCanSeePlayer compares screen
|
||||
-- coordinates only (home/trainers.asm, engine/overworld/
|
||||
-- trainer_sight.asm) -- there is no line-of-sight obstruction check.
|
||||
-- An aligned trainer within range engages through interposed NPCs and
|
||||
-- unwalkable tiles, and the scripted walk-up below (scriptMove) also
|
||||
-- ignores collision, so the trainer simply walks/overlaps through
|
||||
-- anything on the line -- exactly as OAM sprites overlap on hardware.
|
||||
if dist and dist >= 1 and dist <= range then
|
||||
self:startTrainerApproach(npc, dist)
|
||||
return
|
||||
-- Screen-pixel range (CheckSpriteCanSeePlayer), not cell count:
|
||||
-- same facing-line rule as before, but the $fc Y quirk excludes the
|
||||
-- 4-tiles-north tile that cell math would still count as in range.
|
||||
if dist and dist >= 1 then
|
||||
local pixelDist = trainerSightPixelDist(npc, p, horizontal)
|
||||
if pixelDist > 0 and pixelDist <= range * 16 then
|
||||
self:startTrainerApproach(npc, dist)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3492,7 +3514,9 @@ function OverworldState:billboard(fx, fy, vw, vh, colors, keyed, drawFn)
|
||||
end
|
||||
|
||||
function OverworldState:drawWorld()
|
||||
-- advance the water/flower tile animation (runs under dialogs too)
|
||||
-- advance the water/flower tile animation (runs under dialogs too).
|
||||
-- TileRenderer.tick uses wall-clock 60Hz steps so display refresh rate
|
||||
-- does not speed or slow the cycle (issue #4).
|
||||
require("src.render.TileRenderer").tick()
|
||||
-- let the renderer know whether a spinner puzzle is currently sliding
|
||||
-- the player, so it can flicker the arrow tiles between the blur and
|
||||
|
||||
+20
-4
@@ -81,6 +81,14 @@ end
|
||||
|
||||
-- Advance one fixed step; returns true when a step just completed.
|
||||
function Player:update()
|
||||
-- land-frame walk pose lasts only through the draw after completion;
|
||||
-- the next update (idle or a chained step) clears it
|
||||
self.stepLanded = false
|
||||
-- Ledge-hop arc is cosmetic but must track the fixed 60Hz logic step,
|
||||
-- not love.draw's display refresh (issue #4: >59fps ended early).
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
self.hopFrames = self.hopFrames - 1
|
||||
end
|
||||
if self.turnTimer > 0 then
|
||||
self.turnTimer = self.turnTimer - 1
|
||||
end
|
||||
@@ -109,6 +117,11 @@ function Player:update()
|
||||
self.px, self.py = self.cellX * 16, self.cellY * 16
|
||||
self.moving = false
|
||||
self.stepFlip = not self.stepFlip
|
||||
-- keep animClock's pose on this frame (issue #82): bike steps land
|
||||
-- mid-cycle (animClock % 16 == 8), and walkPhase used to snap to
|
||||
-- stand whenever moving cleared — a stand flash every tile on the
|
||||
-- bike, and sometimes after dismount when the clock is desynced
|
||||
self.stepLanded = true
|
||||
return true
|
||||
end
|
||||
return false
|
||||
@@ -119,7 +132,7 @@ function Player:facingCell()
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
if not self.moving and not self.stepLanded then return 0 end
|
||||
-- walk frame during the middle of each 16-frame animation cycle
|
||||
local p = (self.animClock or self.progress) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
@@ -129,10 +142,13 @@ local SPIN_ORDER = { "down", "left", "up", "right" }
|
||||
|
||||
function Player:draw(camX, camY)
|
||||
local py = self.py
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs
|
||||
-- ledge hops arc (set for 2 cells by the ledge handler); surfing bobs.
|
||||
-- hopFrames counts down in Player:update (fixed step), never here.
|
||||
if self.hopFrames and self.hopFrames > 0 then
|
||||
self.hopFrames = self.hopFrames - 1
|
||||
local t = 1 - self.hopFrames / (self.hopTotal or 32)
|
||||
local total = self.hopTotal or 32
|
||||
-- update runs before draw, so remaining N means N steps already
|
||||
-- consumed this hop → t matches the old draw-side post-decrement phase
|
||||
local t = 1 - self.hopFrames / total
|
||||
py = py - math.floor(10 * math.sin(t * math.pi) + 0.5)
|
||||
-- the shadow stays on the ground under the jumper: one 8x8 tile
|
||||
-- mirrored into a 2x2 block (normal/XFLIP/YFLIP/both) whose top-left
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
-- Driver: exercises src/link/Net.lua's TCP relay backend for real, inside
|
||||
-- LOVE (real lua-enet/luasocket), against a pokeserver already running at
|
||||
-- 127.0.0.1:7778 (POKEPORT_RELAY_ADDR overrides). Doesn't touch the game
|
||||
-- UI at all -- just proves host/join/relay/close over a real socket.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Net = require("src.link.Net")
|
||||
local addr = os.getenv("POKEPORT_RELAY_ADDR") or "127.0.0.1:7778"
|
||||
|
||||
local host = Net.new()
|
||||
local ok = host:hostOnline(addr)
|
||||
U.log("hostOnline ok:", ok, "error:", host.error)
|
||||
|
||||
local frames = 0
|
||||
while not host.code and not host.error and frames < 180 do
|
||||
host:update()
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("host code:", host.code, "error:", host.error)
|
||||
|
||||
local guest = Net.new()
|
||||
local gok = guest:joinOnline(addr, host.code)
|
||||
U.log("joinOnline ok:", gok, "error:", guest.error)
|
||||
|
||||
frames = 0
|
||||
while (not host.paired or not guest.paired) and frames < 180 do
|
||||
host:update()
|
||||
guest:update()
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("host.paired:", host.paired, "guest.paired:", guest.paired)
|
||||
|
||||
host:send({ type = "hello", name = "RED" })
|
||||
local relayed = nil
|
||||
frames = 0
|
||||
while not relayed and frames < 180 do
|
||||
host:update() -- flushes host's queued send
|
||||
guest:update()
|
||||
for _, msg in ipairs(guest:poll()) do
|
||||
if msg.type == "hello" then relayed = msg end
|
||||
end
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("relayed hello name:", relayed and relayed.name)
|
||||
|
||||
guest:close()
|
||||
frames = 0
|
||||
while not host.closed and frames < 180 do
|
||||
host:update()
|
||||
frames = frames + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.log("host saw peer_gone / closed:", host.closed)
|
||||
|
||||
host:close()
|
||||
|
||||
local pass = host.code ~= nil and host.paired and guest.paired
|
||||
and relayed ~= nil and relayed.name == "RED" and host.closed
|
||||
U.log("NET_RELAY_SMOKE:", pass and "PASS" or "FAIL")
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
-- Driver: screenshots the new online-play menu surface (LinkState's
|
||||
-- restructured top menu, the online host/join flow). Pushed directly
|
||||
-- (bypassing Start-menu navigation, matching options_test.lua's
|
||||
-- convention) so it doesn't depend on party/save state.
|
||||
--
|
||||
-- Run with no mods discoverable (mod enable/disable only takes effect at
|
||||
-- the next boot -- see Handshake.onlineAllowed's comment -- so a live
|
||||
-- toggle can't simulate "vanilla" mid-session; the mod directory has to
|
||||
-- actually be absent for this run).
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
|
||||
local LinkState = require("src.link.LinkState")
|
||||
|
||||
local function closeAnyOpenScreens()
|
||||
while game.stack:top() and game.stack:top().exitWith do
|
||||
game.stack:top():exitWith(nil)
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
-- top menu (3 rows: LAN / ONLINE MATCH / TOURNAMENT)
|
||||
game.stack:push(LinkState.new(game))
|
||||
U.wait(5)
|
||||
U.shot(game, DIR .. "/link_0_top_menu.png")
|
||||
U.tap(game, "down"); U.wait(2)
|
||||
U.shot(game, DIR .. "/link_1_top_menu_online.png")
|
||||
|
||||
-- ONLINE MATCH -> HOST ONLINE (needs pokeserver reachable at the
|
||||
-- default relay address; POKEPORT_RELAY_ADDR overrides)
|
||||
U.tap(game, "a"); U.wait(3) -- into onlineMenu
|
||||
U.shot(game, DIR .. "/link_2_online_menu.png")
|
||||
U.tap(game, "a"); U.wait(20) -- HOST ONLINE -> connect
|
||||
U.shot(game, DIR .. "/link_3_online_hosting.png")
|
||||
|
||||
-- back out, try JOIN ONLINE's code-entry screen
|
||||
closeAnyOpenScreens()
|
||||
game.stack:push(LinkState.new(game))
|
||||
U.wait(5)
|
||||
U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row
|
||||
U.tap(game, "a"); U.wait(3)
|
||||
U.tap(game, "down"); U.wait(2) -- JOIN ONLINE row
|
||||
U.tap(game, "a"); U.wait(3)
|
||||
U.shot(game, DIR .. "/link_4_code_entry.png")
|
||||
U.tap(game, "up"); U.wait(1)
|
||||
U.tap(game, "right"); U.wait(1)
|
||||
U.shot(game, DIR .. "/link_5_code_entry_scrubbed.png")
|
||||
closeAnyOpenScreens()
|
||||
|
||||
U.log("ONLINE_PLAY_DRIVER: done")
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Driver: joins the tournament tournament_host_test.lua creates, reading
|
||||
-- the code from a shared file since the two LOVE processes otherwise
|
||||
-- can't coordinate. Meant for a fresh throwaway POKEPORT_IDENTITY, so it
|
||||
-- injects a test party directly (mirroring run_link_tests.lua's
|
||||
-- makeFakeGame) rather than playing through a whole new-game intro.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney"
|
||||
local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
|
||||
-- the default tournament rule is exactly 3 Pokemon; overwrite whatever
|
||||
-- this identity's party actually is (safe: a match uses clamped copies)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "BLASTOISE", 50),
|
||||
Pokemon.new(game.data, "GENGAR", 35),
|
||||
Pokemon.new(game.data, "ALAKAZAM", 45),
|
||||
}
|
||||
game.save.player.name = "BLUE"
|
||||
|
||||
local CodeEntry = require("src.link.CodeEntry")
|
||||
local Tournament = require("src.link.Tournament")
|
||||
local t = Tournament.new(game)
|
||||
game.stack:push(t)
|
||||
U.wait(3)
|
||||
|
||||
local code = nil
|
||||
local waited = 0
|
||||
while not code and waited < 1800 do
|
||||
local f = io.open(CODE_FILE, "r")
|
||||
if f then
|
||||
local c = f:read("*l")
|
||||
f:close()
|
||||
if c and #c == 6 then code = c end
|
||||
end
|
||||
U.wait(1)
|
||||
waited = waited + 1
|
||||
end
|
||||
U.log("guest read code:", code)
|
||||
if not code then
|
||||
U.log("TOURNAMENT_GUEST_DRIVER: never saw a code, aborting")
|
||||
return
|
||||
end
|
||||
|
||||
U.tap(game, "down"); U.wait(2) -- JOIN row
|
||||
U.tap(game, "a"); U.wait(3) -- into codeEntry
|
||||
U.shot(game, DIR .. "/guest_0_code_entry.png")
|
||||
for i = 1, CodeEntry.LENGTH do
|
||||
local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true)
|
||||
if idx then t.codeEntry.chars[i] = idx end
|
||||
end
|
||||
U.tap(game, "a"); U.wait(3) -- confirm -> startJoining
|
||||
U.shot(game, DIR .. "/guest_1_joining.png")
|
||||
|
||||
waited = 0
|
||||
while #t.roster == 0 and t.stage ~= "done" and waited < 1800 do
|
||||
U.wait(1)
|
||||
waited = waited + 1
|
||||
end
|
||||
U.log("guest roster:", table.concat(t.roster or {}, ","))
|
||||
U.shot(game, DIR .. "/guest_2_roster.png")
|
||||
|
||||
local doneWait = 0
|
||||
while t.stage ~= "done" and doneWait < 5400 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
doneWait = doneWait + 1
|
||||
end
|
||||
U.shot(game, DIR .. "/guest_3_done.png")
|
||||
U.log("TOURNAMENT_GUEST_DRIVER: champion=", t.champion, "stage=", t.stage)
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Driver: hosts a real 2-player tournament against a real running
|
||||
-- pokeserver, paired with tournament_guest_test.lua running in a second
|
||||
-- LOVE process. Coordinates over a shared code file (TOURNEY_CODE_FILE)
|
||||
-- since the two processes have no other way to talk before the code
|
||||
-- exists. The default tournament rule is exactly 3 Pokemon, so this
|
||||
-- overwrites whatever party the identity actually has with 3 fixed mons
|
||||
-- -- a tournament match never touches the real save (clamped copies, same
|
||||
-- as any other link battle), so clobbering it here for the test is safe.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney"
|
||||
local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt"
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
Pokemon.new(game.data, "PIKACHU", 30),
|
||||
Pokemon.new(game.data, "SNORLAX", 40),
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
|
||||
local Tournament = require("src.link.Tournament")
|
||||
local t = Tournament.new(game)
|
||||
game.stack:push(t)
|
||||
U.wait(3)
|
||||
|
||||
U.shot(game, DIR .. "/host_0_menu.png")
|
||||
U.tap(game, "a"); U.wait(3) -- HOST -> hostSettings
|
||||
U.shot(game, DIR .. "/host_1_settings.png")
|
||||
U.tap(game, "start"); U.wait(30) -- create with defaults (3 mons, any/any, 6s)
|
||||
U.shot(game, DIR .. "/host_2_hosting.png")
|
||||
U.log("host code:", t.code, "error:", t.net and t.net.error)
|
||||
|
||||
local f = io.open(CODE_FILE, "w")
|
||||
if f then f:write(t.code or ""); f:close() end
|
||||
|
||||
local waited = 0
|
||||
while #t.roster < 2 and waited < 1800 do
|
||||
U.wait(1)
|
||||
waited = waited + 1
|
||||
end
|
||||
U.log("host roster:", table.concat(t.roster, ","))
|
||||
U.shot(game, DIR .. "/host_3_roster.png")
|
||||
|
||||
U.tap(game, "a"); U.wait(1) -- start_tournament
|
||||
U.shot(game, DIR .. "/host_4_bracket.png")
|
||||
|
||||
local doneWait = 0
|
||||
while t.stage ~= "done" and doneWait < 5400 do
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
doneWait = doneWait + 1
|
||||
end
|
||||
U.shot(game, DIR .. "/host_5_done.png")
|
||||
U.log("TOURNAMENT_HOST_DRIVER: champion=", t.champion, "stage=", t.stage)
|
||||
end
|
||||
@@ -620,6 +620,22 @@ check(PaletteFX.pal({ palettes = nil }, "ROUTE") == gbc.palettes.ROUTE,
|
||||
"RED++ still has ROUTE (aliased from VIRIDIAN)")
|
||||
check(PaletteFX.effectiveColors(gbc.palettes.MEWMON) == gbc.palettes.MEWMON,
|
||||
"RED++ passes zone colors through like GBC")
|
||||
-- issue #84: CELADON_DINER shares LOBBY block 29 (table top) with
|
||||
-- CELADON_MART_ROOF (#52); both need the $37->$5a BROWN alias
|
||||
do
|
||||
local aliases = PaletteFX.TILE_ALIASES
|
||||
local roof = aliases and aliases.CELADON_MART_ROOF
|
||||
local diner = aliases and aliases.CELADON_DINER
|
||||
check(roof ~= nil and diner ~= nil,
|
||||
"CELADON_MART_ROOF and CELADON_DINER both have TILE_ALIASES")
|
||||
check(diner == roof,
|
||||
"diner reuses the same lobby table-top alias as the mart roof")
|
||||
local al = diner and diner[1]
|
||||
check(al and al.block == 29 and al.tile == 0x37 and al.alias == 0x5a
|
||||
and al.group == 5 and al.cells[5] and al.cells[6]
|
||||
and al.cells[9] and al.cells[10],
|
||||
"lobby table-top alias remaps block 29 cells 5/6/9/10")
|
||||
end
|
||||
-- issue #128: RED++'s gbc pack is Red-derived; Blue must keep ROM LOGO1
|
||||
-- (and the Blue-only SLOTS* rows) so the title ribbon is blue, not red
|
||||
do
|
||||
@@ -738,6 +754,97 @@ do
|
||||
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
|
||||
end
|
||||
|
||||
-- ------- battle transition cascade outside the OG square + white letterbox
|
||||
do
|
||||
local rects = {}
|
||||
local color = { 1, 1, 1, 1 }
|
||||
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
|
||||
local savedGetDims = love.graphics.getDimensions
|
||||
local savedGetPix = love.graphics.getPixelDimensions
|
||||
-- taller-than-fit window so the 160x144 letterbox leaves real voids
|
||||
-- (default 640x576 is an exact 4x fit with nothing outside the square)
|
||||
love.graphics.getDimensions = function() return 800, 600 end
|
||||
love.graphics.getPixelDimensions = function() return 800, 600 end
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = {
|
||||
mode = mode, x = x, y = y, w = w, h = h,
|
||||
r = color[1], g = color[2], b = color[3], a = color[4],
|
||||
}
|
||||
end
|
||||
love.graphics.setColor = function(r, g, b, a)
|
||||
color[1], color[2], color[3], color[4] = r, g, b, a or 1
|
||||
end
|
||||
|
||||
Renderer:init()
|
||||
local btGame = { renderer = Renderer, stack = { pop = noop } }
|
||||
local wipe = BattleTransition.new(btGame, nil, {})
|
||||
wipe.phase = "wipe"
|
||||
wipe.t = math.floor(wipe.wipeLen / 2)
|
||||
Renderer:beginFrame(true)
|
||||
Renderer:beginWorldPass()
|
||||
Renderer:endWorldPass()
|
||||
wipe:draw()
|
||||
check(Renderer.battleCascadeProg ~= nil
|
||||
and Renderer.battleCascadeProg > 0
|
||||
and Renderer.battleCascadeProg < 1,
|
||||
"battle wipe publishes mid-progress cascade to the renderer")
|
||||
rects = {}
|
||||
Renderer:endFrame(nil, fullWorldZones())
|
||||
local cascadeTile
|
||||
for _, r in ipairs(rects) do
|
||||
-- fitScale at 800x600 is min(5,4)=4 → 32px tiles outside the letterbox
|
||||
if r.mode == "fill" and r.r == 0 and r.a == 1 and r.w == 32 and r.h == 32 then
|
||||
cascadeTile = r
|
||||
break
|
||||
end
|
||||
end
|
||||
check(cascadeTile ~= nil,
|
||||
"endFrame paints cascading black tiles outside the OG wipe square")
|
||||
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
|
||||
love.graphics.getDimensions = savedGetDims
|
||||
love.graphics.getPixelDimensions = savedGetPix
|
||||
end
|
||||
|
||||
do
|
||||
-- BattleState asks for white letterbox voids instead of black bars
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
check(BattleState.letterboxWhite == true,
|
||||
"BattleState opts into white letterbox fill")
|
||||
local rects = {}
|
||||
local color = { 1, 1, 1, 1 }
|
||||
local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor
|
||||
love.graphics.rectangle = function(mode, x, y, w, h)
|
||||
rects[#rects + 1] = {
|
||||
mode = mode, x = x, y = y, w = w, h = h,
|
||||
r = color[1], g = color[2], b = color[3], a = color[4],
|
||||
}
|
||||
end
|
||||
love.graphics.setColor = function(r, g, b, a)
|
||||
color[1], color[2], color[3], color[4] = r, g, b, a or 1
|
||||
end
|
||||
local Game = require("src.core.Game")
|
||||
local savedStack = Game.stack
|
||||
Game.stack = {
|
||||
states = { { letterboxWhite = true, isOpaque = true } },
|
||||
visibleBase = function() return 1 end,
|
||||
}
|
||||
Renderer:init()
|
||||
Renderer:beginFrame(false)
|
||||
rects = {}
|
||||
Renderer:endFrame(nil, nil)
|
||||
local clear
|
||||
for _, r in ipairs(rects) do
|
||||
if r.mode == "fill" and r.x == 0 and r.y == 0 and r.w == 640 and r.h == 576 then
|
||||
clear = r
|
||||
break
|
||||
end
|
||||
end
|
||||
check(clear and clear.r == 1 and clear.g == 1 and clear.b == 1,
|
||||
"endFrame fills the window white when the visible base wants letterboxWhite")
|
||||
Game.stack = savedStack
|
||||
love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor
|
||||
end
|
||||
|
||||
-- ------- the transition.style hook
|
||||
|
||||
local stack = { pop = function() end }
|
||||
|
||||
+29
-9
@@ -206,7 +206,8 @@ end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
|
||||
"musicVol", "sfxVol", "musicFilter", "colors", "tilt",
|
||||
"gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" }
|
||||
"gbcfx", "zoom", "voidFill", "videoMode", "fpsCap",
|
||||
"speed", "mods", "controls" }
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -236,17 +237,35 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||
for _ = 1, 10 do om.rows[5].step(om.game, -1) end
|
||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
|
||||
-- ZOOM / VOID FILL rows
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
om.game.save.options.zoom = 0
|
||||
Zoom.offset = 0
|
||||
check(om.rows[11].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
||||
om.rows[11].step(om.game, 1)
|
||||
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
||||
"ZOOM row steps to IN1")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "water"
|
||||
and TileRenderer.voidFill == "water",
|
||||
"VOID FILL row cycles TREES → WATER")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
||||
om.rows[12].step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
||||
|
||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||
om.game.save.options.fpsCap = nil
|
||||
check(om.rows[12].value(om.game) == "60",
|
||||
check(om.rows[14].value(om.game) == "60",
|
||||
"MAX FPS row defaults to 60 with no saved cap")
|
||||
om.rows[12].step(om.game, 1)
|
||||
om.rows[14].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
check(om.rows[14].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
om.game.save.options.fpsCap = 160
|
||||
om.rows[12].step(om.game, 1)
|
||||
om.rows[14].step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||
om.rows[12].step(om.game, -1)
|
||||
om.rows[14].step(om.game, -1)
|
||||
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
||||
|
||||
-- ------- FrameCap normalize / cycle (issue #88)
|
||||
@@ -276,7 +295,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[14].activate(mgGame)
|
||||
om.rows[16].activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -286,7 +305,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[15].activate(cbGame)
|
||||
om.rows[17].activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
@@ -295,7 +314,8 @@ check(bm.screenId == "BindingsMenu",
|
||||
check(#bm.items == 8, "one row per logical button")
|
||||
check(bm.items[1].label == "UP" and bm.items[1].right == "UP"
|
||||
and bm.items[5].label == "A" and bm.items[5].right == "Z"
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE",
|
||||
and bm.items[7].label == "START" and bm.items[7].right == "ESCAPE"
|
||||
and bm.items[8].label == "SELECT" and bm.items[8].right == "TAB/BACK",
|
||||
"with no rebind the rows mirror the fixed map")
|
||||
check(cbGame.save.options.bindings == nil,
|
||||
"opening the screen alone writes nothing")
|
||||
|
||||
@@ -432,6 +432,50 @@ check(actsFaint.fly and actsFaint.cut and actsFaint.surf and actsFaint.strength,
|
||||
"fainted mon still lists FLY/CUT/SURF/STRENGTH in the party submenu")
|
||||
popToOW()
|
||||
|
||||
-- ===========================================================================
|
||||
-- #83: FLY/TELEPORT use CheckIfInOutsideMap (OVERWORLD + PLATEAU), so the
|
||||
-- outdoor strip between Victory Road and the Elite Four / Indigo Plateau
|
||||
-- building allows Fly like any other outdoor overworld map.
|
||||
-- ===========================================================================
|
||||
Game.save.party = { mkMon("AERODACTYL", "FLY", "TELEPORT") }
|
||||
Game.save.inventory = { THUNDERBADGE = true }
|
||||
ow = pushOW("INDIGO_PLATEAU", 10, 5, "down")
|
||||
eq(ow.map.def.tileset, "PLATEAU", "Indigo Plateau outdoor uses PLATEAU tileset")
|
||||
local pmIndigo = PartyMenu.new(Game)
|
||||
Game.stack:push(pmIndigo)
|
||||
frame({ "a" })
|
||||
local actsIndigo = submenuActions(pmIndigo)
|
||||
check(actsIndigo.fly, "FLY listed on Indigo Plateau outdoor (PLATEAU)")
|
||||
check(actsIndigo.escape, "TELEPORT listed on Indigo Plateau outdoor (PLATEAU)")
|
||||
popToOW()
|
||||
|
||||
ow = pushOW("ROUTE_23", 10, 6, "down")
|
||||
eq(ow.map.def.tileset, "PLATEAU", "Route 23 uses PLATEAU tileset")
|
||||
local pmR23 = PartyMenu.new(Game)
|
||||
Game.stack:push(pmR23)
|
||||
frame({ "a" })
|
||||
check(submenuActions(pmR23).fly, "FLY listed on Route 23 (PLATEAU)")
|
||||
popToOW()
|
||||
|
||||
-- Indoors at the lobby still blocks FLY/TELEPORT (MART tileset)
|
||||
ow = pushOW("INDIGO_PLATEAU_LOBBY", 7, 8, "down")
|
||||
check(ow.map.def.tileset ~= "OVERWORLD" and ow.map.def.tileset ~= "PLATEAU",
|
||||
"Indigo lobby is not an outside tileset")
|
||||
local pmLobby = PartyMenu.new(Game)
|
||||
Game.stack:push(pmLobby)
|
||||
frame({ "a" })
|
||||
local actsLobby = submenuActions(pmLobby)
|
||||
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
|
||||
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
|
||||
popToOW()
|
||||
|
||||
-- restore fainted field-move mon for the STRENGTH/SURF cases below
|
||||
Game.save.party = { fainted }
|
||||
Game.save.inventory = {
|
||||
THUNDERBADGE = true, CASCADEBADGE = true,
|
||||
RAINBOWBADGE = true, SOULBADGE = true,
|
||||
}
|
||||
|
||||
-- STRENGTH activation from a fainted user (name text + strengthActive)
|
||||
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
|
||||
clearCaptured()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
-- Regression: bike / desynced walk steps must not flash stand on land (issue #82).
|
||||
--
|
||||
-- walkPhase used to return 0 whenever moving was false. A step clears
|
||||
-- moving on its final FixedStep tick, so the draw after landing snapped
|
||||
-- to stand even when animClock was mid walk-cycle. Bike steps are 8
|
||||
-- frames, so they land at animClock % 16 == 8 (walk) every tile — always
|
||||
-- stuttery. Walking after a bike ride inherits a desynced animClock and
|
||||
-- hit the same stand flash "sometimes."
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_bike_walk_anim.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
|
||||
|
||||
local Player = require("src.world.Player")
|
||||
local S = require("tests.harness").suite("parity bike walk anim")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function makePlayer(onBike)
|
||||
local p = Player.new(Data, 5, 5, "down")
|
||||
p.onBike = onBike
|
||||
if onBike then
|
||||
p.stepFramesCur = p.bikeStepFrames or 8
|
||||
else
|
||||
p.stepFramesCur = p.stepFrames or 16
|
||||
end
|
||||
p.animClock = 0
|
||||
return p
|
||||
end
|
||||
|
||||
local function startStep(p)
|
||||
p.moving = true
|
||||
p.progress = 0
|
||||
p.targetX, p.targetY = p.cellX, p.cellY + 1
|
||||
end
|
||||
|
||||
-- --- bike: every land frame mid-cycle must stay walk ---
|
||||
local bike = makePlayer(true)
|
||||
local landPhases = {}
|
||||
for tile = 1, 4 do
|
||||
startStep(bike)
|
||||
local stepLen = bike.stepFramesCur
|
||||
for _ = 1, stepLen do
|
||||
local done = bike:update()
|
||||
if done then
|
||||
landPhases[#landPhases + 1] = bike:walkPhase()
|
||||
eq(bike.moving, false, "bike step clears moving on land")
|
||||
check(bike.stepLanded, "bike land latches stepLanded for draw")
|
||||
end
|
||||
end
|
||||
end
|
||||
-- lands at animClock 8, 16, 24, 32 → %16 = 8, 0, 8, 0
|
||||
-- walk band is 4..11, so lands at 8 must be phase 1; at 0 must be phase 0
|
||||
eq(landPhases[1], 1, "bike land at animClock%16==8 keeps walk pose")
|
||||
eq(landPhases[2], 0, "bike land at animClock%16==0 is stand (in-band)")
|
||||
eq(landPhases[3], 1, "bike land at animClock%16==8 keeps walk pose again")
|
||||
eq(landPhases[4], 0, "bike land at animClock%16==0 is stand again")
|
||||
|
||||
-- continuous bike phases across two tiles: no forced stand in the walk band
|
||||
bike = makePlayer(true)
|
||||
local phases = {}
|
||||
for _ = 1, 2 do
|
||||
startStep(bike)
|
||||
for _ = 1, bike.stepFramesCur do
|
||||
bike:update()
|
||||
phases[#phases + 1] = bike:walkPhase()
|
||||
end
|
||||
end
|
||||
-- frames 4..11 of animClock are walk; across 16 ticks that is indices 4..11
|
||||
for i = 4, 11 do
|
||||
eq(phases[i], 1, "bike continuous walk band has no stand flash at " .. i)
|
||||
end
|
||||
|
||||
-- idle after land: next update drops the latch → stand
|
||||
bike = makePlayer(true)
|
||||
startStep(bike)
|
||||
for _ = 1, bike.stepFramesCur do bike:update() end
|
||||
eq(bike:walkPhase(), 1, "latched land frame still walk")
|
||||
bike:update()
|
||||
eq(bike.stepLanded, false, "idle update clears stepLanded")
|
||||
eq(bike:walkPhase(), 0, "truly idle is stand")
|
||||
|
||||
-- --- walk after desynced animClock (post-bike): land must not force stand ---
|
||||
local walk = makePlayer(false)
|
||||
walk.animClock = 8 -- leftover from a bike half-cycle
|
||||
startStep(walk)
|
||||
local lastPhase
|
||||
for _ = 1, walk.stepFramesCur do
|
||||
walk:update()
|
||||
lastPhase = walk:walkPhase()
|
||||
end
|
||||
eq(walk.animClock % 16, 8, "desynced walk lands mid walk-cycle")
|
||||
eq(lastPhase, 1, "desynced walk land keeps walk pose (no post-bike stutter)")
|
||||
|
||||
S.finish()
|
||||
@@ -80,5 +80,46 @@ check(drawn and drawn[1] == canvas and drawn[2] == 0 and drawn[3] == 0,
|
||||
|
||||
GBCFX.setLevel(0)
|
||||
|
||||
-- issue #136: Android/iOS refuse GBC FX (shader soft-bricks the APK)
|
||||
check(GBCFX.isSupported(), "desktop / headless stub supports GBC FX")
|
||||
local prevSystem = love.system
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
check(not GBCFX.isSupported(), "Android reports GBC FX unsupported")
|
||||
GBCFX.setLevel(3)
|
||||
eq(GBCFX.level, 0, "setLevel forces OFF on Android")
|
||||
eq(GBCFX.cycle(), 0, "cycle stays OFF on Android")
|
||||
local opts = { gbcfx = 4 }
|
||||
check(GBCFX.applyOptions(opts) == true,
|
||||
"applyOptions reports a cleared persisted level on Android")
|
||||
eq(opts.gbcfx, 0, "applyOptions clears opts.gbcfx on Android")
|
||||
eq(GBCFX.level, 0, "applyOptions leaves level OFF on Android")
|
||||
check(not GBCFX.active(), "active() is false on Android")
|
||||
eq(GBCFX.shader(), nil, "shader() is nil on Android")
|
||||
love.system = { getOS = function() return "iOS" end }
|
||||
check(not GBCFX.isSupported(), "iOS reports GBC FX unsupported")
|
||||
love.system = prevSystem
|
||||
check(GBCFX.isSupported(), "support restores when OS stub is removed")
|
||||
-- desktop path still applies a level after leaving the mobile gate
|
||||
GBCFX.applyOptions({ gbcfx = 2 })
|
||||
eq(GBCFX.level, 2, "applyOptions still sets levels on desktop")
|
||||
GBCFX.setLevel(0)
|
||||
|
||||
-- Options menu hides the GBC FX row on Android
|
||||
local OptionsMenu = require("src.ui.OptionsMenu")
|
||||
love.system = { getOS = function() return "Android" end }
|
||||
local om = OptionsMenu.new({
|
||||
data = { rulesets = {}, constants = {} },
|
||||
save = { options = {} },
|
||||
stack = { pop = function() end },
|
||||
input = { wasPressed = function() return false end },
|
||||
modStatus = { available = {} },
|
||||
})
|
||||
local hasGbc = false
|
||||
for _, row in ipairs(om.rows) do
|
||||
if row.id == "gbcfx" then hasGbc = true end
|
||||
end
|
||||
check(not hasGbc, "Options menu omits GBC FX on Android")
|
||||
love.system = prevSystem
|
||||
|
||||
-- === summary ===
|
||||
S.finish()
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
-- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves.
|
||||
--
|
||||
-- Select marks a slot, move the cursor, Select (or A) swaps. Defaults:
|
||||
-- Tab / either Shift / gamepad Back. Self-contained; also picked up by
|
||||
-- tests/run_tests.lua's parity_* glob.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Input = require("src.core.Input")
|
||||
local S = require("tests.harness").suite("parity move swap")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local function freshGame()
|
||||
local mon = Pokemon.new(Data, "NIDORAN_M", 8)
|
||||
mon.moves = {
|
||||
{ id = "TACKLE", pp = 35 },
|
||||
{ id = "LEER", pp = 30 },
|
||||
{ id = "HORN_ATTACK", pp = 25 },
|
||||
{ id = "POISON_STING", pp = 35 },
|
||||
}
|
||||
return {
|
||||
data = Data,
|
||||
input = Input,
|
||||
save = {
|
||||
party = { mon },
|
||||
player = { name = "RED" },
|
||||
inventory = {},
|
||||
options = {},
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
flags = {},
|
||||
money = 0,
|
||||
},
|
||||
stack = { push = function() end, pop = function() end, top = function() end },
|
||||
}
|
||||
end
|
||||
|
||||
local function tapKey(battle, key)
|
||||
Input:keypressed(key)
|
||||
Input:step()
|
||||
battle:update(0)
|
||||
Input:keyreleased(key)
|
||||
end
|
||||
|
||||
local function tapPad(battle, button)
|
||||
Input:gamepadpressed(nil, button)
|
||||
Input:step()
|
||||
battle:update(0)
|
||||
Input:gamepadreleased(nil, button)
|
||||
end
|
||||
|
||||
-- Default Select sources all edge the logical select button.
|
||||
do
|
||||
Input:init()
|
||||
for _, key in ipairs({ "tab", "rshift", "lshift" }) do
|
||||
Input:reset()
|
||||
Input:keypressed(key)
|
||||
Input:step()
|
||||
check(Input:wasPressed("select"), key .. " maps to select")
|
||||
end
|
||||
Input:reset()
|
||||
Input:gamepadpressed(nil, "back")
|
||||
Input:step()
|
||||
check(Input:wasPressed("select"), "gamepad back maps to select")
|
||||
end
|
||||
|
||||
-- Fight menu: Select, move, Select swaps slots 1 and 2.
|
||||
do
|
||||
Input:init()
|
||||
local game = freshGame()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.phase = "moveSelect"
|
||||
battle.moveIndex = 1
|
||||
battle.moveSwapIndex = nil
|
||||
local a = battle.player.curMoves[1].id
|
||||
local b = battle.player.curMoves[2].id
|
||||
tapKey(battle, "tab")
|
||||
eq(battle.moveSwapIndex, 1, "first Select marks the current slot")
|
||||
tapKey(battle, "down")
|
||||
eq(battle.moveIndex, 2, "cursor moved to slot 2")
|
||||
tapKey(battle, "tab")
|
||||
check(battle.moveSwapIndex == nil, "second Select clears the mark")
|
||||
eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move")
|
||||
eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move")
|
||||
eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync")
|
||||
end
|
||||
|
||||
-- Same reorder via gamepad Back (SDL "back" = controller Select/View).
|
||||
do
|
||||
Input:init()
|
||||
local game = freshGame()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.phase = "moveSelect"
|
||||
battle.moveIndex = 1
|
||||
battle.moveSwapIndex = nil
|
||||
local a = battle.player.curMoves[1].id
|
||||
local b = battle.player.curMoves[2].id
|
||||
tapPad(battle, "back")
|
||||
tapPad(battle, "dpdown")
|
||||
tapPad(battle, "back")
|
||||
eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1")
|
||||
eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2")
|
||||
end
|
||||
|
||||
-- A confirms a pending swap (bag-style), without starting the turn.
|
||||
do
|
||||
Input:init()
|
||||
local game = freshGame()
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.phase = "moveSelect"
|
||||
battle.moveIndex = 1
|
||||
battle.moveSwapIndex = nil
|
||||
local a = battle.player.curMoves[1].id
|
||||
local b = battle.player.curMoves[2].id
|
||||
tapKey(battle, "tab")
|
||||
tapKey(battle, "down")
|
||||
tapKey(battle, "z") -- A
|
||||
eq(battle.phase, "moveSelect", "A completes a pending swap without attacking")
|
||||
eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1")
|
||||
eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Parity test: Pokemon Tower 5F purified-zone heal pad (#81).
|
||||
--
|
||||
-- pokered scripts/PokemonTower5F.asm PokemonTower5FDefaultScript:
|
||||
-- coords (10,8)/(11,8)/(10,9)/(11,9); CheckAndSetEvent
|
||||
-- EVENT_IN_PURIFIED_ZONE so the heal fires once until the player
|
||||
-- leaves; HealParty -> GBFadeOutToWhite -> Delay3 x2 ->
|
||||
-- GBFadeInFromWhite -> _PokemonTower5FPurifiedZoneText (no heal jingle).
|
||||
--
|
||||
-- Self-contained; run via `luajit tests/parity_tower_heal_pad.lua`.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
local S = require("tests.harness").suite("parity tower heal pad")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local M = dofile("data/scripts/story3.lua")
|
||||
local tower = M.POKEMON_TOWER_5F
|
||||
check(tower ~= nil and type(tower.onStep) == "function",
|
||||
"POKEMON_TOWER_5F has an onStep heal-pad trigger")
|
||||
|
||||
local function cmds(rows)
|
||||
local out = {}
|
||||
for _, row in ipairs(rows) do out[#out + 1] = row[1] end
|
||||
return out
|
||||
end
|
||||
|
||||
local function gameWith(flags)
|
||||
return { save = { flags = flags or {} } }
|
||||
end
|
||||
|
||||
local function owWith()
|
||||
local ran = {}
|
||||
return {
|
||||
runner = {
|
||||
isRunning = function() return false end,
|
||||
run = function(_, rows) ran[#ran + 1] = rows end,
|
||||
},
|
||||
}, ran
|
||||
end
|
||||
|
||||
-- ---- 1. pad coords + leave clears latch --------------------------------
|
||||
do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
check(not tower.onStep(game, ow, 9, 8), "off-pad X does not heal")
|
||||
check(not tower.onStep(game, ow, 10, 7), "off-pad Y does not heal")
|
||||
eq(#ran, 0, "no script off the pad")
|
||||
end
|
||||
|
||||
do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
check(tower.onStep(game, ow, 10, 8), "first step on (10,8) heals")
|
||||
check(game.save.flags.EVENT_IN_PURIFIED_ZONE, "latches EVENT_IN_PURIFIED_ZONE")
|
||||
eq(#ran, 1, "heal script runs once")
|
||||
|
||||
check(tower.onStep(game, ow, 11, 9),
|
||||
"staying on the pad still consumes the step (BIT_NO_BATTLES)")
|
||||
eq(#ran, 1, "heal does not re-fire while still on the pad")
|
||||
|
||||
check(not tower.onStep(game, ow, 12, 9), "stepping off clears and returns false")
|
||||
check(not game.save.flags.EVENT_IN_PURIFIED_ZONE,
|
||||
"EVENT_IN_PURIFIED_ZONE resets off the pad")
|
||||
|
||||
check(tower.onStep(game, ow, 11, 8), "re-entering the pad heals again")
|
||||
eq(#ran, 2, "a fresh visit runs the heal script again")
|
||||
end
|
||||
|
||||
-- ---- 2. all four pad tiles trigger -------------------------------------
|
||||
for _, cell in ipairs({ { 10, 8 }, { 11, 8 }, { 10, 9 }, { 11, 9 } }) do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
check(tower.onStep(game, ow, cell[1], cell[2]),
|
||||
("pad tile (%d,%d) heals"):format(cell[1], cell[2]))
|
||||
eq(#ran, 1, ("pad tile (%d,%d) queued a script"):format(cell[1], cell[2]))
|
||||
end
|
||||
|
||||
-- ---- 3. heal sequence matches pokered order ----------------------------
|
||||
do
|
||||
local game = gameWith()
|
||||
local ow, ran = owWith()
|
||||
tower.onStep(game, ow, 10, 9)
|
||||
local rows = ran[1]
|
||||
check(rows ~= nil, "heal rows captured")
|
||||
local sequence = table.concat(cmds(rows), ",")
|
||||
eq(sequence, "heal_party,fade,wait,wait,fade,show_text",
|
||||
"Tower pad sequence is heal → fade out → Delay3×2 → fade in → text")
|
||||
eq(rows[2][3], "white", "fades out to white")
|
||||
eq(rows[3][2], 3, "first Delay3 is 3 frames")
|
||||
eq(rows[4][2], 3, "second Delay3 is 3 frames")
|
||||
eq(rows[5][3], "white", "fades in from white")
|
||||
eq(rows[6][2], "_PokemonTower5FPurifiedZoneText",
|
||||
"shows the purified-zone text")
|
||||
for _, row in ipairs(rows) do
|
||||
check(row[1] ~= "play_once", "no Music_PkmnHealed on the Tower pad")
|
||||
end
|
||||
end
|
||||
|
||||
S.finish()
|
||||
@@ -169,4 +169,53 @@ do
|
||||
"tile behind the trainer never engages (CheckPlayerIsInFrontOfSprite)")
|
||||
end
|
||||
|
||||
-- === (4) Route 9 Bug Catcher: 4-tiles-north screen Y $fc quirk ===
|
||||
-- Object 7 = SPRITE_YOUNGSTER at (22,2), STAY DOWN, OPP_BUG_CATCHER
|
||||
-- (data/maps/objects/Route9.asm); header range 4 (Route9TrainerHeader6).
|
||||
-- A ledge sits on (22,5); the walkable tile below it is (22,6) at cell
|
||||
-- distance 4. Cell math would engage, but pokered's unsigned screen-Y
|
||||
-- distance with SPRITESTATEDATA1_YPIXELS=$fc is $c0 > range<<4 ($40), so
|
||||
-- the trainer must not challenge through that ledge (GitHub #76).
|
||||
local ROUTE9_BUG_CATCHER = 7
|
||||
|
||||
local function freshRoute9(px, py, facing)
|
||||
while Game.stack:top() do Game.stack:pop() end
|
||||
Game.save = SaveData.newGame()
|
||||
Input:init()
|
||||
OW.engaging = false
|
||||
OW.emote = nil
|
||||
Game.stack:push(OW, "ROUTE_9", px, py, facing)
|
||||
local ow = Game.stack:top()
|
||||
local trainer
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.def.index == ROUTE9_BUG_CATCHER then trainer = npc end
|
||||
end
|
||||
return ow, trainer
|
||||
end
|
||||
|
||||
do
|
||||
local ow, trainer = freshRoute9(22, 6, "up")
|
||||
check(trainer ~= nil and trainer.cellX == 22 and trainer.cellY == 2,
|
||||
"Route 9 Bug Catcher stands at (22,2)")
|
||||
eq(trainer.facing, "down", "Bug Catcher faces down (STAY DOWN)")
|
||||
local header = Data:trainerHeader("Route9", ROUTE9_BUG_CATCHER)
|
||||
eq(header and header.range, 4, "Bug Catcher sight range is 4")
|
||||
|
||||
for _ = 1, 10 do frame(ow) end
|
||||
check(not ow.engaging,
|
||||
"distance 4 south of DOWN trainer: no engage (screen Y $fc quirk)")
|
||||
|
||||
-- same plateau, still in pixel range: distance 2 and 3 must engage
|
||||
local ow2, t2 = freshRoute9(22, 4, "up")
|
||||
local guard = 0
|
||||
while not ow2.engaging and guard < 10 do guard = guard + 1; frame(ow2) end
|
||||
check(ow2.engaging and t2.cellY == 2,
|
||||
"distance 2 on the upper plateau still engages")
|
||||
|
||||
local ow3 = freshRoute9(22, 3, "up")
|
||||
guard = 0
|
||||
while not ow3.engaging and guard < 10 do guard = guard + 1; frame(ow3) end
|
||||
check(ow3.engaging, "distance 3 on the upper plateau still engages")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -127,6 +127,130 @@ else
|
||||
print("skip real enet pairing (lua-enet not available under this interpreter)")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- relay (TCP) transport
|
||||
-- Pure framing logic needs no socket at all: Net:drainLines()/handleTCPLine
|
||||
-- operate directly on rxBuf, so this much runs even under plain luajit.
|
||||
do
|
||||
local n = Net.new()
|
||||
local encoded = Json.encode({ type = "hosted", code = "ABCDEF" })
|
||||
n.rxBuf = encoded .. "\n"
|
||||
n:drainLines()
|
||||
eq(n.code, "ABCDEF", "relay framing: a complete hosted line sets net.code")
|
||||
check(n.rxBuf == "", "relay framing: a complete line is fully consumed")
|
||||
|
||||
local n2 = Net.new()
|
||||
n2.rxBuf = encoded:sub(1, 5) -- the line straddles two reads
|
||||
n2:drainLines()
|
||||
check(n2.code == nil, "relay framing: a partial line doesn't parse yet")
|
||||
n2.rxBuf = n2.rxBuf .. encoded:sub(6) .. "\n"
|
||||
n2:drainLines()
|
||||
eq(n2.code, "ABCDEF", "relay framing: completing the line resolves it")
|
||||
|
||||
local n3 = Net.new()
|
||||
n3.rxBuf = Json.encode({ type = "join_error", reason = "not_found" }) .. "\n"
|
||||
n3:drainLines()
|
||||
check(n3.error ~= nil and n3.closed, "relay framing: join_error sets error and closes")
|
||||
|
||||
local n4 = Net.new()
|
||||
n4.rxBuf = Json.encode({ type = "paired" }) .. "\n"
|
||||
n4:drainLines()
|
||||
check(n4.paired, "relay framing: paired flips net.paired")
|
||||
|
||||
local n5 = Net.new()
|
||||
n5.paired = true
|
||||
n5.rxBuf = Json.encode({ type = "peer_gone" }) .. "\n"
|
||||
n5:drainLines()
|
||||
check(n5.closed, "relay framing: peer_gone closes the connection")
|
||||
|
||||
local n6 = Net.new()
|
||||
n6.rxBuf = Json.encode({ type = "hello", name = "RED" }) .. "\n"
|
||||
n6:drainLines()
|
||||
eq(#n6.inbox, 1, "relay framing: an unrecognized control type lands in the inbox")
|
||||
eq(n6.inbox[1] and n6.inbox[1].name, "RED", "relay framing: ...with its payload intact")
|
||||
end
|
||||
|
||||
-- real pokeserver over TCP localhost (only when luasocket is present, i.e.
|
||||
-- inside LOVE or a luajit with luasocket installed, AND node is on PATH to
|
||||
-- spawn the real relay; otherwise this section is skipped, same spirit as
|
||||
-- the enet gate above)
|
||||
local hasSocket = pcall(require, "socket")
|
||||
local nodeCheck = os.execute("command -v node >/dev/null 2>&1")
|
||||
local hasNode = nodeCheck == true or nodeCheck == 0
|
||||
if not hasSocket then
|
||||
print("skip real relay pairing (luasocket not available under this interpreter)")
|
||||
elseif not hasNode then
|
||||
print("skip real relay pairing (node not on PATH to spawn pokeserver)")
|
||||
else
|
||||
local PORT = 17778
|
||||
local pidFile = os.tmpname()
|
||||
os.execute(("(cd ../pokeserver && PORT=%d HTTP_PORT=%d node server.js >/tmp/pokeserver_test.log 2>&1 & echo $! > %q)")
|
||||
:format(PORT, PORT + 1, pidFile))
|
||||
|
||||
local function busyWait(seconds)
|
||||
local t0 = os.clock()
|
||||
while os.clock() - t0 < seconds do end
|
||||
end
|
||||
|
||||
local function tcpConnectable(host, port)
|
||||
local socket = require("socket")
|
||||
local tcp = socket.tcp()
|
||||
tcp:settimeout(0.2)
|
||||
local ok = tcp:connect(host, port)
|
||||
tcp:close()
|
||||
return ok ~= nil
|
||||
end
|
||||
|
||||
local ready = false
|
||||
for _ = 1, 50 do
|
||||
ready = tcpConnectable("127.0.0.1", PORT)
|
||||
if ready then break end
|
||||
busyWait(0.1)
|
||||
end
|
||||
|
||||
if not ready then
|
||||
print("skip real relay pairing (couldn't reach the spawned pokeserver)")
|
||||
else
|
||||
local host = Net.new()
|
||||
check(host:hostOnline("127.0.0.1:" .. PORT), "relay: hostOnline connects: " .. tostring(host.error))
|
||||
local deadline = os.clock() + 3
|
||||
while not host.code and os.clock() < deadline do host:update() end
|
||||
check(host.code ~= nil, "relay: a real server assigns a room code")
|
||||
|
||||
local guest = Net.new()
|
||||
check(guest:joinOnline("127.0.0.1:" .. PORT, host.code or ""),
|
||||
"relay: joinOnline connects: " .. tostring(guest.error))
|
||||
deadline = os.clock() + 3
|
||||
while (not host.paired or not guest.paired) and os.clock() < deadline do
|
||||
host:update()
|
||||
guest:update()
|
||||
end
|
||||
check(host.paired and guest.paired, "relay: both sides pair over a real TCP server")
|
||||
|
||||
host:send({ type = "hello", name = "RED" })
|
||||
local relayed = nil
|
||||
deadline = os.clock() + 3
|
||||
while not relayed and os.clock() < deadline do
|
||||
host:update()
|
||||
guest:update()
|
||||
for _, m in ipairs(guest:poll()) do
|
||||
if m.type == "hello" then relayed = m end
|
||||
end
|
||||
end
|
||||
eq(relayed and relayed.name, "RED", "relay: a message round-trips through the real server")
|
||||
|
||||
host:close()
|
||||
guest:close()
|
||||
end
|
||||
|
||||
local pidHandle = io.open(pidFile, "r")
|
||||
if pidHandle then
|
||||
local pid = pidHandle:read("*l")
|
||||
pidHandle:close()
|
||||
if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end
|
||||
end
|
||||
os.remove(pidFile)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- trade session
|
||||
local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) }
|
||||
local partyB = { Pokemon.new(Data, "MACHOKE", 32) }
|
||||
@@ -240,6 +364,133 @@ eq(gameA.save.money, 3000, "no prize money in link battles")
|
||||
eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp,
|
||||
"the real party is untouched (battle used clamped copies)")
|
||||
|
||||
-- ---------------------------------------------------------------- tournament shot clock
|
||||
-- opts.turnLimit only applies to tournament matches; the guest mashes
|
||||
-- through its own menu every frame while the host never presses anything,
|
||||
-- so the host's clock is the only one that can expire.
|
||||
local gameC = makeFakeGame("PIKACHU")
|
||||
local gameD = makeFakeGame("SNORLAX")
|
||||
gameD.save.player.name = "YELLOW"
|
||||
local netC, netD = Net.loopbackPair()
|
||||
local packedC = Protocol.packParty(gameC.save.party)
|
||||
local packedD = Protocol.packParty(gameD.save.party)
|
||||
local battleC = LinkBattle.newHost(gameC, netC, {
|
||||
myParty = packedC, theirParty = packedD, theirName = "YELLOW", seed = 42,
|
||||
turnLimit = 0.05,
|
||||
})
|
||||
local battleD = LinkBattle.newGuest(gameD, netD, {
|
||||
myParty = packedD, theirParty = packedC, theirName = "RED", seed = 42,
|
||||
turnLimit = 0.05,
|
||||
})
|
||||
local resC, resD = nil, nil
|
||||
battleC.onFinish = function(r) resC = r end
|
||||
battleD.onFinish = function(r) resD = r end
|
||||
gameC.stack:push(battleC)
|
||||
gameD.stack:push(battleD)
|
||||
|
||||
-- both sides need "a" to get through the intro's messages/animations
|
||||
-- (send-out poofs, cries...) before the host's own menu even shows; only
|
||||
-- once the host's decision point is actually up does withholding input
|
||||
-- from it mean anything
|
||||
local guardIntro = 0
|
||||
while battleC.phase ~= "menu" and guardIntro < 6000 do
|
||||
guardIntro = guardIntro + 1
|
||||
Input.pressed = { a = true }
|
||||
gameC.stack:update(1 / 60)
|
||||
gameD.stack:update(1 / 60)
|
||||
end
|
||||
check(battleC.phase == "menu", "shot clock: host reaches its own decision point")
|
||||
|
||||
local guard2 = 0
|
||||
while resD == nil and guard2 < 6000 do
|
||||
guard2 = guard2 + 1
|
||||
Input.pressed = { a = true }
|
||||
gameD.stack:update(1 / 60) -- guest mashes through its menu
|
||||
Input.pressed = {}
|
||||
gameC.stack:update(1 / 60) -- host presses nothing; its clock ticks down
|
||||
end
|
||||
check(resD == "win", "shot clock: the timed-out player's opponent wins immediately")
|
||||
|
||||
-- the timed-out host still has to dismiss its own "time's up" message to
|
||||
-- reach finish() -- exactly like a slow-but-present player would; this
|
||||
-- isn't the clock's business, just the ordinary message queue
|
||||
local guard3 = 0
|
||||
while resC == nil and guard3 < 6000 do
|
||||
guard3 = guard3 + 1
|
||||
Input.pressed = { a = true }
|
||||
gameC.stack:update(1 / 60)
|
||||
end
|
||||
check(resC == "lose", "shot clock: the timed-out host is recorded as the loser")
|
||||
|
||||
-- ---------------------------------------------------------------- tournament spectator replay
|
||||
-- A spectator (LinkBattle.newSpectator) reconstructs the same lockstep
|
||||
-- battle from a copy of the wire traffic tagged by side -- exactly what
|
||||
-- pokeserver's tournament fan-out gives it. Feed it directly here rather
|
||||
-- than standing up a real relay: wrap the host/guest loopback sends so
|
||||
-- every message they exchange also lands, tagged, in a fake spectator net.
|
||||
local gameE = makeFakeGame("CHARIZARD")
|
||||
local gameF = makeFakeGame("BLASTOISE")
|
||||
gameF.save.player.name = "BLUE"
|
||||
local gameSpec = makeFakeGame("RATTATA")
|
||||
local netE, netF = Net.loopbackPair()
|
||||
local packedE = Protocol.packParty(gameE.save.party)
|
||||
local packedF = Protocol.packParty(gameF.save.party)
|
||||
local specSeed = 13579
|
||||
|
||||
local specInbox = {}
|
||||
local origSendE, origSendF = netE.send, netF.send
|
||||
netE.send = function(self, msg)
|
||||
origSendE(self, msg)
|
||||
table.insert(specInbox, { type = "spectate", side = "host", msg = msg })
|
||||
end
|
||||
netF.send = function(self, msg)
|
||||
origSendF(self, msg)
|
||||
table.insert(specInbox, { type = "spectate", side = "guest", msg = msg })
|
||||
end
|
||||
local specNet = {
|
||||
closed = false,
|
||||
update = function() end,
|
||||
poll = function()
|
||||
local msgs = specInbox
|
||||
specInbox = {}
|
||||
return msgs
|
||||
end,
|
||||
}
|
||||
|
||||
local battleE = LinkBattle.newHost(gameE, netE, {
|
||||
myParty = packedE, theirParty = packedF, theirName = "BLUE", seed = specSeed,
|
||||
})
|
||||
local battleF = LinkBattle.newGuest(gameF, netF, {
|
||||
myParty = packedF, theirParty = packedE, theirName = "RED", seed = specSeed,
|
||||
})
|
||||
local battleSpec = LinkBattle.newSpectator(gameSpec, specNet, {
|
||||
hostParty = packedE, guestParty = packedF, hostName = "RED", guestName = "BLUE",
|
||||
seed = specSeed,
|
||||
})
|
||||
check(battleSpec ~= nil, "spectator battle constructs")
|
||||
eq(battleSpec.spectating, true, "spectator battle is marked as such (not a reportable match)")
|
||||
|
||||
local resE, resF = nil, nil
|
||||
battleE.onFinish = function(r) resE = r end
|
||||
battleF.onFinish = function(r) resF = r end
|
||||
gameE.stack:push(battleE)
|
||||
gameF.stack:push(battleF)
|
||||
gameSpec.stack:push(battleSpec)
|
||||
|
||||
local guard3 = 0
|
||||
while (resE == nil or resF == nil) and guard3 < 60000 do
|
||||
guard3 = guard3 + 1
|
||||
Input.pressed = { a = true }
|
||||
gameE.stack:update(1 / 60)
|
||||
gameF.stack:update(1 / 60)
|
||||
gameSpec.stack:update(1 / 60)
|
||||
end
|
||||
check(resE ~= nil and resF ~= nil, "spectator test: the underlying match completes")
|
||||
eq(battleSpec.player.mon.hp, battleE.player.mon.hp,
|
||||
"spectator's host-side HP matches the host's own view")
|
||||
eq(battleSpec.enemy.mon.hp, battleF.player.mon.hp,
|
||||
"spectator's guest-side HP matches the guest's own view")
|
||||
|
||||
-- ---------------------------------------------------------------- mod link compat
|
||||
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
|
||||
-- assert-based checks, so it lands here as a single pass/fail line.
|
||||
|
||||
+173
-14
@@ -1072,6 +1072,81 @@ do
|
||||
mh:performMove(mh.player, mh.enemy, { id = "DOUBLESLAP", pp = 10 })
|
||||
check(hasText(mh, "Hit the enemy\n5 times!"),
|
||||
"player multi-hit uses _MultiHitText")
|
||||
-- #85: multi-hit replays PlayMoveAnimation per strike (pokered
|
||||
-- GetPlayerAnimationType loop), interleaved with each HP drain
|
||||
do
|
||||
local anims, seq = 0, {}
|
||||
for _, r in ipairs(mh.queue) do
|
||||
if r.anim == "DOUBLESLAP" then
|
||||
anims = anims + 1
|
||||
seq[#seq + 1] = "anim"
|
||||
elseif r.drain then
|
||||
seq[#seq + 1] = "drain"
|
||||
end
|
||||
end
|
||||
eq(anims, 5, "multi-hit queues the move animation once per hit")
|
||||
eq(table.concat(seq, ","),
|
||||
"anim,drain,anim,drain,anim,drain,anim,drain,anim,drain",
|
||||
"multi-hit interleaves anim + drain per strike")
|
||||
for _, r in ipairs(mh.queue) do
|
||||
if r.anim == "DOUBLESLAP" then
|
||||
check(r.hit ~= nil, "each multi-hit anim carries hit blink/sfx")
|
||||
end
|
||||
end
|
||||
end
|
||||
-- #85: crit / effectiveness reprint each strike (.moveDidNotMiss
|
||||
-- before the GetPlayerAnimationType loop-back). DOUBLE_KICK is a
|
||||
-- fixed 2-hit Fighting move -- SE vs Normal SNORLAX.
|
||||
do
|
||||
local function countText(b, s)
|
||||
local n = 0
|
||||
for _, it in ipairs(b.queue) do
|
||||
if it.text and it.text:find(s, 1, true) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 20) }
|
||||
local mhk = BattleState.newWild(Game, "SNORLAX", 40)
|
||||
mhk.rng = mkseq({ 0, 255, 255 }) -- hit, no crit, max roll
|
||||
mhk:performMove(mhk.player, mhk.enemy, { id = "DOUBLE_KICK", pp = 10 })
|
||||
eq(countText(mhk, "It's super\neffective!"), 2,
|
||||
"multi-hit prints effectiveness once per strike")
|
||||
local seq = {}
|
||||
for _, r in ipairs(mhk.queue) do
|
||||
if r.anim == "DOUBLE_KICK" then seq[#seq + 1] = "anim"
|
||||
elseif r.drain then seq[#seq + 1] = "drain"
|
||||
elseif r.text == "It's super\neffective!" then seq[#seq + 1] = "se"
|
||||
elseif r.text and r.text:find("times!", 1, true) then seq[#seq + 1] = "count"
|
||||
end
|
||||
end
|
||||
eq(table.concat(seq, ","), "anim,drain,se,anim,drain,se,count",
|
||||
"multi-hit queues SE text between strikes, count after")
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Events = require("src.mods.Events")
|
||||
local savedE, savedH = Runtime.events, Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.install(Events.new(), hooks)
|
||||
local unsub = hooks:wrap("battle.crit", function() return true end)
|
||||
local mhc = BattleState.newWild(Game, "SNORLAX", 40)
|
||||
mhc.rng = mkseq({ 0, 255 }) -- acc, damage; crit from the hook
|
||||
mhc:performMove(mhc.player, mhc.enemy, { id = "DOUBLE_KICK", pp = 10 })
|
||||
eq(countText(mhc, "Critical hit!"), 2,
|
||||
"multi-hit prints Critical hit! once per strike")
|
||||
local cseq = {}
|
||||
for _, r in ipairs(mhc.queue) do
|
||||
if r.anim == "DOUBLE_KICK" then cseq[#cseq + 1] = "anim"
|
||||
elseif r.drain then cseq[#cseq + 1] = "drain"
|
||||
elseif r.text == "Critical hit!" then cseq[#cseq + 1] = "crit"
|
||||
elseif r.text == "It's super\neffective!" then cseq[#cseq + 1] = "se"
|
||||
end
|
||||
end
|
||||
eq(table.concat(cseq, ","), "anim,drain,crit,se,anim,drain,crit,se",
|
||||
"crit then effectiveness follow each multi-hit drain")
|
||||
unsub()
|
||||
Runtime.install(savedE, savedH)
|
||||
end
|
||||
Game.save.party = { Pokemon.new(Data, "SNORLAX", 30) }
|
||||
local mh2 = BattleState.newWild(Game, "RATTATA", 5)
|
||||
mh2.rng = mkseq({ 7, 0, 255, 255 })
|
||||
@@ -1498,6 +1573,13 @@ do
|
||||
eq(back.options.colors, "og", "options.lua round-trips colors")
|
||||
eq(back.options.tilt, 2, "options.lua round-trips tilt")
|
||||
eq(back.options.gbcfx, 3, "options.lua round-trips gbcfx")
|
||||
-- zoom / voidFill ride the same options.lua path when present
|
||||
rep.options.zoom = -2
|
||||
rep.options.voidFill = "water"
|
||||
SD.saveOptions(rep.options)
|
||||
local optBack = SD.loadOptions()
|
||||
eq(optBack.zoom, -2, "options.lua round-trips zoom")
|
||||
eq(optBack.voidFill, "water", "options.lua round-trips voidFill")
|
||||
local origOpts, loadedOpts = rep.options, back.options
|
||||
rep.options, back.options = nil, nil
|
||||
local same, where = deepEq(rep, back, "save")
|
||||
@@ -1645,6 +1727,16 @@ do
|
||||
ow.runner = { isRunning = function() return true end }
|
||||
check(not Zoom.gateOK(ow, ow), "gate closed while a script runs")
|
||||
Zoom.reset()
|
||||
|
||||
-- cycle wraps survey → … → close-up → survey; labels track offset
|
||||
eq(Zoom.offsetLabel(0), "FIT", "zoom label FIT at offset 0")
|
||||
eq(Zoom.offsetLabel(-2), "OUT2", "zoom label OUT for negative offset")
|
||||
eq(Zoom.offsetLabel(3), "IN3", "zoom label IN for positive offset")
|
||||
Zoom.offset = S -- max close-up
|
||||
eq(Zoom.cycle(S), 1 - S, "zoom cycle wraps from max in to full survey")
|
||||
Zoom.applyOptions({ zoom = -1 })
|
||||
eq(Zoom.offset, -1, "applyOptions restores saved zoom offset")
|
||||
Zoom.reset()
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- zoom camera
|
||||
@@ -2232,6 +2324,8 @@ do
|
||||
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
|
||||
eq(og.save.options.tilt, 0, "new saves default TILT to OFF")
|
||||
eq(og.save.options.gbcfx, 0, "new saves default GBC FX to OFF")
|
||||
eq(og.save.options.zoom, 0, "new saves default ZOOM to FIT")
|
||||
eq(og.save.options.voidFill, "trees", "new saves default VOID FILL to TREES")
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"new saves default VIDEO MODE to WINDOWED")
|
||||
eq(om.scroll, 0, "options viewport starts at the top")
|
||||
@@ -2270,7 +2364,25 @@ do
|
||||
for _ = 1, 4 do press("a") end
|
||||
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 11, "cursor reaches VIDEO MODE")
|
||||
eq(om.index, 11, "cursor reaches ZOOM")
|
||||
local ZoomOpt = require("src.render.Zoom")
|
||||
press("a")
|
||||
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
|
||||
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
|
||||
press("left")
|
||||
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches VOID FILL")
|
||||
local TR = require("src.render.TileRenderer")
|
||||
press("a")
|
||||
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
|
||||
eq(TR.voidFill, "water", "TileRenderer.voidFill tracks VOID FILL option")
|
||||
press("a")
|
||||
eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK")
|
||||
press("a")
|
||||
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
|
||||
press("down")
|
||||
eq(om.index, 13, "cursor reaches VIDEO MODE")
|
||||
press("a")
|
||||
eq(og.save.options.videoMode, "borderless",
|
||||
"A cycles VIDEO MODE to BORDERLESS")
|
||||
@@ -2278,7 +2390,7 @@ do
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"VIDEO MODE wraps back to WINDOWED")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches MAX FPS")
|
||||
eq(om.index, 14, "cursor reaches MAX FPS")
|
||||
press("a")
|
||||
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
|
||||
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
|
||||
@@ -2287,7 +2399,7 @@ do
|
||||
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
|
||||
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
|
||||
press("down")
|
||||
eq(om.index, 13, "cursor reaches GAME SPEED")
|
||||
eq(om.index, 15, "cursor reaches GAME SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
|
||||
-- Driven by the level list rather than a literal press count: adding a
|
||||
@@ -2296,25 +2408,27 @@ do
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 14, "cursor reaches MODS")
|
||||
eq(om.index, 16, "cursor reaches MODS")
|
||||
press("down")
|
||||
eq(om.index, 15, "cursor reaches CONTROLS")
|
||||
eq(om.index, 17, "cursor reaches CONTROLS")
|
||||
press("down")
|
||||
eq(om.index, 16, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen")
|
||||
eq(om.index, 18, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 13, "CANCEL keeps the last option boxes on screen")
|
||||
om:draw() -- smoke: scrolled layout draws under the headless stub
|
||||
press("a")
|
||||
check(popped, "A on CANCEL closes the options menu")
|
||||
local om2 = OptionsMenu.new(og)
|
||||
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
|
||||
eq(om2.index, 16, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail")
|
||||
eq(om2.index, 18, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 13, "wrapping to CANCEL scrolls to the tail")
|
||||
-- headless-safe: no love.audio, setters only update internal state
|
||||
require("src.core.Music").applyOptions(og.save.options)
|
||||
require("src.core.Sound").applyOptions(og.save.options)
|
||||
PaletteFX.applyOptions(og.save.options)
|
||||
Tilt.applyOptions(og.save.options)
|
||||
GBCFX.applyOptions(og.save.options)
|
||||
require("src.render.Zoom").applyOptions(og.save.options)
|
||||
require("src.render.TileRenderer").applyOptions(og.save.options)
|
||||
VideoMode.applyOptions(og.save.options)
|
||||
end
|
||||
end
|
||||
@@ -2611,22 +2725,67 @@ do
|
||||
tostring(c[3]), tostring(c[4])))
|
||||
end
|
||||
end
|
||||
|
||||
-- == issue #4: ledge hop countdown is fixed-step, not draw-rate ==
|
||||
-- hopFrames used to decrement inside Player:draw, so >59fps ended the
|
||||
-- arc early and <59fps stretched it. Countdown belongs in update().
|
||||
do
|
||||
local Player = require("src.world.Player")
|
||||
local p = Player.new(Data, 5, 6, "down")
|
||||
p.hopFrames, p.hopTotal = 32, 32
|
||||
p:draw(0, 0)
|
||||
p:draw(0, 0)
|
||||
p:draw(0, 0)
|
||||
eq(p.hopFrames, 32, "draw does not consume hopFrames")
|
||||
for _ = 1, 10 do p:update() end
|
||||
eq(p.hopFrames, 22, "ten fixed updates consume ten hopFrames")
|
||||
for _ = 1, 22 do p:update() end
|
||||
eq(p.hopFrames, 0, "hop expires after hopTotal fixed updates")
|
||||
p:update()
|
||||
eq(p.hopFrames, 0, "hopFrames stays at 0 once expired")
|
||||
end
|
||||
|
||||
-- == issue #4: tile anim tick accumulates wall-clock into 60Hz steps ==
|
||||
do
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.setSpinning(true)
|
||||
local before = TileRenderer.spinBlurActive()
|
||||
for _ = 1, 8 do TileRenderer.tick(1 / 60) end
|
||||
check(before ~= TileRenderer.spinBlurActive(),
|
||||
"tick(1/60) advances water/spinner clock at fixed 60Hz")
|
||||
local mid = TileRenderer.spinBlurActive()
|
||||
TileRenderer.tick(1 / 120)
|
||||
eq(TileRenderer.spinBlurActive(), mid,
|
||||
"sub-frame dt does not advance the tile anim clock")
|
||||
TileRenderer.tick(1 / 120)
|
||||
-- second half-frame completes one step; phase may or may not flip
|
||||
-- (8-tick blur period), but the clock must have accepted the step
|
||||
TileRenderer.setSpinning(false)
|
||||
end
|
||||
end
|
||||
|
||||
-- ================= BUGS.md batch: border-tree =================
|
||||
do
|
||||
-- ---------------------------------------------------------------- border fill (tree wall)
|
||||
-- ---------------------------------------------------------------- border fill (tree wall / VOID FILL)
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
-- OVERWORLD maps fill beyond-edge space with the solid tree wall $0F
|
||||
-- (ViridianCity/CeruleanCity/CeladonCity border_block: four regular-tree
|
||||
-- metatiles); per-map borders like Pallet's all-grass $0B (the
|
||||
-- CutTreeBlockSwaps $0B->$0A block) only apply to other tilesets
|
||||
-- OVERWORLD maps fill beyond-edge space from VOID FILL (default trees $0F);
|
||||
-- per-map borders like Pallet's all-grass $0B only apply to other tilesets
|
||||
TileRenderer.setVoidFill("trees")
|
||||
eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x0F,
|
||||
"OVERWORLD border fill uses the tree wall block")
|
||||
eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7,
|
||||
"interior border fill keeps the map's border block")
|
||||
eq(TileRenderer.borderBlockFor({ def = Data.maps.PALLET_TOWN }), 0x0F,
|
||||
"Pallet Town border fill is trees, not its all-grass border block")
|
||||
TileRenderer.setVoidFill("water")
|
||||
eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), 0x43,
|
||||
"VOID FILL water uses the solid water border block")
|
||||
TileRenderer.setVoidFill("black")
|
||||
eq(TileRenderer.borderBlockFor({ def = { tileset = "OVERWORLD", borderBlock = 33 } }), false,
|
||||
"VOID FILL black disables the tiled border block")
|
||||
eq(TileRenderer.borderBlockFor({ def = { tileset = "HOUSE", borderBlock = 7 } }), 7,
|
||||
"VOID FILL does not change interior borders")
|
||||
TileRenderer.setVoidFill("trees")
|
||||
local treeWallCuttable = false
|
||||
for _, swap in ipairs(Data.field.cutTreeSwaps) do
|
||||
if swap.before == 0x0F then treeWallCuttable = true end
|
||||
|
||||
Reference in New Issue
Block a user