Compare commits

...

85 Commits

Author SHA1 Message Date
bryanthaboi fef844e220 Merge pull request #663 from bryanthaboi/dev 2026-08-02 08:49:26 -04:00
bryanthaboi 0f45bb5792 ci: push iOS app-repo.json to main via release deploy key
The release workflow's direct commit of mobile/ios/app-repo.json was
rejected once main went PR-only (GH006). main's protection now lives in
a ruleset whose only bypass actor is deploy keys, so the step commits
with git and pushes over SSH using the RELEASE_DEPLOY_KEY secret
instead of EndBug/add-and-commit with GITHUB_TOKEN.

Also syncs the iOS app repo steps and app-repo.json into dev; they
previously existed only on main.
2026-08-02 08:45:35 -04:00
bryanthaboi c195c5da7d Merge pull request #662 from bryanthaboi/dev 2026-08-02 08:28:57 -04:00
bryanthaboi cb26367f46 Merge pull request #655 from spiritsnails/fix/warden-yesno-645 2026-08-02 08:26:05 -04:00
bryanthaboi 31dc89c0d7 Merge pull request #652 from spiritsnails/fix/enemy-sendout-early-sprite 2026-08-02 08:24:32 -04:00
bryanthaboi c95c3003f7 Merge pull request #661 from castdrian/metal 2026-08-02 08:24:10 -04:00
bryanthaboi 35b3fa6d9c CLOSES #455, CLOSES #487, CLOSES #501, CLOSES #540, CLOSES #585, CLOSES #591, CLOSES #593, CLOSES #595, CLOSES #597, CLOSES #599, CLOSES #600, CLOSES #606, CLOSES #607, CLOSES #610, CLOSES #613, CLOSES #616, CLOSES #620, CLOSES #626, CLOSES #632, CLOSES #633, CLOSES #647 2026-08-02 08:19:28 -04:00
Adrian Castro e217d62f42 chore(ci): add iOS build metadata to artifact comment 2026-08-02 13:44:59 +02:00
Adrian Castro e62801caaf chore: remove legacy install badge filenames 2026-08-02 13:42:16 +02:00
Adrian Castro e6008bb299 chore: simplify install badge filenames 2026-08-02 13:41:51 +02:00
Adrian Castro 9a49646d8a feat(ios): add app repository and install badges 2026-08-02 13:34:26 +02:00
spiritsnails 9934e4d765 fix: Warden's House gibberish line offers YES/NO
WardensHouseWardenText prints Gibberish1, calls YesNoChoice, and answers
with Gibberish2 on yes / Gibberish3 on no (scripts/WardensHouse.asm). The
port printed the question and ended the script. Both reply strings were
already extracted and unused, so this is script wiring only.

The branch targets become labels: they were hand-numbered absolute rows,
already mis-pointed once (#535), and any insert meant renumbering jumps
that had no way to announce they were stale.

Also fixes the parity suite's show_text instrumentation, which stopped
forwarding arguments at `subs` and so dropped the extraOpts carrying
Commands.ask's choice callback -- silently turning every ask under test
back into a plain show_text.
2026-08-02 00:34:46 -06:00
spiritsnails 2b75c07571 fix: enemy mon appears before its send-out animation
The trainer intro was the only enemy send-out path that never set
enemySendingOut, so the front sprite drew at full size the moment the
trainer pic walked off, held through "X sent out Y!", and the grow-in
then played over a mon that had already arrived. Set it with the pic
teardown and clear it with startGrowIn, matching the mid-battle
replacement and the player's own send-out.

Also repairs two parity suites that could not load at all: their game
stubs lacked input.isDown, which battle text has read every frame since
typing started honouring PrintLetterDelay. intro_chrome additionally
pressed A inside PromptText's ProtectedDelay3 hold, which ignores the
button for TEXT_PRE_ADVANCE frames.
2026-08-01 23:55:23 -06:00
bryanthaboi ebbc55c4d0 Merge pull request #586 from andimiller/surfing-pikachu-fix
feat(yellow): port SurfingPikachu behaviour
2026-08-02 00:58:32 -04:00
bryanthaboi 4e6ee3629f Merge pull request #649 from spiritsnails/fix/battle-fixed-ui-scale
fix: battle screens keep their composition when they open a menu or prompt
2026-08-02 00:58:14 -04:00
spiritsnails 46bd0f6709 fix: battle screens keep their composition when they open a menu or prompt
BATTLE SIZE "fixed" draws the battle as a discrete letterbox rather than
filling the window, and BATTLE BG "world" composes it over the live map.
Everything the battle then opens broke out of that composition, because
each piece of the frame's geometry was read off a fact about THIS FRAME
instead of about the battle:

* Renderer:uiScale follows the survey zoom only while a world is behind
  the UI, gated on worldActive -- this frame's world pass.  PartyMenu and
  ListMenu are opaque, so pushing one makes StateStack:visibleBase skip
  the map, no world pass runs, and the menu loses the step-down and blits
  a whole integer scale larger than the battle it just covered.  Held
  with uiWorldHold, the same whole-stack rule uiFill and the battle dim
  already use.  ("fill" hid this: it overrides the scale outright.)

* Game:draw started the frame at visibleBase, so that same opaque menu
  cut the overworld -- and the world pass with it -- out of the frame
  entirely, collapsing a "world" backdrop to endFrame's flat black clear.
  A world-bg battle now keeps the frame starting from underneath itself
  (drawBaseInStack).  Only the START of the draw moves; the clear stays
  keyed to the real visibleBase, so the menu still gets its opaque canvas
  and draws exactly as before.

* worldZones was keyed to that same clear base, so it came out nil for a
  frame whose world pass HAD run -- dropping endFrame's world blit onto
  the UI zone list instead, smearing the party menu's own HP-bar palettes
  across a world-canvas-sized image.  Keyed to whether the map drew.

* endFrame's letterbox clear read letterboxWhite off visibleBase alone,
  so an opaque menu over a BG "white" battle flipped its surround to
  black the same way.  Same whole-stack hold.

* ChoiceBox bottom-anchored unconditionally, docking it to the WINDOW
  edge.  That is only right when it rides the dialogue box beneath it,
  which is anchored there too; TextBox now passes the anchor and nothing
  else does, so the battle's switch offer and the shop/PC confirms stay
  over the screen that pushed them.

* TextBox anchors likewise: a battle is a self-contained SCREEN, not the
  window, and pokered prints its text box in the same 160x144 tilemap as
  the HUD.  The caught-mon nickname prompt was landing a whole letterbox
  below the blanked battle field it is printed on.  BattleState.holdsUI-
  Anchors holds setUIAnchor off while a battle is in the stack; the
  overworld's own dialogue box still docks to the screen edge.
2026-08-01 22:55:58 -06:00
bryanthaboi e6c1ed8753 Merge pull request #643 from spiritsnails/fix/yesno-box-overlap
fix: yes/no box popping up after the question text closes, not over it
2026-08-02 00:14:47 -04:00
spiritsnails 2b92562538 fix: yes/no box popping up after the question text closes, not over it
several places (Commands.ask, the give-a-nickname prompt, PC box release/
change confirms, start menu save/quit) pushed a bare ChoiceBox after the
preceding text box already popped on an A press, instead of riding
TextBox's opts.choice like the rest of the engine. the YES/NO box now
comes up while the question is still on screen, matching the original.
2026-08-01 21:36:30 -06:00
bryanthaboi 078bb1ade6 Merge pull request #634 from bryanthaboi/dev 2026-08-01 21:19:29 -04:00
bryanthaboi f205464aa4 Merge pull request #627 from spiritsnails/parity-fixes 2026-08-01 21:18:52 -04:00
spiritsnails b820d3917c fixing failed tests/harnesses 2026-08-01 19:13:03 -06:00
bryanthaboi fea81f03fc Merge branch 'dev' 2026-08-01 21:10:08 -04:00
bryanthaboi 75aa71a886 Merge pull request #629 from bryanthaboi/dependabot/github_actions/actions/upload-artifact-7
build(deps): bump actions/upload-artifact from 4 to 7
2026-08-01 20:54:55 -04:00
bryanthaboi 398eefc380 Merge pull request #630 from bryanthaboi/dependabot/github_actions/actions/checkout-7
build(deps): bump actions/checkout from 4 to 7
2026-08-01 20:54:41 -04:00
bryanthaboi b549e319c2 Update README.md 2026-08-01 20:54:08 -04:00
dependabot[bot] 1a57b67e14 build(deps): bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 23:57:13 +00:00
bryanthaboi 378246728b Merge pull request #628 from spiritsnails/fix/oaks-lab-rival-music 2026-08-01 19:57:10 -04:00
dependabot[bot] 41e326f376 build(deps): bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 23:57:09 +00:00
bryanthaboi f9adeca5ca Merge pull request #618 from castdrian/metal 2026-08-01 19:56:29 -04:00
bryanthaboi 838f87c224 Merge pull request #619 from ShaneMcGovernIE/fix/viridian-old-man-yellow-617 2026-08-01 19:55:49 -04:00
spiritsnails 5b519165fc Fix #596: missing rival battle music in Oak's Lab
The Yellow port (oaks_lab_yellow.lua) already swaps in Music_MeetRival
for the "Hey wait! Let's check out our Pokemon!" challenge and restores
Music_OaksLab once the rival walks out, but the Red/Blue script never
did, leaving the lab battle silent before and after the fight.
2026-08-01 17:41:35 -06:00
spiritsnails 6b012a31ce Guarantee the post-battle fade-in by moving it into BattleState:finish()
pushBattle wrapped onFinish to push Transition.battleReturn on any
non-lose result, but that only fires for callers that route through
pushBattle correctly. Moving the same push into BattleState:finish()
instead -- the one choke point every battle (wild, trainer, walk-up,
scripted, link) already passes through on exit -- makes the fade
unconditional rather than dependent on each call site's wiring.
2026-08-01 16:41:51 -06:00
Adrian Castro 1a2b23a066 ci: configure dependabot for actions 2026-08-02 00:26:48 +02:00
spiritsnails f109530c5f Route scripted battles through pushBattle so the entry wipe actually plays
Commands.lua's start_battle and old-man-demo battle commands pushed
BattleState straight onto the stack, bypassing pushBattle (and therefore
BattleTransition) entirely. Every script-triggered battle -- gym leaders,
the rival, Giovanni, the catch tutorial -- cut straight to the battle
screen with no transition wipe. Only the walk-up trainer-sight path
(OverworldState:engageTrainer) went through pushBattle already.
2026-08-01 16:12:29 -06:00
spiritsnails 4cb132199c Fix ADVANCED palette pack using Gen 2 per-species palettes instead of Gen 1's
pokered-gbc's palettes.asm carries two species->palette tables gated on
GEN_2_GRAPHICS; data/palettes_gbc.lua had imported the per-species Gen 2
table, shaded for Gen 2 sprite art this port doesn't use, instead of Gen
1's own assignments. Bulbasaur wore PAL_BULBASAUR's red-orange, Squirtle
wore PAL_SQUIRTLE's shell brown on his head. Palette values are unchanged;
only which palette each species points at is corrected.
2026-08-01 16:04:22 -06:00
Adrian Castro fe16b3259c ci(ios): comment artifacts after pull request builds 2026-08-02 00:01:53 +02:00
Adrian Castro 2e46954fe0 ci(ios): use release signing in verification 2026-08-01 23:56:04 +02:00
spiritsnails aedc63c40d Port timing/parity fixes, seamless battle transitions, faithful-res lock, and zoom-aware UI anchoring
Ports from a downstream fork, hand-surgered hunk-by-hunk to exclude the
fork's randomizer/pokescript work and to skip a FixedStep jitter-tolerance
attempt that never fixed the stutter it targeted.

- src/core/Timing.lua: hardware-accurate frame-delay catalog ported from
  pret/pokered, feeding BattleState:waitNext, EffectRegistry's miss/crit
  beats, TextBox/ChoiceBox scroll and prompt holds, and the battle
  silhouette slide/shake/blink/faint timings.
- Seamless battle transitions: Renderer:drawBattleWipe replaces the old
  160x144-only cascade with one wipe drawn over the whole surface at any
  zoom or window size; BattleTransition's per-style frame lengths are
  corrected against pokered-c's derivation; Transition.battleReturn adds
  the post-battle GBFadeInFromWhite the port never had.
- BATTLE SIZE / BATTLE BG options (BattleState:wantsFillScale/bgMode,
  Game.fillScaleInStack/worldBgBattleDim): battle surface can fill the
  window instead of the fixed integer letterbox, and the area around it
  can show white/black/the dimmed overworld instead of only white.
- src/core/FaithfulRes.lua: locks the window to an exact 160x144 multiple.
- Zoom-aware UI anchoring: Renderer:uiScale steps the UI down with survey
  zoom (gated to worldActive so the title/intro never shrink);
  Renderer:setUIAnchor lets TextBox, ChoiceBox, and an opted-in Menu
  (the START menu) pin themselves to a screen edge instead of the
  zoomed-out letterbox.
2026-08-01 15:52:34 -06:00
Adrian Castro 00d13d3c35 ci(ios): comment release artifacts on pull requests 2026-08-01 23:43:12 +02:00
Adrian Castro 1b8b3ad538 ci(ios): build device release on canonical repo 2026-08-01 23:43:11 +02:00
Adrian Castro 5f75cfd691 ci: verify release builds on fork runners 2026-08-01 23:43:11 +02:00
Adrian Castro 5f89def2ce ci: select hosted runners for forks 2026-08-01 23:43:08 +02:00
Adrian Castro 7c26eb9a24 ci(ios): gate hosted build on iOS changes 2026-08-01 23:43:07 +02:00
Shane McGovern d951fe8fc5 Fix Viridian old man tutorial dialog in Yellow (#617) 2026-08-01 22:38:09 +01:00
bryanthaboi ac01135ca8 Merge pull request #615 from bryanthaboi/dev 2026-08-01 16:53:54 -04:00
bryanthaboi 5e89e35e02 Merge pull request #614 from castdrian/metal 2026-08-01 16:52:41 -04:00
bryanthaboi 7e7afeaf82 Mask the self-hosted runner's home path in release logs 2026-08-01 16:45:37 -04:00
Adrian Castro c6fa6d294b fix(ios): support LÖVE 12 file bridge 2026-08-01 22:22:49 +02:00
bryanthaboi fc2d17fc78 Merge pull request #612 from bryanthaboi/dev 2026-08-01 16:03:28 -04:00
Andi Miller 6fb5aa08ff Merge branch 'dev' into surfing-pikachu-fix 2026-08-01 21:02:06 +01:00
bryanthaboi 9f072285a6 Merge pull request #611 from ShaneMcGovernIE/fix/oak-last-ball-601 2026-08-01 16:00:52 -04:00
bryanthaboi c58ebe4b9b Merge pull request #609 from castdrian/metal 2026-08-01 15:59:33 -04:00
Shane McGovern 0dd187fe30 Fix wrong dialogue on Prof. Oak's leftover ball (#601)
After the player picks a starter and the rival takes his, every ball on
the lab table fell through to 'Those are POKé BALLs' instead of the
leftover-mon line.  Port the pokered OaksLabSelectedPokeBallScript ->
OaksLabLastMonScript beat: with EVENT_GOT_STARTER set, Oak turns to face
the player and reads 'That's PROF.OAK's last Pokémon!'.  The ROM's
'#MON' ligature is spelled out as Pokémon.

The renumbering also drops the table's nine out-of-range 'jump 21'
rows (run-time 'end' idioms) for explicit "end" targets, so the
script now validates cleanly.

Tests: T2 suite drives the ball talk table through a ScriptRunner-
compatible executor (leftover text, pre-escort text, pre-pick offer)
plus a T3 driver that talks to the leftover ball in a real game.
2026-08-01 20:55:31 +01:00
Adrian Castro ea78792c03 fix(ios): build releases with LÖVE 12 2026-08-01 21:45:23 +02:00
bryanthaboi 9954225701 Merge pull request #603 from bryanthaboi/dev 2026-08-01 14:32:23 -04:00
bryanthaboi 6bb2e078c0 Scrub mod manifest strings to valid UTF-8
A manifest whose name, version, description, or category carries invalid
UTF-8 (a BOM, Latin-1 bytes) crashed the launcher's MODS panel, since
love.graphics.printf raises on invalid UTF-8. Manifest.validate now drops
invalid bytes and a leading BOM from those strings, in place so the
badge's raw.category read agrees.
2026-08-01 14:22:30 -04:00
bryanthaboi 323c59e54f Merge pull request #594 from bryanthaboi/dev 2026-08-01 13:03:22 -04:00
bryanthaboi d26d63ed38 Merge pull request #588 from techmore/fix/prof-oaks-pc-flow 2026-08-01 12:58:03 -04:00
bryanthaboi 2009df3dd1 CLOSES #575, CLOSES #578, CLOSES #584, CLOSES #589 2026-08-01 12:56:13 -04:00
techmore f0f3e9634b fix: restore PROF. OAK's PC intro, jingle timing and closing link
CLOSES #576

The launcher skipped oaks_pc.asm's whole session -- the access text and
the 'Want to get your #DEX rated?' YES/NO -- and played the Pokedex_Rating
jingle the moment the entry was picked, before any text printed.  Now the
access text types out, the YES/NO pops, and only once the completion line
and the rating tier have printed does the jingle sound (DisplayDexRating
-> PlayPokedexRatingSfx, auto.wait hands the box to the A/B path), then
the 'Closed link to PROF.OAK's PC.' tail closes the session.
2026-08-01 11:01:19 -04:00
Andi Miller 1fe35335a5 feat(yellow): port SurfingPikachu behaviour
Yellow's IsSurfingPikachuInParty swaps the player's overworld sheet to
a Pikachu-on-a-surfboard when the party mon that knows SURF is a
Pikachu. The recomp was missing both halves of this: the sheet was
never extracted, and the engine had no seam for the swap.

Extraction: SurfingPikachuSprite (gfx/sprites/surfing_pikachu.2bpp)
loads outside SpriteSheetPointerTable via
LoadSurfingPlayerSpriteGraphics2, the same bypass RedBikeSprite uses.
Added the symbol to the Yellow manifest and a parallel extract in
RomExtractor / build_rom_data / extract/sprites, minting
SPRITE_SURFING_PIKACHU. PaletteFX colors it (player OBP palette, same
as the bike).

Engine: new field.playerSprites.surfPikachu (default
SPRITE_SURFING_PIKACHU, guarded so before extraction the ride keeps
the Seel). Player.new caches surfPikachuSprite; pose() picks it when
surfing and the SURF-mon is a Pikachu. New
OverworldState:syncSurfingPikachu derives the flag from partyKnows at
every surf-state toggle (mount, dismount, fly, teleport, blackout,
forced-surf tile, boot-restore). Runtime-only, re-derived at load so a
party change between save and load is honored.

Lane B: RFC at docs/rfcs/0001-surfing-pikachu-sprite.md.
Backward-compatible — existing mods see no change (surf still defaults
to SPRITE_SEEL; surfPikachu only resolves on a Yellow import after
regeneration). Parity tests in tests/parity_surfing_pikachu_sprite.lua
(12/12) and tests/mod_world_tests.lua (19229/19229 with the new
boot-seed checks). tests/parity_cinnabar_east_surf.lua (24/24)
unchanged.

Regeneration required: re-run make_yellow_manifest.py against a
pret/pokeyellow checkout, then re-import the Yellow ROM.
2026-08-01 15:50:22 +01:00
bryanthaboi ca1beaefe7 Merge pull request #582 from castdrian/metal 2026-08-01 10:41:40 -04:00
bryanthaboi 24cf1b3317 Merge pull request #583 from BartInTheField/fix-encounter-slide-577 2026-08-01 10:41:25 -04:00
Bart in 't Veld 88e2ec2042 Fix encounter silhouette slide speed and blackout (#577)
The battle intro slide ran at 4px/frame over 40 frames -- twice the
original speed -- and drew the pics in their normal palette instead of
as black silhouettes.

SlidePlayerAndEnemySilhouettesOnScreen scrolls SCX from $90 to 0 at
2px/frame (~72 frames) under the %11100100 silhouette palette, only
running SET_PAL_BATTLE once the pics land. Match that: introSlide starts
at 80 with a 2px/frame offset (80 frames over the full 160px width), and
picImage bakes both pics through PAL_BLACK while introSlide > 0, exactly
like the evolution movie (#279).

parity_battle_intro_chrome waited a hardcoded 45 frames for the slide to
land; bumped to 85 to cover the slower slide.
2026-08-01 15:25:54 +02:00
Adrian Castro be60c7c1d5 fix(ios): present voxel pipeline canvases 2026-08-01 14:37:18 +02:00
bryanthaboi 9be21380c7 Merge pull request #581 from bryanthaboi/dev
bug smashing on a saturday
2026-08-01 08:14:38 -04:00
bryanthaboi 22fd196045 Merge pull request #579 from castdrian/metal
feat: metal support and make ios usable
2026-08-01 08:13:34 -04:00
bryanthaboi d5293dffb8 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-08-01 08:10:22 -04:00
bryanthaboi 9326b07583 CLOSES #415, CLOSES #484, CLOSES #488, CLOSES #492, CLOSES #497, CLOSES #541, CLOSES #559, CLOSES #562, CLOSES #563, CLOSES #564, CLOSES #565, CLOSES #566, CLOSES #567, CLOSES #568, CLOSES #569, CLOSES #570, CLOSES #571, CLOSES #572 2026-08-01 08:10:20 -04:00
Adrian Castro 90437a5d8d fix(ios): export saves from Love directory 2026-08-01 13:07:36 +02:00
Adrian Castro c4ec5dc1c5 test(ios): cover save export picker 2026-08-01 12:58:42 +02:00
bryanthaboi 3b19826f1e Merge pull request #543 from BartInTheField/render-second-screen-seam 2026-08-01 06:56:38 -04:00
Adrian Castro 95e4715bc3 fix(ios): declare Love 12 compatibility 2026-08-01 12:54:05 +02:00
Adrian Castro 81edc97d86 fix(ios): package updater boot module 2026-08-01 12:44:26 +02:00
Adrian Castro 92c8bac714 fix(ios): support direct picker results 2026-08-01 12:36:18 +02:00
Adrian Castro 1df04aa18d fix(ios): route native picker results 2026-08-01 12:32:22 +02:00
Bart in 't Veld 218b9249a2 render: add render.compose seam + second-screen bridge
Expose a generic seam so a mod can drive a second screen without the
engine owning any dual-screen layout policy:

- render.compose hook in Renderer:endFrame hands a mod the finished
  world + UI canvases, their SGB zones, the frame metrics,
  Renderer:blitCanvas (lifted from the internal blit closure) and the
  SecondScreen bridge. Return true to take over the window; no wrap (or
  calling next) runs the normal single-window composite byte-for-byte.
- SecondScreen.lua + the Android Presentation bridge (love_android_
  secondary_* in common/android.cpp, GameActivity secondary display)
  as the optional physical-second-display transport.

No battle-render changes: a mod lays out the two screens (including any
battle split) itself. Ships with a unit test, no-mod parity via
gate_hooks, and docs/modding.md (D14).
2026-08-01 09:35:24 +02:00
bryanthaboi 50947eec2a Merge pull request #489 from mresnick67/android-step-bridge 2026-08-01 00:21:40 -04:00
bryanthaboi 2624bb0210 Merge pull request #537 from sirj0k3r/hook/exp 2026-08-01 00:21:20 -04:00
bryanthaboi 6e9685f302 Merge pull request #538 from sirj0k3r/hook/low-health 2026-08-01 00:21:10 -04:00
sirj0k3r 33899c237e + Implemented tests for battle.low_health_alarm hook 2026-07-31 22:17:19 +01:00
sirj0k3r 0fad222569 + Implemented tests for battle.catch_exp and battle.exp_award hooks 2026-07-31 22:10:26 +01:00
sirj0k3r ddcce5b95a + Implemented Exp hook 2026-07-31 21:14:23 +01:00
sirj0k3r 6fd9741fab + Implemented Low Health Alert hook for modding 2026-07-31 21:12:21 +01:00
Myles Resnick caa65182dc Android: hardware step-counter bridge for step-sync mods
love.system.syncHealthSteps() now exists on Android, matching the iOS
Health bridge merged in #452 and using the same JNI route as the SAF
picker (wrap_System.cpp -> System.cpp -> common/android.cpp ->
GameActivity over JNI):

- GameActivity.syncHealthSteps: one-shot read of the hardware
  TYPE_STEP_COUNTER sensor (cumulative since boot, counted by the OS
  whether or not any app runs). The reading is anchored in
  SharedPreferences so a walk is never credited twice; a reading below
  the anchor means the phone rebooted, which re-anchors without
  crediting. Deltas (50k clamp) merge into steps_pending.json in the
  save identity dir - the same contract as the iOS GRHealthBridge, so
  the Pokewalker mod works unchanged on both platforms.
- ACTIVITY_RECOGNITION declared in the app manifest (Android 10+
  runtime prompt on first sync; granted -> the sensor read runs
  immediately via onRequestPermissionsResult). The build script's
  permission trim leaves it alone.
- Nothing in the base game calls the new seam; without a consumer mod
  the only cost is one dormant manifest permission.
- build_android.sh: shadow-build from a space-free temp dir when the
  checkout path contains spaces - ndk-build is GNU make underneath and
  cannot cope with paths like "xCode Projects".
- mobile/ANDROID.md: step-bridge dev notes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:11:54 -04:00
209 changed files with 20197 additions and 1078 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

+83 -6
View File
@@ -10,8 +10,6 @@ name: ci
# The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects # The T3 content tier asserts Pokemon Red facts; scripts/test.sh detects
# data/generated/ is absent and skips it rather than failing. # data/generated/ is absent and skips it rather than failing.
# #
# Runs alongside release.yml, which is untouched by this file.
on: on:
push: push:
# Integration branch + release branch. PRs already run via pull_request # Integration branch + release branch. PRs already run via pull_request
@@ -24,12 +22,91 @@ concurrency:
group: ci-${{ github.ref }} group: ci-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
ios-changes:
name: detect iOS changes
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.paths.outputs.changed }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: paths
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.sha }}
run: |
if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(mobile/ios/|scripts/build_ios\.sh$|\.github/workflows/(ci|release)\.yml$)'; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
ios-build:
name: iOS build
needs: ios-changes
if: needs.ios-changes.outputs.changed == 'true'
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
outputs:
ipa_url: ${{ steps.upload-ipa.outputs.artifact-url }}
steps:
- uses: actions/checkout@v7
- name: import signing certificate
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
keychain_path="$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db"
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
p12="$ci_dir/signing.p12"
passfile="$ci_dir/signing.pass"
[ -f "$p12" ] && [ -f "$passfile" ] || exit 1
p12pw="$(cat "$passfile")"
kcpw="$(openssl rand -base64 24)"
echo "::add-mask::$kcpw"
security delete-keychain "$keychain_path" 2>/dev/null || true
security create-keychain -p "$kcpw" "$keychain_path"
security set-keychain-settings "$keychain_path"
security unlock-keychain -p "$kcpw" "$keychain_path"
security import "$p12" -P "$p12pw" -k "$keychain_path" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kcpw" "$keychain_path" >/dev/null
existing="$(security list-keychains -d user | sed -e 's/^[[:space:]]*//' -e 's/"//g')"
security list-keychains -d user -s "$keychain_path" $existing
- name: install xcbeautify
run: brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: build iOS release
env:
CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
if [ "$CANONICAL_REPOSITORY" = true ]; then
scripts/build_ios.sh --fetch --device --release
else
scripts/build_ios.sh --fetch --release
fi
- name: upload iOS release artifact
id: upload-ipa
if: github.repository == 'bryanthaboi/gen1recomp'
uses: actions/upload-artifact@v7
with:
name: gen1recomp-ios-ipa
path: dist/ios/gen1recomp.ipa
if-no-files-found: error
retention-days: 7
- name: clean up signing keychain
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: security delete-keychain "$RUNNER_TEMP/gen1recomp-ci-signing.keychain-db" 2>/dev/null || true
headless: headless:
name: headless suites (no ROM) name: headless suites (no ROM)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
# LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is # LuaJIT, not lua5.4: LOVE 11.x embeds LuaJIT 2.1 and the engine is
# written to Lua 5.1 semantics, so CI must run the interpreter the # written to Lua 5.1 semantics, so CI must run the interpreter the
@@ -47,7 +124,7 @@ jobs:
name: fixture dataset integrity name: fixture dataset integrity
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y luajit - run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow - run: python3 -m pip install --upgrade pillow
@@ -89,7 +166,7 @@ jobs:
name: screenshot differ (capture not yet wired) name: screenshot differ (capture not yet wired)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- run: python3 -m pip install --upgrade pillow - run: python3 -m pip install --upgrade pillow
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots # 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
@@ -137,7 +214,7 @@ jobs:
name: mod lint (no ROM-derived content) name: mod lint (no ROM-derived content)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
# the MK305 dump check key-diffs shipped tables through luajit, and # the MK305 dump check key-diffs shipped tables through luajit, and
# modkit treats a missing interpreter as a fatal MK100 -- without # modkit treats a missing interpreter as a fatal MK100 -- without
@@ -0,0 +1,53 @@
name: iOS artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
pull-requests: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- id: artifact
env:
GH_TOKEN: ${{ github.token }}
RUN_ID: ${{ github.event.workflow_run.id }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }}
run: |
artifact_id="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts" --jq '.artifacts[] | select(.name == "gen1recomp-ios-ipa") | .id')"
[ -n "$artifact_id" ] || exit 0
head_owner="${HEAD_REPOSITORY%%/*}"
pr_number="$(gh api "repos/$GITHUB_REPOSITORY/pulls?state=open&head=$head_owner:$HEAD_BRANCH" --jq '.[0].number // empty')"
[ -n "$pr_number" ] || exit 0
echo "artifact_url=https://github.com/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts/$artifact_id" >> "$GITHUB_OUTPUT"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
- name: Get build info
id: build-info
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
commit_hash="$(printf '%s' "$HEAD_SHA" | cut -c1-7)"
build_time="$(date "+%Y-%m-%d %H:%M:%S")"
echo "hash=$commit_hash" >> "$GITHUB_OUTPUT"
echo "time=$build_time" >> "$GITHUB_OUTPUT"
- name: comment iOS artifact
if: steps.artifact.outputs.pr_number != ''
uses: thollander/actions-comment-pull-request@v3
with:
message: |
[gen1recomp.ipa](${{ steps.artifact.outputs.artifact_url }})
**Commit**: [#${{ steps.build-info.outputs.hash }}](https://github.com/${{ github.event.workflow_run.head_repository.full_name }}/commit/${{ github.event.workflow_run.head_sha }})
**Build Time**: `${{ steps.build-info.outputs.time }}`
<sub>This comment was automatically generated. [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})</sub>
pr-number: ${{ steps.artifact.outputs.pr_number }}
comment-tag: ios-build-result
github-token: ${{ github.token }}
+88 -9
View File
@@ -24,6 +24,7 @@ on:
paths-ignore: paths-ignore:
- '.github/**' - '.github/**'
- '**.md' - '**.md'
- 'mobile/ios/app-repo.json'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
version: version:
@@ -42,11 +43,17 @@ concurrency:
jobs: jobs:
release: release:
runs-on: [self-hosted, macOS] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
steps: steps:
# The self-hosted runner lives under the machine owner's home
# directory; mask it first so absolute paths in every later step's
# output show up as *** in the public workflow logs.
- name: Mask runner paths
run: echo "::add-mask::$HOME"
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
fetch-tags: true fetch-tags: true
@@ -121,6 +128,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Import signing certificate into a temporary keychain - name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: | run: |
set -euo pipefail set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db" KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
@@ -170,14 +178,23 @@ jobs:
set -euo pipefail set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}" scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Build iOS - name: Install xcbeautify
run: | run: |
set -euo pipefail set -euo pipefail
# Device Release IPA; signs with the Apple Development identity on brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
# the runner (auto team detection). Users on other Apple IDs still
# re-sign or build via docs/ios-install.md. - name: Build iOS
scripts/build_ios.sh --fetch --device --release \ env:
--version "${{ steps.ver.outputs.version }}" CANONICAL_REPOSITORY: ${{ github.repository == 'bryanthaboi/gen1recomp' }}
run: |
set -euo pipefail
if [ "$CANONICAL_REPOSITORY" = true ]; then
scripts/build_ios.sh --fetch --device --release \
--version "${{ steps.ver.outputs.version }}"
else
scripts/build_ios.sh --fetch --release \
--version "${{ steps.ver.outputs.version }}"
fi
- name: Build Anbernic RG34XXSP port - name: Build Anbernic RG34XXSP port
run: | run: |
@@ -187,6 +204,7 @@ jobs:
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}" ./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app - name: Notarize & staple macOS app
if: github.repository == 'bryanthaboi/gen1recomp'
run: | run: |
set -euo pipefail set -euo pipefail
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}" ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
@@ -221,6 +239,7 @@ jobs:
echo "Notarized + stapled ✓" echo "Notarized + stapled ✓"
- name: Stage release assets - name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp'
id: assets id: assets
run: | run: |
set -euo pipefail set -euo pipefail
@@ -260,6 +279,7 @@ jobs:
cat "$outdir/sha256sums.txt" cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release - name: Publish GitHub Release
if: github.repository == 'bryanthaboi/gen1recomp'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
@@ -364,7 +384,66 @@ jobs:
echo "Published release $tag" echo "Published release $tag"
- name: Update iOS app repository
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
v="${{ steps.ver.outputs.version }}"
ipa="dist/release/gen1recomp-${v}-ios.ipa"
app_repo="mobile/ios/app-repo.json"
[ -f "$ipa" ] || { echo "::error::$ipa not found"; exit 1; }
[ -f "$app_repo" ] || { echo "::error::$app_repo not found"; exit 1; }
date="$(date -u +"%Y-%m-%d")"
size="$(wc -c < "$ipa" | tr -d '[:space:]')"
download_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${v}/gen1recomp-${v}-ios.ipa"
entry="$(jq -n \
--arg version "$v" \
--arg date "$date" \
--arg download_url "$download_url" \
--argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"}')"
if jq -e --arg version "$v" \
'any(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions[]?; .version == $version)' \
"$app_repo" >/dev/null; then
jq --arg version "$v" --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= map(if .version == $version then $entry else . end)' \
"$app_repo" > "$app_repo.tmp"
else
jq --argjson entry "$entry" \
'(.apps[] | select(.bundleIdentifier == "com.theboisclub.gen1recomp").versions) |= [$entry] + .' \
"$app_repo" > "$app_repo.tmp"
fi
mv "$app_repo.tmp" "$app_repo"
# main is PR-only for everyone except deploy keys (the "main protection"
# ruleset's bypass actor), so this push must authenticate with the
# RELEASE_DEPLOY_KEY deploy key over SSH; the workflow's GITHUB_TOKEN
# would be rejected by the branch protection.
- name: Commit iOS app repository
if: github.repository == 'bryanthaboi/gen1recomp'
env:
DEPLOY_KEY: ${{ secrets.RELEASE_DEPLOY_KEY }}
run: |
set -euo pipefail
git add mobile/ios/app-repo.json
if git diff --cached --quiet; then
echo "app-repo.json unchanged; nothing to push"
exit 0
fi
key="$RUNNER_TEMP/release-deploy-key"
printf '%s\n' "$DEPLOY_KEY" > "$key"
chmod 600 "$key"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "chore(ios): update app-repo.json [skip ci]"
git -c core.sshCommand="ssh -i $key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
push "git@github.com:${GITHUB_REPOSITORY}.git" "HEAD:${GITHUB_REF_NAME}"
rm -f "$key"
- name: Clean up signing keychain - name: Clean up signing keychain
if: always() if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: | run: |
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/release-deploy-key"
+10
View File
@@ -118,6 +118,7 @@ not a hard error, so the list can grow without breaking old mods.
| `QUEST` | New story, NPCs, dialogue, cutscenes | content | | `QUEST` | New story, NPCs, dialogue, cutscenes | content |
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul | | `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
| `GRAPHICS` | Sprite / tileset / palette / font changes | content | | `GRAPHICS` | Sprite / tileset / palette / font changes | content |
| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content |
| `AUDIO` | Music, sfx, cries | content | | `AUDIO` | Music, sfx, cries | content |
| `UI` | New or modified screens, menus, overlays | content / overhaul | | `UI` | New or modified screens, menus, overlays | content / overhaul |
| `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content | | `TOOL` | Dev/QoL utilities, overlays, inter-mod libraries | content |
@@ -128,6 +129,15 @@ not a hard error, so the list can grow without breaking old mods.
keeps validating with the value it has shipped since before the taxonomy keeps validating with the value it has shipped since before the taxonomy
existed. existed.
A translation may also set `"language": true` in the manifest. That is the
one claim online play acts on: an install running nothing but verified
translations may take an ONLINE MATCH or a TOURNAMENT instead of being
asked to restart vanilla. The claim is checked, not taken -- the mod
qualifies only if every record it writes lands in `text`, `strings` or
`font`, it wraps no hook, subscribes to no event and requests no
permission. Anything else and it is an ordinary content mod that happens to
ship text.
### 4. `mod.card` ### 4. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies, The manifest is the *engine's* contract: identity, load order, dependencies,
+26
View File
@@ -4,6 +4,9 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
behavior are hand-written Lua; game data and graphics are decoded from a ROM behavior are hand-written Lua; game data and graphics are decoded from a ROM
supplied by the player. supplied by the player.
> [!CAUTION]
> **We are NOT affiliated with the website `gen1recomp[.]com`** That website is not run by this project, was not authorized by us, and we have no idea who operates it. It is impersonating this project; do not download anything from it, and treat anything it hosts or claims as untrustworthy. Even if the site currently links back to this repository, the people behind it can change its content at any time, so nothing on it should ever be trusted. This GitHub repository and the Discord linked below are the only official sources for this project.
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p> <p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu) **SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
@@ -75,6 +78,19 @@ The packaged app contains neither a ROM nor pre-extracted game data. Music,
sound effects, and cries are synthesized while the game runs from compact sound effects, and cries are synthesized while the game runs from compact
audio channel programs copied out of the verified ROM. audio channel programs copied out of the verified ROM.
### A note on Windows Defender warnings
Windows Defender sometimes flags the Windows build with a generic
machine-learning detection such as `Trojan:Win32/Wacatac!ml` (#621). This is
a known false positive: the exe is the official LÖVE runtime with the game
archive appended (the standard way LÖVE games ship), and Defender's
heuristics distrust unsigned executables with appended data. Every release
publishes SHA-256 checksums (`sha256sums.txt`) so you can verify your
download, and you can confirm a flagged file yourself on
[VirusTotal](https://www.virustotal.com), where these builds come back clean
on every engine except Defender's heuristic. False positives are reported to
Microsoft as they come up.
## Controls ## Controls
@@ -195,6 +211,16 @@ Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
build and install from source on a Mac instead, see build and install from source on a Mac instead, see
[docs/ios-install.md](docs/ios-install.md). [docs/ios-install.md](docs/ios-install.md).
<div>
<a href="https://intradeus.github.io/http-protocol-redirector?r=sidestore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/sidestore-badge.png" alt="Add to SideStore" height="60"></a>
&nbsp;
<a href="https://intradeus.github.io/http-protocol-redirector?r=feather://source/https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/feather-badge.png" alt="Add to Feather" height="60"></a>
&nbsp;
<a href="https://intradeus.github.io/http-protocol-redirector?r=altstore://source?url=https://github.com/bryanthaboi/gen1recomp/raw/refs/heads/main/mobile/ios/app-repo.json"><img src="./.github/resources/altstore-badge.png" alt="Add to AltStore" height="60"></a>
&nbsp;
<a href="https://github.com/bryanthaboi/gen1recomp/releases/latest"><img src="./.github/resources/github-badge.png" alt="Download from GitHub" height="60"></a>
</div>
## Handhelds ## Handhelds
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
+1 -1
View File
@@ -49,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480 t.window.minwidth = 480
t.window.minheight = 360 t.window.minheight = 360
end end
t.version = "11.5" t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1 t.window.vsync = 1
t.modules.joystick = true t.modules.joystick = true
t.modules.physics = false t.modules.physics = false
+165 -151
View File
@@ -1683,158 +1683,172 @@ return {
{ 0, 0, 0 }, { 0, 0, 0 },
}, },
}, },
-- Species -> palette NAME, taken from pokered-gbc data/pokemon/palettes.asm
-- ELSE branch -- the one that is NOT gated on GEN_2_GRAPHICS.
--
-- That file has two tables. The IF GEN_2_GRAPHICS branch assigns a
-- PER-SPECIES palette (PAL_BULBASAUR, PAL_SQUIRTLE, ...) authored for Gen 2
-- sprite art; the ELSE branch keeps Gen 1's own assignments (GREENMON,
-- CYANMON, ...). This port extracts Gen 1 pics from the ROM, so the Gen 1
-- branch is the matching one. The per-species table was imported here by
-- mistake, which pointed every mon at colours shaded for different art:
-- Bulbasaur wore PAL_BULBASAUR's red-orange over a sprite that has no red
-- on it, and Squirtle wore PAL_SQUIRTLE's shell brown on his head.
--
-- The palette VALUES below are still pokered-gbc's, so ADVANCED keeps its
-- richer colours -- only which palette each species points at changed.
pokemon = { pokemon = {
ABRA = "ABRA", ABRA = "YELLOWMON",
AERODACTYL = "AERODACTYL", AERODACTYL = "GRAYMON",
ALAKAZAM = "ALAKAZAM", ALAKAZAM = "YELLOWMON",
ARBOK = "ARBOK", ARBOK = "PURPLEMON",
ARCANINE = "ARCANINE", ARCANINE = "REDMON",
ARTICUNO = "ARTICUNO", ARTICUNO = "BLUEMON",
BEEDRILL = "BEEDRILL", BEEDRILL = "YELLOWMON",
BELLSPROUT = "BELLSPROUT", BELLSPROUT = "GREENMON",
BLASTOISE = "BLASTOISE", BLASTOISE = "CYANMON",
BULBASAUR = "BULBASAUR", BULBASAUR = "GREENMON",
BUTTERFREE = "BUTTERFREE", BUTTERFREE = "CYANMON",
CATERPIE = "CATERPIE", CATERPIE = "GREENMON",
CHANSEY = "CHANSEY", CHANSEY = "PINKMON",
CHARIZARD = "CHARIZARD", CHARIZARD = "REDMON",
CHARMANDER = "CHARMANDER", CHARMANDER = "REDMON",
CHARMELEON = "CHARMELEON", CHARMELEON = "REDMON",
CLEFABLE = "CLEFABLE", CLEFABLE = "PINKMON",
CLEFAIRY = "CLEFAIRY", CLEFAIRY = "PINKMON",
CLOYSTER = "CLOYSTER", CLOYSTER = "GRAYMON",
CUBONE = "CUBONE", CUBONE = "GRAYMON",
DEWGONG = "DEWGONG", DEWGONG = "BLUEMON",
DIGLETT = "DIGLETT", DIGLETT = "BROWNMON",
DITTO = "DITTO", DITTO = "GRAYMON",
DODRIO = "DODRIO", DODRIO = "BROWNMON",
DODUO = "DODUO", DODUO = "BROWNMON",
DRAGONAIR = "DRAGONAIR", DRAGONAIR = "BLUEMON",
DRAGONITE = "DRAGONITE", DRAGONITE = "BROWNMON",
DRATINI = "DRATINI", DRATINI = "GRAYMON",
DROWZEE = "DROWZEE", DROWZEE = "YELLOWMON",
DUGTRIO = "DUGTRIO", DUGTRIO = "BROWNMON",
EEVEE = "EEVEE", EEVEE = "GRAYMON",
EKANS = "EKANS", EKANS = "PURPLEMON",
ELECTABUZZ = "ELECTABUZZ", ELECTABUZZ = "YELLOWMON",
ELECTRODE = "ELECTRODE", ELECTRODE = "YELLOWMON",
EXEGGCUTE = "EXEGGCUTE", EXEGGCUTE = "PINKMON",
EXEGGUTOR = "EXEGGUTOR", EXEGGUTOR = "GREENMON",
FARFETCHD = "FARFETCH_D", FARFETCHD = "BROWNMON",
FEAROW = "FEAROW", FEAROW = "BROWNMON",
FLAREON = "FLAREON", FLAREON = "REDMON",
GASTLY = "GASTLY", GASTLY = "PURPLEMON",
GENGAR = "GENGAR", GENGAR = "PURPLEMON",
GEODUDE = "GEODUDE", GEODUDE = "GRAYMON",
GLOOM = "GLOOM", GLOOM = "REDMON",
GOLBAT = "GOLBAT", GOLBAT = "BLUEMON",
GOLDEEN = "GOLDEEN", GOLDEEN = "REDMON",
GOLDUCK = "GOLDUCK", GOLDUCK = "CYANMON",
GOLEM = "GOLEM", GOLEM = "GRAYMON",
GRAVELER = "GRAVELER", GRAVELER = "GRAYMON",
GRIMER = "GRIMER", GRIMER = "PURPLEMON",
GROWLITHE = "GROWLITHE", GROWLITHE = "BROWNMON",
GYARADOS = "GYARADOS", GYARADOS = "BLUEMON",
HAUNTER = "HAUNTER", HAUNTER = "PURPLEMON",
HITMONCHAN = "HITMONCHAN", HITMONCHAN = "BROWNMON",
HITMONLEE = "HITMONLEE", HITMONLEE = "BROWNMON",
HORSEA = "HORSEA", HORSEA = "CYANMON",
HYPNO = "HYPNO", HYPNO = "YELLOWMON",
IVYSAUR = "IVYSAUR", IVYSAUR = "GREENMON",
JIGGLYPUFF = "JIGGLYPUFF", JIGGLYPUFF = "PINKMON",
JOLTEON = "JOLTEON", JOLTEON = "YELLOWMON",
JYNX = "JYNX", JYNX = "MEWMON",
KABUTO = "KABUTO", KABUTO = "BROWNMON",
KABUTOPS = "KABUTOPS", KABUTOPS = "BROWNMON",
KADABRA = "KADABRA", KADABRA = "YELLOWMON",
KAKUNA = "KAKUNA", KAKUNA = "YELLOWMON",
KANGASKHAN = "KANGASKHAN", KANGASKHAN = "BROWNMON",
KINGLER = "KINGLER", KINGLER = "REDMON",
KOFFING = "KOFFING", KOFFING = "PURPLEMON",
KRABBY = "KRABBY", KRABBY = "REDMON",
LAPRAS = "LAPRAS", LAPRAS = "CYANMON",
LICKITUNG = "LICKITUNG", LICKITUNG = "PINKMON",
MACHAMP = "MACHAMP", MACHAMP = "GRAYMON",
MACHOKE = "MACHOKE", MACHOKE = "GRAYMON",
MACHOP = "MACHOP", MACHOP = "GRAYMON",
MAGIKARP = "MAGIKARP", MAGIKARP = "REDMON",
MAGMAR = "MAGMAR", MAGMAR = "REDMON",
MAGNEMITE = "MAGNEMITE", MAGNEMITE = "GRAYMON",
MAGNETON = "MAGNETON", MAGNETON = "GRAYMON",
MANKEY = "MANKEY", MANKEY = "BROWNMON",
MAROWAK = "MAROWAK", MAROWAK = "GRAYMON",
MEOWTH = "MEOWTH", MEOWTH = "YELLOWMON",
METAPOD = "METAPOD", METAPOD = "GREENMON",
MEW = "MEW", MEW = "MEWMON",
MEWTWO = "MEWTWO", MEWTWO = "MEWMON",
MOLTRES = "MOLTRES", MOLTRES = "REDMON",
MR_MIME = "MR_MIME", MR_MIME = "PINKMON",
MUK = "MUK", MUK = "PURPLEMON",
NIDOKING = "NIDOKING", NIDOKING = "PURPLEMON",
NIDOQUEEN = "NIDOQUEEN", NIDOQUEEN = "BLUEMON",
NIDORAN_F = "NIDORAN_F", NIDORAN_F = "BLUEMON",
NIDORAN_M = "NIDORAN_M", NIDORAN_M = "PURPLEMON",
NIDORINA = "NIDORINA", NIDORINA = "BLUEMON",
NIDORINO = "NIDORINO", NIDORINO = "PURPLEMON",
NINETALES = "NINETALES", NINETALES = "YELLOWMON",
ODDISH = "ODDISH", ODDISH = "GREENMON",
OMANYTE = "OMANYTE", OMANYTE = "BLUEMON",
OMASTAR = "OMASTAR", OMASTAR = "BLUEMON",
ONIX = "ONIX", ONIX = "GRAYMON",
PARAS = "PARAS", PARAS = "REDMON",
PARASECT = "PARASECT", PARASECT = "REDMON",
PERSIAN = "PERSIAN", PERSIAN = "YELLOWMON",
PIDGEOT = "PIDGEOT", PIDGEOT = "BROWNMON",
PIDGEOTTO = "PIDGEOTTO", PIDGEOTTO = "BROWNMON",
PIDGEY = "PIDGEY", PIDGEY = "BROWNMON",
PIKACHU = "PIKACHU", PIKACHU = "YELLOWMON",
PINSIR = "PINSIR", PINSIR = "BROWNMON",
POLIWAG = "POLIWAG", POLIWAG = "BLUEMON",
POLIWHIRL = "POLIWHIRL", POLIWHIRL = "BLUEMON",
POLIWRATH = "POLIWRATH", POLIWRATH = "BLUEMON",
PONYTA = "PONYTA", PONYTA = "REDMON",
PORYGON = "PORYGON", PORYGON = "GRAYMON",
PRIMEAPE = "PRIMEAPE", PRIMEAPE = "BROWNMON",
PSYDUCK = "PSYDUCK", PSYDUCK = "YELLOWMON",
RAICHU = "RAICHU", RAICHU = "YELLOWMON",
RAPIDASH = "RAPIDASH", RAPIDASH = "REDMON",
RATICATE = "RATICATE", RATICATE = "GRAYMON",
RATTATA = "RATTATA", RATTATA = "GRAYMON",
RHYDON = "RHYDON", RHYDON = "GRAYMON",
RHYHORN = "RHYHORN", RHYHORN = "GRAYMON",
SANDSHREW = "SANDSHREW", SANDSHREW = "BROWNMON",
SANDSLASH = "SANDSLASH", SANDSLASH = "BROWNMON",
SCYTHER = "SCYTHER", SCYTHER = "GREENMON",
SEADRA = "SEADRA", SEADRA = "CYANMON",
SEAKING = "SEAKING", SEAKING = "REDMON",
SEEL = "SEEL", SEEL = "BLUEMON",
SHELLDER = "SHELLDER", SHELLDER = "GRAYMON",
SLOWBRO = "SLOWBRO", SLOWBRO = "PINKMON",
SLOWPOKE = "SLOWPOKE", SLOWPOKE = "PINKMON",
SNORLAX = "SNORLAX", SNORLAX = "PINKMON",
SPEAROW = "SPEAROW", SPEAROW = "BROWNMON",
SQUIRTLE = "SQUIRTLE", SQUIRTLE = "CYANMON",
STARMIE = "STARMIE", STARMIE = "GRAYMON",
STARYU = "STARYU", STARYU = "REDMON",
TANGELA = "TANGELA", TANGELA = "BLUEMON",
TAUROS = "TAUROS", TAUROS = "GRAYMON",
TENTACOOL = "TENTACOOL", TENTACOOL = "CYANMON",
TENTACRUEL = "TENTACRUEL", TENTACRUEL = "CYANMON",
VAPOREON = "VAPOREON", VAPOREON = "CYANMON",
VENOMOTH = "VENOMOTH", VENOMOTH = "PURPLEMON",
VENONAT = "VENONAT", VENONAT = "PURPLEMON",
VENUSAUR = "VENUSAUR", VENUSAUR = "GREENMON",
VICTREEBEL = "VICTREEBEL", VICTREEBEL = "GREENMON",
VILEPLUME = "VILEPLUME", VILEPLUME = "REDMON",
VOLTORB = "VOLTORB", VOLTORB = "YELLOWMON",
VULPIX = "VULPIX", VULPIX = "REDMON",
WARTORTLE = "WARTORTLE", WARTORTLE = "CYANMON",
WEEDLE = "WEEDLE", WEEDLE = "YELLOWMON",
WEEPINBELL = "WEEPINBELL", WEEPINBELL = "GREENMON",
WEEZING = "WEEZING", WEEZING = "PURPLEMON",
WIGGLYTUFF = "WIGGLYTUFF", WIGGLYTUFF = "PINKMON",
ZAPDOS = "ZAPDOS", ZAPDOS = "YELLOWMON",
ZUBAT = "ZUBAT", ZUBAT = "BLUEMON",
}, },
source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**", source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
world = { world = {
+9 -7
View File
@@ -1,10 +1,9 @@
-- BikeShop (BIKE_SHOP) flavor dialogue -- BikeShop (BIKE_SHOP) flavor dialogue
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm -- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
-- --
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle -- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent -- voucher exchange and the BICYCLE/CANCEL price window need more than
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the -- command rows (#568).
-- scope of these two flavor NPCs and is left unported here.
return { return {
BIKE_SHOP = { BIKE_SHOP = {
@@ -20,12 +19,15 @@ return {
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike -- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
-- before the player owns a bike -> TheseBikesAreExpensiveText -- before the player owns a bike -> TheseBikesAreExpensiveText
-- after the player owns a bike -> CoolBikeText -- after the player owns a bike -> CoolBikeText
-- The check reads the bag, not the event: the port hands out the
-- BICYCLE itself (a key item, so it cannot be tossed), and that also
-- reads right on saves written before the clerk set the event (#567).
TEXT_BIKESHOP_YOUNGSTER = { TEXT_BIKESHOP_YOUNGSTER = {
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2 { "check_item", "BICYCLE" }, -- 2
{ "jump_if_true", 5 }, -- 3 { "jump_if_true", 6 }, -- 3
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4 { "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
{ "jump", 6 }, -- 5 { "jump", "end" }, -- 5
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6 { "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
}, },
}, },
@@ -35,14 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
-- .loop: print WhichBadgeText, then show the badge list menu again -- .loop: print WhichBadgeText, then show the badge list menu again
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function() push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local Strings = require("src.core.Strings")
local items = {} local items = {}
for _, id in ipairs(BADGE_ORDER) do for _, id in ipairs(BADGE_ORDER) do
local idef = game.data.items[id] local idef = game.data.items[id]
items[#items + 1] = { label = idef and idef.name or id, value = id } items[#items + 1] = { label = idef and idef.name or id, value = id }
end end
-- PrintListMenuEntries prints ListMenuCancelText once it hits the
-- list's $FF terminator (home/list_menu.asm), so every
-- DisplayListMenuID list ends in an on-screen CANCEL row; picking it
-- takes DisplayListMenuIDLoop's ExitListMenu path, the same .done
-- exit as B (#569)
items[#items + 1] = { label = Strings("CANCEL") }
local menu = ListMenu.new(game, "", items, { local menu = ListMenu.new(game, "", items, {
onChoose = function(item) onChoose = function(item)
game.stack:pop() game.stack:pop()
if not item.value then
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
return
end
push(game, t[BADGE_TEXT[item.value]], loop) push(game, t[BADGE_TEXT[item.value]], loop)
end, end,
onCancel = function() onCancel = function()
+41
View File
@@ -85,6 +85,47 @@ return {
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText", alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!", alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}), }),
-- Yellow keeps all three giveaways with the same events and amounts
-- but renames the objects and their text labels: FISHING_GURU ->
-- FISHING_GURU1, CLERK2 -> MIDDLE_AGED_MAN2, GENTLEMAN ->
-- FISHING_GURU2 (pokeyellow/scripts/GameCorner.asm). The Red/Blue
-- keys above never match there, so Yellow needs its own three (#552).
TEXT_GAMECORNER_FISHING_GURU1 = coinGiver({
event = "EVENT_GOT_10_COINS",
amount = 10,
askLabel = "_GameCornerFishingGuru1WantToPlayText",
askFallback = "Kid, do you want\nto play?",
receivedLabel = "_GameCornerFishingGuru1Received10CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru1DontNeedMyCoinsText",
coinCaseFullFallback = "You don't need my\ncoins!",
alreadyGotLabel = "_GameCornerFishingGuru1WinsComeAndGoText",
alreadyGotFallback = "Wins seem to come\nand go.",
}),
TEXT_GAMECORNER_MIDDLE_AGED_MAN2 = coinGiver({
event = "EVENT_GOT_20_COINS_2",
amount = 20,
askLabel = "_GameCornerMiddleAgedMan2WantSomeCoinsText",
askFallback = "What's up? Want\nsome coins?",
receivedLabel = "_GameCornerMiddleAgedMan2Received20CoinsText",
coinCaseFullLabel = "_GameCornerMiddleAgedMan2YouHaveLotsOfCoinsText",
coinCaseFullFallback = "You have lots of\ncoins!",
alreadyGotLabel = "_GameCornerMiddleAgedMan2INeedMoreCoinsText",
alreadyGotFallback = "Darn! I need more\ncoins for the\vPOKéMON I want!",
}),
TEXT_GAMECORNER_FISHING_GURU2 = coinGiver({
event = "EVENT_GOT_20_COINS",
amount = 20,
askLabel = "_GameCornerFishingGuru2ThrowingMeOffText",
askFallback = "Hey, what? You're\nthrowing me off!\vHere are some\vcoins, shoo!",
receivedLabel = "_GameCornerFishingGuru2Received20CoinsText",
coinCaseFullLabel = "_GameCornerFishingGuru2YouGotYourOwnCoinsText",
coinCaseFullFallback = "You've got your\nown coins!",
alreadyGotLabel = "_GameCornerFishingGuru2CloselyWatchTheReelsText",
alreadyGotFallback = "The trick is to\nwatch the reels\vclosely!",
}),
}, },
}, },
} }
+99 -19
View File
@@ -9,7 +9,8 @@
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither -- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing. -- tile gates on facing.
local Menu = require("src.ui.Menu") local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm): -- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
@@ -25,33 +26,112 @@ local STATUS_LABELS = {
{ " FRZ", "_ViridianBlackboardFrozenText" }, { " FRZ", "_ViridianBlackboardFrozenText" },
} }
-- The headings list is a two-column menu, which src/ui/Menu.lua does not do
-- (it stacks one column), so the layout lives here (#591). .blackboardLoop:
-- TextBoxBorder at hlcoord 0, 0 with `lb bc, 6, 10` is the 12x8 box,
-- StatusAilmentText1 (" SLP"/" PSN"/" PAR") is placed at hlcoord 1, 2 and
-- StatusAilmentText2 (" BRN"/" FRZ"/" QUIT") at hlcoord 6, 2. LEFT/RIGHT
-- move wTopMenuItemX between those two columns and swap wMenuItemOffset
-- between 0 and 3 while leaving wCurrentMenuItem (the row) alone; UP/DOWN
-- are not in wMenuWatchedKeys, so they only slide the cursor and loop.
local BOARD_LABELS = {}
for i, row in ipairs(STATUS_LABELS) do BOARD_LABELS[i] = row[1] end
BOARD_LABELS[#BOARD_LABELS + 1] = " QUIT"
local BOARD_COL_X = { 1, 6 }
local BOARD_ROW_Y = 2
local BOARD_ROWS = 3
local StatusBoard = {}
StatusBoard.__index = StatusBoard
function StatusBoard.new(game, onPick, onQuit)
return setmetatable({ game = game, col = 1, row = 1, labels = BOARD_LABELS,
onPick = onPick, onQuit = onQuit }, StatusBoard)
end
-- flat index = pokered's wMenuItemOffset (0 or 3) + wCurrentMenuItem (0..2),
-- so 1..5 are the statuses in ViridianBlackboardStatusPointers order and 6
-- is QUIT
function StatusBoard:selection()
return (self.col - 1) * BOARD_ROWS + self.row
end
function StatusBoard:update()
local input = self.game.input
if input:wasPressed("up") then
-- wMenuWrappingEnabled is never set here, so both ends are hard stops
if self.row > 1 then self.row = self.row - 1 end
elseif input:wasPressed("down") then
if self.row < BOARD_ROWS then self.row = self.row + 1 end
elseif input:wasPressed("left") then
self.col = 1
elseif input:wasPressed("right") then
self.col = 2
elseif input:wasPressed("a") or input:wasPressed("b") then
-- HandleMenuInput_ (home/window.asm) beeps for the PAD_A | PAD_B branch,
-- and B and QUIT share .exitBlackboard
require("src.core.Sound").play(self.game.data, "Press_AB")
local sel = self:selection()
if input:wasPressed("b") or sel > #STATUS_LABELS then
self.onQuit()
else
self.onPick(sel)
end
end
end
function StatusBoard:draw()
Font.drawBox(0, 0, 12, 8)
love.graphics.setColor(0, 0, 0, 1)
for i, label in ipairs(BOARD_LABELS) do
local col = i <= BOARD_ROWS and 1 or 2
local row = i - (col - 1) * BOARD_ROWS
Font.draw(label, BOARD_COL_X[col] * 8, (BOARD_ROW_Y + row - 1) * 8)
end
-- wTopMenuItemX equals the column PlaceString started at, so the cursor
-- covers the blank each label leads with
Font.drawCode(Theme.cursor, BOARD_COL_X[self.col] * 8,
(BOARD_ROW_Y + self.row - 1) * 8)
love.graphics.setColor(1, 1, 1, 1)
end
local function blackboard(game) local function blackboard(game)
local text = game.data.text or {} local text = game.data.text or {}
local items, showMenu, askHeading local openBoard
function showMenu() -- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop,
game.stack:push(Menu.new(game, items, -- and nothing inside the loop clears them again: after a status blurb
{ tx = 0, ty = 0, tw = 12, th = 8, rowStep = 1 })) -- `jp .blackboardLoop` comes back with the cursor still on the row and
-- column the player just picked. One StatusBoard lives for the whole
-- reading and is re-pushed each pass, so only entering the blackboard
-- resets to the left column / top row (#591).
local board
-- .blackboardLoop reprints ViridianSchoolBlackboardText2 and only then
-- calls HandleMenuInput, so the prompt is on screen for exactly as long as
-- the headings list is. That text ends in `done`, not `prompt`
-- (data/text/text_2.asm:646), so PrintText returns with the box still up
-- and never waits for a button: TextBox opts.stay holds it open under the
-- list and these callbacks pop the pair together (#591).
local function closeBoard()
game.stack:pop() -- the headings list
game.stack:pop() -- the held "Which heading" box under it
end end
-- ViridianSchoolBlackboardText2 is reprinted on every .blackboardLoop local function pick(i)
-- pass, immediately before HandleMenuInput closeBoard()
function askHeading() game.stack:push(TextBox.new(game,
text[STATUS_LABELS[i][2]] or STATUS_LABELS[i][1], openBoard))
end
function openBoard()
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?", text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
showMenu)) nil, { stay = { onShown = function()
board = board or StatusBoard.new(game, pick, closeBoard)
game.stack:push(board)
end } }))
end end
items = {}
for i, row in ipairs(STATUS_LABELS) do
local label, key = row[1], row[2]
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game, text[key] or label, askHeading))
end }
end
-- no onSelect: Menu's own pop closes the box, matching .exitBlackboard
items[#items + 1] = { label = " QUIT" }
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1 text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.", or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
askHeading)) openBoard))
end end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm): -- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
+2 -1
View File
@@ -48,7 +48,8 @@ end
if GameVersion.isYellow() then if GameVersion.isYellow() then
for _, file in ipairs({ "data.scripts.yellow_gifts", for _, file in ipairs({ "data.scripts.yellow_gifts",
"data.scripts.yellow_jessie_james", "data.scripts.yellow_jessie_james",
"data.scripts.yellow_beach_house" }) do "data.scripts.yellow_beach_house",
"data.scripts.yellow_viridian_old_man" }) do
for mapId, mod in pairs(require(file)) do for mapId, mod in pairs(require(file)) do
MapScripts.attachBase(mapId, mod) MapScripts.attachBase(mapId, mod)
end end
+46 -7
View File
@@ -6,6 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear. -- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText / -- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript. -- OaksLabRivalTakePokeBallScript.
-- * Leftover ball (after the pick): Oak turns and reads the last-mon
-- line instead of re-offering the starter (OaksLabLastMonScript, #601).
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak -- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with -- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party -- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
@@ -23,7 +25,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "jump_if_true", 20 }, -- 2 { "jump_if_true", 20 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating) -- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3 { "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4 { "jump_if_false", 23 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef -- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily -- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text; -- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -31,7 +33,7 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "push_screen", "DexEntryMenu", { "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5 { species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6 { "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7 { "jump_if_false", "end" }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives -- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the -- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1. -- nickname prompt follows "you got X", matching Gen1.
@@ -52,9 +54,19 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER" { RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE" or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 17 or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18 { "jump", "end" }, -- 18
{ "jump", 21 }, -- 19 (spacer) { "jump", "end" }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20 -- a leftover ball after the player's pick: Oak turns to face the
-- player and reads the last-mon line instead of re-offering the
-- starter (scripts/OaksLab.asm OaksLabSelectedPokeBallScript ->
-- OaksLabLastMonScript; #601). The ROM's "#MON" ligature is spelled
-- out as Pokémon here.
{ "face_object", 5, "down" }, -- 20
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 21
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
-- through into the pre-pick line below (#601 remnant, reported on #600)
{ "jump", "end" }, -- 22
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 23
} }
end end
@@ -62,10 +74,22 @@ return {
talk = { talk = {
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_ -- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk- -- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating -- in, full Pokédex speech, rival exit, Route 22 arm).
-- (DisplayDexRating) is still skipped.
TEXT_OAKSLAB_OAK1 = { TEXT_OAKSLAB_OAK1 = {
{ "face_player" }, { "face_player" },
-- OaksLabOak1Text leads with the dex-rating branch (#600): with
-- EVENT_PALLET_AFTER_GETTING_POKEBALLS set (converted saves), or
-- 2+ species owned once the Pokédex is in hand, Oak asks how it is
-- coming and rates it (predef DisplayDexRating). Red keeps the
-- GOT_POKEDEX gate that Yellow's copy of this text drops
-- (data/scripts/oaks_lab_yellow.lua).
{ "check_flag", "EVENT_PALLET_AFTER_GETTING_POKEBALLS" },
{ "jump_if_true", "dex_rating" },
{ "check_dex_owned", 2 },
{ "jump_if_false", "no_rating" },
{ "check_flag", "EVENT_GOT_POKEDEX" },
{ "jump_if_true", "dex_rating" },
{ "label", "no_rating" },
{ "check_item", "POKE_BALL" }, { "check_item", "POKE_BALL" },
{ "jump_if_true", "come_see" }, { "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" }, { "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
@@ -151,6 +175,14 @@ return {
{ "label", "come_see" }, { "label", "come_see" },
{ "show_text", "_OaksLabOak1ComeSeeMeSometimesText" }, { "show_text", "_OaksLabOak1ComeSeeMeSometimesText" },
{ "jump", "end" },
-- .HowIsYourPokedexComingText ends on `prompt` and OaksLabOak1Text
-- sets wDoNotWaitForButtonPressAfterDisplayingText, so the seen/owned
-- tally follows with no button wait (engine/events/pokedex_rating.asm)
{ "label", "dex_rating" },
{ "show_text", "_OaksLabOak1HowIsYourPokedexComingText" },
{ "dex_rating" },
}, },
TEXT_OAKSLAB_CHARMANDER_POKE_BALL = TEXT_OAKSLAB_CHARMANDER_POKE_BALL =
@@ -245,7 +277,12 @@ return {
and y >= 6 then and y >= 6 then
local rival = ow:npcByIndex(1) local rival = ow:npcByIndex(1)
if not rival then return false end if not rival then return false end
-- OaksLabRivalChallengesPlayerScript swaps in the rival encounter
-- fanfare for the taunt/challenge exchange, same as the Yellow port
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
local rows = { local rows = {
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1 { "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
} }
-- the rival routes to a free cell beside the player -- the rival routes to a free cell beside the player
@@ -285,6 +322,8 @@ return {
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" }) table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 }) table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
-- restore the lab theme once he's walked out, same as the Yellow port
table.insert(rows, { "play_music", "Music_OaksLab" })
ow.runner:run(rows, { npc = rival }) ow.runner:run(rows, { npc = rival })
return true return true
end end
+21 -9
View File
@@ -71,8 +71,12 @@ return {
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" }, { "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" }, { "set_flag", "EVENT_GOT_POKEDEX" },
{ "set_flag", "EVENT_OAK_GOT_PARCEL" }, { "set_flag", "EVENT_OAK_GOT_PARCEL" },
-- OaksLabOakGivesPokedexScript: HideObject TOGGLE_LYING_OLD_MAN /
-- ShowObject TOGGLE_OLD_MAN_2 -- Yellow's tutorial old man stands
-- on the sleeper's cell (18,9); the Red/Blue walker OLD_MAN at
-- (17,5) never appears in Yellow (#617)
{ "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" }, { "hide_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" }, { "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
{ "stop_music" }, { "stop_music" },
{ "play_music", "Music_MeetRival" }, { "play_music", "Music_MeetRival" },
{ "move_npc_to", RIVAL, 4, 7 }, { "move_npc_to", RIVAL, 4, 7 },
@@ -143,17 +147,25 @@ return {
-- OaksLabRivalExclamationScript: "!" over the rival -- OaksLabRivalExclamationScript: "!" over the rival
{ "emote", RIVAL, "shock" }, { "emote", RIVAL, "shock" },
} }
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove: -- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
-- the rival cuts across to the ball WHILE the player standing -- bytes are Yellow's own step-right encoding, decoded by
-- below it is bumped two tiles right (both movements run in the -- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
-- same beat, so the walk overlaps the shove like the original) -- PAD_RIGHT x2 shove is NOT queued alongside it:
-- OaksLabRivalTakesPokeballScript .asm_1c564 polls every frame and
-- only simulates the pair once wNPCNumScriptedSteps reads 1 -- i.e.
-- as the rival begins the LAST byte, the step onto the tile the
-- player is standing on. Starting both on one row had Red stroll
-- off the Eevee while the rival was still at the top of the table
-- (#559).
if py == 4 then if py == 4 then
rows[#rows + 1] = { "walk_npc", RIVAL, rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
{ "down", "right", "right", "right" }, { wait = false } } -- this one runs concurrently with the shove below
rows[#rows + 1] = { "walk_npc", RIVAL, { "right" }, { wait = false } }
rows[#rows + 1] = { "face_player_dir", "left" } rows[#rows + 1] = { "face_player_dir", "left" }
rows[#rows + 1] = { "move_player", "right", 2 } rows[#rows + 1] = { "move_player", "right", 2 }
-- let the rival finish the last stretch to (7,4) -- move_player blocks for both tiles, so the rival has already
rows[#rows + 1] = { "wait", 40 } -- landed on (7,4); this is just the beat before he turns up
rows[#rows + 1] = { "wait", 20 }
else else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 } rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end end
+71 -29
View File
@@ -4,9 +4,11 @@
-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over -- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over
-- 30 SAFARI BALLs and starts the 502-step game -- 30 SAFARI BALLs and starts the 502-step game
-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502, -- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502,
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back -- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player
-- so you can't slip past. Returning to the gate ends the game and the -- up through the north warp into the zone. Declining walks you back
-- worker takes the leftover balls back. -- so you can't slip past. Returning to the gate ends the game, the
-- worker takes the leftover balls back and the auto-walk drops you 3
-- cells below the warp you came in by (#540).
-- --
-- Step/ball bookkeeping lives in src/world/OverworldController.lua -- Step/ball bookkeeping lives in src/world/OverworldController.lua
-- (safariStep/safariGameOver, from -- (safariStep/safariGameOver, from
@@ -19,7 +21,33 @@ local FEE = 500
local BALLS = 30 local BALLS = 30
local STEPS = 502 local STEPS = 502
local function startGame(game, t, done, balls, introText) -- SafariZoneGateSafariZoneWorker1WouldYouLikeToJoinText .success closes with
-- `ld a, PAD_UP / ld c, 3 / SafariZoneEntranceAutoWalk`: paying walks the
-- player up out of the gate and through the north warp, it is never left to
-- the player. EVENT_IN_SAFARI_ZONE is already set when that walk runs, so
-- the two gate steps taken before the warp fires are charged against
-- wSafariSteps (home/overworld.asm:307-310) -- which is why the counter
-- reads 500/500 on arrival even though the script wrote 502 (#540). The
-- port's counter only runs on the nine interior maps (FieldDefaults
-- safari.stepMaps, OverworldState:inSafariStepZone), so charge those two
-- steps here instead.
local function walkIntoZone(game, ow)
local p = ow.player
-- only from the two trigger cells in front of the worker, which are the
-- columns the north warps sit on; a player who paid after TALKING to him
-- from somewhere else walks in on their own, as they do today
local w = p.cellY == 2 and ow.map:warpAtCell(p.cellX, 0) or nil
if not w then return end
ow:scriptMove(p, "up", 2, function()
local st = game.save.safari
if st then st.steps = st.steps - 2 end
-- scripted steps skip onStepComplete (and with it CheckWarpsNoCollision),
-- so take that warp explicitly once the walk lands on it
ow:takeWarp(w.def)
end)
end
local function startGame(game, ow, t, done, balls, introText)
game.save.safari = { balls = balls or BALLS, steps = STEPS } game.save.safari = { balls = balls or BALLS, steps = STEPS }
game.save.safariNags = nil game.save.safariNags = nil
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
@@ -31,7 +59,10 @@ local function startGame(game, t, done, balls, introText)
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!" or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!" local luck = t._SafariZoneGateSafariZoneWorker1GoodLuckText or "Good Luck!"
game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, done)) game.stack:push(TextBox.new(game, paid .. pa .. "\f" .. luck, function()
if done then done() end
walkIntoZone(game, ow)
end))
end end
-- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of -- Yellow's soft-lock fix (scripts/SafariZoneGate_2.asm): a player short of
@@ -52,7 +83,7 @@ local function yellowLowCost(game, ow, t, done, back)
or "\fOh, all right, pay\nme what you have.") or "\fOh, all right, pay\nme what you have.")
.. "\f" .. (t._SafariZoneLowCostText2 .. "\f" .. (t._SafariZoneLowCostText2
or "But, I can't give\nyou all 30 BALLs.") or "But, I can't give\nyou all 30 BALLs.")
startGame(game, t, done, balls, intro) startGame(game, ow, t, done, balls, intro)
return return
end end
local nag = game.save.safariNags or 0 local nag = game.save.safariNags or 0
@@ -62,7 +93,7 @@ local function yellowLowCost(game, ow, t, done, back)
(t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?") (t._SafariZoneLowCostText8 or "Read my lips, NO!\nGet it?")
.. (t._SafariZoneLowCostText3 .. (t._SafariZoneLowCostText3
or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!") or "\fYou're persistent,\naren't you?\fOK, you can go in\nfor free, but\njust this once!")
startGame(game, t, done, 1, intro) startGame(game, ow, t, done, 1, intro)
return return
end end
local nags = { local nags = {
@@ -100,7 +131,7 @@ local function joinPrompt(game, ow, done)
end end
else else
game.save.money = game.save.money - FEE game.save.money = game.save.money - FEE
startGame(game, t, done) startGame(game, ow, t, done)
end end
end)) end))
end)) end))
@@ -135,27 +166,38 @@ M.SAFARI_ZONE_GATE = {
-- no walks you back into the zone -- no walks you back into the zone
onEnter = function(game, ow) onEnter = function(game, ow)
if not game.save.safari or ow.player.cellY > 1 then return end if not game.save.safari or ow.player.cellY > 1 then return end
local TextBox = require("src.render.TextBox") -- QUEUED, never pushed: onEnter runs inside the arriving warp's
local ChoiceBox = require("src.ui.ChoiceBox") -- Transition midpoint, and Transition:finish pops whatever is on top
local t = game.data.text -- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is
game.stack:push(TextBox.new(game, -- swallowed, and on a build where it survived it drew over a screen
t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?", -- still faded to black (#540). Same contract as M.HALL_OF_FAME in
function() -- data/scripts/story.lua.
game.stack:push(ChoiceBox.new(game, function(yes) --
if not yes then -- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the
-- back into the zone through the entrance warp -- return-balls text, faces the player down and runs
local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1] -- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the
ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up") -- counter row 3 cells below the warp you came in by; NO prints
return -- "Good Luck!" and walks one step back up through that same warp.
end local rightSide = ow.player.cellX ~= 3
game.save.safari = nil local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1]
game.stack:push(TextBox.new(game, ow:queueScript({
(t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText { "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" },
or "Please return any\nSAFARI BALLs you\nhave left.") { "jump_if_false", "stay" },
.. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText -- the port never reaches SafariZoneGateLeavingSafariScript's own
or "Did you get a\ngood haul?\fCome again!"))) -- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the
end)) -- counter), so the sign-off rides on this path
end)) { "show_text", "_SafariZoneGateSafariZoneWorker1ReturnSafariBallsText" },
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText" },
-- no value: set_field assigns nil, which is how save.safari is cleared
{ "set_field", "safari" },
-- move_player runs through scriptMove, which skips onStepComplete, so
-- walking back down past (x,2) cannot re-fire the join trigger
{ "move_player", "down", 3 },
{ "jump", "end" },
{ "label", "stay" },
{ "show_text", "_SafariZoneGateSafariZoneWorker1GoodLuckText" },
{ "warp", "SAFARI_ZONE_CENTER", dest.x, dest.y, "up" },
})
end, end,
} }
+42 -1
View File
@@ -11,7 +11,7 @@
-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and -- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and
-- B3F's pluggedByHolesOn) plus the generic -- B3F's pluggedByHolesOn) plus the generic
-- OverworldState:boulderIntoHole in src/world/OverworldController.lua; -- OverworldState:boulderIntoHole in src/world/OverworldController.lua;
-- no per-map onEnter hook is needed here. -- no per-map onEnter hook is needed for the boulders.
local M = {} local M = {}
@@ -23,4 +23,45 @@ M.VERMILION_GYM = {
end, end,
} }
-- The PLAYER falling down those same holes (#599). field.seafoam's holes
-- carry only the BOULDER's object cell on the floor below (landsAt), not
-- the player's landing, so the player's landing is spelled out here: it
-- comes from data/maps/special_warps.asm DungeonWarpList/DungeonWarpData,
-- which the importer does not extract. Same shape as MANSION_HOLES in
-- data/scripts/story6.lua and VICTORY_ROAD_3F.onStep in
-- data/scripts/story.lua; CAVERN $22 is a walkable tile, so the fall has
-- to be an onStep, not a collision block.
--
-- Sources: scripts/SeafoamIslands1F.asm Seafoam1HolesCoords (17,6)/(24,6),
-- B1F.asm Seafoam2HolesCoords (18,6)/(23,6), B2F.asm Seafoam3HolesCoords
-- (19,6)/(22,6), B3F.asm Seafoam4HolesCoords (3,16)/(6,16). Each floor
-- sets wDungeonWarpDestinationMap and calls IsPlayerOnDungeonWarp, and
-- wCoordIndex picks that floor's DungeonWarpData row. Unconditional in
-- the original: a plugged hole still drops the player. The B3F and B4F
-- landings are water; setMap's CheckForceBikeOrSurf pass
-- (OverworldState:checkForcedMovement) mounts SURF on arrival.
local HOLE_FALLS = {
SEAFOAM_ISLANDS_1F = { { 17, 6, "SEAFOAM_ISLANDS_B1F", 18, 7 },
{ 24, 6, "SEAFOAM_ISLANDS_B1F", 23, 7 } },
SEAFOAM_ISLANDS_B1F = { { 18, 6, "SEAFOAM_ISLANDS_B2F", 19, 7 },
{ 23, 6, "SEAFOAM_ISLANDS_B2F", 22, 7 } },
SEAFOAM_ISLANDS_B2F = { { 19, 6, "SEAFOAM_ISLANDS_B3F", 18, 7 },
{ 22, 6, "SEAFOAM_ISLANDS_B3F", 19, 7 } },
SEAFOAM_ISLANDS_B3F = { { 3, 16, "SEAFOAM_ISLANDS_B4F", 4, 14 },
{ 6, 16, "SEAFOAM_ISLANDS_B4F", 5, 14 } },
}
for mapId, holes in pairs(HOLE_FALLS) do
M[mapId] = M[mapId] or {}
M[mapId].onStep = function(game, ow, x, y)
for _, h in ipairs(holes) do
if x == h[1] and y == h[2] then
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
return true
end
end
return false
end
end
return M return M
+55 -12
View File
@@ -188,7 +188,12 @@ M.BILLS_HOUSE = {
end end
if ow.player.facing == "down" then if ow.player.facing == "down" then
-- the player is standing on his straight path: walk around -- the player is standing on his straight path: walk around
-- (.PokemonWalkAroundPlayerMovement) -- (.PokemonWalkAroundPlayerMovement). BillsHouseScript2 runs
-- BillsHousePikachuWatchPlayer first on this branch, so a
-- Pikachu that is still following steps clear of Bill's detour
-- and turns to watch the player (#455).
require("src.world.PikachuFollower")
.onBillWalksAroundPlayer(game, ow)
ow:scriptMove(npc, "right", 1, function() ow:scriptMove(npc, "right", 1, function()
ow:scriptMove(npc, "up", 2, function() ow:scriptMove(npc, "up", 2, function()
ow:scriptMove(npc, "left", 1, function() ow:scriptMove(npc, "left", 1, function()
@@ -600,12 +605,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText)
} }
end end
-- A beaten Snorlax is gone for good: Route12/Route16DefaultScript run
-- HideObject in the same breath as the battle that sets
-- EVENT_BEAT_ROUTEnn_SNORLAX, so "event set, object still on the map" is a
-- state the asm cannot produce. Here it can (a mod's world:toggleObject, a
-- save edited or migrated from a build older than the flag), and it is a
-- dead end: ItemEffects' adjacentSleepingSnorlax refuses to wake a Snorlax
-- whose beat flag is set (ItemUsePokeFlute's CheckEvent, engine/items/
-- item_effects.asm), so the sleeper sits in the road forever and Cycling
-- Road is unreachable (#585). Reconcile the toggle from the flag on every
-- entry -- the mirror of SaveData.lua's toggle -> flag backfill, and the
-- same repair shape the Silph Co. floors use below.
local function hideBeatenSnorlax(mapId, objName, beatFlag)
return function(game, ow)
if not game.save.flags[beatFlag] then return end
local Commands = require("src.script.Commands")
Commands.hide_object({ game = game, save = game.save, overworld = ow },
mapId, objName)
end
end
-- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via -- snorlaxWake is looked up by ItemEffects.lua/BagMenu.lua (via
-- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax; -- data/scripts/init.lua's M.get) and run when the flute wakes Snorlax;
-- objName/beatFlag let ItemEffects find the NPC and check whether it's -- objName/beatFlag let ItemEffects find the NPC and check whether it's
-- already been beaten before allowing the wake. -- already been beaten before allowing the wake.
M.ROUTE_12 = { M.ROUTE_12 = {
talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } }, talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } },
onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX",
"EVENT_BEAT_ROUTE12_SNORLAX"),
snorlaxWake = { snorlaxWake = {
objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX", objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX",
script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX", script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX",
@@ -614,6 +641,8 @@ M.ROUTE_12 = {
} }
M.ROUTE_16 = { M.ROUTE_16 = {
talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } }, talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } },
onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX",
"EVENT_BEAT_ROUTE16_SNORLAX"),
snorlaxWake = { snorlaxWake = {
objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX", objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX",
script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX", script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX",
@@ -645,16 +674,18 @@ M.SAFARI_ZONE_SECRET_HOUSE = {
M.WARDENS_HOUSE = { M.WARDENS_HOUSE = {
talk = { talk = {
TEXT_WARDENSHOUSE_WARDEN = { TEXT_WARDENSHOUSE_WARDEN = {
-- Labelled rather than hand-numbered: the branches here have been
-- re-pointed twice now (#535, #645), and every insert used to mean
-- renumbering three jumps that had no way of announcing they were stale.
{ "face_player" }, -- 1 { "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_HM04" }, -- 2 { "check_flag", "EVENT_GOT_HM04" }, -- 2
-- #535: previously jumped to the same silent-end target as the -- #535: previously jumped to the same silent-end target as the
-- give-then-thank fallthrough (row 13), so the warden said nothing -- give-then-thank fallthrough, so the warden said nothing on every
-- on every visit after the trade. pokered's .got_item branch -- visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText, -- (scripts/WardensHouse.asm) instead prints HM04ExplanationText.
-- so route here to the new row 17 that does the same. { "jump_if_true", "got_hm04" }, -- 3
{ "jump_if_true", 17 }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4 { "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5 { "jump_if_false", "no_teeth" }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6 { "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
{ "take_item", "GOLD_TEETH", 1 }, -- 7 { "take_item", "GOLD_TEETH", 1 }, -- 7
{ "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8 { "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8
@@ -663,15 +694,27 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10 { "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11 { "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12 { "set_flag", "EVENT_GOT_HM04" }, -- 12
{ "jump", 18 }, -- 13 (already got it this convo; jp .done) { "jump", "end" }, -- 13 (jp .done)
{ "jump", 18 }, -- 14 (unused)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15 -- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice,
{ "jump", 18 }, -- 16 (#535: skip the new explanation row below) -- and the warden answers the same gibberish either way -- Gibberish2
-- on yes, Gibberish3 on no (scripts/WardensHouse.asm). The port
-- printed the question and walked off before the answer.
{ "label", "no_teeth" }, -- 14
{ "ask", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump_if_true", "gibberish_yes" }, -- 16
{ "show_text", "_WardensHouseWardenGibberish3Text" }, -- 17
{ "jump", "end" }, -- 18
{ "label", "gibberish_yes" }, -- 19
{ "show_text", "_WardensHouseWardenGibberish2Text" }, -- 20
{ "jump", "end" }, -- 21
-- #535: pokered .got_item branch (scripts/WardensHouse.asm) -- -- #535: pokered .got_item branch (scripts/WardensHouse.asm) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set. -- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm): -- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house. -- HM04 teaches Strength, and hints at the Safari Zone secret house.
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 17 { "label", "got_hm04" }, -- 22
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
}, },
}, },
} }
+130 -11
View File
@@ -413,22 +413,141 @@ M.POKEMON_FAN_CLUB = {
}, },
} }
-- BikeShopClerkText (scripts/BikeShop.asm) runs three ways: the BICYCLE is
-- already yours, you are carrying the BIKE VOUCHER, or you get the sales
-- pitch. The pitch draws its own window (TextBoxBorder hlcoord 0,0, b=4
-- c=15) holding BikeShopMenuText and BikeShopMenuPrice, and leaves it up
-- while the clerk keeps talking in the bottom box: the original never
-- erases it before TextScriptEnd (#568).
local BikeShopWindow = {}
BikeShopWindow.__index = BikeShopWindow
function BikeShopWindow.new(game, footer, onChoose)
local self = setmetatable({}, BikeShopWindow)
self.game = game
self.onChoose = onChoose
self.index = 1
self.active = true
-- BikeShopClerkDoYouLikeItText stays on screen under the window for as
-- long as the menu is up. The text box pushed on top types it out and
-- pops itself; this copy of its last page takes over from there, so the
-- bottom box never blanks between the pitch and the answer. Paginated
-- with the themed column budget so the copy breaks where the box did.
local TextBox = require("src.render.TextBox")
local box = require("src.ui.Theme").textBox or {}
local pages = TextBox.paginate(TextBox.substitute(game, footer), box.maxCols)
self.footer = pages[#pages]
return self
end
function BikeShopWindow:update()
-- one answer only: the boxes pushed by onChoose sit on top of this
-- state, but a second A on the same frame must not fire it twice
if not self.active then return end
local input = self.game.input
-- HandleMenuInput with wMenuWrappingEnabled clear: the two rows clamp
if input:wasPressed("up") then
self.index = 1
elseif input:wasPressed("down") then
self.index = 2
elseif input:wasPressed("a") or input:wasPressed("b") then
local cancelled = input:wasPressed("b") -- bit B_PAD_B -> .cancel
require("src.core.Sound").play(self.game.data, "Press_AB")
self.active = false
self.onChoose(not cancelled and self.index == 1)
end
end
function BikeShopWindow:draw()
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
Font.drawBox(0, 0, 17, 6)
love.graphics.setColor(0, 0, 0, 1)
local bike = self.game.data.items.BICYCLE
Font.draw(bike and bike.name or "BICYCLE", 16, 16) -- hlcoord 2, 2
Font.draw("¥1000000", 64, 24) -- hlcoord 8, 3
Font.draw(Strings("CANCEL"), 16, 32) -- `next` skips a row
-- wTopMenuItemX 1, wTopMenuItemY 2, rows two apart
Font.drawCode(Theme.cursor, 8, self.index == 1 and 16 or 32)
if self.footer then
-- same geometry TextBox resolves against, so a themed box matches
local box = Theme.textBox or {}
local tx, ty = box.tx or 0, box.ty or 12
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(tx, ty, box.tw or 20, box.th or 6)
love.graphics.setColor(0, 0, 0, 1)
for i, line in ipairs(self.footer) do
Font.draw(line, (tx + 1) * 8, (ty + 2 * i) * 8)
end
end
love.graphics.setColor(1, 1, 1, 1)
end
M.BIKE_SHOP = { M.BIKE_SHOP = {
talk = { talk = {
TEXT_BIKESHOP_CLERK = function(game, ow, npc, done) TEXT_BIKESHOP_CLERK = function(game, ow, npc, done)
local Bag = require("src.inventory.Bag")
local Flags = require("src.script.Flags")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
if (game.save.inventory.BICYCLE or 0) > 0 then local t = game.data.text
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done)) local function say(text, after, opts)
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then game.stack:push(TextBox.new(game, text, after, opts))
game.save.inventory.BIKE_VOUCHER = nil
game.save.inventory.BICYCLE = 1
game.stack:push(TextBox.new(game,
("Oh, that's a\nBIKE VOUCHER!\f%s exchanged\nit for a BICYCLE!")
:format(game.save.player.name), done))
else
game.stack:push(TextBox.new(game,
"A BICYCLE costs\n¥1000000. Sorry,\nno instalments!", done))
end end
-- CheckEvent EVENT_GOT_BICYCLE. Saves made before the clerk started
-- setting the event still have the bike in the bag, so either counts.
if (game.save.inventory.BICYCLE or 0) > 0
or Flags.get(game.save, "EVENT_GOT_BICYCLE") then
say(t._BikeShopClerkHowDoYouLikeYourBicycleText, done)
return
end
-- .dontHaveBike: IsItemInBag BIKE_VOUCHER
if (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
say(t._BikeShopClerkOhThatsAVoucherText, function()
-- GiveItem's `jr nc, .BagFull`: the voucher is only spent once
-- the BICYCLE is actually in the bag
if not Bag.add(game.save, "BICYCLE", 1) then
say(t._BikeShopBagFullText, done)
return
end
Bag.remove(game.save, "BIKE_VOUCHER", 1)
Flags.set(game.save, "EVENT_GOT_BICYCLE")
-- BikeShopExchangedVoucherText carries sound_get_key_item; the
-- map runs EnableAutoTextBoxDrawing, so the box still waits for
-- a button once the jingle has played (auto.wait, #247)
say(t._BikeShopExchangedVoucherText, done, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(game.data, "Get_Key_Item")
end },
})
end)
return
end
-- .dontHaveVoucher: welcome, then the BICYCLE/CANCEL window
say(t._BikeShopClerkWelcomeText, function()
local pitch = t._BikeShopClerkDoYouLikeItText
game.stack:push(BikeShopWindow.new(game, pitch, function(bought)
local function comeAgain()
say(t._BikeShopComeAgainText, function()
game.stack:pop() -- the window, still up under the text
done()
end)
end
if bought then
-- a million is out of anyone's reach: BikeShopCantAffordText
say(t._BikeShopCantAffordText, comeAgain)
else
comeAgain()
end
end))
-- PrintText BikeShopClerkDoYouLikeItText, then straight into
-- HandleMenuInput: the box types out and hands over without
-- waiting, leaving the window's copy of the line on screen
say(pitch, nil, { auto = { delay = 0 } })
end)
end, end,
}, },
} }
+72 -15
View File
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
-- --
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO -- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the -- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move", -- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
-- balls dodged). An earlier version of this port turned the player back -- dodged) or comes off in the unveil (makeUnveiledGhost, #492). An earlier
-- version of this port turned the player back
-- without the scope and never opened the battle, which made 6F -- without the scope and never opened the battle, which made 6F
-- impassable on any route that skips Rocket Hideout; vanilla lets the -- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below). -- battle open and a POKE_DOLL end it (see wBattleResult below).
@@ -133,7 +134,13 @@ M.POKEMON_TOWER_6F = {
-- or not the scope revealed it, so the "can't be caught" state rides -- or not the scope revealed it, so the "can't be caught" state rides
-- the battle instead of IsGhostBattle alone (#444) -- the battle instead of IsGhostBattle alone (#444)
battle.noCatch = true battle.noCatch = true
if not game.save.inventory.SILPH_SCOPE then -- InitWildBattle enters disguised for the RESTLESS SOUL either way
-- (core.asm:6698-6700). The scope does not skip the disguise, it
-- buys the unveil PrintBeginningBattleText .isMarowak plays over it
-- before the battle proceeds as an ordinary wild one (#492).
if game.save.inventory.SILPH_SCOPE then
battle:makeUnveiledGhost()
else
battle:makeGhost() battle:makeGhost()
end end
battle.onFinish = function(result) battle.onFinish = function(result)
@@ -353,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
end)) end))
end, end,
-- Yellow swaps Rocket1/Rocket2 for Jessie & James and keeps a single
-- grunt, spelled ROCKETHIDEOUTB4F_ROCKET / TEXT_ROCKETHIDEOUTB4F_ROCKET
-- (pokeyellow/scripts/RocketHideoutB4F.asm), so the Red/Blue key above
-- never matched there and the LIFT KEY never dropped (#552, a
-- regression of #105). Yellow also moves the drop off the second
-- talk: RocketHideoutB4FRocketEndBattleText is text_promptbutton +
-- text_asm, so its SetEvent EVENT_ROCKET_DROPPED_LIFT_KEY / ShowObject
-- TOGGLE_ROCKET_HIDEOUT_B4F_ITEM_5 run the instant the battle ends.
TEXT_ROCKETHIDEOUTB4F_ROCKET = function(game, ow, npc, done)
if not ow:trainerDefeated(npc) then
ow:engageTrainer(npc, function()
-- engageTrainer records the win before it calls back, so this
-- is the end-battle text's SetEvent + ShowObject
if ow:trainerDefeated(npc)
and not game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY then
game.save.flags.EVENT_ROCKET_DROPPED_LIFT_KEY = true
local Commands = require("src.script.Commands")
Commands.show_object(
{ game = game, save = game.save, overworld = ow },
"ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY")
end
done()
end)
return
end
-- RocketHideoutB4FRocketAfterBattleText: later talks only reprint
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text._RocketHideoutB4FRocketAfterBattleText
or "Oh no! I dropped\nthe LIFT KEY!", done))
end,
TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done) TEXT_ROCKETHIDEOUTB4F_GIOVANNI = function(game, ow, npc, done)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm -- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath. -- owns both the engage and the BeatGiovanniScript aftermath.
@@ -502,45 +541,56 @@ M.GAME_CORNER = {
end)) end))
end) end)
end, end,
-- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a
-- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the
-- object const and from every one of his text labels
-- (GameCornerClerkText, pokeyellow/scripts/GameCorner.asm) with an
-- identical body, so each line resolves under both spellings and the
-- handler is bound to both text ids just below (#552).
TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done) TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done)
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text local t = game.data.text
local function line(suffix, fallback)
return t["_GameCornerClerk1" .. suffix]
or t["_GameCornerClerk" .. suffix]
or fallback
end
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText line("DoYouNeedSomeGameCoinsText",
or "Do you need some\ngame coins?\f¥1000 for 50."), function() "Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes) game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then if not yes then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._GameCornerClerk1PleaseComePlaySometimeText line("PleaseComePlaySometimeText",
or "No? Please come\nplay sometime!", done)) "No? Please come\nplay sometime!"), done))
return return
end end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need -- scripts/GameCorner.asm GameCornerClerk1Text: coins need
-- the COIN CASE and room for at least 9 coins (Has9990Coins) -- the COIN CASE and room for at least 9 coins (Has9990Coins)
if not game.save.inventory.COIN_CASE then if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._GameCornerClerk1DontHaveCoinCaseText line("DontHaveCoinCaseText",
or "You don't have a\nCOIN CASE!", done)) "You don't have a\nCOIN CASE!"), done))
return return
end end
if (game.save.coins or 0) >= 9990 then if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._GameCornerClerk1CoinCaseIsFullText line("CoinCaseIsFullText",
or "Oops! Your COIN\nCASE is full.", done)) "Oops! Your COIN\nCASE is full."), done))
return return
end end
if game.save.money < 1000 then if game.save.money < 1000 then
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
t._GameCornerClerk1CantAffordTheCoinsText line("CantAffordTheCoinsText",
or "You can't afford\nthe coins!", done)) "You can't afford\nthe coins!"), done))
return return
end end
game.save.money = game.save.money - 1000 game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50) game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
(t._GameCornerClerk1ThanksHereAre50CoinsText line("ThanksHereAre50CoinsText",
or "Thanks! Here are\nyour 50 coins!") "Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done)) .. ("\fCOINS: %d"):format(game.save.coins), done))
end)) end))
end)) end))
@@ -548,6 +598,13 @@ M.GAME_CORNER = {
}, },
} }
-- Yellow's coin clerk is GAMECORNER_CLERK / TEXT_GAMECORNER_CLERK where Red
-- and Blue spell him CLERK1, and the two text_asm bodies are identical, so
-- one handler answers both ids. Without the alias the Yellow clerk fell
-- through to his extracted offer line with no yes/no box behind it (#552).
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK =
M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1
-- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm). -- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm).
-- The six mon prizes differ between Red and Blue; the three TM prizes are -- The six mon prizes differ between Red and Blue; the three TM prizes are
-- identical, so they are shared and appended to each version's mon list. -- identical, so they are shared and appended to each version's mon list.
+145
View File
@@ -0,0 +1,145 @@
-- The Viridian City old-man catch tutorial, Yellow's way
-- (pokeyellow scripts/ViridianCity.asm, scripts/ViridianCity_2.asm,
-- scripts/OaksLab.asm). Registered on top of the shared tables by
-- data/scripts/init.lua on a Yellow boot.
--
-- Yellow has TWO gambler objects where Red/Blue have one:
-- * VIRIDIANCITY_OLD_MAN at (17,5) -- a Red/Blue leftover; its
-- toggle stays OFF forever in Yellow, no script ever shows it.
-- * VIRIDIANCITY_OLD_MAN2 at (18,9) -- replaces the sleeper the
-- moment the Pokédex is given (OaksLabOakGivesPokedexScript:
-- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2).
--
-- This is the tutorial old man. The Red/Blue "Are you in a hurry?"
-- yes/no script must NOT run against him: Yellow's
-- _ViridianCityOldManHadMyCoffeeNowText is the apology speech ("I've had
-- my coffee now ... I'll show you how to catch POKéMON as my apology"),
-- and the shared story.lua TEXT_VIRIDIANCITY_OLD_MAN rows hang an
-- invented yes/no over it -- YES printed the TimeIsMoney alias
-- (_ViridianCityOldManLosingMyTouchText) and NO ran the demo, every
-- talk, forever (#617).
--
-- The real flow (ViridianCityCheckWaitingOldMan + ViridianCityOldMan2Text
-- + ViridianCityOldManInitialCatchTrainingScript + ...EndInitial... +
-- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap
-- east of the sleeper's cell -- faces the old man right and the player
-- left, prints the apology, and without any choice runs the demo battle
-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer
-- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work!
-- I must be losing my touch."), the old man walks off (down 6 with the
-- player on (19,9), right 1 otherwise, Pikachu nudged out of the way
-- first) and TOGGLE_OLD_MAN_2 hides. A direct talk does the same.
local M = {}
local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2"
-- Capture the FUNCTION, not the table: attachBase stores the module
-- table itself, so once this file's onStep is attached the table's slot
-- points back here -- delegating through the table would self-recurse.
-- story5's VIRIDIAN_CITY.onStep chains story.lua's sleeping-old-man
-- gate and its own gym-lock step (same pattern as yellow_jessie_james).
local baseViridianStep = require("data.scripts.story5").VIRIDIAN_CITY.onStep
-- pokeyellow text/ViridianCity.asm, _ViridianCityOldManHadMyCoffeeNowText
-- and _ViridianCityOldManLosingMyTouchText, spelled with the extractor's
-- markers (line -> \n, cont -> \v, para -> \f)
local function text(game)
return {
apology = game.data.text._ViridianCityOldManHadMyCoffeeNowText
or "Ahh, I've had my\ncoffee now and I\vfeel great!\fSure, you can go\n"
.. "through!\fI'm sorry I was\nso rude to you!\fI see you're using\n"
.. "a POKéDEX.\fI'll show you how\nto catch POKéMON\vas my apology.",
losingMyTouch = game.data.text._ViridianCityOldManLosingMyTouchText
or "That didn't work!\nI must be losing\vmy touch.\fI've run out of\n"
.. "POKé BALLs too.\fI have to get some\nat POKéMON MART.",
}
end
-- The row list for the initial-tutorial branch, keyed by where the
-- player stands when the battle ends (ViridianCityPostInitialCatchTraining
-- reads wXCoord: (19,9) walks the old man down the corridor, anywhere
-- else walks him right 1 after moving the follower Pikachu aside).
local function oldMan2Rows(game, ow, npc)
local rows = {
{ "show_text", "_ViridianCityOldManHadMyCoffeeNowText" },
{ "old_man_demo" },
{ "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" },
{ "show_text", "_ViridianCityOldManLosingMyTouchText" },
}
if ow.player and ow.player.cellX == 19 then
rows[#rows + 1] =
{ "walk_npc", npc.def.index,
{ "down", "down", "down", "down", "down", "down" } }
else
-- ViridianCityMovePikachu (scripts/ViridianCity_2.asm): Pikachu
-- steps out of the old man's way before he turns right
local PikachuFollower = require("src.world.PikachuFollower")
local pika = ow and PikachuFollower.current(ow)
if pika then
rows[#rows + 1] = { "walk_npc", pika.def.index, { "right" } }
end
rows[#rows + 1] = { "walk_npc", npc.def.index, { "right" } }
end
rows[#rows + 1] =
{ "hide_object", "VIRIDIAN_CITY", OLD_MAN2 }
return rows
end
-- The shared talk handler: TEXT_VIRIDIANCITY_OLD_MAN2's text_asm branch
-- (ViridianCityOldMan2Text) on EVENT_COMPLETED_CATCH_TRAINING.
local function oldMan2Talk(game, ow, npc, done)
if game.save.flags and game.save.flags.EVENT_COMPLETED_CATCH_TRAINING then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game, text(game).losingMyTouch, done))
return
end
ow.runner:run(oldMan2Rows(game, ow, npc), { npc = npc, onDone = done })
end
M.VIRIDIAN_CITY = {
talk = {
TEXT_VIRIDIANCITY_OLD_MAN2 = oldMan2Talk,
},
-- Re-apply the Pokédex swap for a save that already holds the flag but
-- was never standing here when it fired (converted .sav imports, same
-- shape as story.lua's VIRIDIAN_CITY.onEnter). Yellow shows OLD_MAN2,
-- not the Red/Blue OLD_MAN at (17,5), and also puts away a stray
-- OLD_MAN a save made by the pre-#617 build left standing.
onEnter = function(game, ow)
if not (game.save.flags and game.save.flags.EVENT_GOT_POKEDEX) then
return
end
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY")
Commands.hide_object(ctx, "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN")
Commands.show_object(ctx, "VIRIDIAN_CITY", OLD_MAN2)
end,
-- ViridianCityCheckWaitingOldMan: with the Pokédex held and the
-- tutorial undone, (19,9) -- the gap east of the old man, the same
-- cell the sleeper used to gate -- faces him right, turns the player
-- left and starts the OLD_MAN2 flow with no choice.
onStep = function(game, ow, x, y)
if baseViridianStep and baseViridianStep(game, ow, x, y) then
return true
end
local flags = game.save.flags
if not flags.EVENT_GOT_POKEDEX then return false end
if flags.EVENT_COMPLETED_CATCH_TRAINING then return false end
if x ~= 19 or y ~= 9 then return false end
local man
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == OLD_MAN2 then man = npc break end
end
if not man then return false end
man.facing = "right"
ow.player.facing = "left"
oldMan2Talk(game, ow, man, nil)
return true
end,
}
return M
+13
View File
@@ -187,5 +187,18 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state. playfield or pushing an updating game state.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's
normal single-window composite so the mod can decorate around it. `ctx` carries
the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
palette-correct blit of either canvas into an arbitrary screen rect, and the
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `setEnabled`)
for driving a second physical display. This is what lets a mod lay the two
passes out as two stacked Game Boy screens, or push one onto a second screen,
without the engine knowing the layout.
Developer mode also arms the mod loader's dev tripwire, which flags mods Developer mode also arms the mod loader's dev tripwire, which flags mods
that reach outside their permission set. that reach outside their permission set.
+86 -11
View File
@@ -209,10 +209,15 @@ duration:
closes, and apply again after. Fast-forward otherwise runs one peer's closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it. clock faster than the opponent racing it.
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with - **Online play runs vanilla, except for your language.** Picking ONLINE
mods enabled offers to switch them all off and relaunch (mods merge at MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
boot, so a restart is the only way). The restart is confirmed, not off and relaunch (mods merge at boot, so a restart is the only way). The
silent. They stay listed as disabled, ready to switch back on. restart is confirmed, not silent. They stay listed as disabled, ready to
switch back on. A mod that declares itself a translation and provably
writes nothing but text stays on: the two games hash the same link
surface, so a Spanish install and an English one can battle and trade,
each reading the game in its own language and naming the other player's
party out of its own text.
- **Only a meaningful split ends a match.** The per-turn state signature - **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a species, HP, status, stat stages, PP and the rest of the party, and a
@@ -314,12 +319,18 @@ window size on rotation. Desktop testing: `POKEPORT_TOUCH=1 love .` forces
the overlay on and lets the mouse act as a finger (`=0` forces it off). the overlay on and lets the mouse act as a finger (`=0` forces it off).
The launcher's **Touch Controls** button opens a drag editor: move each The launcher's **Touch Controls** button opens a drag editor: move each
button freely, **Disable** to hide the overlay permanently (for button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
controllers / emulation handhelds — distinct from the temporary to hide the overlay permanently (for controllers / emulation handhelds --
gamepad auto-hide), **Reset** for defaults, **Done** to save into distinct from the temporary gamepad auto-hide), **Reset** for defaults,
`options.lua` as normalized window fractions so rotation keeps the **Done** to save into `options.lua` as normalized window fractions so a
relative placement. In-game, Options → **TOUCH PAD** toggles the same different screen keeps the relative placement.
on/off flag without leaving a play session.
Portrait and landscape are edited and saved separately (#633): the editor
follows whichever orientation is on screen, and **Reset** only clears that
one, so a layout that works held upright does not have to double as the
one used sideways. An `options.lua` from before this split keeps its single
layout in both orientations until one of them is edited. In-game, Options →
**TOUCH PAD** toggles the same on/off flag without leaving a play session.
## Translation support ## Translation support
@@ -382,7 +393,9 @@ semantics - so the two windows read as one app. Six tabs:
- **Items**: money, a searchable item picker (replacing the arrows that - **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the configurable bag (20 slots cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage by default), PC storage
with no slot cap, and the eight badges as toggle chips. with no slot cap, and the eight badges as toggle chips. The picker, the bag
and PC storage all scroll under the mouse wheel, so the whole catalog is
reachable one-handed without typing a query.
- **Events**: flags, defeated trainers, taken items and per-map object - **Events**: flags, defeated trainers, taken items and per-map object
toggles, with a real filter field and a two-column paged grid. toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable, - **Map**: any map rendered with the game's own renderer, warps followable,
@@ -433,3 +446,65 @@ own repositories. No index ships with the launcher and none is ever added
automatically, so the tab opens on an "Add an index" prompt until you name automatically, so the tab opens on an "Add an index" prompt until you name
one; paste an index URL or its `owner/repo` and it is remembered in one; paste an index URL or its `owner/repo` and it is remembered in
`options.lua`. More than one index can be added, and the listings merge. `options.lua`. More than one index can be added, and the listings merge.
## Soft reset (all versions)
Holding A, B, START and SELECT together restarts the game the way flicking
a Game Boy's power switch did, dropping straight back to the title screen.
It works from anywhere, including mid-battle, which the QUIT entry on the
start menu cannot do: the original combo is how stationary and gift
Pokemon get their stats rerolled without sitting through a full relaunch.
Unsaved progress is discarded, exactly as on hardware.
As on the original, the four buttons have to stay held for 16 straight
polls (better than a quarter of a second) and any direction in the mix
cancels it, so it is hard to hit by accident -- including on the on-screen
touch controls, where it would take four fingers held on four separate
controls.
## Controls rebinding (CONTROLS screen)
OPTIONS -> CONTROLS lists every Game Boy button with its current keyboard
key and controller button side by side (Z/A). Press A on a row, then press
and release the key or pad button you want; the rebind commits on the
release. If that input already belongs to another row, the two rows swap,
so no button is ever stranded without an input and no input ever serves
two buttons. Holding a second key or pad button while the first is still
down backs out of the capture without touching a keyboard; Escape still
cancels too. SELECT clears one row back to its default, and START resets
every binding after a confirmation.
Controllers a system has no mapping for (common on Linux handhelds and
off-brand pads) report bare button numbers rather than names. Those are
rebindable on the same screen and show up as JOY1, JOY2 and so on in the
controller column. Recognized controllers are read only through their
named buttons, so a rebind on those is never shadowed by the factory
layout underneath it.
## Mod profiles (#593)
The mod manager's PROFILES tab holds named setups. A profile remembers which
mods are on, every mod's own options, and which save slot each game version
plays, so swapping profiles swaps the whole playthrough and not just the mod
list. The setup that existed before profiles shipped becomes PROFILE 1 the
first time the manager opens.
EXPORT.. writes the selected profile to `profiles/<NAME>.g1rmodlist` in the
save directory; drop a `.g1rmodlist` someone shared into that folder and
IMPORT.. adds it. Imported profiles never overwrite an existing one (a name
clash gets a number). Mods the shared profile names but that are not installed
are reported when the profile is applied; installing them is still a manual
trip through the mods list or Find Mods.
## Windows: no console windows on launcher actions
Checking for updates, browsing a mod index, adding a mod repo, installing a
mod and picking a ROM all run a host tool (curl, PowerShell) in a child
process. On Windows those children used to each open their own console
window, so a session could end up buried under half a dozen of them. The
game now claims one console for itself at boot and hides it; the children
inherit that invisible console and nothing pops up. Nothing else changes:
file pickers are ordinary desktop dialogs and still appear normally, and a
run started from a terminal (`lovec.exe`, what `scripts\run.ps1` prefers)
keeps its terminal and its printed output. Set `POKEPORT_CONSOLE=1` to opt
out.
+96
View File
@@ -0,0 +1,96 @@
# RFC 0001 — Port Yellow's `IsSurfingPikachuInParty` surf sprite
## Status
Proposed. Engine: `Player.lua`, `FieldDefaults.lua`,
`OverworldController.lua`, `RomExtractor.lua`, `PaletteFX.lua`. Tools:
`build_rom_data.py`, `extract/sprites.py`, `make_rom_manifest.py`,
`make_yellow_manifest.py`. Tests: `parity_surfing_pikachu_sprite.lua`,
`mod_world_tests.lua`.
**Regeneration required.** The manifest and sprite sheet update by
re-running `make_yellow_manifest.py` against a `pret/pokeyellow`
checkout, then re-importing the Yellow ROM.
## Motivation
Yellow's `IsSurfingPikachuInParty` + `LoadSurfingPlayerSpriteGraphics2`
(`home/map_objects.asm`, `home/overworld.asm`) swap the player's
overworld sheet to `SurfingPikachuSprite` (`gfx/sprites/
surfing_pikachu.2bpp`, a 16×96 walk sheet — not the minigame sheets)
when the party mon that knows SURF is a Pikachu. The recomp misses this
in two places:
1. **Extraction.** `SurfingPikachuSprite` is not in
`SpriteSheetPointerTable` — loaded by its own `ld de,` like
`RedBikeSprite`. The extractor never sees it, and the symbol is not
in the Yellow manifest.
2. **Engine rule.** `field.playerSprites.surf` is one static
(`SPRITE_SEEL`), cached at boot. No seam for "swap when the SURF-mon
is a Pikachu."
## The decision it extends
No prior D-number. Extends the surf-field-move port in
`docs/behavior-porting-notes.md` (the `IsSurfingAllowed` exact port)
with the player-sprite swap vanilla runs alongside it.
## The exact API delta
Backward-compatible, additive-only.
### `field.playerSprites.surfPikachu`
New optional key alongside `walk`/`surf`/`bike`/`fly`, defaults to
`SPRITE_SURFING_PIKACHU`. Guarded in `Player.new` so before extraction
lands the ride keeps the Seel — no plain on-water Pikachu.
### `Player.surfPikachuSprite`
`Player.new` builds a second `SpriteRenderer` when the field resolves.
`pose()` picks it when `surfing and surfingPikachu`.
### `Player.surfingPikachu` (runtime)
Runtime-only boolean (not persisted); re-derived so a party change
between save and load is honored.
### `OverworldState:syncSurfingPikachu()`
Sets `player.surfingPikachu` from `partyKnows("SURF")`. Called at every
surf-state toggle: trySurf, dismount, flyTo, beginTeleportOut,
warpToHealPoint, forced-surf tile, setMap boot-restore.
### Importer — `SPRITE_SURFING_PIKACHU`
`make_yellow_manifest.py` adds `SurfingPikachuSprite` to
`YELLOW_EXTRA_SYMBOLS`. `make_rom_manifest.py`'s `sprite_metadata()`
gains a `surfPikachu` entry (guarded, so Red/Blue unchanged).
`RomExtractor.extractSprites` + `build_rom_data.py` + `extract/sprites.py`
each gain a parallel extract mirroring `RedBikeSprite`.
### `PaletteFX.spriteObp`
`SurfingPikachuSprite` joins `RedBikeSprite` in the no-bracket-index
special case, wearing the player's OBP palette so it colors in GBC mode.
## Migration note for existing mods
**Nothing.** `surf` still defaults to `SPRITE_SEEL`; `surfPikachu`
only resolves on a Yellow import after regeneration. No manifest or
`mod.save` shape changes. An eligibility hook that swaps a rental
SURF-mon still drives the sprite pick via `partyKnows`.
## Parity tests
- **No-mod** (`mod_world_tests.lua`): `surf == "SPRITE_SEEL"`,
`surfPikachu == "SPRITE_SURFING_PIKACHU"` seeded at boot. The 19229-check
`world & maps v2` suite stays green.
- **Mod-API** (`parity_surfing_pikachu_sprite.lua`): `syncSurfingPikachu`
+ `Player:pose` across four party shapes (12/12). The existing
`parity_cinnabar_east_surf.lua` (24/24) stays green.
## Deprecation etiquette
Nothing deprecated. Additive: a new `field.playerSprites` key, a new
runtime flag, a new engine method, a new sprite id.
+382
View File
@@ -0,0 +1,382 @@
# Timing parity with the Game Boy
The port's clock is faithful: `src/core/FixedStep.lua` advances game logic in
whole 1/60 s steps off wall-clock `dt`, the default speed multiplier is 1x
(`src/core/GameSpeed.lua:22`), and audio runs on its own real-time accumulator
so fast-forward cannot pitch it. Almost nothing in `src/` is seconds-based.
What diverges is the **frame budget of composed sequences**. The original
spends a large fraction of its running time inside `DelayFrames` calls that
produce no visible change - the pause after a page break, the beat before a
status move resolves, the drain of an HP bar one point at a time. Those are
invisible in a screenshot and easy to drop when porting behavior rather than
timing. Dropping them is why the port reads as faster and snappier than
hardware even though every individual animation is correct.
This document is the specification: what each sequence costs on hardware, and
where that number comes from.
## Method
`tools/scan_pokered_delays.ps1` walks the disassembly and reports every
frame-consuming wait with its enclosing routine label:
| Kind | Meaning | Frames |
| --- | --- | --- |
| `DelayFrames` | `ld c, N` + `call DelayFrames` (`home/delay.asm:1`) | N |
| `DelayFrame` | one vblank wait (`home/vblank.asm:92`) | 1 |
| `Delay3` | `home/palettes.asm:14`, three frames for a full bg-map update | 3 |
| `Fade` | the `GBFade*` helpers, expanded to their totals below | 24 or 32 |
Current inventory against `pokered-master`: **450 sites** - 181 `DelayFrames`,
164 `Delay3`, 65 `DelayFrame`, 31 `GBFade*`, and 9 `DelayFrames` calls whose
count is computed at runtime.
The four fades all live in `home/fade.asm` and are loops of
`ld c, 8 / call DelayFrames`:
| Routine | Iterations | Frames | Source |
| --- | --- | --- | --- |
| `GBFadeInFromBlack` | 4 | **32** | `home/fade.asm:21` |
| `GBFadeOutToBlack` | 4 | **32** | `home/fade.asm:43` |
| `GBFadeOutToWhite` | 3 | **24** | `home/fade.asm:26` |
| `GBFadeInFromWhite` | 3 | **24** | `home/fade.asm:48` |
## The metric
For each catalog entry, `delta = |port - truth| / truth`; the entry passes at
`delta <= 0.05`. The headline number is the **exposure-weighted** pass rate,
weighting each sequence by how often it occurs in ordinary play - a 30-frame
error on every page of dialogue matters more than a 30-frame error in the Hall
of Fame. Weights are in the tier column: T1 sequences recur constantly, T2 are
frequent, T3 are set pieces seen once or twice per playthrough.
## Tier 1 - constant exposure
These recur every few seconds of play and dominate perceived pacing. Both
sides are verified.
### Overworld and transitions
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| Overworld loop iteration | 2 frames (two `DelayFrame`) | `home/overworld.asm:41-44` | 1 step | `src/core/Game.lua:174` | see note |
| Warp / door: fade out | **32** | `home/overworld.asm:703` -> `GBFadeOutToBlack` | 32 | `src/render/Transition.lua` | **fixed** |
| Warp / door: fade in | **0** (map is drawn under blacked palettes, no fade) | `home/overworld.asm:690-703` | 0 | `src/render/Transition.lua` | **fixed** |
| Return to overworld after a battle | **10** hold, then `GBFadeInFromWhite` **24** | `home/overworld.asm:351-352`, `:22`, `:749-753` | 10 + 24 | `Transition.battleReturn` | **fixed** |
| Special warp entry (fly / teleport / dungeon) | `Delay3` + `GBFadeInFromWhite` = **27** | `engine/overworld/player_animations.asm:5-7` | - | - | unmeasured |
| Dungeon-warp arrival hold | **50** | `engine/overworld/player_animations.asm:43` | - | - | unmeasured |
| Player step (walk) | 16 | 8 loop iterations x 2 frames | 16 | `src/world/Player.lua:14` | **ok** |
| Turn in place | 2 | one extra loop pass | 2 | `src/world/Player.lua:18` | **ok** |
Note on the overworld loop: `OverworldLoop` calls `DelayFrame` and then falls
through to `OverworldLoopLessDelay`, which calls it again - so a full pass
costs 2 frames, and input is sampled every other frame. The port steps logic
and samples input every frame. This does not change walking speed (the 2-frame
loop moves 2 px, giving the same 16 frames per tile) but it does halve input
latency versus hardware. Flagged rather than "wrong": matching it exactly would
make the port feel less responsive than the original does on a modern display,
and it is the one place where a deliberate divergence is defensible.
### Text
The typewriter cadence itself is already correct - 1/3/5 frames per character
from `TextSpeedOptionData`, implemented at `src/render/TextBox.lua:267`. The
gaps around it are missing.
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| `<CONT>` line scroll (after the A press) | `ProtectedDelay3` 3 + 2x `ScrollTextUpOneLine` 5 = **13** | `home/text.asm:262-277`, `:283-307` | 13 | `src/render/TextBox.lua` | **fixed** |
| `<PARA>` paragraph break | `ProtectedDelay3` 3 + clear + **20** = **23** | `home/text.asm:230-243` | 23 | `src/render/TextBox.lua` | **fixed** |
| Page break (`PageChar`) | 3 + **20** = **23** | `home/text.asm:245-260` | 23 | `src/render/TextBox.lua` | **fixed** |
| `TextCommand_PAUSE` | **30** | `home/text.asm:500` | - | - | unmeasured |
| `TextCommand_DOTS` | **10** per dot | `home/text.asm:576` | - | - | unmeasured |
The three ProtectedDelay3 frames are a *pre*-input hold: the arrow is already
up and the button is ignored, because `ManualTextScroll` only starts watching
the joypad after the delay returns. Mashing A through a long conversation
therefore cannot go faster than 3 frames per line on hardware, and now cannot
here either. `PromptText` (`home/text.asm:209-217`) has the same shape, so a
finished page holds three frames before it can be dismissed too.
**Battle text is a second, separate engine.** `BattleState` types its own
messages rather than going through `src/render/TextBox.lua`, so none of the
fixes above reached it and it had drifted further than the overworld box:
| Sequence | Hardware | Source | Was | Now |
| --- | --- | --- | --- | --- |
| Per character | 1 glyph per `wOptions & $f` frames (1/3/5, default **3**) | `home/print_text.asm:4-45` | 2 glyphs **per frame**, option ignored | 3 |
| Per character, A or B held | **1** frame | `print_text.asm:27-36` | 2 glyphs per frame | 1 |
| `<CONT>` pre-input hold | **3** | `home/text.asm:263-267` | 0 | 3 |
| `<CONT>` scroll after the press | **10** | `home/text.asm:280-305` | 0 | 10 |
| Finished page, pre-input hold | **3** | `home/text.asm:213-217` | 0 | 3 |
At the default text speed the battle typewriter was running **six times**
hardware speed, which is most of why battle text read as a blur, and it
ignored the OPTION text-speed setting entirely.
`ScrollTextUpOneLine` is `ld b, 5` of `DelayFrame` (`home/text.asm:301-305`)
and its own comment notes it is "always called twice in a row", so a CONT
scroll blocks for 10 frames. The port's `scrollPx` slide at
`src/render/TextBox.lua:305-307` is a cosmetic 8 px at 2 px/frame running in
`draw()`, not on the logic step, and it does not gate the typewriter.
### Menus
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| Yes/no answer (either option) | **15** | `engine/menus/text_box.asm:322-323`, `:333-334` | 15 | `src/ui/ChoiceBox.lua` | **fixed** |
| List menu open (bag, PC, party-as-list) | **10** | `home/list_menu.asm:55-56` | 0 | `src/ui/ListMenu.lua` | **-100%, open** |
| List menu redraw per input | `Delay3` = **3** | `home/list_menu.asm:64` | 0 | - | **-100%, open** |
| Field move from the party menu | `Delay3` = **3** | `engine/menus/start_sub_menus.asm:4,27,175-203` | 7 (white flash) | `src/render/Transition.lua:8` | see note |
| Teleport from the party menu | **60** + `Delay3` | `engine/menus/start_sub_menus.asm:224-225` | - | - | unmeasured |
The port's 7-frame `white_flash` models `GBPalWhiteOutWithDelay3` plus the
screen-tile restore, which is a defensible reading of the same sequence; it is
listed here to be reconciled against the exact path rather than treated as a
bug.
### Battle entry
The wipe into a battle and the silhouette slide behind it. Numbers here come
from **pokered-c** (`C:\Users\Anthony\pokered`), whose `battle_transition.c`
derives each wipe from `battle_transitions.asm` and then corrects it against a
live side-by-side with the ROM. Where that project's measured value and a
naive reading of the asm disagree, the measured value wins - see the inward
spiral below.
| Sequence | Hardware | Was | Now |
| --- | --- | --- | --- |
| DoubleCircle wipe (wild, weak) | 10 x 3 = **30** | 40 | 30 |
| Circle wipe (wild, strong) | 20 x 3 = **60** | 40 | 60 |
| Spiral outward (trainer, strong) | 360 fills / 3 per frame = **120** | 40 | 120 |
| Spiral inward (trainer, weak) | 7 tiles per `Delay3` = **~150** | 40 | 156 |
| HStripes (dungeon wild, weak) | 20 x 3 = **60** | 24 | 60 |
| VStripes (dungeon wild, strong) | 18 x 3 = **54** | 24 | 54 |
| Shrink (dungeon trainer, weak) | 9 x 6 = **54** | 24 | 54 |
| Split (dungeon trainer, strong) | 9 x 6 = **54** | 24 | 54 |
| Flash before the circle wipes | 12 x 2 x 3 = **72** | 72 | 72 |
| Black hold before the battle draws | not stated by pokered; ~30 floor, calibrated **60** | 30 | 60 |
| Silhouette slide in | 144 px at 2 px/frame = **72** | 40 (160 px at 4 px/frame) | 72 |
| Trainer intro, before balls + text | `WaitForSoundToFinish` + `DelayFrames 20` | 0 | sfx wait + 20 |
The trainer intro's sound is `SFX_Silph_Scope`, extracted here as
`Trainer_Appeared` (`tools/rom_manifest.json` `audio.sfxHeaders`, bank 8 /
`$42bb`). It was being extracted and never played by anything. It now plays
into a clear window: `PrintBeginningBattleText .trainerBattle` does
`PlaySound` then `WaitForSoundToFinish`, which **blocks**, and only then
`DelayFrames 20` before `DrawAllPokeballs` and the text. `BattleState`'s
message queue grew a `waitSound` row for that, since `WaitForSoundToFinish`
waits on the sound actually stopping rather than on a fixed frame count.
**Scripted battles were skipping the transition entirely.** `BattleTransition`
runs from `DoBattleTransitionAndInitBattleVariables`, which both
`InitBattleCommon` (`core.asm:6680`, trainers) and `InitWildBattle` (`:6699`)
call unconditionally - every battle on hardware enters through a wipe. In the
port only `OverworldState:pushBattle` built one, and `Commands.start_battle`
pushed the `BattleState` straight onto the stack. The trainer-*sight* path
went through `pushBattle`, but every **script-driven** battle did not: gym
leaders, the rival, Giovanni, and every scripted wild encounter cut straight
to the battle screen with no transition at all. The catch tutorial
(`old_man_demo`) had the same gap; `InitWildBattle` has no
`BATTLE_TYPE_OLD_MAN` special case, so it gets a wipe too.
**Beyond 160x144.** Both halves of the transition used to stop at the classic
letterbox. The flash filled the 160x144 UI canvas, and the wipe handed the
surrounding window a generic centre-out square cascade
(`Renderer:drawBattleCascade`) regardless of which of the eight styles was
running - so at any zoom a spiral read as "a spiral in a box, with something
else happening around it".
- The flash is a palette write (`rBGP`), and a palette register tints every
pixel the LCD shows; there is no "outside the screen" for it to miss. It is
now published to the renderer as a screen-space veil and painted over the
finished composite, so it covers the whole surface at any zoom.
- The spiral and circle walks are now generated for whatever grid the window
works out to (`BattleTransition.gridOrder`), and the area outside the
letterbox is filled in that order instead of the square cascade. The
authentic 20x18 builders still own the letterbox itself, overrun and all,
so nothing changes at 1x. The generic builders are deliberately *not* the
ROM's walk - out there the hardware has no behaviour to be faithful to,
only a shape to continue.
- `shrink`, `split` and the two stripe styles are plain geometry rather than
a tile order, so they keep the cascade for now. Extending them is
rectangles, not a walk, and has not been done.
Note that the **flash is wild-only**: `BattleTransition_FlashScreen` is called
from `BattleTransition_Circle` (`:585`) and `BattleTransition_DoubleCircle`
(`:628`) and nowhere else. A trainer battle's transition is the spiral -
inward against a weaker foe, outward against a stronger one
(`wBattleTransitionSpiralDirection`, `:119-126`) - with no flash in front of
it. The port's `flash` mapping was already correct; the transition simply
never ran for those battles.
Two of these deserve their reasoning recorded:
**The inward spiral** writes one tile per iteration and calls
`BattleTransition_TransferDelay3` every seventh tile. That helper is not a
one-frame transfer - it is `ld a,1 / ldh [hAutoBGTransferEnabled] / call
Delay3 / xor a / ldh [...]` (`battle_transitions.asm:619`), so the cadence is
7 tiles per **three** frames. Reading it as one frame runs the whole wipe 3x
too fast, ~46 frames against the ROM's ~150. pokered-c hit that exact bug and
caught it on a live comparison.
**The black hold** is not a number pokered states anywhere; it is incidental
load cost that a modern port does not pay. The derivable floor is ~13 frames
(`LoadHpBarAndStatusTilePatterns` 4, `LoadHudTilePatterns` 2, `ClearScreen`'s
`Delay3` 3, the `DisableLCD` LY wait 1, `Delay3` after `EnableLCD` 3), but the
two sprite decompressors (`UncompressSpriteFromDE` for the 7x7 front pic and
`LoadPlayerBackPic`'s uncompress + `ScaleSpriteByTwo`) are bit-level RLE/delta
decoders that cannot be cycle-counted from the asm at all. So the derivation
bottoms out around 25-30 with an unbounded remainder. pokered-c set 60 by ear
against the real ROM and marked it ~95% right rather than frame-matched. The
credible range is 30-60; it should not be "corrected" down toward the floor on
the strength of the derivation, because the omitted decompressors are exactly
the unbounded part. Pinning it exactly wants a frame-by-frame capture.
### Battle turns
| Sequence | Hardware | Source | Port | Port source | Delta |
| --- | --- | --- | --- | --- | --- |
| Player HP bar drain of D HP over P pixels | **D + 2P + 6** | `engine/gfx/hp_bar.asm:81-135`, `:140-159`, `:234` | D + 2P + 6 | `src/battle/BattleState.lua` `stepHPDrain` | **fixed** |
| Enemy HP bar drain over P pixels | **2P + 5** (no per-HP frame) | same, gated at `:207-209` | 2P + 5 | same | **fixed** |
| Status move / missed move beat | **30** | `engine/battle/core.asm:3145,3158,3185-3186`; enemy `:5588` | 30 | `EffectRegistry` `missBeat`, `BattleState:performMove` | **fixed** |
| Applying anim type 1, vertical shake b=8 | **48** | `animations.asm:500-503` | 48 | `BattleState:applyHitFx` | ok |
| Applying anim type 2, fast horizontal b=8 | **72** | `animations.asm:505-508` | 72 | same | ok |
| Applying anim type 3, slow horizontal 6,2 | **48** | `animations.asm:510-512,526-549` | 48 | same | ok |
| Applying anim type 4, `AnimationBlinkMon` | **60** | `animations.asm:514-516`, `:1360-1376` | 60 | same | **fixed** |
| Applying anim type 5, fast horizontal b=2 | **18** | `animations.asm:518-521` | 18 | same | ok |
| Applying anim type 6, slow horizontal 3,2 | **24** | `animations.asm:523-525` | 24 | same | ok |
| Faint slide-down | **14** (`PIC_HEIGHT` x `DelayFrames 2`) | `engine/battle/core.asm:1186-1222` | 14 | `BattleState:onFaint` | **fixed** |
| Before every move animation | `Delay3` = **3** | `engine/battle/core.asm:6635-6640` | 3 | `BattleState:updateQueue` | **fixed** |
| Battle start, after enemy send-out | **40** | `engine/battle/core.asm:152-156` | 40 | `BattleState:enter` | **fixed** |
| Poison / burn / leech-seed tick | **20** | `engine/battle/core.asm:529-530` | 20 | `src/battle/BattleState.lua:1817` | **ok** |
| Switch player mon | **50** | `engine/battle/core.asm:2421-2422` | 50 | `src/battle/BattleState.lua:1666-1668` | **ok** |
| Post-hit hold (crit text or not) | **20** | `engine/battle/core.asm:3798-3814` | 20 | `EffectRegistry.runDamaging` | **fixed** |
| Fainted mon slide-down | **2** per row | `engine/battle/core.asm:1216-1217` | - | - | unmeasured |
| Trainer pic slide off | **2** per column | `engine/battle/core.asm:1267-1268` | - | - | unmeasured |
| Trainer battle victory | **40** | `engine/battle/core.asm:940-941` | - | - | unmeasured |
| Player blackout | **40** | `engine/battle/core.asm:1143-1144` | - | - | unmeasured |
| No moves left (Struggle) | **60** | `engine/battle/core.asm:2753-2754` | - | - | unmeasured |
| Send-out animation | `Delay3` + **4** + **5** | `engine/battle/core.asm:6814-6830` | - | - | unmeasured |
The HP bar was the single largest battle divergence. `UpdateHPBar` steps **one
HP point per loop iteration**; each iteration pays 1 frame in
`UpdateHPBar_PrintHPNumber` whenever `wHPBarType != 0` - the player's own HUD
and the party menu, but not the enemy HUD - plus 2 frames for each pixel the
bar actually moves. The tail (`.animateHPBarDone`, `:132-135`) prints the
number once more, animates one last pixel and falls into `Delay3`, so it costs
6 frames player-side and 5 enemy-side.
A 150 HP mon losing everything therefore costs 150 + 96 + 6 = **252 frames
(4.2 s)** on the player's HUD, against 96 + 5 = 101 on the enemy's. The port
used a flat `maxHP/96` - the enemy-side rate applied to both sides - and ran
that same drain in 96 frames (1.6 s).
## Tier 2 - frequent
Status-effect failures (`engine/battle/effects.asm:161,1162,1205`) hold **50**
frames each. `SwitchAndTeleportEffect` uses 50 and 20
(`:834-901`). Evolution holds **50** then **40**
(`engine/pokemon/evos_moves.asm:123,155`). The healing machine
(`engine/overworld/healing_machine.asm`), item effects
(`engine/items/item_effects.asm`, 259 frames across 8 sites), and the fishing
animation (**10** then **100**, `engine/overworld/player_animations.asm:380,399`)
are all in this tier.
## Tier 3 - set pieces
Highest total budgets in the scan, all seen rarely:
`engine/movie/hall_of_fame.asm` (529 frames / 7 sites),
`engine/link/cable_club.asm` (507 / 12), `engine/movie/credits.asm` (502 / 7),
`engine/movie/trade.asm` (494 / 19), `engine/movie/intro.asm` (317 / 7),
`engine/menus/main_menu.asm` (271 / 15), `engine/menus/save.asm` (250 / 3),
`engine/battle/end_of_battle.asm` (200 / 1, the link-battle win/lose string).
Several of these already have faithful implementations - see
`src/ui/IntroMovie.lua`, `src/ui/Credits.lua`, `src/ui/HallOfFame.lua`, whose
constants cite their asm sources directly.
## Status
Closed, and locked by `tests/engine/timing_parity.lua`:
1. **Text page and CONT breaks** - were 0 frames where hardware spends 13-23,
on every page of every dialogue in the game. Now exact, including the
three-frame pre-input hold that swallows a mashed A.
2. **Player HP bar drain** - was ~2.5x too fast on a typical mon. Now steps
one HP point at a time at the hardware rate, with the enemy HUD correctly
cheaper than the player's.
3. **Warp fade** - was a symmetric 12/12; now 32 out and no fade in, which is
both the right duration and the right shape.
4. **Yes/no answer** - was 0 frames where hardware spends 15, with the cursor
snapping to NO on B for the duration as `.choseSecondMenuItem` does.
5. **Battle entry** - every wipe ran at a flat 40/24 against budgets of 30-156,
the silhouette slide was 40 frames against 72, the black hold was half
what pokered-c calibrated, and the trainer intro's 20-frame gap before the
balls and text was missing entirely. This was the single most compressed
stretch in the game.
6. **Battle turns** - the type-4 blink ran at 20 frames against 60. That is
the applying animation for every plain damaging move the player uses, so
it was the single most-repeated timing error in the game. The 30-frame
beat that hardware spends on every status move and every miss was missing
entirely. The faint slide, unusually, ran *slower* than hardware (30
against 14).
Two things worth recording about that last batch, because both contradict a
plausible reading:
- **`PrintCriticalOHKOText`'s 20-frame hold is not conditional.** The
"no critical hit" early-out at `core.asm:3799` jumps to `.done`, and
`.done` *is* the `ld c, 20 / jp DelayFrames`. Every landed hit pays it,
which is where the beat before "It's super effective!" comes from.
- **`StartBattle`'s 40-frame hold is not conditional either.** The `call nz`
at `core.asm:154` gates only `EnemySendOutFirstMon`; the `DelayFrames 40`
under it runs for wild battles too, and it lands *between* the enemy's
send-out and `.playerSendOutFirstMon` (`:166`) rather than at the end of
the intro.
Trainer victory (24-frame scroll-in + 40-frame hold) and the ball shake
(`SFX_TINK` + `DelayFrames 40` per rock) were already correct -
`BattleState.lua`'s `wait = 64` and `AnimPlayer.lua`'s `emit(40)`. Note that
pokered-c's `BUI_TRAINER_VICTORY_SLIDE` comment calls the scroll-in 14
frames; `_ScrollTrainerPicAfterBattle` is 6 loop passes of `DelayFrames 4`,
so 24 is right and this port already had it.
**Leaving a battle was a cut, not a fade.** `EnterMap` checks
`BIT_BATTLE_OVER_OR_BLACKOUT` and calls `MapEntryAfterBattle`
(`home/overworld.asm:22`), which is `GBFadeInFromWhite` - so the map fades up
from white over 24 frames, behind the 10-frame hold at `:351-352`. The port
popped the battle and the overworld was simply there. `Transition.battleReturn`
supplies both halves, and steps the veil in three palette stages of 8 frames
rather than tweening it, because `GBFadeIncCommon` writes a palette and holds
it with `ld c, 8 / call DelayFrames` (`home/fade.asm:30-41`) three times over.
It is wrapped around `battle.onFinish` in `OverworldState:pushBattle` - the
one funnel every battle goes through - rather than living in `afterBattle`.
That placement matters: a script-driven **win** defers `afterBattle` into
`ctx.afterScript` so an evolution screen cannot be buried under the trainer's
follow-up text (`Commands.start_battle`), and a fade inside `afterBattle`
inherited that deferral. On a rival battle it fired after the post-battle
dialogue *and* the rival's walk-off, rather than when the battle ended.
The rest of `onFinish` runs as the fade's `onDone`, which is also the hardware
order: `MapEntryAfterBattle` fades the map back in and only then does the map
script run. The overworld is frozen meanwhile - `StateStack` updates the top
state only - so nothing moves under the white.
Hardware skips the fade on a dark map (`wMapPalOffset` nonzero takes the
`LoadGBPal` branch at `:754`). This port has no `wMapPalOffset` equivalent -
no map needs FLASH to be lit - so that branch is unreachable here;
`battleReturn` accepts `opts.instant` for it if that ever changes.
Still open, hardware number confirmed but not yet wired:
- List menu open (10) and per-input redraw (`Delay3`).
- Every battle-table row marked "unmeasured" above - the status/miss beat (30)
is the highest-exposure of them, since it is paid on every status move and
every miss.
Entries marked "unmeasured" have confirmed hardware numbers but the port side
has not been traced; they are remaining work, not known-good.
+5
View File
@@ -184,6 +184,11 @@ local function bootGame(version)
end end
function love.load(args) function love.load(args)
-- Before anything can shell out (update check, mod index, ROM picker),
-- claim one hidden console on Windows so those children inherit it instead
-- of each flashing their own cmd.exe window (#606). No-op elsewhere.
require("src.core.HostShell").hideHostConsole()
-- Self-updater boot shell: a fused build may mount and chainload a newer -- Self-updater boot shell: a fused build may mount and chainload a newer
-- downloaded payload here. True means it took over, so we must stop. A -- downloaded payload here. True means it took over, so we must stop. A
-- dev / source checkout no-ops (see src/update/Boot.lua). -- dev / source checkout no-ops (see src/update/Boot.lua).
+39 -1
View File
@@ -50,6 +50,44 @@ chosen file under the app save directory as `picked_rom.gb`,
from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload from that folder on Choose / refocus; see `docs/launcher.md`. The APK payload
itself remains data-free (no embedded ROM or generated cache). itself remains data-free (no embedded ROM or generated cache).
### Step bridge (Pokéwalker mod)
`love.system.syncHealthSteps()``GameActivity.syncHealthSteps` (same
JNI route as the picker: `common/android.cpp`
`modules/system/System.cpp``wrap_System.cpp`). The Java side does a
one-shot read of the hardware `TYPE_STEP_COUNTER` sensor (cumulative
since boot, counted by the OS whether or not any app runs), anchors the
reading in `SharedPreferences` so a walk is never credited twice
(a reading below the anchor means the phone rebooted → re-anchor without
crediting), and stages the delta as `steps_pending.json` in the save
identity dir — the same contract as the iOS `GRHealthBridge`. Nothing in
the base game calls it; the consumer is the
[Pokéwalker mod](https://github.com/mresnick67/Gen1ReComp-Pokewalker),
installed as a mod `.zip` at runtime (its SYNC STEPS option defaults
off).
Android 10+ gates the sensor behind the `ACTIVITY_RECOGNITION` runtime
permission (declared in `app/src/main/AndroidManifest.xml`; keep it out
of the build script's permission trim). The first
`syncHealthSteps()` call shows the system prompt; on grant the sensor
read runs immediately (`onRequestPermissionsResult`,
`STEP_PERMISSION_REQUEST_CODE`).
### Network transport (mod index / mod updates)
`love.system.httpDownload(url, absPath [, userAgent [, accept]])` ->
`GameActivity.httpDownload` (same JNI route as the picker and the step
bridge: `common/android.cpp` -> `modules/system/System.cpp` ->
`wrap_System.cpp`). Android ships no `curl`, which is what the desktop
builds fetch the mod index, mod release lists and mod zips with, so the
"Find mods" tab used to fail with "curl is not available on this
platform" (#597). The Java side is a blocking `HttpsURLConnection` GET
(https only, redirects followed by hand, body renamed into place only
once complete) and runs on LOVE's Lua thread, never the UI thread.
`src/core/HostShell.lua` picks the transport: curl when present,
otherwise this bridge; an APK older than the bridge simply reports no
transport, exactly as a missing curl does.
### SDK / NDK ### SDK / NDK
love-android 11.5a expects: love-android 11.5a expects:
@@ -84,7 +122,7 @@ scripts, tests, and mobile build sources are excluded.
| `app.name` | Pokemon Red | | `app.name` | Pokemon Red |
| `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. | | `app.orientation` | `fullUser`. This is only the manifest default: SDL requests FULL_SENSOR at window creation (resizable window, no `SDL_HINT_ORIENTATIONS`), and `GameActivity.setOrientationBis` remaps that to FULL_USER so the device's rotation lock is honoured. |
| `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted | | `app.version_name` / `app.version_code` | set from `--version X.Y.Z` (code = major*10000 + minor*100 + patch); left as-is if `--version` is omitted |
| Permissions | INTERNET / RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH kept | | Permissions | RECORD_AUDIO / WRITE_EXTERNAL_STORAGE stripped; VIBRATE + BLUETOOTH + INTERNET (link play, mod index) + ACTIVITY_RECOGNITION (step bridge) kept |
## Releases ## Releases
@@ -8,6 +8,11 @@
the link screen shows as "(Operation not permitted)" (issue #287). the link screen shows as "(Operation not permitted)" (issue #287).
scripts/build_android.sh must not strip this again. --> scripts/build_android.sh must not strip this again. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<!-- Step bridge: love.system.syncHealthSteps reads the hardware step
counter, which Android 10+ gates behind this runtime permission.
Requested only on the first sync call (the Pokéwalker mod's SYNC
STEPS option); scripts/build_android.sh must not strip it. -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<!-- OpenGL ES 2.0 --> <!-- OpenGL ES 2.0 -->
<uses-feature android:glEsVersion="0x00020000" /> <uses-feature android:glEsVersion="0x00020000" />
<!-- Touchscreen support --> <!-- Touchscreen support -->
@@ -219,6 +219,75 @@ bool showCreateDocument(const char *suggestedName)
return result; return result;
} }
bool syncHealthSteps()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID method = env->GetStaticMethodID(activity, "syncHealthSteps", "()Z");
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
bool restartApp()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
// Old APK / new liblove skew: fail soft so HostShell.restart can fall
// back to a clean quit instead of aborting on a missing method (#575).
jmethodID method = env->GetStaticMethodID(activity, "restartApp", "()Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
// Does not return on success: the Java side exits the process.
jboolean result = env->CallStaticBooleanMethod(activity, method);
env->DeleteLocalRef(activity);
return result;
}
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept)
{
if (url == nullptr || destPath == nullptr)
return false;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
// Old APK / new liblove skew: report "no transport" the same way a
// missing curl does, instead of aborting on a missing method (#597).
jmethodID method = env->GetStaticMethodID(activity, "httpDownload",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Z");
if (method == nullptr)
{
env->ExceptionClear();
env->DeleteLocalRef(activity);
return false;
}
jstring jurl = env->NewStringUTF(url);
jstring jdest = env->NewStringUTF(destPath);
jstring jua = env->NewStringUTF(userAgent != nullptr ? userAgent : "gen1recomp");
jstring jaccept = accept != nullptr ? env->NewStringUTF(accept) : nullptr;
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jdest, jua, jaccept);
env->DeleteLocalRef(jurl);
env->DeleteLocalRef(jdest);
env->DeleteLocalRef(jua);
if (jaccept != nullptr)
env->DeleteLocalRef(jaccept);
env->DeleteLocalRef(activity);
return result;
}
/* /*
* Helper functions for the filesystem module * Helper functions for the filesystem module
*/ */
@@ -851,4 +920,51 @@ const char *getArg0()
} // android } // android
} // love } // love
extern "C" __attribute__((visibility("default")))
int love_android_secondary_ready()
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "hasSecondaryDisplay", "()Z");
jboolean ready = JNI_FALSE;
if (m)
ready = env->CallStaticBooleanMethod(activity, m);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
return ready ? 1 : 0;
}
extern "C" __attribute__((visibility("default")))
void love_android_push_secondary(const void *rgba, int w, int h)
{
if (!rgba || w <= 0 || h <= 0)
return;
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "updateSecondaryFrame", "(Ljava/nio/ByteBuffer;II)V");
if (m)
{
jobject buf = env->NewDirectByteBuffer((void*) rgba, (jlong) w * (jlong) h * 4);
env->CallStaticVoidMethod(activity, m, buf, w, h);
env->DeleteLocalRef(buf);
}
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
extern "C" __attribute__((visibility("default")))
void love_android_secondary_enable(int on)
{
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
jclass activity = env->FindClass("org/love2d/android/GameActivity");
jmethodID m = env->GetStaticMethodID(activity, "setSecondaryEnabled", "(Z)V");
if (m)
env->CallStaticVoidMethod(activity, m, on ? JNI_TRUE : JNI_FALSE);
else
env->ExceptionClear();
env->DeleteLocalRef(activity);
}
#endif // LOVE_ANDROID #endif // LOVE_ANDROID
@@ -74,6 +74,30 @@ bool showFilePicker(const char *destFilename = nullptr);
**/ **/
bool showCreateDocument(const char *suggestedName = nullptr); bool showCreateDocument(const char *suggestedName = nullptr);
/**
* Pokéwalker step bridge: asks GameActivity to read the hardware step
* counter and stage steps_pending.json in the save identity dir (see
* GameActivity.syncHealthSteps). Returns whether a sync could start.
*/
bool syncHealthSteps();
/**
* Full process relaunch (GameActivity.restartApp): schedules the app's
* launch intent and kills the process, because the in-process
* quit("restart") loop double-inits physfs and crashes (#575). On success
* the process dies inside the Java call and this never returns; false
* means the relaunch could not be scheduled.
**/
bool restartApp();
/**
* Blocking HTTPS GET into destPath (GameActivity.httpDownload). Android has
* no curl binary, so this is the transport src/core/HostShell.lua uses there
* for the mod index and mod updates (#597). userAgent / accept may be null.
* Returns whether a complete file was written.
**/
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
/* /*
* Helper functions for the filesystem module * Helper functions for the filesystem module
*/ */
@@ -212,6 +212,38 @@ bool System::createFile(const char *suggestedName) const
#endif #endif
} }
bool System::syncHealthSteps() const
{
#ifdef LOVE_ANDROID
return love::android::syncHealthSteps();
#else
return false;
#endif
}
bool System::restartApp() const
{
#ifdef LOVE_ANDROID
return love::android::restartApp();
#else
return false;
#endif
}
bool System::httpDownload(const char *url, const char *destPath,
const char *userAgent, const char *accept) const
{
#ifdef LOVE_ANDROID
return love::android::httpDownload(url, destPath, userAgent, accept);
#else
LOVE_UNUSED(url);
LOVE_UNUSED(destPath);
LOVE_UNUSED(userAgent);
LOVE_UNUSED(accept);
return false;
#endif
}
bool System::hasBackgroundMusic() const bool System::hasBackgroundMusic() const
{ {
#if defined(LOVE_ANDROID) #if defined(LOVE_ANDROID)
@@ -127,6 +127,28 @@ public:
**/ **/
virtual bool createFile(const char *suggestedName = nullptr) const; virtual bool createFile(const char *suggestedName = nullptr) const;
/**
* Pokéwalker: stage pending real-world steps (steps_pending.json in the
* save dir) from the platform step source. Android-only; false elsewhere.
*/
virtual bool syncHealthSteps() const;
/**
* Relaunches the whole app with a fresh process (Android only; false
* elsewhere). The in-process love.event.quit("restart") double-inits
* physfs on Android and crashes, so src/core/HostShell.lua calls this
* instead (#575). Does not return on success -- the process exits.
**/
virtual bool restartApp() const;
/**
* Blocking HTTPS GET into an absolute host path (Android only; false
* elsewhere). Android has no curl, which is what every other platform
* fetches the mod index with (#597).
**/
virtual bool httpDownload(const char *url, const char *destPath,
const char *userAgent = nullptr, const char *accept = nullptr) const;
/** /**
* Gets if the user is playing music on background. * Gets if the user is playing music on background.
* Throws an exception on unsupported platforms. * Throws an exception on unsupported platforms.
@@ -109,6 +109,30 @@ int w_createFile(lua_State *L)
return 1; return 1;
} }
int w_syncHealthSteps(lua_State *L)
{
luax_pushboolean(L, instance()->syncHealthSteps());
return 1;
}
int w_restartApp(lua_State *L)
{
// Does not return on success: GameActivity.restartApp exits the process
// after scheduling the relaunch (#575).
luax_pushboolean(L, instance()->restartApp());
return 1;
}
int w_httpDownload(lua_State *L)
{
const char *url = luaL_checkstring(L, 1);
const char *dest = luaL_checkstring(L, 2);
const char *ua = luaL_optstring(L, 3, nullptr);
const char *accept = luaL_optstring(L, 4, nullptr);
luax_pushboolean(L, instance()->httpDownload(url, dest, ua, accept));
return 1;
}
int w_hasBackgroundMusic(lua_State *L) int w_hasBackgroundMusic(lua_State *L)
{ {
lua_pushboolean(L, instance()->hasBackgroundMusic()); lua_pushboolean(L, instance()->hasBackgroundMusic());
@@ -126,6 +150,9 @@ static const luaL_Reg functions[] =
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
{ "httpDownload", w_httpDownload },
{ "hasBackgroundMusic", w_hasBackgroundMusic }, { "hasBackgroundMusic", w_hasBackgroundMusic },
{ 0, 0 } { 0, 0 }
}; };
@@ -31,23 +31,34 @@ import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import android.Manifest; import android.Manifest;
import android.app.AlarmManager;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.PendingIntent;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ActivityInfo; import android.content.pm.ActivityInfo;
import android.content.pm.ApplicationInfo; import android.content.pm.ApplicationInfo;
import android.content.res.AssetManager; import android.content.res.AssetManager;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.media.AudioManager; import android.media.AudioManager;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Vibrator; import android.os.Vibrator;
import android.util.Log; import android.util.Log;
import android.util.DisplayMetrics; import android.util.DisplayMetrics;
@@ -67,6 +78,8 @@ public class GameActivity extends SDLActivity {
public static final int RECORD_AUDIO_REQUEST_CODE = 3; public static final int RECORD_AUDIO_REQUEST_CODE = 3;
public static final int FILE_PICKER_REQUEST_CODE = 4; public static final int FILE_PICKER_REQUEST_CODE = 4;
public static final int FILE_CREATE_REQUEST_CODE = 5; public static final int FILE_CREATE_REQUEST_CODE = 5;
public static final int STEP_PERMISSION_REQUEST_CODE = 6;
public static final int RESTART_REQUEST_CODE = 7;
/** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */ /** @deprecated Prefer FILE_PICKER_REQUEST_CODE; kept for older call sites. */
public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE; public static final int ROM_PICKER_REQUEST_CODE = FILE_PICKER_REQUEST_CODE;
// Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file // Mirrors conf.lua's t.identity ("pokemon-love2d"): where the picked file
@@ -83,6 +96,17 @@ public class GameActivity extends SDLActivity {
// basename as its body, so RomImporter:focus can say so in the launcher // basename as its body, so RomImporter:focus can say so in the launcher
// instead of leaving the player on "No ROM imported" (issue #442). // instead of leaving the player on "No ROM imported" (issue #442).
private static final String PICK_ERROR_FILENAME = "pick_error.flag"; private static final String PICK_ERROR_FILENAME = "pick_error.flag";
// Step bridge (love.system.syncHealthSteps): pending-steps delivery
// consumed by the Pokéwalker mod, same contract as the iOS
// GRHealthBridge. Steps come from the hardware TYPE_STEP_COUNTER
// (cumulative since boot, counted by the OS whether or not any app is
// running), anchored in SharedPreferences so a walk is never credited
// twice.
private static final String PENDING_STEPS_FILENAME = "steps_pending.json";
private static final String STEP_PREFS = "pokewalker_steps";
private static final String STEP_PREF_ANCHOR = "anchor";
private static final String STEP_PREF_ANCHOR_WALLTIME = "anchor_walltime";
private static final long STEP_MAX_PER_SYNC = 50000;
// Destination basename for the in-flight SAF pick (set by showFilePicker). // Destination basename for the in-flight SAF pick (set by showFilePicker).
// Saved/restored across instance state: the picker is a separate activity // Saved/restored across instance state: the picker is a separate activity
// and Android may destroy this one while it is up (memory pressure, or // and Android may destroy this one while it is up (memory pressure, or
@@ -312,12 +336,14 @@ public class GameActivity extends SDLActivity {
Log.d("GameActivity", "Cancelling vibration"); Log.d("GameActivity", "Cancelling vibration");
vibrator.cancel(); vibrator.cancel();
} }
teardownSecondaryDisplay();
super.onPause(); super.onPause();
} }
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
setupSecondaryDisplay();
} }
/** /**
@@ -426,15 +452,23 @@ public class GameActivity extends SDLActivity {
* Shows the system document picker (Storage Access Framework) so the * Shows the system document picker (Storage Access Framework) so the
* player can pick a ROM / mod / save from anywhere (Downloads, Drive, * player can pick a ROM / mod / save from anywhere (Downloads, Drive,
* etc.) without needing to know where the app's external files folder * etc.) without needing to know where the app's external files folder
* is. Requires API 19+ (ACTION_OPEN_DOCUMENT); the picked file (if any) * is. The picked file (if any) arrives later in onActivityResult, not
* arrives later in onActivityResult, not synchronously here. * synchronously here.
*
* API 21+ uses ACTION_OPEN_DOCUMENT; API 16-20 uses an ACTION_GET_CONTENT
* chooser instead. Below 19 OPEN_DOCUMENT does not exist, and on 19/20
* the stock DocumentsUI is unreliable -- it launches and then hands back
* RESULT_CANCELED with no data, which onActivityResult cannot tell apart
* from the player cancelling (#584). GET_CONTENT lets any installed file
* manager serve the pick, and both intents return the same content:// or
* file:// URI shapes, so the result path in onActivityResult stays
* picker-agnostic and unchanged.
* *
* @param destFilename basename under the app save identity (e.g. * @param destFilename basename under the app save identity (e.g.
* picked_rom.gb, picked_mod.zip, picked_save.sav) * picked_rom.gb, picked_mod.zip, picked_save.sav)
*/ */
@Keep @Keep
public static boolean showFilePicker(String destFilename) { public static boolean showFilePicker(String destFilename) {
if (android.os.Build.VERSION.SDK_INT < 19) return false;
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null) return false; if (self == null) return false;
if (destFilename == null || destFilename.length() == 0) { if (destFilename == null || destFilename.length() == 0) {
@@ -448,11 +482,26 @@ public class GameActivity extends SDLActivity {
} }
self.pendingPickFilename = destFilename; self.pendingPickFilename = destFilename;
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); if (android.os.Build.VERSION.SDK_INT >= 21) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*");
try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE);
return true;
} catch (Exception e) {
// Some OEM / TV builds ship without DocumentsUI; fall through
// to the GET_CONTENT chooser below instead of giving up (#584).
Log.d("GameActivity", "could not open document picker: " + e.getMessage());
}
}
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE); intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("*/*"); intent.setType("*/*");
try { try {
self.startActivityForResult(intent, FILE_PICKER_REQUEST_CODE); self.startActivityForResult(
Intent.createChooser(intent, "Choose a file"),
FILE_PICKER_REQUEST_CODE);
return true; return true;
} catch (Exception e) { } catch (Exception e) {
Log.d("GameActivity", "could not open file picker: " + e.getMessage()); Log.d("GameActivity", "could not open file picker: " + e.getMessage());
@@ -478,14 +527,127 @@ public class GameActivity extends SDLActivity {
return showFilePicker(PICKED_SAVE_FILENAME); return showFilePicker(PICKED_SAVE_FILENAME);
} }
/**
* Relaunches the whole app for love.system.restartApp, used by
* src/core/HostShell.lua when a mod toggle needs a cold boot (#575).
* love.event.quit("restart") re-runs LOVE's boot inside the same
* process, and the second love.filesystem.init throws once physfs
* failed to deinit ("already initialized"), killing the app. Instead
* we hand our launch intent to AlarmManager and then exit the process:
* the alarm lives in system_server, so it survives our death and
* cannot race the exit the way a plain startActivity right before
* Runtime.exit can on some OEMs, and the dead process guarantees no
* native (physfs / SDL / JNI) state leaks into the fresh run.
*/
@Keep
public static boolean restartApp() {
GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
try {
Context context = self.getApplicationContext();
Intent intent = context.getPackageManager()
.getLaunchIntentForPackage(context.getPackageName());
if (intent == null) return false;
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
int pendingFlags = PendingIntent.FLAG_CANCEL_CURRENT;
if (android.os.Build.VERSION.SDK_INT >= 23) {
// Mandatory mutability flag on API 31+; harmless from 23 up.
pendingFlags |= PendingIntent.FLAG_IMMUTABLE;
}
PendingIntent pending = PendingIntent.getActivity(
context, RESTART_REQUEST_CODE, intent, pendingFlags);
AlarmManager alarm = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
if (alarm == null) return false;
alarm.set(AlarmManager.RTC, System.currentTimeMillis() + 250, pending);
} catch (Exception e) {
Log.d("GameActivity", "could not schedule restart: " + e.getMessage());
return false;
}
Runtime.getRuntime().exit(0);
return true; // unreachable, but keeps the JNI signature honest
}
/**
* Blocking HTTPS GET into destPath, exposed as love.system.httpDownload
* and used by src/core/HostShell.lua. Android ships no curl binary, so
* every remote fetch the desktop builds do with curl (mod index feeds,
* mod release lists, mod zips, thumbnails) comes through here (#597).
* Runs on LOVE's Lua thread, never the UI thread, so the platform's
* network-on-main-thread rule is not in play and a blocking call matches
* the curl semantics the Lua callers already expect.
*
* Redirects are followed by hand because HttpURLConnection silently drops
* a redirect that changes protocol, and only https is accepted: the feeds
* live on GitHub Pages / raw and a downgrade to http must fail, not fetch.
* The body lands in a .part file and is renamed only once complete, so a
* dropped connection can never leave a half file the caller trusts.
*/
@Keep
public static boolean httpDownload(String url, String destPath, String userAgent, String accept) {
if (url == null || destPath == null) return false;
HttpURLConnection conn = null;
File tmp = new File(destPath + ".part");
try {
String current = url;
for (int hop = 0; hop < 5; hop++) {
URL parsed = new URL(current);
if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
conn = (HttpURLConnection) parsed.openConnection();
conn.setInstanceFollowRedirects(false);
conn.setConnectTimeout(15000);
conn.setReadTimeout(60000);
conn.setRequestProperty("User-Agent",
userAgent == null ? "gen1recomp" : userAgent);
if (accept != null) conn.setRequestProperty("Accept", accept);
int code = conn.getResponseCode();
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
String next = conn.getHeaderField("Location");
conn.disconnect();
conn = null;
if (next == null) return false;
current = new URL(parsed, next).toString();
continue;
}
if (code < 200 || code > 299) return false;
InputStream in = new BufferedInputStream(conn.getInputStream());
OutputStream out = new BufferedOutputStream(new FileOutputStream(tmp));
try {
byte[] buf = new byte[16384];
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
} finally {
try { out.close(); } catch (IOException ignored) {}
try { in.close(); } catch (IOException ignored) {}
}
File dest = new File(destPath);
dest.delete();
if (!tmp.renameTo(dest)) return false;
return dest.length() > 0;
}
return false;
} catch (Exception e) {
Log.d("GameActivity", "httpDownload failed: " + e.getMessage());
return false;
} finally {
if (conn != null) conn.disconnect();
if (tmp.exists()) tmp.delete();
}
}
/** /**
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export * Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
* (pending_export.sav in the app save identity) to Downloads / Drive / * (pending_export.sav in the app save identity) to Downloads / Drive /
* etc. Suggested name is the dialog's default filename. * etc. Suggested name is the dialog's default filename.
*
* CREATE_DOCUMENT does not exist below API 19 and has no pre-SAF
* equivalent, so unlike showFilePicker this stays 19+ (#584); the false
* return degrades on the Lua side (RomImporter export) to "Exported
* inside the app folder", which is the correct pre-KitKat behavior.
*/ */
@Keep @Keep
public static boolean showCreateDocument(String suggestedName) { public static boolean showCreateDocument(String suggestedName) {
if (android.os.Build.VERSION.SDK_INT < 19) return false; if (android.os.Build.VERSION.SDK_INT < 19) return false;
// (see showFilePicker for why the import side got a pre-19 path)
GameActivity self = (GameActivity) mSingleton; GameActivity self = (GameActivity) mSingleton;
if (self == null) return false; if (self == null) return false;
if (suggestedName == null || suggestedName.length() == 0) { if (suggestedName == null || suggestedName.length() == 0) {
@@ -532,6 +694,160 @@ public class GameActivity extends SDLActivity {
} }
} }
/**
* Step sync, called from Lua as love.system.syncHealthSteps()
* (see modules/system/wrap_System.cpp). Asynchronous like the picker:
* returns whether a sync could be started; the result lands later as
* steps_pending.json in the save identity dir, where the Pokéwalker
* mod's poll consumes it.
*
* Android 10+ gates the step counter behind the ACTIVITY_RECOGNITION
* runtime permission; the first call shows the system prompt and a later
* sync (the mod retries on save load / option change) delivers.
*/
@Keep
public static boolean syncHealthSteps() {
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return false;
if (android.os.Build.VERSION.SDK_INT >= 29
&& ActivityCompat.checkSelfPermission(self,
Manifest.permission.ACTIVITY_RECOGNITION)
!= PackageManager.PERMISSION_GRANTED) {
self.runOnUiThread(new Runnable() {
@Override
public void run() {
ActivityCompat.requestPermissions(self,
new String[]{Manifest.permission.ACTIVITY_RECOGNITION},
STEP_PERMISSION_REQUEST_CODE);
}
});
return true;
}
self.startStepSensorRead();
return true;
}
/**
* One-shot read of the cumulative hardware step counter. The sensor
* usually reports its cached value moments after registration; some
* devices hold the event until the next physical step, so the listener
* is given 20 seconds before being torn down (the next sync retries).
*/
private void startStepSensorRead() {
final SensorManager manager =
(SensorManager) getSystemService(Context.SENSOR_SERVICE);
if (manager == null) return;
Sensor counter = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER);
if (counter == null) {
Log.d("GameActivity", "no step counter sensor on this device");
return;
}
final SensorEventListener listener = new SensorEventListener() {
private boolean delivered = false;
@Override
public void onSensorChanged(SensorEvent event) {
if (delivered || event.values.length == 0) return;
delivered = true;
manager.unregisterListener(this);
deliverSteps((long) event.values[0]);
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
};
if (!manager.registerListener(listener, counter,
SensorManager.SENSOR_DELAY_NORMAL)) {
Log.d("GameActivity", "step counter listener registration failed");
return;
}
new Handler(Looper.getMainLooper()).postDelayed(new Runnable() {
@Override
public void run() {
// No-op if the listener already delivered and unregistered.
manager.unregisterListener(listener);
}
}, 20000);
}
/**
* Convert a cumulative counter reading into pending steps. The counter
* resets to zero on reboot: a reading below the stored anchor re-anchors
* without crediting (steps walked between the reboot and this sync are
* lost, which errs on the honest side).
*/
private void deliverSteps(long counterNow) {
SharedPreferences prefs = getSharedPreferences(STEP_PREFS, MODE_PRIVATE);
long anchor = prefs.getLong(STEP_PREF_ANCHOR, -1);
long now = System.currentTimeMillis();
if (anchor < 0 || counterNow < anchor) {
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
Log.d("GameActivity", "step anchor set at " + counterNow);
return;
}
long steps = Math.min(counterNow - anchor, STEP_MAX_PER_SYNC);
long fromWalltime = prefs.getLong(STEP_PREF_ANCHOR_WALLTIME, now);
if (steps <= 0) return;
prefs.edit()
.putLong(STEP_PREF_ANCHOR, counterNow)
.putLong(STEP_PREF_ANCHOR_WALLTIME, now)
.apply();
File dir = saveIdentityDir();
if (!dir.isDirectory() && !dir.mkdirs()) {
Log.d("GameActivity", "cannot create save dir for steps: " + dir);
return;
}
File pending = new File(dir, PENDING_STEPS_FILENAME);
long total = steps;
// Merge with an unconsumed earlier delivery so steps are never lost
// (same contract as the iOS bridge).
if (pending.isFile()) {
try {
byte[] raw = new byte[(int) Math.min(pending.length(), 4096)];
FileInputStream in = new FileInputStream(pending);
int read = in.read(raw);
in.close();
if (read > 0) {
org.json.JSONObject old =
new org.json.JSONObject(new String(raw, 0, read, "UTF-8"));
total += Math.max(0, old.optLong("steps", 0));
}
} catch (Exception e) {
Log.d("GameActivity", "ignoring unreadable pending steps: " + e);
}
}
try {
org.json.JSONObject payload = new org.json.JSONObject();
payload.put("steps", total);
payload.put("from", isoTime(fromWalltime));
payload.put("to", isoTime(now));
File tmp = new File(dir, PENDING_STEPS_FILENAME + ".tmp");
FileOutputStream out = new FileOutputStream(tmp);
out.write(payload.toString().getBytes("UTF-8"));
out.close();
if (!tmp.renameTo(pending)) {
tmp.delete();
Log.d("GameActivity", "could not publish pending steps");
return;
}
Log.d("GameActivity", total + " steps pending for the Pokewalker mod");
} catch (Exception e) {
Log.d("GameActivity", "could not write pending steps: " + e);
}
}
private static String isoTime(long millis) {
java.text.SimpleDateFormat format =
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", java.util.Locale.US);
format.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
return format.format(new java.util.Date(millis));
}
private boolean copyFileToUri(File source, Uri destUri) { private boolean copyFileToUri(File source, Uri destUri) {
InputStream in = null; InputStream in = null;
OutputStream out = null; OutputStream out = null;
@@ -762,6 +1078,17 @@ public class GameActivity extends SDLActivity {
} }
break; break;
} }
case STEP_PERMISSION_REQUEST_CODE: {
if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d("GameActivity", "Step permission granted");
// Deliver right away so the sync the player just
// opted into doesn't wait for the next launch.
startStepSensorRead();
} else {
Log.d("GameActivity", "Did not get step permission.");
}
break;
}
default: default:
super.onRequestPermissionsResult(requestCode, permissions, grantResults); super.onRequestPermissionsResult(requestCode, permissions, grantResults);
} }
@@ -944,4 +1271,182 @@ public class GameActivity extends SDLActivity {
return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so"; return applicationInfo.sourceDir + "!/lib/" + abi + "/?.so";
} }
} }
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
// physical display. Driven from the engine through love_android_secondary_*
// in src/jni/love/src/common/android.cpp.
private static volatile SecondaryPresentation secondaryPresentation;
private static volatile boolean secondaryEnabled = false;
@Keep
public static void setSecondaryEnabled(final boolean on) {
secondaryEnabled = on;
final GameActivity self = (GameActivity) mSingleton;
if (self == null) return;
self.runOnUiThread(new Runnable() {
@Override public void run() {
if (on) setupSecondaryDisplay(); else teardownSecondaryDisplay();
}
});
}
private static void setupSecondaryDisplay() {
GameActivity self = (GameActivity) mSingleton;
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
try {
android.hardware.display.DisplayManager dm =
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
if (dm == null) return;
Display chosen = null;
for (Display d : dm.getDisplays()) {
android.graphics.Point size = new android.graphics.Point();
d.getRealSize(size);
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
+ " size=" + size.x + "x" + size.y);
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
chosen = d;
}
}
if (chosen == null) {
Display[] pres =
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
if (pres != null && pres.length > 0) chosen = pres[0];
}
if (chosen == null) {
Log.d("GameActivity", "no secondary display found");
return;
}
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
p.show();
secondaryPresentation = p;
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
} catch (Throwable t) {
Log.d("GameActivity", "secondary display setup failed: " + t);
secondaryPresentation = null;
}
}
private static void teardownSecondaryDisplay() {
SecondaryPresentation p = secondaryPresentation;
secondaryPresentation = null;
if (p != null) {
try { p.dismiss(); } catch (Throwable t) {}
}
}
@Keep
public static boolean hasSecondaryDisplay() {
return secondaryPresentation != null;
}
@Keep
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
SecondaryPresentation p = secondaryPresentation;
if (p != null && buf != null && w > 0 && h > 0) {
p.updateFrame(buf, w, h);
}
}
private static class SecondaryPresentation extends android.app.Presentation {
private final FrameView frameView;
SecondaryPresentation(Context context, Display display) {
super(context, display);
frameView = new FrameView(context);
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
android.view.Window w = getWindow();
if (w != null) {
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_FULLSCREEN
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
w.setLayout(WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT);
}
setContentView(frameView);
applyImmersive();
frameView.post(new Runnable() {
@Override public void run() { applyImmersive(); }
});
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) applyImmersive();
}
private void applyImmersive() {
android.view.Window w = getWindow();
if (w == null) return;
if (android.os.Build.VERSION.SDK_INT >= 30) {
w.setDecorFitsSystemWindows(false);
android.view.WindowInsetsController c = w.getInsetsController();
if (c != null) {
c.hide(android.view.WindowInsets.Type.systemBars());
c.setSystemBarsBehavior(
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
}
} else {
w.getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
}
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
frameView.updateFrame(buf, w, h);
}
}
private static class FrameView extends View {
private android.graphics.Bitmap bitmap;
private final android.graphics.Rect dst = new android.graphics.Rect();
private final android.graphics.Paint paint = new android.graphics.Paint();
private final Object lock = new Object();
private int fw, fh;
FrameView(Context context) {
super(context);
paint.setFilterBitmap(false);
paint.setAntiAlias(false);
setBackgroundColor(0xFF000000);
}
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
synchronized (lock) {
if (bitmap == null || fw != w || fh != h) {
if (bitmap != null) bitmap.recycle();
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
fw = w; fh = h;
}
buf.rewind();
bitmap.copyPixelsFromBuffer(buf);
}
postInvalidate();
}
@Override
protected void onDraw(android.graphics.Canvas canvas) {
synchronized (lock) {
if (bitmap == null || fw == 0 || fh == 0) return;
int vw = getWidth(), vh = getHeight();
int s = Math.min(vw / fw, vh / fh);
if (s < 1) s = 1;
int dw = fw * s, dh = fh * s;
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
dst.set(dx, dy, dx + dw, dy + dh);
canvas.drawColor(0xFF000000);
canvas.drawBitmap(bitmap, null, dst, paint);
}
}
}
} }
+1 -1
View File
@@ -1 +1 @@
11.5 12.0
+17 -38
View File
@@ -1,4 +1,4 @@
# iOS build (LÖVE 11.5) # iOS build (LÖVE 12.0)
> **Native ROM/mod/save import.** The iOS build ships a Swift > **Native ROM/mod/save import.** The iOS build ships a Swift
> document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`) > document-picker bridge (`native/GRPickerBridge.swift` + `GRBootstrap.m`)
@@ -20,20 +20,17 @@
> The note below about a missing "UIDocumentPicker handoff" is > The note below about a missing "UIDocumentPicker handoff" is
> resolved by this bridge. > resolved by this bridge.
macOS + Xcode only. Pins the official **LÖVE 11.5** iOS Xcode tree macOS + Xcode only. Fetches the **LÖVE 12.0** source tree and matching Apple
(`love-11.5-ios-source.zip` from [love2d/love releases](https://github.com/love2d/love/releases/tag/11.5)), dependencies from the official [LÖVE source](https://github.com/love2d/love)
matching `conf.lua`'s `t.version = "11.5"`. and [Apple dependencies](https://github.com/love2d/love-apple-dependencies)
repositories. `conf.lua` declares LÖVE 12.0 on iOS and 11.5 elsewhere.
There is no separate `love2d/love-ios` GitHub repo for 11.5; the release zip Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `12.0`.
**is** the vendored iOS project (Xcode project under
`love-src/platform/xcode/love.xcodeproj`, target `love-ios`).
Pin file: [`LOVE_VERSION`](./LOVE_VERSION) → `11.5`.
## Quick start (simulator) ## Quick start (simulator)
```bash ```bash
# Fetch LÖVE 11.5 iOS sources (once) + build for Simulator # Fetch LÖVE 12.0 iOS sources and dependencies (once) + build for Simulator
scripts/build_ios.sh --fetch scripts/build_ios.sh --fetch
``` ```
@@ -80,10 +77,10 @@ Manual out-of-band steps:
| Path | Role | | Path | Role |
|------|------| |------|------|
| `LOVE_VERSION` | Engine pin (`11.5`) | | `LOVE_VERSION` | Engine pin (`12.0`) |
| `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) | | `overlays/love-ios.plist` | Portrait-only Info.plist + display name **Pokemon Red** (copied over the upstream plist every build) |
| `love-src/` | Downloaded `love-11.5-ios-source` tree (**gitignored**, do not commit) | | `love-src/` | Downloaded LÖVE 12.0 source tree (**gitignored**, do not commit) |
| `cache/` | Downloaded zips (**gitignored**) | | `cache/` | Temporary source and dependency checkout data (**gitignored**) |
| `build/` | `xcodebuild` derived data (**gitignored**) | | `build/` | `xcodebuild` derived data (**gitignored**) |
Game payload lands at: Game payload lands at:
@@ -94,28 +91,10 @@ and is fused into the built `.app` (LÖVE auto-runs any bundled `*.love`).
## Apple libraries dependency ## Apple libraries dependency
The official `love-11.5-ios-source.zip` already ships prebuilt iOS `scripts/build_ios.sh --fetch` retrieves the matching iOS libraries and the
xcframeworks under `platform/xcode/ios/libraries/` (SDL2, LuaJIT, freetype, SDL3 framework from
ogg, vorbis, theora, modplug). [love-apple-dependencies](https://github.com/love2d/love-apple-dependencies).
Re-run it if either dependency directory is absent.
If that folder is missing or incomplete (e.g. you cloned sources without
libs), download the matching prebuilts and install them:
```bash
curl -fL -o mobile/ios/cache/love-11.5-apple-libraries.zip \
https://github.com/love2d/love/releases/download/11.5/love-11.5-apple-libraries.zip
unzip -q mobile/ios/cache/love-11.5-apple-libraries.zip -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \
mobile/ios/love-src/platform/xcode/ios/libraries
```
`scripts/build_ios.sh` checks for `libraries/SDL2.xcframework` and fails with
these instructions if it is absent.
Upstream also documents
[love-apple-dependencies](https://github.com/love2d/love-apple-dependencies)
as an alternate source of the same libraries.
## App identity ## App identity
@@ -134,7 +113,7 @@ so refreshing `love-src/` does not lose branding.
| Flag | Meaning | | Flag | Meaning |
|------|---------| |------|---------|
| *(default)* | Simulator, Debug, no signing | | *(default)* | Simulator, Debug, no signing |
| `--fetch` | Download/extract `love-11.5-ios-source.zip` if `love-src/` is missing | | `--fetch` | Fetch the LÖVE 12.0 source tree and Apple dependencies if `love-src/` is missing |
| `--device` | Build against `iphoneos` instead of `iphonesimulator` | | `--device` | Build against `iphoneos` instead of `iphonesimulator` |
| `--release` | `Release` configuration instead of `Debug` | | `--release` | `Release` configuration instead of `Debug` |
| `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` | | `--package-only` | Zip `game.love` + apply plist overlay; skip `xcodebuild` |
@@ -147,5 +126,5 @@ Also: `scripts/build.sh ios` delegates here (`--release` is forwarded).
- iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks` - iOS platform installed in Xcode (Settings → Platforms). `xcodebuild -showsdks`
should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib should list `iphonesimulator` / `iphoneos`. A partial install can fail IB/xib
compiles with `iOS … Platform Not Installed` even when the SDK name appears. compiles with `iOS … Platform Not Installed` even when the SDK name appears.
- `love-src/` present (`--fetch` or manual unzip of `love-11.5-ios-source.zip`) - `love-src/` present (`--fetch`)
- iOS libraries under `love-src/platform/xcode/ios/libraries/` (see above) - iOS libraries under `love-src/platform/xcode/ios/libraries/` and SDL3 under `love-src/platform/xcode/shared/Frameworks/`
+17
View File
@@ -0,0 +1,17 @@
{
"name": "gen1recomp App Repo",
"identifier": "com.theboisclub.gen1recomp.repo",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png",
"apps": [
{
"name": "gen1recomp",
"bundleIdentifier": "com.theboisclub.gen1recomp",
"developerName": "bryanthaboi",
"iconURL": "https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main/assets/logo/logo.png",
"localizedDescription": "Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke",
"tintColor": "3b5ca8",
"category": "games",
"versions": []
}
]
}
+1 -5
View File
@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>en</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Pokemon Red</string> <string>gen1recomp</string>
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
<dict> <dict>
@@ -49,10 +49,6 @@
<false/> <false/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>Launch Screen</string> <string>Launch Screen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>opengles-2</string>
</array>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<true/> <true/>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
+7 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 11.5 """Applies gen1recomp's iOS native-bridge patches to the fetched LÖVE 12.0
source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first source tree (mobile/ios/love-src/). Idempotent AND re-appliable: the first
run stashes a pristine `.orig` copy of every file it rewrites, and later run stashes a pristine `.orig` copy of every file it rewrites, and later
runs always start over from that copy — so editing the patch content here runs always start over from that copy — so editing the patch content here
@@ -39,6 +39,7 @@ WRAP_INCLUDES = """
#ifdef LOVE_IOS #ifdef LOVE_IOS
#include <objc/runtime.h> #include <objc/runtime.h>
#include <objc/message.h> #include <objc/message.h>
#include <string>
#include "filesystem/Filesystem.h" #include "filesystem/Filesystem.h"
#endif #endif
""" % MARKER """ % MARKER
@@ -53,8 +54,12 @@ WRAP_FUNCS = """
#ifdef LOVE_IOS #ifdef LOVE_IOS
static const char *gr_saveDirectory() static const char *gr_saveDirectory()
{ {
static std::string saveDirectory;
auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM); auto fs = Module::getInstance<love::filesystem::Filesystem>(Module::M_FILESYSTEM);
return fs != nullptr ? fs->getSaveDirectory() : ""; if (fs == nullptr)
return "";
saveDirectory = fs->getSaveDirectory();
return saveDirectory.c_str();
} }
static int gr_callBridge(lua_State *L, const char *className, static int gr_callBridge(lua_State *L, const char *className,
+5
View File
@@ -35,6 +35,7 @@ TARGET="all"
NOTARY_PROFILE="notary-profile" NOTARY_PROFILE="notary-profile"
NOTARIZE=true NOTARIZE=true
IOS_RELEASE=false IOS_RELEASE=false
IOS_IPA=false
say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; }
@@ -48,6 +49,7 @@ while [ $# -gt 0 ]; do
--notary-profile) NOTARY_PROFILE="$2"; shift ;; --notary-profile) NOTARY_PROFILE="$2"; shift ;;
--no-notarize) NOTARIZE=false ;; --no-notarize) NOTARIZE=false ;;
--release) IOS_RELEASE=true ;; --release) IOS_RELEASE=true ;;
--ipa) IOS_IPA=true ;;
*) fail "unknown argument: $1" ;; *) fail "unknown argument: $1" ;;
esac esac
shift shift
@@ -298,6 +300,9 @@ build_android() {
if [ "$VERSION_EXPLICIT" = true ]; then if [ "$VERSION_EXPLICIT" = true ]; then
args+=(--version "$VERSION") args+=(--version "$VERSION")
fi fi
if [ "$IOS_IPA" = true ]; then
args+=(--ipa)
fi
"$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"} "$ROOT/scripts/build_android.sh" ${args[@]+"${args[@]}"}
} }
+29 -3
View File
@@ -198,6 +198,10 @@ pack_game_love() {
rm -f "$LOVE_FILE" rm -f "$LOVE_FILE"
# tools/save-editor ships with the app: the launcher's Edit button on a save # tools/save-editor ships with the app: the launcher's Edit button on a save
# row opens it in-process, so it must be inside the archive (see build.sh). # row opens it in-process, so it must be inside the archive (see build.sh).
# Deliberately NO fused mods: a mod inside game.love sits in the read-only
# APK, so the mod manager's Delete can't remove it and it reappears every
# launch. Pokewalker ships as an importable .zip instead, which gives it
# a real install/upgrade/delete lifecycle.
(cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \ (cd "$ROOT" && zip -q -9 -r "$LOVE_FILE" \
main.lua conf.lua src data assets tools/save-editor \ main.lua conf.lua src data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \ tools/rom_manifest.json tools/rom_manifest_blue.json \
@@ -290,10 +294,32 @@ require_android_sdk() {
# --------------------------------------------------------------- gradle # --------------------------------------------------------------- gradle
run_gradle() { run_gradle() {
local task="assembleEmbedNoRecordDebug" local task="assembleEmbedNoRecordDebug"
say "building APK ($task)" local build_dir="$ANDROID_DIR"
# ndk-build is GNU make underneath and cannot cope with spaces anywhere in
# the project path ("Your APP_BUILD_SCRIPT points to an unknown file").
# When this checkout lives at a spaced path (e.g. "~/xCode Projects/..."),
# shadow the android tree to a space-free location and build there; the
# shadow persists across runs so gradle/ndk builds stay incremental.
case "$ANDROID_DIR" in
*" "*)
build_dir="${TMPDIR:-/tmp}/gen1recomp-android-shadow"
say "path contains spaces (ndk-build cannot handle them);"
say "shadow-building in: $build_dir"
mkdir -p "$build_dir"
rsync -a --delete \
--exclude=".gradle" --exclude="app/build" --exclude="love/build" \
--exclude="local.properties" \
"$ANDROID_DIR/" "$build_dir/"
if [ -f "$ANDROID_DIR/local.properties" ]; then
cp "$ANDROID_DIR/local.properties" "$build_dir/local.properties"
fi
;;
esac
say "building APK ($task)"
if ! ( if ! (
cd "$ANDROID_DIR" cd "$build_dir"
./gradlew --no-daemon "$task" ./gradlew --no-daemon "$task"
); then ); then
fail "gradle $task failed. fail "gradle $task failed.
@@ -302,7 +328,7 @@ run_gradle() {
You can still iterate on the .love payload with: scripts/build_android.sh --package-only" You can still iterate on the .love payload with: scripts/build_android.sh --package-only"
fi fi
local out_dir="$ANDROID_DIR/app/build/outputs/apk/embedNoRecord/debug" local out_dir="$build_dir/app/build/outputs/apk/embedNoRecord/debug"
if [ -d "$out_dir" ]; then if [ -d "$out_dir" ]; then
say "APK output:" say "APK output:"
find "$out_dir" -name '*.apk' -exec ls -lh {} \; find "$out_dir" -name '*.apk' -exec ls -lh {} \;
+113 -56
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 11.5's # Packages the LÖVE2D Pokémon Red port into an iOS app via LÖVE 12.0's
# official iOS Xcode project (love-11.5-ios-source.zip). # iOS Xcode project.
# #
# Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install] # Usage: scripts/build_ios.sh [--fetch] [--device] [--release] [--install]
# [--version X.Y.Z] [--package-only] # [--version X.Y.Z] [--package-only]
@@ -11,14 +11,14 @@
# --install after a --device build, install the app onto the # --install after a --device build, install the app onto the
# first connected iPhone/iPad (unlock it first) # first connected iPhone/iPad (unlock it first)
# --release Release configuration # --release Release configuration
# --version X.Y.Z stamp MARKETING_VERSION / CURRENT_PROJECT_VERSION # --version X.Y.Z stamp the engine version into game.love plus
# --fetch Download love-11.5-ios-source.zip into mobile/ios/love-src/ # MARKETING_VERSION / CURRENT_PROJECT_VERSION
# --fetch Fetch LÖVE 12.0 sources and Apple dependencies into mobile/ios/love-src/
# --package-only Zip game.love + apply plist overlay; skip xcodebuild # --package-only Zip game.love + apply plist overlay; skip xcodebuild
# #
# Prerequisites: # Prerequisites:
# - macOS + Xcode (xcodebuild) # - macOS + Xcode (xcodebuild)
# - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md) # - mobile/ios/love-src/ (see --fetch / mobile/ios/README.md)
# - prebuilt iOS libraries under love-src/platform/xcode/ios/libraries/
# #
# Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy) # Output: dist/ios/<Config>-<sdk>/gen1recomp.app (convenience copy)
# dist/ios/gen1recomp.ipa (device builds only) # dist/ios/gen1recomp.ipa (device builds only)
@@ -50,21 +50,22 @@ DISPLAY_NAME="gen1recomp"
# capabilities like HealthKit are involved), so a per-team default # capabilities like HealthKit are involved), so a per-team default
# lets anyone build without colliding with someone else's app # lets anyone build without colliding with someone else's app
# 4. simulator: the project default (no App ID registration involved) # 4. simulator: the project default (no App ID registration involved)
BUNDLE_ID="${GEN1_BUNDLE_ID:-}" BUNDLE_ID="${GEN1_BUNDLE_ID:-com.theboisclub.gen1recomp}"
if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then if [ -z "$BUNDLE_ID" ] && [ -f "$IOS_DIR/bundle_id.local" ]; then
BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")" BUNDLE_ID="$(tr -d '[:space:]' < "$IOS_DIR/bundle_id.local")"
fi fi
LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 11.5)" LOVE_VERSION="$(tr -d '[:space:]' < "$IOS_DIR/LOVE_VERSION" 2>/dev/null || echo 12.0)"
IOS_SOURCE_ZIP="love-${LOVE_VERSION}-ios-source.zip" LOVE_SOURCE_REF="${LOVE_SOURCE_REF:-main}"
APPLE_LIBS_ZIP="love-${LOVE_VERSION}-apple-libraries.zip" APPLE_DEPENDENCIES_REF="${APPLE_DEPENDENCIES_REF:-main}"
IOS_SOURCE_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${IOS_SOURCE_ZIP}" LOVE_SOURCE_REPO="https://github.com/love2d/love.git"
APPLE_LIBS_URL="https://github.com/love2d/love/releases/download/${LOVE_VERSION}/${APPLE_LIBS_ZIP}" APPLE_DEPENDENCIES_REPO="https://github.com/love2d/love-apple-dependencies.git"
FETCH=false FETCH=false
DEVICE=false DEVICE=false
RELEASE=false RELEASE=false
PACKAGE_ONLY=false PACKAGE_ONLY=false
INSTALL=false INSTALL=false
CREATE_IPA=false
# Last resort for an incomplete source export, mirroring build_android.sh. # Last resort for an incomplete source export, mirroring build_android.sh.
MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}" MANIFEST_BASE_URL="${MANIFEST_BASE_URL:-https://raw.githubusercontent.com/bryanthaboi/gen1recomp/main}"
MANIFESTS="" MANIFESTS=""
@@ -82,6 +83,7 @@ while [ $# -gt 0 ]; do
--release) RELEASE=true ;; --release) RELEASE=true ;;
--package-only) PACKAGE_ONLY=true ;; --package-only) PACKAGE_ONLY=true ;;
--install) INSTALL=true ;; --install) INSTALL=true ;;
--ipa) CREATE_IPA=true ;;
--version) VERSION="$2"; shift ;; --version) VERSION="$2"; shift ;;
-h|--help) -h|--help)
sed -n '2,24p' "$0" sed -n '2,24p' "$0"
@@ -92,6 +94,10 @@ while [ $# -gt 0 ]; do
shift shift
done done
if $CREATE_IPA; then
DEVICE=true
fi
VERSION_CODE="" VERSION_CODE=""
if [ -n "$VERSION" ]; then if [ -n "$VERSION" ]; then
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
@@ -154,25 +160,19 @@ fi
# --------------------------------------------------------------- fetch love-src # --------------------------------------------------------------- fetch love-src
fetch_love_ios() { fetch_love_ios() {
mkdir -p "$CACHE" mkdir -p "$CACHE"
local zip_path="$CACHE/$IOS_SOURCE_ZIP"
if [ ! -f "$zip_path" ]; then
say "downloading $IOS_SOURCE_ZIP (LÖVE $LOVE_VERSION iOS sources)"
curl -fL --progress-bar "$IOS_SOURCE_URL" -o "$zip_path" \
|| fail "download failed: $IOS_SOURCE_URL"
else
say "using cached $zip_path"
fi
say "extracting into $LOVE_SRC"
rm -rf "$LOVE_SRC"
local tmp local tmp
tmp="$(mktemp -d "$CACHE/extract.XXXXXX")" tmp="$(mktemp -d "$CACHE/extract.XXXXXX")"
unzip -q "$zip_path" -d "$tmp" say "fetching LÖVE $LOVE_VERSION sources ($LOVE_SOURCE_REF)"
# Zip root is love-<version>-ios-source/ git clone --depth 1 --branch "$LOVE_SOURCE_REF" "$LOVE_SOURCE_REPO" "$tmp/love" \
local extracted || fail "failed to fetch LÖVE sources from $LOVE_SOURCE_REPO"
extracted="$(find "$tmp" -maxdepth 1 -mindepth 1 -type d ! -name '__MACOSX' | head -1)" say "fetching Apple dependencies ($APPLE_DEPENDENCIES_REF)"
[ -n "$extracted" ] || fail "unexpected layout inside $IOS_SOURCE_ZIP" git clone --depth 1 --branch "$APPLE_DEPENDENCIES_REF" "$APPLE_DEPENDENCIES_REPO" "$tmp/dependencies" \
mv "$extracted" "$LOVE_SRC" || fail "failed to fetch Apple dependencies from $APPLE_DEPENDENCIES_REPO"
rm -rf "$LOVE_SRC"
mv "$tmp/love" "$LOVE_SRC"
mkdir -p "$LIBS_DIR" "$XCODE_DIR/shared"
cp -R "$tmp/dependencies/iOS/libraries/." "$LIBS_DIR"
cp -R "$tmp/dependencies/shared/." "$XCODE_DIR/shared"
rm -rf "$tmp" rm -rf "$tmp"
say "love-src ready (LÖVE $LOVE_VERSION)" say "love-src ready (LÖVE $LOVE_VERSION)"
} }
@@ -182,18 +182,12 @@ if [ ! -d "$XCODE_DIR/love.xcodeproj" ]; then
fetch_love_ios fetch_love_ios
else else
fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/. fail "LÖVE $LOVE_VERSION iOS sources not found at mobile/ios/love-src/.
Fetch them (documented download of love-${LOVE_VERSION}-ios-source.zip): Fetch them:
scripts/build_ios.sh --fetch scripts/build_ios.sh --fetch
Or manually:
mkdir -p mobile/ios/cache
curl -fL -o mobile/ios/cache/$IOS_SOURCE_ZIP \\
$IOS_SOURCE_URL
unzip -q mobile/ios/cache/$IOS_SOURCE_ZIP -d mobile/ios/cache
mv mobile/ios/cache/love-${LOVE_VERSION}-ios-source mobile/ios/love-src
See mobile/ios/README.md." See mobile/ios/README.md."
fi fi
elif $FETCH; then elif $FETCH; then
say "love-src already present; skipping download (delete mobile/ios/love-src to refresh)" say "love-src already present; skipping fetch (delete mobile/ios/love-src to refresh)"
fi fi
[ -d "$XCODE_DIR/love.xcodeproj" ] \ [ -d "$XCODE_DIR/love.xcodeproj" ] \
@@ -201,25 +195,16 @@ fi
# --------------------------------------------------------------- apple libraries # --------------------------------------------------------------- apple libraries
require_ios_libraries() { require_ios_libraries() {
if [ -d "$LIBS_DIR/SDL2.xcframework" ]; then if [ -d "$LIBS_DIR/SDL2.xcframework" ] && [ -d "$XCODE_DIR/shared/Frameworks/SDL3.xcframework" ]; then
return 0 return 0
fi fi
fail "prebuilt iOS libraries missing at: fail "prebuilt iOS libraries missing at:
$LIBS_DIR $LIBS_DIR
love-ios expects SDL2.xcframework (and friends) there. and shared/Frameworks.
The official love-${LOVE_VERSION}-ios-source.zip normally includes them. Re-fetch the LÖVE $LOVE_VERSION source tree and its Apple dependencies:
If they are absent, install love-${LOVE_VERSION}-apple-libraries.zip:
mkdir -p mobile/ios/cache scripts/build_ios.sh --fetch"
curl -fL -o mobile/ios/cache/$APPLE_LIBS_ZIP \\
$APPLE_LIBS_URL
unzip -q mobile/ios/cache/$APPLE_LIBS_ZIP -d mobile/ios/cache
rm -rf mobile/ios/love-src/platform/xcode/ios/libraries
cp -R mobile/ios/cache/love-apple-dependencies/iOS/libraries \\
mobile/ios/love-src/platform/xcode/ios/libraries
See mobile/ios/README.md (Apple libraries dependency)."
} }
require_ios_libraries require_ios_libraries
@@ -232,6 +217,30 @@ apply_ios_branding() {
cp "$OVERLAY_PLIST" "$dest" cp "$OVERLAY_PLIST" "$dest"
} }
apply_ios_icon() {
local source="$ROOT/assets/logo/logo.png"
local target="$XCODE_DIR/Images.xcassets/iOS AppIcon.appiconset"
[ -f "$source" ] || fail "missing iOS icon source: $source"
[ -d "$target" ] || fail "missing iOS app icon set: $target"
local entry name size
while IFS=: read -r name size; do
sips -z "$size" "$size" "$source" --out "$target/$name" >/dev/null
done <<'EOF'
icon-1024pt@1x.png:1024
icon-29pt@1x.png:29
icon-29pt@2x.png:58
icon-29pt@3x.png:87
icon-40pt@1x.png:40
icon-40pt@2x.png:80
icon-40pt@3x.png:120
icon-60pt@2x.png:120
icon-60pt@3x.png:180
icon-76pt@1x.png:76
icon-76pt@2x.png:152
icon-83.5pt@2x.png:167
EOF
}
# --------------------------------------------------------------- game.love # --------------------------------------------------------------- game.love
# Every version's import manifest has to ship or that game's ROM import fails in # Every version's import manifest has to ship or that game's ROM import fails in
# the built app: decodeManifest (src/import/RomImporter.lua) errors outright when # the built app: decodeManifest (src/import/RomImporter.lua) errors outright when
@@ -326,12 +335,43 @@ pack_game_love() {
# in the built app while dev, which reads the source tree, stayed green. # in the built app while dev, which reads the source tree, stayed green.
archive_entries="$(unzip -Z1 "$LOVE_FILE")" archive_entries="$(unzip -Z1 "$LOVE_FILE")"
# shellcheck disable=SC2086 # MANIFESTS is a deliberate word list # shellcheck disable=SC2086 # MANIFESTS is a deliberate word list
for required in tools/save-editor/App.lua tools/save-editor/Kit.lua \ for required in src/update/Boot.lua tools/save-editor/App.lua \
tools/save-editor/panels/Party.lua $MANIFESTS; do tools/save-editor/Kit.lua tools/save-editor/panels/Party.lua \
$MANIFESTS; do
printf '%s\n' "$archive_entries" | grep -qx "$required" \ printf '%s\n' "$archive_entries" | grep -qx "$required" \
|| fail "game.love is missing $required" || fail "game.love is missing $required"
done done
say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE" say "game.love: $(du -h "$LOVE_FILE" | cut -f1) -> $LOVE_FILE"
# This script packs its own game.love (it does not reuse build.sh's), so it
# stamps the release version the same way build.sh and build_android.sh do:
# patch a copy of Version.lua (engine set to $VERSION) under a throwaway
# staging dir and replace the entry inside the archive in place -- never the
# source tree. Stamping the Info.plist alone is not enough: the mod loader
# reads Version.engine out of game.love (src/mods/Loader.lua game_version
# gate), so an unstamped archive reports "0.0.0-dev" and every mod with a
# version floor is rejected on iOS while it loads on desktop (#613).
# VERSION is already validated as X.Y.Z above; when it is empty the packaged
# game keeps the "0.0.0-dev" default so a dev build cannot pass for a
# release. The stamp is read back out and the build fails if it did not take.
if printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
say "stamping engine version $VERSION into game.love"
local stamp_dir
stamp_dir="$(mktemp -d)"
mkdir -p "$stamp_dir/src/core"
sed -E "s/(engine[[:space:]]*=[[:space:]]*\")[^\"]*(\")/\1$VERSION\2/" \
"$ROOT/src/core/Version.lua" > "$stamp_dir/src/core/Version.lua"
(cd "$stamp_dir" && zip -q "$LOVE_FILE" src/core/Version.lua)
local version_re
version_re="$(printf '%s' "$VERSION" | sed 's/\./\\./g')"
unzip -p "$LOVE_FILE" src/core/Version.lua \
| grep -Eq "engine[[:space:]]*=[[:space:]]*\"$version_re\"" \
|| fail "version stamp failed: game.love does not report engine $VERSION"
rm -rf "$stamp_dir"
say "stamped engine version: $VERSION"
else
say "no X.Y.Z --version, shipping default engine (no stamp)"
fi
} }
# Ensure game.love is in the love-ios Copy Bundle Resources phase (idempotent). # Ensure game.love is in the love-ios Copy Bundle Resources phase (idempotent).
@@ -534,11 +574,27 @@ run_xcodebuild() {
say "xcodebuild love-ios ($config / $sdk)" say "xcodebuild love-ios ($config / $sdk)"
set +e set +e
( local xc_status
cd "$XCODE_DIR" if command -v xcbeautify >/dev/null 2>&1; then
xcodebuild "${args[@]}" (
) cd "$XCODE_DIR"
local xc_status=$? xcodebuild "${args[@]}"
) 2>&1 | xcbeautify
local pipeline_status=("${PIPESTATUS[@]}")
local xcode_status=${pipeline_status[0]}
local beautify_status=${pipeline_status[1]}
if [ "$xcode_status" -ne 0 ]; then
xc_status=$xcode_status
else
xc_status=$beautify_status
fi
else
(
cd "$XCODE_DIR"
xcodebuild "${args[@]}"
)
xc_status=$?
fi
set -e set -e
if [ "$xc_status" -ne 0 ]; then if [ "$xc_status" -ne 0 ]; then
fail "xcodebuild failed (exit $xc_status). fail "xcodebuild failed (exit $xc_status).
@@ -640,6 +696,7 @@ install_to_device() {
# --------------------------------------------------------------- main # --------------------------------------------------------------- main
apply_ios_branding apply_ios_branding
apply_ios_icon
say "applying iOS native bridge patches (picker/Files support)" say "applying iOS native bridge patches (picker/Files support)"
python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed" python3 "$IOS_DIR/patch_love_src.py" || fail "patch_love_src.py failed"
ensure_manifests ensure_manifests
+1
View File
@@ -123,6 +123,7 @@ if [ -f data/generated/maps.lua ]; then
run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua run_tier "T3 save editor: events + dex" "$LUA" tests/save_editor_task7_tests.lua
run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua run_tier "T3 save editor: map browser" "$LUA" tests/save_editor_task8_tests.lua
run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua run_tier "T3 save editor: mod awareness" "$LUA" tests/save_editor_mod_tests.lua
run_tier "T3 save editor: wheel scrolling" "$LUA" tests/save_editor_wheel_bug595_test.lua
run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua run_tier "T5 link (loopback lockstep)" "$LUA" tests/run_link_tests.lua
fi fi
else else
+441 -73
View File
@@ -23,6 +23,7 @@ local Pokemon = require("src.pokemon.Pokemon")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Status = require("src.battle.Status") local Status = require("src.battle.Status")
local Timing = require("src.core.Timing")
local TrainerAI = require("src.battle.TrainerAI") local TrainerAI = require("src.battle.TrainerAI")
local TurnOrder = require("src.battle.TurnOrder") local TurnOrder = require("src.battle.TurnOrder")
local TypeChart = require("src.battle.TypeChart") local TypeChart = require("src.battle.TypeChart")
@@ -36,6 +37,16 @@ BattleState.isOpaque = true
-- window reads as one continuous battle screen (no black bars). -- window reads as one continuous battle screen (no black bars).
BattleState.letterboxWhite = true BattleState.letterboxWhite = true
-- A battle is a self-contained SCREEN, not the window. The overworld's
-- dialogue box docks to the window edge on purpose (Renderer:setUIAnchor) --
-- a box floating in the middle of a zoomed-out map reads as detached. A
-- battle is the opposite: pokered draws its text box and YES/NO in the same
-- 160x144 tilemap as the HUD, and pulling them out to the window edge splits
-- the composition in two -- the caught-mon nickname prompt lands a whole
-- letterbox below the white field it is supposed to be printed on. Anchors
-- are held off for as long as a battle is in the stack.
BattleState.holdsUIAnchors = true
-- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on -- BATTLE LAYOUT: the classic 160x144 arrangement, or the widescreen one on
-- a 304x144 surface (src/battle/WideBattle.lua). Only the composition -- a 304x144 surface (src/battle/WideBattle.lua). Only the composition
-- differs; every battler, queue and animation below is shared. Menus and -- differs; every battler, queue and animation below is shared. Menus and
@@ -50,6 +61,41 @@ function BattleState:wideLayout()
return self:isWideBattleLayout() return self:isWideBattleLayout()
end end
-- BATTLE SIZE: "fixed" keeps the classic integer-scaled letterbox (a GB pixel
-- is a whole number of screen pixels, and the battle is the same size at any
-- zoom); "fill" scales the battle surface to the window instead, so it fills
-- vertically. Filling means a fractional scale, so pixels stop being evenly
-- sized -- that is the trade, which is why it is a setting rather than a
-- change. Only the battle surface is affected; the overworld is unchanged.
function BattleState:wantsFillScale()
local options = self.game and self.game.save and self.game.save.options
return options and options.battleFit == "fill" or false
end
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
-- that grow as the window gets bigger or the view is zoomed out. The battle
-- screen itself is untouched: it keeps its white paper field in every mode.
--
-- "white" the display mode's paper shade (the classic look)
-- "black" plain black bars
-- "world" the frozen overworld, dimmed
--
-- "world" works by making the battle NON-opaque: StateStack:visibleBase then
-- finds the overworld below it and Game:draw keeps drawing the map, so the
-- voids show it instead of a flat clear. The battle still paints its own
-- opaque 160x144 field over the top, so only the surround changes.
function BattleState:bgMode()
local options = self.game and self.game.save and self.game.save.options
local mode = options and options.battleBg
if mode == "black" or mode == "world" then return mode end
return "white"
end
-- How far to dim the overworld behind a "world" background, 0..1. Enough
-- that the battle reads as the foreground rather than competing with a fully
-- lit map behind it.
BattleState.BG_WORLD_DIM = 0.55
-- Renderer:setUISize asks the top state for its surface before anything draws -- Renderer:setUISize asks the top state for its surface before anything draws
function BattleState:uiSize() function BattleState:uiSize()
if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end if self:wideLayout() then return WideBattle.WIDTH, WideBattle.HEIGHT end
@@ -291,10 +337,17 @@ function BattleState:picImage(img)
or PaletteFX.mode == "classic" or PaletteFX.mode == "classic"
if self.grayPics or mono then return grayImage(img) end if self.grayPics or mono then return grayImage(img) end
-- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go
-- dark with the HP bars while the blackout text is up (#292). Below the -- dark with the HP bars while the blackout text is up (#292). The intro
-- mono check on purpose: the forced-mono modes re-threshold the whole -- silhouette slide (SlidePlayerAndEnemySilhouettesOnScreen) darkens the
-- frame downstream, and the DMG had no SGB darkening to begin with. -- same way: the original slides both pics in under the %11100100
if self.blackedOut then return blackImage(self.data, img) end -- silhouette palette and only runs SET_PAL_BATTLE once they have landed,
-- so a still-sliding pic reads as a black silhouette, exactly like the
-- evolution movie's PAL_BLACK (#577). Below the mono check on purpose:
-- the forced-mono modes re-threshold the whole frame downstream, and the
-- DMG had no SGB darkening to begin with.
if self.blackedOut or (self.introSlide or 0) > 0 then
return blackImage(self.data, img)
end
return fadeImage(img, self:activeBgp()) return fadeImage(img, self:activeBgp())
end end
@@ -645,17 +698,63 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
return self return self
end end
-- The disguise itself. InitWildBattle .isGhost (engine/battle/core.asm)
-- swaps only the pic and the nick -- wEnemyMonSpecies2 still holds the real
-- species, so the SGB palette stays the disguised mon's. ghostReal keeps
-- what LoadEnemyMonData puts back when the scope unveils.
local function disguiseAsGhost(self)
self.ghostReal = { name = self.enemy.name, sprite = self.enemy.sprite }
self.enemy.name = "GHOST"
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png",
monPalette(self.data, self.enemy.mon.species))
self.introText = Strings("The GHOST\nappeared!")
end
-- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope -- Pokémon Tower ghosts (engine/battle/core.asm): without the Silph Scope
-- the enemy is "GHOST", you're too scared to attack, and balls fail. -- the enemy is "GHOST", you're too scared to attack, and balls fail.
function BattleState:makeGhost() function BattleState:makeGhost()
self.ghost = true self.ghost = true
self.enemy.name = "GHOST" disguiseAsGhost(self)
-- the ghost keeps the disguised mon's SGB palette: InitWildBattle end
-- swaps only the pic, wEnemyMonSpecies2 still holds the real species
-- (engine/battle/core.asm InitWildBattle .isGhost) -- The RESTLESS SOUL with the SILPH SCOPE in the bag. Mechanically this is
self.enemy.sprite = getImage("assets/generated/battle/front/ghost.png", -- not a ghost battle at all -- IsGhostBattle (core.asm) returns false the
monPalette(self.data, self.enemy.mon.species)) -- moment the scope is in the bag, so the mon attacks, is attacked and rolls
self.introText = Strings("The GHOST\nappeared!") -- for flight normally -- but InitWildBattle still enters disguised: its
-- .isGhost branch fires on wCurOpponent == RESTLESS_SOUL as well. Taking
-- the disguise off is PrintBeginningBattleText .isMarowak's job
-- (engine/battle/common_text.asm), which enter() queues through
-- queueScopeReveal (#492).
function BattleState:makeUnveiledGhost()
self.scopeReveal = true
disguiseAsGhost(self)
end
-- MarowakAnim (engine/battle/ghost_marowak_anim.asm) counted in frames: the
-- ghost pic is copied into OAM and flashed 8 times by xor-ing rOBP1 with
-- $80 (10 frames a side), the BG pic underneath is swapped to the RESTLESS
-- SOUL, rOBP1 is shifted left two bits every 10 frames until the ghost has
-- faded into the paper (3 shifts), and the Marowak copy fades back in the
-- same way (4 shifts). Alpha over the paper is what those OBP shifts look
-- like on screen.
BattleState.GHOST_FLASH_FRAMES = 80
BattleState.GHOST_FADE_OUT_FRAMES = 30
BattleState.GHOST_FADE_IN_FRAMES = 40
BattleState.GHOST_REVEAL_FRAMES = 150
-- The rows .isMarowak prints over the disguise, after the "GHOST appeared!"
-- box enter() has already queued: the unveil line, MarowakAnim, then "Wild
-- MAROWAK appeared!" under the restored name. The fall-through into
-- .playSFX at the end is the same SFX_TRAINER_APPEARED the port does not
-- play for trainer intros either, so it is left out here too.
function BattleState:queueScopeReveal()
local unveiled = self.data.text and self.data.text._UnveiledGhostText
self:say(unveiled
or Strings("SILPH SCOPE\nunveiled the\vGHOST's identity!"))
self:act(function() self.ghostReveal = { t = 0 } end)
table.insert(self.queue, { wait = BattleState.GHOST_REVEAL_FRAMES })
self:say(Strings("Wild %s\nappeared!",
self.ghostReal and self.ghostReal.name or self.enemy.name))
end end
-- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN, -- The old man's catch tutorial (BATTLE_TYPE_OLD_MAN,
@@ -669,6 +768,12 @@ end
function BattleState:makeOldManDemo(name) function BattleState:makeOldManDemo(name)
self.demo = true self.demo = true
self.demoName = name or "OLD MAN" self.demoName = name or "OLD MAN"
-- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType:
-- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU
-- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower
-- name the caller passes IS that distinction here, so it picks the pic too
-- -- data/scripts/story2.lua is the only site that names PROF.OAK (#557).
self.oakDemo = name == "PROF.OAK"
-- Yellow's Pallet intro runs this before the player owns any mon -- Yellow's Pallet intro runs this before the player owns any mon
-- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the -- (BATTLE_TYPE_PIKACHU precedes the lab gift), so newWild flagged the
-- battle dead for lack of a party. The demo never sends out, draws, or -- battle dead for lack of a party. The demo never sends out, draws, or
@@ -785,9 +890,30 @@ function BattleState:drainNext(battler, stopAt)
{ drain = true, battler = battler, stopAt = stopAt }) { drain = true, battler = battler, stopAt = stopAt })
end end
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar): -- Queue a pure frame hold at the current insert point, the way the original
-- the bar animates a pixel per two frames, so displayed HP moves at -- spends DelayFrames between the beats of a turn. Mirrors sayNext/drainNext
-- maxHP/96 per frame (48-pixel bar). Returns true while animating. -- so a caller can interleave holds with messages in source order.
function BattleState:waitNext(frames)
if not frames or frames <= 0 then return end
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = frames })
end
-- One frame of the HP-bar drain (engine/gfx/hp_bar.asm UpdateHPBar).
--
-- The original walks the bar ONE HP POINT per loop iteration (:81-120), and
-- what each iteration costs depends on the side:
-- * UpdateHPBar_PrintHPNumber spends a DelayFrame (:234) reprinting the
-- number, but only when wHPBarType is nonzero (:207-209) -- the player's
-- own HUD and the party menu, never the enemy's;
-- * UpdateHPBar_AnimateHPBar spends 2 frames for each pixel the bar
-- actually moved (:147-148), and most single-HP steps move none.
-- So the player's bar drains at 1 HP per frame plus 2 frames per pixel,
-- while the enemy's costs nothing until it crosses a pixel boundary. The
-- old flat maxHP/96 rate was the enemy-side formula applied to both, which
-- ran a 150 HP mon's full drain in 96 frames against hardware's 249.
--
-- Returns true while animating.
function BattleState:stepHPDrain() function BattleState:stepHPDrain()
local busy = false local busy = false
for _, b in ipairs({ self.player, self.enemy }) do for _, b in ipairs({ self.player, self.enemy }) do
@@ -798,14 +924,30 @@ function BattleState:stepHPDrain()
and b.shownHP >= b.drainFloor then and b.shownHP >= b.drainFloor then
goal = b.drainFloor goal = b.drainFloor
end end
if b.shownHP ~= goal then if (b.drainHold or 0) > 0 then
local step = math.max(1, b.mon.stats.hp) / 96 b.drainHold = b.drainHold - 1
if b.shownHP > goal then busy = true
b.shownHP = math.max(goal, b.shownHP - step) elseif b.shownHP ~= goal then
else local maxHP = math.max(1, b.mon.stats.hp)
b.shownHP = math.min(goal, b.shownHP + step) local playerSide = (b == self.player)
local cost = 0
-- consume whole HP steps until this frame's budget is spent; on the
-- enemy HUD several free steps can land in the same frame
while b.shownHP ~= goal and cost < 1 do
local nextHP = b.shownHP + ((b.shownHP > goal) and -1 or 1)
cost = cost + Timing.hpDrainStepFrames(b.shownHP, nextHP,
maxHP, playerSide)
b.shownHP = nextHP
end end
busy = busy or b.shownHP ~= goal b.drainHold = math.max(0, cost - 1)
b.draining = true
busy = true
elseif b.draining then
-- .animateHPBarDone's final number print, one more pixel step and
-- Delay3 (hp_bar.asm:132-135); this frame is the first of them
b.draining = nil
b.drainHold = Timing.hpDrainClosingFrames(b == self.player) - 1
busy = true
end end
end end
end end
@@ -880,6 +1022,14 @@ function BattleState:updateQueue()
self.waitFrames = self.waitFrames - 1 self.waitFrames = self.waitFrames - 1
return true return true
end end
-- WaitForSoundToFinish (home/delay.asm:15-20) blocks until the sfx has
-- actually stopped sounding, which is how the original gives a sound its
-- own clear window instead of letting the next beat play over it
if self.waitingSound then
local src = self.waitingSound
if src and src.isPlaying and src:isPlaying() then return true end
self.waitingSound = nil
end
-- an HP-bar drain holds the queue until the bar catches up -- an HP-bar drain holds the queue until the bar catches up
if self.draining then if self.draining then
if self:stepHPDrain() then return true end if self:stepHPDrain() then return true end
@@ -931,6 +1081,12 @@ function BattleState:updateQueue()
self.waitFrames = item.wait self.waitFrames = item.wait
return true return true
end end
if item.waitSound then
-- the source is fetched now, not when the row was queued, so the
-- act() that started the sound has already run
self.waitingSound = item.waitSound()
return true
end
if item.mimicSelect then if item.mimicSelect then
-- pause the queue on Mimic's copy menu (MoveSelectionMenu with -- pause the queue on Mimic's copy menu (MoveSelectionMenu with
-- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1) -- wMoveMenuType = 1 lists the enemy's moves; cursor starts on 1)
@@ -951,6 +1107,16 @@ function BattleState:updateQueue()
-- the animation ends (hitRow rows carry a hit with no animation -- -- the animation ends (hitRow rows carry a hit with no animation --
-- thrash/rage continuation turns that skip the announcement). -- thrash/rage continuation turns that skip the announcement).
if item.anim or item.hitRow then if item.anim or item.hitRow then
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
-- between the move's announcement and the first frame of its
-- animation. Put the row back and pay that first.
if item.anim and not item.animDelayed then
item.animDelayed = true
table.insert(self.queue, 1, item)
self.waitFrames = Timing.MOVE_ANIM_PRE
return true
end
local mdef = item.anim and self.data.moves[item.anim] local mdef = item.anim and self.data.moves[item.anim]
local anim = mdef and mdef.anim local anim = mdef and mdef.anim
if item.anim == "POOF_ANIM" then if item.anim == "POOF_ANIM" then
@@ -1022,17 +1188,38 @@ function BattleState:updateQueue()
-- a \v CONT wait holds the box until A/B, then scrolls the next line in -- a \v CONT wait holds the box until A/B, then scrolls the next line in
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216) -- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
if self.msgWaiting then if self.msgWaiting then
-- _ContText prints the â–¼ and runs ProtectedDelay3 BEFORE ManualTextScroll
-- starts watching the joypad (home/text.asm:263-267), so three frames
-- pass with the arrow up and the button ignored
if (self.msgPreWait or 0) > 0 then
self.msgPreWait = self.msgPreWait - 1
return true
end
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.msgWaiting = nil self.msgWaiting = nil
self:beginMsgLine() self:beginMsgLine()
-- then the two ScrollTextUpOneLine calls block for 5 frames each
-- (home/text.asm:280-305) before the next line starts typing
self.waitFrames = Timing.TEXT_SCROLL_PAIR
end end
return true return true
end end
local cur = self.shown[#self.shown] local cur = self.shown[#self.shown]
if #cur < #self.codes then if #cur < #self.codes then
-- battle typewriter cadence: two glyphs per fixed step (as before) -- Battle text prints through the same PrintText path as everything
for _ = 1, 2 do -- else, so it pays PrintLetterDelay per character (home/print_text.asm:
if #cur >= #self.codes then break end -- 4-45): hFrameCounter is loaded from wOptions & $f -- the OPTION text
-- speed, 1/3/5, default 3 -- and the loop spins until it drains, unless
-- A or B is held, which collapses the wait to a single DelayFrame.
-- This used to run two glyphs per frame flat, six times hardware speed
-- at the default setting, and ignored the text-speed option entirely.
local delay = (self.game.save.options and self.game.save.options.textSpeed)
or 3
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
if input:isDown("a") or input:isDown("b") then delay = 1 end
self.charTimer = (self.charTimer or 0) + 1
while self.charTimer >= delay and #cur < #self.codes do
self.charTimer = self.charTimer - delay
cur[#cur + 1] = self.codes[#cur + 1] cur[#cur + 1] = self.codes[#cur + 1]
self.charIndex = self.charIndex + 1 self.charIndex = self.charIndex + 1
end end
@@ -1041,6 +1228,7 @@ function BattleState:updateQueue()
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full) -- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
if self.lines[self.lineIndex + 1].cont then if self.lines[self.lineIndex + 1].cont then
self.msgWaiting = true self.msgWaiting = true
self.msgPreWait = Timing.TEXT_PRE_ADVANCE
else else
self:beginMsgLine() self:beginMsgLine()
end end
@@ -1067,8 +1255,18 @@ function BattleState:updateQueue()
-- only on a \v CONT hold (#317). A flag of its own, not msgWaiting: -- only on a \v CONT hold (#317). A flag of its own, not msgWaiting:
-- that branch above scrolls the NEXT line in, which this page has not -- that branch above scrolls the NEXT line in, which this page has not
-- got, so reusing it would call beginMsgLine on a drained message. -- got, so reusing it would call beginMsgLine on a drained message.
self.msgPrompt = true if not self.msgPrompt then
if input:wasPressed("a") or input:wasPressed("b") then self.msgPrompt = true
-- PromptText runs ProtectedDelay3 between writing the arrow and
-- ManualTextScroll (home/text.asm:213-217), so the page holds for
-- three frames with the button ignored before it can be dismissed.
-- Without it a queued A press could clear a page the same frame its
-- last glyph landed, which is most of "it doesn't hold sometimes".
self.msgPromptWait = Timing.TEXT_PRE_ADVANCE
end
if (self.msgPromptWait or 0) > 0 then
self.msgPromptWait = self.msgPromptWait - 1
elseif input:wasPressed("a") or input:wasPressed("b") then
self.msgPrompt = nil self.msgPrompt = nil
self.current = nil self.current = nil
end end
@@ -1190,7 +1388,11 @@ function BattleState:enter()
Music.playBattle(self.data, self.musicKind) Music.playBattle(self.data, self.musicKind)
-- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both -- intro presentation (SlidePlayerAndEnemySilhouettesOnScreen): both
-- sides slide in; the trainer pics stay up until the send-outs -- sides slide in; the trainer pics stay up until the send-outs
self.introSlide = 40 -- BATTLE BG "world" drops this battle's opacity so StateStack keeps drawing
-- the overworld underneath it (see bgMode). Per instance, so the class
-- default stays opaque for every other battle and for older saves.
self.isOpaque = self:bgMode() ~= "world"
self.introSlide = Timing.BATTLE_SLIDE_IN_FRAMES
self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil self.showEnemyTrainer = self.kind == "trainer" and self.trainerPic ~= nil
-- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the -- DrawAllPokeballs (common_text.asm:27) puts the party ball rows AND the
-- HUD corner/underline tiles under them (PlacePlayerHUDTiles / -- HUD corner/underline tiles under them (PlacePlayerHUDTiles /
@@ -1214,7 +1416,8 @@ function BattleState:enter()
-- fade, the ground-padding measurement and battle_sprite_scales. -- fade, the ground-padding measurement and battle_sprite_scales.
local backPath, backTrueColor = local backPath, backTrueColor =
require("src.pokemon.Sprites").playerPath(self.data, "back", require("src.pokemon.Sprites").playerPath(self.data, "back",
{ kind = "battle", demo = self.demo, battle = self }) { kind = "battle", demo = self.demo, oakDemo = self.oakDemo,
battle = self })
self.playerBackPic = getImage(backPath, self.playerBackPic = getImage(backPath,
namedPalette(self.data, "MEWMON"), backTrueColor) namedPalette(self.data, "MEWMON"), backTrueColor)
self.showPlayerBack = self.playerBackPic ~= nil self.showPlayerBack = self.playerBackPic ~= nil
@@ -1229,11 +1432,35 @@ function BattleState:enter()
-- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry -- battle calls PlayCry BEFORE PrintText WildMonAppearedText, so the cry
-- sounds with the "Wild X appeared!" box instead of waiting on the A -- sounds with the "Wild X appeared!" box instead of waiting on the A
-- press that clears its `prompt` (#303). The Silph-Scope-less tower -- press that clears its `prompt` (#303). The Silph-Scope-less tower
-- ghost gets no cry at all (common_text.asm:43-48). -- ghost gets no cry at all (common_text.asm:43-48), and neither does the
if self.kind ~= "trainer" and self.kind ~= "link" and not self.ghost then -- unveiled MAROWAK: .isMarowak never reaches PlayCry (#492).
if self.kind ~= "trainer" and self.kind ~= "link"
and not self.ghost and not self.scopeReveal then
queueEnemyCry() queueEnemyCry()
end end
-- PrintBeginningBattleText .trainerBattle (common_text.asm): a trainer
-- battle gives SFX_SILPH_SCOPE a clear window -- PlaySound, then
-- WaitForSoundToFinish, which blocks -- and only after `ld c, 20 /
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
-- The balls and the text used to appear on the same frame the silhouettes
-- landed, so the sound had to share its whole duration with the ball draw
-- and the text scroll instead of landing on its own.
--
-- The sfx is extracted as "Trainer_Appeared" (tools/rom_manifest.json
-- sfxHeaders, bank 8 / $42bb -- the same header pokered names
-- SFX_Silph_Scope); nothing had ever played it.
if self.kind == "trainer" then
self:act(function()
self.introSfx = require("src.core.Sound").play(self.data,
"Trainer_Appeared")
end)
table.insert(self.queue, { waitSound = function() return self.introSfx end })
table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP })
end
self:say(self.introText) self:say(self.introText)
-- the unveil rides on that same box, before _InitBattleCommon clears the
-- intro chrome below (#492)
if self.scopeReveal then self:queueScopeReveal() end
-- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is -- _InitBattleCommon (core.asm:6755-6762): the instant the intro text is
-- dismissed both HUD blocks are cleared and ClearSprites drops the -- dismissed both HUD blocks are cleared and ClearSprites drops the
-- pokeball OAM, so the intro chrome never returns for the rest of the -- pokeball OAM, so the intro chrome never returns for the rest of the
@@ -1249,12 +1476,21 @@ function BattleState:enter()
table.insert(self.queue, { wait = 16 }) table.insert(self.queue, { wait = 16 })
self:act(function() self:act(function()
self.showEnemyTrainer = false self.showEnemyTrainer = false
-- the slot is EMPTY from here until AnimateSendingOutMon runs below:
-- the pic has walked off and the mon is still in its ball, so nothing
-- stands in the enemy slot while TrainerSentOutText prints. Without
-- this the front sprite popped in full-size the instant the trainer
-- left, sat there through the whole text box, and the grow-in then
-- restarted it from nothing -- the mon appearing before it was sent
-- out. Mirrors the flag the mid-battle replacement already sets.
self.enemySendingOut = true
self:slidePic("foe") self:slidePic("foe")
end) end)
self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name)) self:say(Strings("%s sent\nout %s!", self.trainer.name, self.enemy.name))
self:act(function() self:act(function()
-- EnemySendOutFirstMon (core.asm:1421-1434): after the text the -- EnemySendOutFirstMon (core.asm:1421-1434): after the text the
-- pic grows out of the ball (AnimateSendingOutMon), then the cry -- pic grows out of the ball (AnimateSendingOutMon), then the cry
self.enemySendingOut = false
self:startGrowIn(self.enemy) self:startGrowIn(self.enemy)
end) end)
queueEnemyCry() queueEnemyCry()
@@ -1271,6 +1507,14 @@ function BattleState:enter()
end) end)
queueEnemyCry() queueEnemyCry()
end end
-- StartBattle .foundFirstAliveEnemyMon (core.asm:152-156): the `call nz`
-- gates only EnemySendOutFirstMon -- the `ld c, 40 / call DelayFrames`
-- after it is unconditional, so a wild battle pays it too, between
-- "Wild X appeared!" and "Go! Y!". It lands before .playerSendOutFirstMon
-- (:166), not at the end of the intro. Appended, not waitNext'd: the
-- intro is built linearly, and waitNext's insert point is for rows added
-- while the queue is already running.
table.insert(self.queue, { wait = Timing.BATTLE_START_SENDOUT })
if not self.safari and not self.demo then if not self.safari and not self.demo then
-- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic -- StartBattle .playerSendOutFirstMon (core.asm:236-240): the back pic
-- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5, -- walks off the LEFT edge (SlideTrainerPicOffScreen, hlcoord 1,5,
@@ -2442,24 +2686,27 @@ function BattleState:applyHitFx(hit)
prog[#prog + 1] = { dy = 0, frames = 3 } prog[#prog + 1] = { dy = 0, frames = 3 }
end end
self.fx.shakeProg = prog self.fx.shakeProg = prog
self.waitFrames = 48 -- the predef blocks until the shake settles self.waitFrames = Timing.SHAKE_VERTICAL -- the predef blocks until it settles
elseif t == 2 then elseif t == 2 then
self.fx.shakeProg = fastShakeProg(8) self.fx.shakeProg = fastShakeProg(8)
self.waitFrames = 72 self.waitFrames = Timing.SHAKE_HORIZ_HEAVY
elseif t == 3 then elseif t == 3 then
self.fx.shakeProg = slowShakeProg(6, 2) self.fx.shakeProg = slowShakeProg(6, 2)
self.waitFrames = 48 self.waitFrames = Timing.SHAKE_HORIZ_SLOW
elseif t == 4 then elseif t == 4 then
if hit.blink then if hit.blink then
self.fx.blink = { target = hit.blink, frames = 20 } -- AnimationBlinkMon: 6 iterations of hide/5 frames/show/5 frames.
self.waitFrames = 20 -- This is the animation for every plain damaging move the player
-- uses, and it ran at a third of its length.
self.fx.blink = { target = hit.blink, frames = Timing.BLINK_MON }
self.waitFrames = Timing.BLINK_MON
end end
elseif t == 5 then elseif t == 5 then
self.fx.shakeProg = fastShakeProg(2) self.fx.shakeProg = fastShakeProg(2)
self.waitFrames = 18 self.waitFrames = Timing.SHAKE_HORIZ_LIGHT
elseif t == 6 then elseif t == 6 then
self.fx.shakeProg = slowShakeProg(3, 2) self.fx.shakeProg = slowShakeProg(3, 2)
self.waitFrames = 24 self.waitFrames = Timing.SHAKE_HORIZ_SLOW2
end end
end end
@@ -2663,6 +2910,35 @@ function BattleState:updateFx()
end end
end end
end end
-- The Silph Scope unveil (MarowakAnim; queueScopeReveal parks a wait row
-- of GHOST_REVEAL_FRAMES over it): the ghost flashes, fades out, the pic
-- and the nick swap back the way LoadEnemyMonData restores them, and the
-- real mon fades in (#492).
local gr = self.ghostReveal
if gr and self.enemy then
gr.t = gr.t + 1
local pf = self:picFxFor(self.enemy)
local flashEnd = BattleState.GHOST_FLASH_FRAMES
local outEnd = flashEnd + BattleState.GHOST_FADE_OUT_FRAMES
if gr.t <= flashEnd then
pf.fade = (math.floor((gr.t - 1) / 10) % 2 == 1) and 0.5 or 1
elseif gr.t <= outEnd then
pf.fade = 1 - math.ceil((gr.t - flashEnd) / 10) / 3
else
if not gr.swapped then
gr.swapped = true
local real = self.ghostReal
if real then
self.enemy.name = real.name or self.enemy.name
self.enemy.sprite = real.sprite or self.enemy.sprite
end
end
pf.fade = math.min(1, math.ceil((gr.t - outEnd) / 10) / 4)
end
if gr.t >= BattleState.GHOST_REVEAL_FRAMES then
self.ghostReveal, self.scopeReveal, pf.fade = nil, nil, nil
end
end
-- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then -- the send-out grow-in (AnimateSendingOutMon): 3+4+5 frames, then
-- the pic draws at full size again -- the pic draws at full size again
if self.growIn then if self.growIn then
@@ -2677,10 +2953,24 @@ function BattleState:updateFx()
-- so a sounding siren rides out the next hit's HP drain instead of -- so a sounding siren rides out the next hit's HP drain instead of
-- dropping out mid-announcement (#293) -- dropping out mid-announcement (#293)
self.lowHealthAlarmOn = self:lowHealthAlarmActive() self.lowHealthAlarmOn = self:lowHealthAlarmActive()
if self.lowHealthAlarmOn then -- battle.low_health_alarm: on/off toggle for the siren loop, ctx.on
Sound.startLoop(self.data, "Low_Health_Alarm") -- mirrors self.lowHealthAlarmOn. Vanilla just starts/stops the loop
-- each frame; a mod can wrap this to reshape the toggle (e.g. force
-- ctx.on false after some budget) before letting vanilla act on it.
if Runtime.wantsHook("battle.low_health_alarm") then
Runtime.call("battle.low_health_alarm", function(ctx)
if ctx.on then
Sound.startLoop(ctx.battle.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end, { on = self.lowHealthAlarmOn, battle = self })
else else
Sound.stopLoop("Low_Health_Alarm") if self.lowHealthAlarmOn then
Sound.startLoop(self.data, "Low_Health_Alarm")
else
Sound.stopLoop("Low_Health_Alarm")
end
end end
end end
@@ -3194,10 +3484,13 @@ function BattleState:onFaint(battler)
Sound.playCry(self.data, battler.mon.species) Sound.playCry(self.data, battler.mon.species)
Sound.play(self.data, "Faint_Fall") Sound.play(self.data, "Faint_Fall")
self.fx = self.fx or {} self.fx = self.fx or {}
self.fx.faint = { battler = battler, frames = 30 } -- SlideDownFaintedMonPic: PIC_HEIGHT (7) slide steps, each closing with
-- DelayFrames 2 (core.asm:1186-1222). The port held this one twice as
-- long as hardware.
self.fx.faint = { battler = battler, frames = Timing.FAINT_SLIDE }
end) end)
self.nextInsert = (self.nextInsert or 0) + 1 self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 30 }) table.insert(self.queue, self.nextInsert, { wait = Timing.FAINT_SLIDE })
if not battler.isPlayer and self.kind == "wild" then if not battler.isPlayer and self.kind == "wild" then
-- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild -- FaintEnemyPokemon .wild_win (core.asm:792-795): beating a wild
-- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON -- mon calls EndLowHealthAlarm and starts MUSIC_DEFEATED_WILD_MON
@@ -3216,7 +3509,10 @@ function BattleState:onFaint(battler)
end end
end end
function BattleState:enemyMonFainted() -- Exp for the defeated enemy, shared by the faint path (enemyMonFainted)
-- and, when a mod's battle.catch_exp hook says so, the catch path
-- (storeCaughtMon).
function BattleState:awardExp()
-- exp is split among the mons that fought this enemy -- exp is split among the mons that fought this enemy
-- (engine/battle/experience.asm); traded mons earn x1.5; each -- (engine/battle/experience.asm); traded mons earn x1.5; each
-- participant gets the full stat exp -- participant gets the full stat exp
@@ -3294,27 +3590,44 @@ function BattleState:enemyMonFainted()
end end
end end
end end
-- with EXP.ALL, participants split half the exp and the other half -- battle.exp_award: the participant/EXP.ALL split, factored out so a
-- is divided among the whole party (engine/battle/experience.asm) -- mod can replace it wholesale (e.g. a flat undivided share to every
local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0 -- non-fainted party mon) without re-deriving participants/alive.
for _, mon in ipairs(alive) do -- ctx.applyShare(mon, split, announce) is the same helper vanilla uses.
applyShare(mon, participants * (expAll and 2 or 1), true) local function vanillaExpAward(ctx)
end -- with EXP.ALL, participants split half the exp and the other half
if expAll then -- is divided among the whole party (engine/battle/experience.asm)
-- the second GainExperience pass sets the gain flags for the WHOLE local expAll = (self.game.save.inventory.EXP_ALL or 0) > 0
-- party, so DivideExpDataByNumMonsGainingExp divides the already for _, mon in ipairs(ctx.alive) do
-- halved-and-participant-divided exp again by the party count, and ctx.applyShare(mon, ctx.participants * (expAll and 2 or 1), true)
-- .partyMonLoop still skips fainted mons (core.asm:818-858 + end
-- experience.asm:9-13); each mon gets its own GainedText with the if expAll then
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no -- the second GainExperience pass sets the gain flags for the WHOLE
-- summary line -- party, so DivideExpDataByNumMonsGainingExp divides the already
for _, mon in ipairs(self.game.save.party) do -- halved-and-participant-divided exp again by the party count, and
if mon.hp > 0 then -- .partyMonLoop still skips fainted mons (core.asm:818-858 +
applyShare(mon, math.max(1, participants) * #self.game.save.party * 2, "expAll") -- experience.asm:9-13); each mon gets its own GainedText with the
-- "with EXP.ALL," tail (wBoostExpByExpAll) -- pokered prints no
-- summary line
for _, mon in ipairs(self.game.save.party) do
if mon.hp > 0 then
ctx.applyShare(mon, math.max(1, ctx.participants) * #self.game.save.party * 2, "expAll")
end
end end
end end
end end
local awardCtx = { battle = self, participants = participants, alive = alive,
applyShare = applyShare }
if Runtime.wantsHook("battle.exp_award") then
Runtime.call("battle.exp_award", vanillaExpAward, awardCtx)
else
vanillaExpAward(awardCtx)
end
self.participants = {} self.participants = {}
end
function BattleState:enemyMonFainted()
self:awardExp()
if self.kind == "trainer" then if self.kind == "trainer" then
-- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan -- EnemySendOutFirstMon / AnyEnemyPokemonAliveCheck (core.asm): scan
@@ -3353,10 +3666,12 @@ function BattleState:enemyMonFainted()
-- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443) -- SwitchPlayerMon runs AFTER TrainerSentOutText (core.asm:1436-1443)
local shiftSwitchMon = nil local shiftSwitchMon = nil
if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then if style ~= "set" and partyCount > 1 and self.player.mon.hp > 0 then
-- _TrainerAboutToUseText: "X is" / "about to use" then cont nick, -- _TrainerAboutToUseText (data/text/text_2.asm): "X is" / "about to
-- then para "Will PLAYER" / "change POKéMON?" with YES/NO. -- use" then a CONT to the nick, not a fresh box -- the box scrolls
self:say(Strings("%s is\nabout to use", self.trainer.name)) -- "X is" off so "about to use" stays above the name, instead of the
self:say(Strings("%s!", nextName)) -- page ending on a bare nick (#565). Then para "Will PLAYER" /
-- "change POKéMON?" with YES/NO.
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
self:sayChoice( self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name), Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
function(yes) function(yes)
@@ -3474,8 +3789,18 @@ function BattleState:enemyMonFainted()
-- PrintEndBattleText prints one text box; a `para` (\f) inside it -- PrintEndBattleText prints one text box; a `para` (\f) inside it
-- starts a fresh page, which is a message row of its own here (five -- starts a fresh page, which is a message row of its own here (five
-- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText) -- EndBattleTexts carry one, e.g. _Route9Youngster1EndBattleText)
-- TrainerEndBattleText (home/trainers.asm) prints _TrainerNameText
-- (wNameBuffer then ": ", data/text/text_1.asm) and only then the
-- saved pointer, so the loss line opens with the trainer class tag on
-- its first line (RIVAL1/2/3 tag with the rival's name:
-- TrainerNamePointers aims those entries at wTrainerName). The tag
-- prints once, so a `para` page carries no second copy (#566).
local tag = self.trainer and self.trainer.name
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then self:sayNext(page) end if page ~= "" then
self:sayNext(tag and (tag .. ": " .. page) or page)
tag = nil
end
end end
end end
self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize)) self:sayNext(Strings("%s got ¥%d\nfor winning!", self.game.save.player.name, prize))
@@ -3875,6 +4200,12 @@ function BattleState:storeCaughtMon()
-- (item_effects.asm:472-501), regenerating its move list from the -- (item_effects.asm:472-501), regenerating its move list from the
-- base data -- a Mimic'd slot never leaves the battle with it -- base data -- a Mimic'd slot never leaves the battle with it
self:restoreMimicked(self.enemy) self:restoreMimicked(self.enemy)
-- battle.catch_exp: vanilla catches never grant exp; a mod can flip
-- this to true to pay out the same award a faint would have.
if Runtime.wantsHook("battle.catch_exp")
and Runtime.call("battle.catch_exp", function() return false end, { battle = self }) then
self:awardExp()
end
local game = self.game local game = self.game
local dex = game.save.pokedex local dex = game.save.pokedex
local species = self.enemy.mon.species local species = self.enemy.mon.species
@@ -4118,7 +4449,21 @@ function BattleState:finish()
require("src.core.Music").restoreMap(self.data) require("src.core.Music").restoreMap(self.data)
self.game.stack:pop() self.game.stack:pop()
Runtime.emit("battle.ended", { battle = self, result = self.result or "run" }) Runtime.emit("battle.ended", { battle = self, result = self.result or "run" })
if self.onFinish then self.onFinish(self.result or "run") end -- Coming back from the battle screen is a fade, not a cut: EnterMap sees
-- BIT_BATTLE_OVER_OR_BLACKOUT set and runs MapEntryAfterBattle
-- (home/overworld.asm:22, :749-753) = GBFadeInFromWhite. This is the one
-- choke point every battle -- wild, trainer, walk-up, scripted, link --
-- passes through on its way out, so the fade is guaranteed here rather
-- than depending on each caller having wrapped onFinish correctly.
local result = self.result or "run"
local onFinish = self.onFinish
if result == "lose" then
-- the blackout path warps to the heal point with its own transition
if onFinish then onFinish(result) end
return
end
self.game.stack:push(require("src.render.Transition").battleReturn(self.game,
function() if onFinish then onFinish(result) end end))
end end
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
@@ -4174,10 +4519,14 @@ function BattleState:growInScale(battler)
end end
-- battler hidden this frame? (damage blink) -- battler hidden this frame? (damage blink)
--
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
-- DelayFrames 5, six times over (animations.asm:1360-1376) -- a 10-frame
-- period, not 8. With Timing.BLINK_MON that is exactly six blinks.
function BattleState:fxHidden(battler) function BattleState:fxHidden(battler)
local fx = self.fx local fx = self.fx
if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then if fx and fx.blink and fx.blink.target == battler and fx.blink.frames > 0 then
return self.frame % 8 < 4 return self.frame % 10 < 5
end end
return false return false
end end
@@ -4304,6 +4653,18 @@ function BattleState:drawBattlerPic(battler, x, y, scale)
end end
if battler.fainted then return end if battler.fainted then return end
-- MarowakAnim's rOBP1 fades (updateFx): alpha over the paper is what those
-- palette shifts look like, and the test has to sit ahead of the plain
-- fast path below (#492)
local fadePf = self.picFx and self.picFx[battler]
if fadePf and fadePf.fade then
local cr, cg, cb, ca = love.graphics.getColor()
love.graphics.setColor(cr, cg, cb, ca * fadePf.fade)
love.graphics.draw(img, x, y, 0, scale, scale)
love.graphics.setColor(cr, cg, cb, ca)
return
end
local pf = self.picFx and self.picFx[battler] local pf = self.picFx and self.picFx[battler]
if not pf or (not pf.kind and not pf.hidden and not pf.minimized if not pf or (not pf.kind and not pf.hidden and not pf.minimized
and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then and (pf.ox or 0) == 0 and (pf.oy or 0) == 0) then
@@ -4869,11 +5230,12 @@ function BattleState:drawHUDs(slide)
self:drawBallRow(self.enemyParty, 64, 16, -8) self:drawBallRow(self.enemyParty, 64, 16, -8)
end end
-- Safari shows only the ball count; the old man demo shows neither mon -- A safari / old-man battle has no player mon out, so no player HUD (see
if self.safari then -- hidePlayer below). Nothing takes its place: PrintSafariZoneSteps -- the
love.graphics.setColor(0, 0, 0, 1) -- "nnn/500 / BALLx nn" box -- is start-menu only and returns early off the
Font.draw(("BALLx%2d"):format(self.safari.balls), 88, 72) -- nine interior maps (engine/overworld/player_state.asm:219-224), so no
end -- ball count belongs over the battlefield. The count lives in the BALL
-- menu item instead (drawTextArea, #540).
-- Party pokeball rows and the HUD chrome under them, for exactly the -- Party pokeball rows and the HUD chrome under them, for exactly the
-- window DrawAllPokeballs owns (common_text.asm:27, with the intro text). -- window DrawAllPokeballs owns (common_text.asm:27, with the intro text).
-- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs -- SetupOwnPartyPokeballs runs in EVERY battle, so the player's row belongs
@@ -4978,6 +5340,11 @@ function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6) Font.drawBox(0, 12, 20, 6)
Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112)
Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128) Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN"), 112, 128)
-- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print
-- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two
-- digits, space padded -- right after the "BALLx" label at columns
-- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540)
Font.draw(("%2d"):format(self.safari.balls), 56, 112)
Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16)
else else
-- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> / -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT <PK><MN> /
@@ -5069,7 +5436,8 @@ function BattleState:drawClassic()
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = self.frame % 4 < 2 and 2 or -2 sx = self.frame % 4 < 2 and 2 or -2
end end
local slide = (self.introSlide or 0) * 4 -- intro slide-in offset -- intro slide-in offset: 2 px per frame, so 144 px over 72 frames
local slide = (self.introSlide or 0) * Timing.BATTLE_SLIDE_PX_PER_FRAME
if self:colorMode() then if self:colorMode() then
-- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the -- SGB pipeline: gray BG canvas -> (wavy) -> zone recolor with the
+25
View File
@@ -9,9 +9,22 @@ local MoveEffects = require("src.battle.MoveEffects")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local StatusRegistry = require("src.battle.StatusRegistry") local StatusRegistry = require("src.battle.StatusRegistry")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local EffectRegistry = {} local EffectRegistry = {}
-- A move that misses -- or that registers as missed, which in Gen 1 includes
-- a type immunity and damage floored to zero -- skips its animation and
-- falls into PlayerCheckIfFlyOrChargeEffect's `ld c, 30 / call DelayFrames`
-- (engine/battle/core.asm:3155-3158 and :3185; enemy twin at :5588) before
-- anything is printed. EXPLODE_EFFECT is the exception: core.asm:3157
-- branches it to PlayPlayerMoveAnimation instead, so it pays the animation
-- rather than the hold -- the same condition that gates cancelMoveAnim.
local function missBeat(battle, record)
if record and record.explode then return end
battle:waitNext(Timing.MOVE_STATUS_OR_MISS)
end
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy -- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
-- mon's nickname (home/text.asm PlaceMoveUsersName) -- mon's nickname (home/text.asm PlaceMoveUsersName)
local function displayName(b) local function displayName(b)
@@ -92,6 +105,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if target.invulnerable and not neverMiss then if target.invulnerable and not neverMiss then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a -- MoveHitTest's INVULNERABLE branch sets the same wMoveMissed as a
-- failed accuracy roll (core.asm:5260), and the miss handler still -- failed accuracy roll (core.asm:5260), and the miss handler still
@@ -120,6 +134,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if not battle:accuracyRoll(move, user, target) then if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed) -- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
-- Jump Kick crash, Explode self-destruct -- Jump Kick crash, Explode self-destruct
if record and record.onMiss then record.onMiss(ctx, "accuracy") end if record and record.onMiss then record.onMiss(ctx, "accuracy") end
@@ -147,6 +162,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
end end
if not counterable or (battle.lastDamage or 0) == 0 then if not counterable or (battle.lastDamage or 0) == 0 then
battle:cancelMoveAnim() battle:cancelMoveAnim()
missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
return return
end end
@@ -170,6 +186,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.typeMult == 0 then if info.typeMult == 0 then
-- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim -- type immunity zeros damage and sets wMoveMissed in Gen 1, so no anim
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
missBeat(battle, record)
battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target))) battle:sayNext(Strings("It doesn't affect\n%s!", displayName(target)))
if record and record.onMiss then record.onMiss(ctx, "immune") end if record and record.onMiss then record.onMiss(ctx, "immune") end
return return
@@ -177,6 +194,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
if info.missed then if info.missed then
-- 0.25x floored the damage to zero: the original registers a miss -- 0.25x floored the damage to zero: the original registers a miss
if not (record and record.explode) then battle:cancelMoveAnim() end if not (record and record.explode) then battle:cancelMoveAnim() end
missBeat(battle, record)
battle:sayNext(Strings("%s's\nattack missed!", displayName(user))) battle:sayNext(Strings("%s's\nattack missed!", displayName(user)))
if record and record.onMiss then record.onMiss(ctx, "floored") end if record and record.onMiss then record.onMiss(ctx, "floored") end
return return
@@ -231,6 +249,13 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- every strike -- damage was only rolled once -- every strike -- damage was only rolled once
if info.crit then battle:sayNext(Strings("Critical hit!")) end if info.crit then battle:sayNext(Strings("Critical hit!")) end
if info.ohko then battle:sayNext(Strings("One-hit KO!")) end if info.ohko then battle:sayNext(Strings("One-hit KO!")) end
-- PrintCriticalOHKOText closes with `ld c, 20 / jp DelayFrames` at its
-- .done label (core.asm:3812-3814) -- and the no-crit path jumps to that
-- same label (:3799), so this hold is paid on EVERY landed hit, not just
-- critical ones. It sits between the crit text and DisplayEffectiveness
-- (:3228-3229), which is where the beat before "It's super effective!"
-- comes from.
battle:waitNext(Timing.CRIT_OHKO_TEXT)
if info.typeMult > 10 then if info.typeMult > 10 then
battle:sayNext(Strings("It's super\neffective!")) battle:sayNext(Strings("It's super\neffective!"))
elseif info.typeMult < 10 then elseif info.typeMult < 10 then
+14 -6
View File
@@ -435,8 +435,18 @@ local function drainHalf(text)
end end
end end
-- fixed damage still respects type immunity (AdjustDamageForMoveType -- OHKO is the only "damage but not through normal calculations" family
-- flags the miss before the special-damage override) -- that still runs the type chart. CalculateDamage hands OHKO_EFFECT to
-- JumpToOHKOMoveEffect (engine/battle/core.asm:4329) and, when the effect
-- did not set wMoveMissed, execution falls through to
-- AdjustDamageForMoveType (core.asm:3147), which multiplies the 65535 by
-- the 0 matchup and flags the miss. Fixed damage and Super Fang do not:
-- they are the SetDamageEffects table (data/battle/set_damage_effects.asm)
-- and core.asm:3139 jumps straight to MoveHitTest, skipping
-- CalculateDamage, AdjustDamageForMoveType and RandomizeDamage, while
-- ApplyAttackToEnemyPokemon (core.asm:4612) writes wDamage with no
-- effectiveness step. So in Gen 1 NIGHT_SHADE hits Normal-types and
-- SUPER_FANG hits Ghosts, and only OHKO_EFFECT calls this (#616).
local function immuneMsg(ctx) local function immuneMsg(ctx)
if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then if TypeChart.effectiveness(ctx.move.type, ctx.target.curTypes) == 0 then
return Strings("It doesn't affect\n%s!", displayName(ctx.target)) return Strings("It doesn't affect\n%s!", displayName(ctx.target))
@@ -466,8 +476,7 @@ MoveEffects.full = {
SPECIAL_DAMAGE_EFFECT = { SPECIAL_DAMAGE_EFFECT = {
chooseDamage = function(ctx) chooseDamage = function(ctx)
local blocked = immuneMsg(ctx) -- no immunity check: SetDamageEffects skips AdjustDamageForMoveType (#616)
if blocked then return nil, blocked end
local dmg = fixedDamageFor(ctx) local dmg = fixedDamageFor(ctx)
if not dmg then return nil, "But, it failed!" end if not dmg then return nil, "But, it failed!" end
return dmg, plainInfo() return dmg, plainInfo()
@@ -475,8 +484,7 @@ MoveEffects.full = {
}, },
SUPER_FANG_EFFECT = { SUPER_FANG_EFFECT = {
chooseDamage = function(ctx) chooseDamage = function(ctx)
local blocked = immuneMsg(ctx) -- also SetDamageEffects: halves a Ghost's HP in Gen 1 (#616)
if blocked then return nil, blocked end
return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo() return math.max(1, math.floor(ctx.target.mon.hp / 2)), plainInfo()
end, end,
}, },
+51 -12
View File
@@ -134,12 +134,13 @@ local function drawHUDs(battle, slide)
drawStatusPanel(battle, battle.enemy, 0, 0, false) drawStatusPanel(battle, battle.enemy, 0, 0, false)
end end
if battle.safari then -- No player status panel in a safari / old-man battle: no mon of the
Font.drawBox(23, 7, 15, 4) -- player's is out. Nothing replaces it either -- the ball count is a menu
love.graphics.setColor(0, 0, 0, 1) -- item, not a HUD element (DisplayBattleMenu prints wNumSafariBalls inside
Font.draw(("BALLx%2d"):format(battle.safari.balls), 200, 72) -- the battle menu box, engine/battle/core.asm:2074-2079), so it rides in
elseif battle.player and not battle.demo and not battle.showPlayerBack -- drawCommandMenu below like the classic layout's (#540).
and slide == 0 then if not battle.safari and battle.player and not battle.demo
and not battle.showPlayerBack and slide == 0 then
drawStatusPanel(battle, battle.player, 184, 56, true) drawStatusPanel(battle, battle.player, 184, 56, true)
end end
drawIntroBalls(battle) drawIntroBalls(battle)
@@ -172,6 +173,9 @@ local function drawCommandMenu(battle)
Font.drawBox(0, 13, 38, 5) Font.drawBox(0, 13, 38, 5)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("BALLx"), 16, 112) Font.draw(Strings("BALLx"), 16, 112)
-- wNumSafariBalls immediately after the label, as at hlcoord 7,14
-- (engine/battle/core.asm:2074-2079) (#540)
Font.draw(("%2d"):format(battle.safari.balls), 56, 112)
Font.draw(Strings("BAIT"), 168, 112) Font.draw(Strings("BAIT"), 168, 112)
Font.draw(Strings("THROW ROCK"), 16, 128) Font.draw(Strings("THROW ROCK"), 16, 128)
Font.draw(Strings("RUN"), 168, 128) Font.draw(Strings("RUN"), 168, 128)
@@ -183,6 +187,21 @@ local function drawCommandMenu(battle)
Font.drawBox(0, 13, 20, 5) Font.drawBox(0, 13, 20, 5)
Font.drawBox(20, 13, 18, 5) Font.drawBox(20, 13, 18, 5)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
-- The old-man / PROF.OAK catch demo has no party, so makeOldManDemo parks
-- the WILD mon in battle.player as a placeholder (BattleState:1209). The
-- classic layout's demo branch never names anyone (DisplayBattleMenu,
-- core.asm:2038-2049, just draws the menu), so naming it here printed
-- "What will PIKACHU do?" over Oak's scripted throw (#557). Leave the
-- prompt side blank and run the same scripted hand the classic does.
if battle.demo then
Font.draw(Strings("FIGHT"), 176, 112)
Font.drawCode(0xE1, 240, 112); Font.drawCode(0xE2, 248, 112)
Font.draw(Strings("ITEM"), 176, 128)
Font.draw(Strings("RUN"), 240, 128)
-- next to FIGHT for the first 80 frames, then ITEM
Font.drawCode(0xED, 168, (battle.demoTimer or 0) <= 80 and 112 or 128)
return
end
Font.draw(Strings("What will"), 8, 112) Font.draw(Strings("What will"), 8, 112)
local who = battle.player and battle.player.name or "" local who = battle.player and battle.player.name or ""
Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128) Font.draw(fitName(who, 112) .. Strings(" do?"), 8, 128)
@@ -305,22 +324,42 @@ function WideBattle.draw(battle)
if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then if sx == 0 and sy == 0 and fx and fx.shake and fx.shake > 0 then
sx = battle.frame % 4 < 2 and 2 or -2 sx = battle.frame % 4 < 2 and 2 or -2
end end
local slide = (battle.introSlide or 0) * 4 -- same 2 px/frame silhouette slide the 160px layout uses
local slide = (battle.introSlide or 0)
* require("src.core.Timing").BATTLE_SLIDE_PX_PER_FRAME
-- Each side keeps its original sprite pixels and placement math: the two -- Each side keeps its original sprite pixels and placement math: the two
-- 160x144 OAM regions are translated apart and clipped into the wider -- 160x144 OAM regions are translated apart and clipped into the wider
-- battlefield rather than either monster being scaled. wideRegion tells -- battlefield rather than either monster being scaled. wideRegion tells
-- drawBattlerPic its own side window is already the clip. -- drawBattlerPic its own side window is already the clip. A shake moves
-- each region's clip with its contents, so a pic pushed toward a region
-- edge is not sheared off it (a vertical shake used to clip the player's
-- feet at FIELD_BOTTOM).
battle.wideRegion = true battle.wideRegion = true
inRegion(0, 32, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy, inRegion(sx, 32 + sy, 160, WideBattle.FIELD_BOTTOM - 32, 20 + sx, 8 + sy,
function() battle:drawPicsLayer(slide, 0, 0, "player", true) end) function() battle:drawPicsLayer(slide, 0, 0, "player", true) end)
inRegion(160, 0, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy, inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end) function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
battle.wideRegion = nil battle.wideRegion = nil
drawHUDs(battle, slide) -- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
-- PredefShakeScreenVertically and AnimationShakeScreenHorizontallySlow
-- displace the HUDs and the message window along with the pics, exactly as
-- drawClassic offsets its whole BG canvas. Shaking only the two pic
-- regions left the foe gliding sideways across a nailed-down screen on
-- every applying-attack shake -- TAIL WHIP and every other status move
-- (#562). The OAM anim layer stays put, as it does in drawClassic.
local function shaken(fn)
if sx == 0 and sy == 0 then return fn() end
g.push()
g.translate(sx, sy)
fn()
g.pop()
end
shaken(function() drawHUDs(battle, slide) end)
drawAnimationLayer(battle) drawAnimationLayer(battle)
drawTextArea(battle) shaken(function() drawTextArea(battle) end)
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
g.setColor(1, 1, 1, 0.85) g.setColor(1, 1, 1, 0.85)
+8 -2
View File
@@ -418,16 +418,22 @@ function ChipAudio.newCry(data, species, resolved)
}) })
end end
-- Two channels for the same reason ChipSynth.renderEffectData renders stereo:
-- a mono Source is spatialized by OpenAL at the listener position and spreads
-- over every output an interface has (#626). The siren itself is unchanged,
-- both channels carry the same sample.
function ChipAudio.newLowHealthAlarm() function ChipAudio.newLowHealthAlarm()
local samples = math.floor(SAMPLE_RATE * 62 / 60) local samples = math.floor(SAMPLE_RATE * 62 / 60)
local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 1) local data = love.sound.newSoundData(samples, SAMPLE_RATE, 16, 2)
local phase = 0 local phase = 0
for index = 0, samples - 1 do for index = 0, samples - 1 do
local frame = math.floor(index * 60 / SAMPLE_RATE) % 31 local frame = math.floor(index * 60 / SAMPLE_RATE) % 31
local register = frame < 11 and 0x750 or 0x6EE local register = frame < 11 and 0x750 or 0x6EE
local frequency = 131072 / (2048 - register) local frequency = 131072 / (2048 - register)
phase = (phase + frequency / SAMPLE_RATE) % 1 phase = (phase + frequency / SAMPLE_RATE) % 1
data:setSample(index, (phase < 0.5 and 1 or -1) * 0.25) local value = (phase < 0.5 and 1 or -1) * 0.25
data:setSample(index, 1, value)
data:setSample(index, 2, value)
end end
return love.audio.newSource(data, "static") return love.audio.newSource(data, "static")
end end
+22 -5
View File
@@ -808,9 +808,22 @@ local function soundData(engine, samples, channels)
return result return result
end end
-- Render a one-shot effect (SFX/cry) to a mono SoundData, or nil when it is -- Render a one-shot effect (SFX/cry) to a two-channel SoundData, or nil when
-- too short to be audible. The caller wraps it in a static love.audio.Source -- it is too short to be audible. The caller wraps it in a static
-- (a playback concern, hence not done here). -- love.audio.Source (a playback concern, hence not done here).
--
-- The synthesis is mono (one summed value per frame, unlike the music path's
-- sampleStereo), but the buffer is written stereo on purpose: OpenAL only
-- spatializes 1-channel Sources, and a Source left at the default (0,0,0)
-- position, exactly where the listener sits, is rendered as an ambient sound
-- spread over EVERY output channel the device exposes at gains that differ
-- from the front pair. On an interface with more than two outputs that put
-- the SFX on outputs 5+6 as well, while the 2-channel music source
-- (ChipAudio.playMusic) stayed on 1+2 (#626). Multi-channel buffers skip
-- spatialization entirely and map onto the front pair, so duplicating the
-- sample costs one buffer's memory and makes effects route exactly like
-- music. Deliberately not sampleStereo: that honors the NR51 panning byte
-- and would newly hard-pan any effect whose header issues command 0xEE.
local function renderEffectData(data, header, options) local function renderEffectData(data, header, options)
if not header then return nil end if not header then return nil end
options = options or {} options = options or {}
@@ -825,8 +838,12 @@ local function renderEffectData(data, header, options)
values[count] = engine:sample() values[count] = engine:sample()
end end
if count < math.floor(SAMPLE_RATE / 100) then return nil end if count < math.floor(SAMPLE_RATE / 100) then return nil end
local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 1) local result = love.sound.newSoundData(count, SAMPLE_RATE, 16, 2)
for index = 1, count do result:setSample(index - 1, values[index]) end for index = 1, count do
local value = values[index]
result:setSample(index - 1, 1, value)
result:setSample(index - 1, 2, value)
end
return result return result
end end
+7
View File
@@ -77,6 +77,13 @@ end
function Data:applyVersionedFieldData() function Data:applyVersionedFieldData()
if require("src.core.GameVersion").isYellow() then if require("src.core.GameVersion").isYellow() then
self.field.trades = copy(YELLOW_TRADES) self.field.trades = copy(YELLOW_TRADES)
-- The old man's catch demo is a RATTATA in Yellow
-- (scripts/ViridianCity.asm ViridianCityOldManStartCatchTrainingScript
-- .SetupBattle: ld a, RATTATA / ld [wCurOpponent], a) but the Yellow
-- manifest inherited Red's WEEDLE field.oldManBattle (#617), so old
-- Yellow caches carry the wrong demo species too. The fixed import
-- manifest below stamps RATTATA for fresh imports.
self.field.oldManBattle = { species = "RATTATA", level = 5 }
end end
end end
+129
View File
@@ -0,0 +1,129 @@
-- Faithful resolution: lock the window to an exact integer multiple of the
-- Game Boy's 160x144 screen, 1X through 4X.
--
-- At any other window size the renderer picks the largest integer scale that
-- fits and letterboxes the remainder (Renderer:fitScale), so the game is
-- already crisp -- what it is not is *exact*: there are bars, and at a wide
-- window a lot of them. Locking the window to 160*N x 144*N removes the
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
--
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
-- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs
-- that lack love.window.
local FaithfulRes = {}
FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
FaithfulRes.DEFAULT = 0
-- conf.lua's floor for the resizable desktop window, restored when the lock
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
-- as well as set the size or LOVE clamps the window back up.
FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
-- whether this module currently owns the window size
FaithfulRes.locked = false
function FaithfulRes.normalize(v)
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
if v < 0 then return 0 end
if v > 4 then return 4 end
return v
end
function FaithfulRes.label(v)
v = FaithfulRes.normalize(v)
if v == 0 then return "OFF" end
return tostring(v) .. "X"
end
function FaithfulRes.cycle(v, dir)
local levels = FaithfulRes.LEVELS
local cur = 1
for i, level in ipairs(levels) do
if level == FaithfulRes.normalize(v) then cur = i break end
end
return levels[(cur - 1 + (dir or 1)) % #levels + 1]
end
function FaithfulRes.isMobile()
if not love or not love.system or not love.system.getOS then return false end
local osName = love.system.getOS()
return osName == "Android" or osName == "iOS"
end
-- Physical pixels per LOVE unit for the CURRENT window.
--
-- Deliberately NOT love.window.getDPIScale: that reports the display's
-- scaling factor even when the window is not high-DPI aware, and conf.lua
-- only sets t.window.highdpi on mobile. On a plain desktop window a unit IS
-- a pixel, so dividing by the display scale just shrinks the window -- at
-- 125% scaling a 2X request became 256x230 pixels, which Renderer:fitScale
-- floors to 1, and 4X became 512x461, which floors to 3. That is exactly
-- the "2X renders at 1X, 4X renders at 3X" this shipped with.
--
-- Measuring the ratio the window actually reports is correct in both worlds:
-- 1 on a plain desktop window, the real scale on a high-DPI one.
local function pixelsPerUnit()
local g = love and love.graphics
if not (g and g.getDimensions and g.getPixelDimensions) then return 1 end
local uw = tonumber((g.getDimensions()))
local pw = tonumber((g.getPixelDimensions()))
if not uw or not pw or uw <= 0 or pw <= 0 then return 1 end
return pw / uw
end
-- The window size in LOVE UNITS that puts 160*v x 144*v PHYSICAL pixels on
-- screen.
function FaithfulRes.size(v)
v = FaithfulRes.normalize(v)
if v == 0 then return nil end
local ratio = pixelsPerUnit()
return math.floor(FaithfulRes.WIDTH * v / ratio + 0.5),
math.floor(FaithfulRes.HEIGHT * v / ratio + 0.5)
end
-- Push the lock into the live window. Returns true when the window is
-- locked afterwards.
function FaithfulRes.apply(v)
if FaithfulRes.isMobile() then return false end
if not love or not love.window or not love.window.setMode
or not love.window.getMode then
return false
end
v = FaithfulRes.normalize(v)
local curW, curH, flags = love.window.getMode()
flags = flags or {}
if v == 0 then
-- only touch the window if we were the one holding it: an OFF setting on
-- boot must not resize a window the player sized themselves
if not FaithfulRes.locked then return false end
flags.resizable = true
flags.minwidth, flags.minheight = FaithfulRes.MIN_W, FaithfulRes.MIN_H
love.window.setMode(curW, curH, flags)
FaithfulRes.locked = false
return false
end
local w, h = FaithfulRes.size(v)
-- An exact size and a desktop-fullscreen mode cannot both hold. The lock
-- is the more specific request, so it wins and drops fullscreen; VIDEO MODE
-- reads BORDERLESS until the player changes it, which then releases this.
flags.fullscreen = false
-- resizing by hand would silently break the lock, and nothing re-applies it
-- (there is no love.resize handler -- the renderer re-reads the size every
-- frame), so the window is fixed while locked rather than left draggable.
flags.resizable = false
flags.minwidth, flags.minheight = w, h
love.window.setMode(w, h, flags)
FaithfulRes.locked = true
return true
end
function FaithfulRes.applyOptions(opts)
return FaithfulRes.apply(opts and opts.faithfulRes)
end
return FaithfulRes
+29 -4
View File
@@ -7,9 +7,22 @@ local FixedStep = {}
FixedStep.STEP = 1 / 60 FixedStep.STEP = 1 / 60
local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
-- been paid for. Half a step is the balanced point: a frame has to come in
-- ~8ms short before it drops a step and ~8ms long before it doubles one up.
-- Zero -- what this used to leave behind -- has no margin at all on the long
-- side, so the accumulator settles a hair BELOW one step and parks there, and
-- ordinary sub-millisecond vsync wobble then flips it between 0 and 2 steps
-- every few frames for the rest of the session. That is why pacing stayed
-- visibly broken after a route/city seam and nowhere else: crossConnection in
-- src/world/OverworldController.lua is the only caller of discardCatchup, and
-- warps go through Transition instead (issue #487).
local RESEED_PHASE = 0.5
function FixedStep:init(callback) function FixedStep:init(callback)
self.accum = 0 self.accum = 0
self.callback = callback self.callback = callback
self.suppressCatchup = false
end end
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15 -- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
@@ -20,6 +33,16 @@ end
FixedStep.maxAccum = MAX_ACCUM FixedStep.maxAccum = MAX_ACCUM
function FixedStep:update(dt) function FixedStep:update(dt)
-- A hitch's oversized dt lands on the frame AFTER discardCatchup was
-- called (the hitch itself already ran inside the current step); absorb
-- that one frame as a single step instead of the normal accumulator so
-- the burst it would otherwise release doesn't play out as a slide.
if self.suppressCatchup then
self.suppressCatchup = false
self.accum = self.STEP * RESEED_PHASE
self.callback(self.STEP)
return
end
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM) self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
while self.accum >= self.STEP do while self.accum >= self.STEP do
self.accum = self.accum - self.STEP self.accum = self.accum - self.STEP
@@ -27,12 +50,14 @@ function FixedStep:update(dt)
end end
end end
-- Drop any pending catch-up steps. A hitch inside one logic step (map -- Drop any pending catch-up steps and arm the one-frame clamp above. A
-- seam setMap / song start) makes the next real-time dt huge; without this -- hitch inside one logic step (map seam setMap / song start) makes the
-- the while-loop above would advance many walk frames before the next -- next real-time dt huge; without this the while-loop above would advance
-- draw, which looks like a slide with no leg animation (issue #93). -- many walk frames before the next draw, which looks like a slide with no
-- leg animation (issue #93).
function FixedStep:discardCatchup() function FixedStep:discardCatchup()
self.accum = 0 self.accum = 0
self.suppressCatchup = true
end end
return FixedStep return FixedStep
+158 -2
View File
@@ -178,6 +178,22 @@ function Game:step(dt)
-- this logic tick, not one tick later. With no wrapper this is a no-op. -- this logic tick, not one tick later. With no wrapper this is a no-op.
ModRuntime.call("input.step", function() end, self, dt) ModRuntime.call("input.step", function() end, self, dt)
self.input:step() self.input:step()
-- A+B+SELECT+START held for 16 steps: SoftReset (home/init.asm) stops the
-- audio, whites the palettes out and falls through into Init, i.e. the
-- power-on boot, so everything unsaved is gone and the title sequence
-- comes back -- exactly what returnToTitle does for the START menu's QUIT.
-- The check lives here, above stack:update, so it fires from any state:
-- overworld, battle, a menu or a cutscene, the way _Joypad's does (#563).
-- Input:reset clears the four still-physically-held buttons so the title
-- screen does not read A as a menu choice on its first frame.
-- a tool mod (or a harness) may hand Game a stand-in input with no
-- chord bookkeeping; those simply never soft reset
if self.input.softResetStep and self.input:softResetStep() then
Input:reset()
TouchControls:reset()
self:returnToTitle()
return
end
-- serviced unconditionally: a link battle's ENet transport must not -- serviced unconditionally: a link battle's ENet transport must not
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily -- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
-- on top of BattleState (see LinkBattle.new) -- on top of BattleState (see LinkBattle.new)
@@ -249,6 +265,38 @@ end
-- exactly as the owning state computed it -- exactly as the owning state computed it
local function sameZones(_, zones) return zones end local function sameZones(_, zones) return zones end
-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
-- during the battle must not drop the dim for a frame.
function Game.worldBgBattleDim(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.bgMode and state:bgMode() == "world" then
return state.BG_WORLD_DIM or 0.55
end
end
return nil
end
-- Does anything on the stack want the surface scaled to FILL the window
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
-- integer scale?
--
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
-- because the party menu, bag and text boxes a battle opens must not snap the
-- surface back to the fixed scale for a frame; the title screen and intro want
-- it unconditionally, since neither has a world behind it and neither has any
-- reason to sit in a small box in the middle of a large window.
function Game.fillScaleInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.wantsFillScale and state:wantsFillScale() then
return true
end
end
return false
end
-- A wide battle owns the surface until it leaves the stack. The party, -- A wide battle owns the surface until it leaves the stack. The party,
-- bag, choice and text states it opens still draw their original 160px UI, -- bag, choice and text states it opens still draw their original 160px UI,
-- but the canvas must not snap to 160px between those states. -- but the canvas must not snap to 160px between those states.
@@ -262,6 +310,49 @@ function Game.wideBattleInStack(stack)
return nil return nil
end end
-- Whether a state on the stack composes its own screen and so wants the
-- edge anchors held off (BattleState.holdsUIAnchors). Whole-stack, like
-- everything else here: the text box and YES/NO a battle puts up are states
-- of their own sitting above it, and they are exactly the elements that must
-- stay inside the battle's composition rather than dock to the window.
function Game.uiAnchorsHeldInStack(stack)
for i = #(stack and stack.states or {}), 1, -1 do
local state = stack.states[i]
if state and state.holdsUIAnchors then return true end
end
return false
end
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
-- the bag) becomes that base, cutting the overworld -- and with it the world
-- pass -- out of the frame entirely. The backdrop the battle established
-- then collapses to endFrame's flat black clear for as long as the menu is
-- up. So a world-bg battle keeps the frame starting from underneath itself
-- until it leaves the stack, the same hold uiFill and the dim already use.
--
-- Only the START of the draw moves. The clear stays keyed to the real
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
-- before; what changes is the window AROUND its letterbox, which keeps
-- showing the map instead of going black. Both menus fill their own
-- 160x144 field first, so nothing beneath them shows through it.
function Game.drawBaseInStack(stack, visibleBase)
local states = stack and stack.states or {}
for i = visibleBase - 1, 1, -1 do
local state = states[i]
if state and state.bgMode and state:bgMode() == "world" then
-- restart the search from under the battle: the highest opaque state at
-- or below it (the overworld), not the menu sitting over it
for j = i, 1, -1 do
if states[j].isOpaque then return j end
end
return 1
end
end
return visibleBase
end
-- Shift classic SGB zones to the centred UI. A full-width base zone extends -- Shift classic SGB zones to the centred UI. A full-width base zone extends
-- into both margins, keeping the canvas' paper color continuous; narrower -- into both margins, keeping the canvas' paper color continuous; narrower
-- sprite and status zones move with the classic UI content. -- sprite and status zones move with the classic UI content.
@@ -287,6 +378,10 @@ function Game:draw()
-- white clear -- white clear
local base = self.stack:visibleBase() local base = self.stack:visibleBase()
local worldBelow = self.stack.states[base] == self.overworld local worldBelow = self.stack.states[base] == self.overworld
-- a world-bg battle keeps the map drawing under whatever it opened, so the
-- world pass can run for a frame whose CLEAR is still an opaque menu's
local drawFrom = Game.drawBaseInStack(self.stack, base)
local worldDrawn = self.stack.states[drawFrom] == self.overworld
-- A wide battle holds its 304px surface through every menu or prompt it -- A wide battle holds its 304px surface through every menu or prompt it
-- opens. States that do not draw the wide battle composition are centred -- opens. States that do not draw the wide battle composition are centred
-- in that surface below, so their classic coordinates and hit testing stay -- in that surface below, so their classic coordinates and hit testing stay
@@ -303,8 +398,26 @@ function Game:draw()
else else
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT) Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
end end
-- BATTLE SIZE: scale the battle surface to the window instead of the
-- classic integer letterbox. Read from the whole stack, not just the top,
-- so a party menu or text box opened mid-battle keeps the same surface.
Renderer.uiFill = Game.fillScaleInStack(self.stack)
-- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
-- the stack for the same reason as uiFill above -- a prompt opened during
-- the battle must not drop the dim for a frame.
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
-- ...and for the same reason the UI's own scale has to know the world is
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
-- steps the UI down with the survey zoom only while a world is behind it,
-- gated on this frame's world pass -- which the party menu and the bag end
-- by being opaque. Without this hold they lose the step-down and blit at
-- full fit scale over a battle drawn at the zoomed-out one.
Renderer.uiWorldHold = Renderer.battleDim ~= nil
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
-- instead of letting them dock to the window edge.
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
Renderer:beginFrame(worldBelow) Renderer:beginFrame(worldBelow)
for i = self.stack:visibleBase(), #self.stack.states do for i = drawFrom, #self.stack.states do
local state = self.stack.states[i] local state = self.stack.states[i]
local wideState = state and state.isWideBattleLayout local wideState = state and state.isWideBattleLayout
and state:isWideBattleLayout() and state:isWideBattleLayout()
@@ -341,7 +454,13 @@ function Game:draw()
if ModRuntime.wantsHook("render.zones") then if ModRuntime.wantsHook("render.zones") then
zones = ModRuntime.call("render.zones", sameZones, self, zones) zones = ModRuntime.call("render.zones", sameZones, self, zones)
end end
if worldBelow and self.overworld.sgbWorldZones then -- Keyed to whether the map actually DREW, not to whether it is the clear's
-- base: an opaque menu over a world-bg battle still renders the world pass
-- (drawBaseInStack), and leaving worldZones nil there drops endFrame's
-- world blit onto the UI zone list instead -- the party menu's own HP-bar
-- palettes, in 160x144 space, smeared across a world-canvas-sized image.
-- That is the offset, red-for-green map behind the menu.
if worldDrawn and self.overworld.sgbWorldZones then
worldZones = self.overworld:sgbWorldZones() worldZones = self.overworld:sgbWorldZones()
end end
local viewport = Renderer:endFrame(zones, worldZones) local viewport = Renderer:endFrame(zones, worldZones)
@@ -475,8 +594,16 @@ function Game:restartWithMods()
require("src.core.HostShell").restart() require("src.core.HostShell").restart()
end end
-- Releases reach Input even while a top state captures raw input: a
-- swallowed key-up would strand a held-state flag for a key Input saw go
-- down before the capture armed (the stuck-flag hazard Input:reset
-- exists for). The top state only OBSERVES the release afterwards,
-- unlike onKeyPressed above which owns the press, so BindingsMenu can
-- commit a capture on the key-up (#589).
function Game:keyreleased(key) function Game:keyreleased(key)
Input:keyreleased(key) Input:keyreleased(key)
local top = self.stack and self.stack:top()
if top and top.onKeyReleased then top:onKeyReleased(key) end
end end
function Game:gamepadpressed(joystick, button) function Game:gamepadpressed(joystick, button)
@@ -493,7 +620,10 @@ function Game:gamepadpressed(joystick, button)
end end
function Game:gamepadreleased(joystick, button) function Game:gamepadreleased(joystick, button)
-- same observe-after-Input contract as Game:keyreleased (#589)
Input:gamepadreleased(joystick, button) Input:gamepadreleased(joystick, button)
local top = self.stack and self.stack:top()
if top and top.onGamepadReleased then top:onGamepadReleased(button) end
end end
function Game:gamepadaxis(joystick, axis, value) function Game:gamepadaxis(joystick, axis, value)
@@ -513,15 +643,38 @@ local function isAccelerometer(joystick)
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end end
-- BindingsMenu's raw-stick capture rides the same top-state routing as the
-- keyboard and gamepad paths (#632). Only a stick SDL does not recognize
-- as a gamepad reaches the capture: a recognized pad raises BOTH
-- joystickpressed and gamepadpressed for one press, and the joystick half
-- would otherwise beat its own gamepadpressed to the armed row and record
-- "JOY1" for a button the player can plainly see is A. Same predicate as
-- Input's, kept local here so Game never reaches into Input's internals.
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
function Game:joystickpressed(joystick, button) function Game:joystickpressed(joystick, button)
if isAccelerometer(joystick) then return end if isAccelerometer(joystick) then return end
TouchControls:noteGamepad() TouchControls:noteGamepad()
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickPressed then
top:onJoystickPressed(button)
return
end
Input:joystickpressed(joystick, button) Input:joystickpressed(joystick, button)
end end
function Game:joystickreleased(joystick, button) function Game:joystickreleased(joystick, button)
if isAccelerometer(joystick) then return end if isAccelerometer(joystick) then return end
-- same observe-after-Input contract as Game:keyreleased (#589): the
-- capture watches the release, it never owns it, so a held-state flag
-- Input saw go down before the capture armed cannot be stranded
Input:joystickreleased(joystick, button) Input:joystickreleased(joystick, button)
local top = self.stack and self.stack:top()
if isRawStick(joystick) and top and top.onJoystickReleased then
top:onJoystickReleased(button)
end
end end
function Game:joystickaxis(joystick, axis, value) function Game:joystickaxis(joystick, axis, value)
@@ -631,6 +784,9 @@ function Game:applyOptions(opts)
-- returns true when a persisted GBC FX level was cleared on mobile -- returns true when a persisted GBC FX level was cleared on mobile
local gbcCleared = require("src.render.GBCFX").applyOptions(opts) local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").applyOptions(opts)
-- after VideoMode: a faithful-resolution lock is an exact window size, so
-- it has to be the last word on the window (it drops fullscreen to hold)
require("src.core.FaithfulRes").applyOptions(opts)
-- normalizes a nil/garbage cap to the 60 default, so old saves with no -- normalizes a nil/garbage cap to the 60 default, so old saves with no
-- fpsCap key pace at the standard rate (issue #88) -- fpsCap key pace at the standard rate (issue #88)
require("src.core.FrameCap").applyOptions(opts) require("src.core.FrameCap").applyOptions(opts)
+187
View File
@@ -13,6 +13,55 @@ function HostShell.envPrefix()
return "" return ""
end end
-- Windows: every host tool we shell out to (curl for the update and mod-index
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
-- spawned through io.popen / os.execute, which run it under cmd.exe. A
-- GUI-subsystem process owns no console, so each of those children allocates
-- its own -- one console window flashing per call, several stacking up during
-- a mod install or an update (#606). #74 fixed the same storm for the
-- per-file cache mkdir by dropping the shell entirely (src/import/CacheFs.lua);
-- the callers above genuinely need one, so we do the other half: allocate a
-- single console for ourselves, once, and hide it. A child inherits the
-- parent's console when the parent has one, so every later spawn attaches to
-- that invisible console and pops up nothing. GUI dialogs the children raise
-- (the PowerShell OpenFileDialog) are desktop windows and still appear.
--
-- Skipped when a console already exists, which is the developer case
-- (lovec.exe, what scripts/run.ps1 prefers, or t.console), so printed output
-- keeps landing in the terminal the game was launched from. POKEPORT_CONSOLE=1
-- opts out entirely and restores the old behaviour. Memoized; non-Windows and
-- FFI-less builds no-op. Called once from love.load before anything shells
-- out (main.lua).
local consoleHidden = nil
function HostShell.hideHostConsole()
if consoleHidden ~= nil then return consoleHidden end
consoleHidden = false
if os.getenv("POKEPORT_CONSOLE") == "1" then return consoleHidden end
local okFfi, ffi = pcall(require, "ffi")
if not okFfi or ffi.os ~= "Windows" then return consoleHidden end
-- kernel32 (AllocConsole/GetConsoleWindow) and user32 (ShowWindow) are
-- already loaded in any LOVE process, so ffi.C resolves both -- the same
-- assumption CacheFs makes for CreateDirectoryA.
pcall(ffi.cdef, [[
void *GetConsoleWindow(void);
int AllocConsole(void);
int ShowWindow(void *hWnd, int nCmdShow);
]])
local ok, hidden = pcall(function()
if ffi.C.GetConsoleWindow() ~= nil then return false end
if ffi.C.AllocConsole() == 0 then return false end
local hwnd = ffi.C.GetConsoleWindow()
if hwnd == nil then return false end
ffi.C.ShowWindow(hwnd, 0) -- SW_HIDE
return true
end)
consoleHidden = (ok and hidden) or false
return consoleHidden
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed -- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode) function HostShell.popen(command, mode)
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
@@ -26,9 +75,30 @@ end
-- ("Failed to initialize filesystem: already initialized") and the relaunch -- ("Failed to initialize filesystem: already initialized") and the relaunch
-- crashes. So on an AppImage we relaunch the executable; the fresh process's -- crashes. So on an AppImage we relaunch the executable; the fresh process's
-- Boot step mounts any downloaded update exactly as a manual relaunch would. -- Boot step mounts any downloaded update exactly as a manual relaunch would.
-- Android hits the same wall (#575): the vendored love.cpp loops runlove()
-- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's
-- destructor fails ("files still open") whenever any physfs handle survives
-- lua_close, so the second PHYSFS_init throws the same "already initialized"
-- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run.
-- On every other platform the in-process restart works, so keep it. -- On every other platform the in-process restart works, so keep it.
function HostShell.restart() function HostShell.restart()
if not (love and love.event and love.event.quit) then return end if not (love and love.event and love.event.quit) then return end
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Android" then
-- restartApp kills the process on success, so a true return is never
-- observed; false means the bridge could not schedule the relaunch.
-- An older APK whose liblove predates the bridge (love.system.restartApp
-- is nil) has no crash-free in-process restart, so quit to the OS
-- cleanly and let the player relaunch by hand -- worse than restarting,
-- but better than the guaranteed crash of quit("restart") (#575).
if love.system.restartApp and love.system.restartApp() then return end
love.event.quit()
return
end
local appimage = os.getenv("APPIMAGE") local appimage = os.getenv("APPIMAGE")
if not appimage then if not appimage then
love.event.quit("restart") love.event.quit("restart")
@@ -48,4 +118,121 @@ function HostShell.restart()
ffi.C.execv(appimage, ffi.cast("char *const *", argv)) ffi.C.execv(appimage, ffi.cast("char *const *", argv))
end end
-- ------- HTTP transport ----------------------------------------------------
--
-- Every remote fetch (mod index, mod releases, thumbnails) used to shell out
-- to curl, which macOS / Windows 10+ / desktop Linux all ship and Android does
-- not: adding a mod index on Android died with "curl is not available on this
-- platform" (#597). Android goes through the GameActivity.httpDownload JNI
-- bridge instead (HttpsURLConnection, using the INTERNET permission link play
-- already needs), surfaced by our vendored liblove as
-- love.system.httpDownload(url, absPath, userAgent, accept). Both transports
-- block the calling thread and deal in whole files, so callers keep exactly
-- the contract they had with curl.
-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
function HostShell.quote(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
function HostShell.haveCurl()
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end
-- The bridge only exists in our Android liblove. An older APK reports nil
-- here and falls back to the "no transport" error the callers already show;
-- the iOS build compiles the same wrapper but always returns false, so gate
-- on the OS as well and keep its error message honest.
local function haveBridge()
if not (love and love.system and type(love.system.httpDownload) == "function") then
return false
end
return love.system.getOS and love.system.getOS() == "Android"
end
-- Is any transport available at all? Callers gate on this, never on curl.
function HostShell.canFetch()
return HostShell.haveCurl() or haveBridge()
end
-- Download url to an absolute host path. Returns true, or nil plus an error.
-- The curl branch deliberately ignores curl's exit code, as the download paths
-- always did: callers judge the result by the file they got.
function HostShell.httpDownload(url, absPath, userAgent, accept)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
return true
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
if ok and done then return true end
return nil, "download failed"
end
-- GET returning the body. curl streams it through a pipe; the Android bridge
-- can only write a file, so there we fetch into the save directory (the only
-- writable root on Android) and read it back.
function HostShell.httpGet(url, userAgent, accept)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 "
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. HostShell.quote(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
if not (love.filesystem and love.filesystem.getSaveDirectory) then
return nil, "fetch needs LOVE"
end
local dirOk, saveDir = pcall(love.filesystem.getSaveDirectory)
if not dirOk or not saveDir or saveDir == "" then
return nil, "no save directory"
end
local name = "http_fetch.tmp"
pcall(love.filesystem.remove, name)
local ok, err = HostShell.httpDownload(url, saveDir .. "/" .. name, userAgent, accept)
if not ok then return nil, err end
local readOk, body = pcall(love.filesystem.read, name)
pcall(love.filesystem.remove, name)
if not readOk or type(body) ~= "string" or body == "" then
return nil, "empty response from " .. url
end
return body
end
return HostShell return HostShell
+95 -4
View File
@@ -41,7 +41,14 @@ local STICK_OFF = 0.3
-- Generic SDL joysticks expose the left stick as the first two numbered -- Generic SDL joysticks expose the left stick as the first two numbered
-- axes and the D-pad as a hat. This is common on Linux handhelds whose -- axes and the D-pad as a hat. This is common on Linux handhelds whose
-- controller has no game-controller database entry. -- controller has no game-controller database entry. The indices below are
-- the desktop XInput order and are only meaningful for such pads: raw
-- numbering is per-driver, and SDL's iOS/MFi driver packs only the buttons
-- a pad actually reports, which slides the D-pad down onto 7..10 (#620).
-- These are the raw DEFAULTS only: applyBindings layers the player's
-- "joyN" pad bindings over them (#632), and only a stick SDL does NOT
-- recognize as a gamepad is ever served out of this table -- see
-- joystickpressed below.
local RAW_BUTTON_BINDINGS = { local RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b", [1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start", [7] = "select", [8] = "start", [9] = "select", [10] = "start",
@@ -66,9 +73,10 @@ end
-- BindingsMenu:storeBinding) -- without this the menu records a choice -- BindingsMenu:storeBinding) -- without this the menu records a choice
-- that never actually reaches gameplay. -- that never actually reaches gameplay.
function Input:applyBindings(overlay) function Input:applyBindings(overlay)
local keys, pads = {}, {} local keys, pads, joys = {}, {}, {}
for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end for key, action in pairs(DEFAULT_BINDINGS) do keys[key] = action end
for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end for button, action in pairs(DEFAULT_GAMEPAD_BINDINGS) do pads[button] = action end
for index, action in pairs(RAW_BUTTON_BINDINGS) do joys[index] = action end
for actionId, binding in pairs(overlay or {}) do for actionId, binding in pairs(overlay or {}) do
if type(binding) == "table" then if type(binding) == "table" then
if binding.key then keys[binding.key] = actionId end if binding.key then keys[binding.key] = actionId end
@@ -77,8 +85,20 @@ function Input:applyBindings(overlay)
keys[binding] = actionId keys[binding] = actionId
end end
end end
-- A pad binding named "joyN" is the Nth button of a stick SDL has no
-- game-controller-database entry for, captured on the joystick path by
-- src/ui/BindingsMenu.lua (#632). It deliberately rides the existing
-- pad slot: the CONTROLS row, the swap in BindingsMenu:storeBinding and
-- START's reset-all then all stay one code path, and this loop is the
-- only place that has to know what the name means. Laid over the raw
-- defaults AFTER them, so a rebind wins the button it claims.
for padName, action in pairs(pads) do
local n = tonumber(padName:match("^joy(%d+)$"))
if n then joys[n] = action end
end
self.keyBindings = keys self.keyBindings = keys
self.padBindings = pads self.padBindings = pads
self.joyBindings = joys
end end
-- Purely event-driven state (press sets true, release sets false) has no -- Purely event-driven state (press sets true, release sets false) has no
@@ -194,13 +214,30 @@ function Input:gamepadreleased(joystick, button)
end end
end end
-- LOVE raises love.joystickpressed for EVERY stick, including ones SDL
-- recognizes as gamepads, which raise love.gamepadpressed for the same
-- physical press as well. Answering both meant the fixed raw table
-- re-asserted the factory A/B/START/SELECT map underneath the player's
-- rebinds, so swapping A and B in CONTROLS pressed both at once and any
-- controller rebind of those four looked ignored; on iOS the MFi driver's
-- packing put the D-pad on 7..10, so a D-pad press also fired SELECT or
-- START (#620, #632). A recognized pad is served by the gamepad path
-- alone; the raw path exists for sticks with no game-controller-database
-- entry. A nil joystick is a raw stick: that is how
-- tests/input_hold_test.lua and the drivers drive this path.
local function isRawStick(joystick)
return not (joystick and joystick.isGamepad and joystick:isGamepad())
end
function Input:joystickpressed(joystick, button) function Input:joystickpressed(joystick, button)
local btn = RAW_BUTTON_BINDINGS[button] if not isRawStick(joystick) then return end
local btn = self.joyBindings[button]
if btn then press(self, btn, "joy:" .. button) end if btn then press(self, btn, "joy:" .. button) end
end end
function Input:joystickreleased(joystick, button) function Input:joystickreleased(joystick, button)
local btn = RAW_BUTTON_BINDINGS[button] if not isRawStick(joystick) then return end
local btn = self.joyBindings[button]
if btn then release(self, btn, "joy:" .. button) end if btn then release(self, btn, "joy:" .. button) end
end end
@@ -240,6 +277,7 @@ function Input:gamepadaxis(joystick, axis, value)
end end
function Input:joystickaxis(joystick, axis, value) function Input:joystickaxis(joystick, axis, value)
if not isRawStick(joystick) then return end
if axis == 1 then if axis == 1 then
self:gamepadaxis(joystick, "leftx", value) self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then elseif axis == 2 then
@@ -247,7 +285,12 @@ function Input:joystickaxis(joystick, axis, value)
end end
end end
-- Same duplicate-event rule as joystickpressed (#620, #632): a recognized
-- pad's D-pad already arrived as dpup/dpdown/dpleft/dpright through the
-- gamepad map, so letting the hat answer too would re-assert the factory
-- directions on top of a direction rebind.
function Input:joystickhat(joystick, hat, direction) function Input:joystickhat(joystick, hat, direction)
if not isRawStick(joystick) then return end
local source = "hat:" .. hat local source = "hat:" .. hat
for _, btn in ipairs(self.hatDirs[hat] or {}) do for _, btn in ipairs(self.hatDirs[hat] or {}) do
release(self, btn, source) release(self, btn, source)
@@ -263,8 +306,56 @@ function Input:isDown(btn)
return self.state[btn] or false return self.state[btn] or false
end end
-- True when the on-screen overlay is one of the live sources holding this
-- button (see overlayPressed above). Player:turnWindow widens the
-- turn-in-place tap window on touch, where a press and release can never be
-- as short as a physical d-pad's (#415).
function Input:isTouchDown(btn)
local sources = self.sources[btn]
return (sources and sources["touch:" .. btn]) and true or false
end
function Input:wasPressed(btn) function Input:wasPressed(btn)
return self.pressed[btn] or false return self.pressed[btn] or false
end end
-- Soft reset (#563). _Joypad (engine/joypad.asm) tests the RAW joypad read
-- with `cp PAD_BUTTONS` -- an equality, not a mask -- so the combo counts
-- only while A, B, SELECT and START are the only buttons down; any d-pad
-- direction in the mix cancels it. That test sits ahead of the wJoyIgnore
-- and BIT_DISABLE_JOYPAD masking below it, which is why the reset still
-- works mid-battle and mid-cutscene where ordinary input is thrown away.
-- TrySoftReset then burns one DelayFrame per pass and decrements hSoftReset,
-- seeded with 16 by Init (home/init.asm), so the combo has to survive 16
-- consecutive polls. That hold is also what keeps the on-screen overlay
-- safe: it already takes four separate fingers on four separate controls,
-- and they all have to stay put for better than a quarter of a second.
local SOFT_RESET_FRAMES = 16
function Input:softResetHeld()
if not (self.state.a and self.state.b
and self.state.start and self.state.select) then
return false
end
return not (self.state.up or self.state.down
or self.state.left or self.state.right)
end
-- Ticked once per fixed step by Game:step; true on the step the countdown
-- runs out. hSoftReset is never re-seeded on release in the original, so
-- its count leaks across a whole session; a port that copied that would
-- eventually reset on a stray four-button press, so the counter re-arms as
-- soon as the combo drops.
function Input:softResetStep()
if not self:softResetHeld() then
self.softResetFrames = nil
return false
end
local left = (self.softResetFrames or SOFT_RESET_FRAMES) - 1
self.softResetFrames = left
if left > 0 then return false end
self.softResetFrames = nil
return true
end
return Input return Input
+26 -2
View File
@@ -219,6 +219,15 @@ function SaveData.defaultOptions()
-- battle screen composition: og (the 160x144 original) | wide -- battle screen composition: og (the 160x144 original) | wide
-- (304x144, src/battle/WideBattle.lua) -- (304x144, src/battle/WideBattle.lua)
battleLayout = "og", battleLayout = "og",
-- BATTLE SIZE: "fixed" = the classic integer-scaled letterbox; "fill" =
-- scale the battle surface to the window so it fills vertically. See
-- BattleState:wantsFillScale.
battleFit = "fixed",
-- BATTLE BG: what fills the screen behind and around the battle.
-- "white" = the display mode's paper shade (the classic look),
-- "black" = plain black bars, "world" = the frozen overworld showing
-- through, dimmed. See BattleState:bgMode.
battleBg = "white",
ruleset = "gen1_faithful", ruleset = "gen1_faithful",
-- 0-7 like the GB's NR50 master volume -- 0-7 like the GB's NR50 master volume
musicVol = 7, musicVol = 7,
@@ -239,6 +248,9 @@ function SaveData.defaultOptions()
voidFill = "trees", voidFill = "trees",
-- windowed | borderless (desktop fullscreen); ignored on mobile -- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed", videoMode = "windowed",
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
-- src/core/FaithfulRes.lua. Ignored on mobile.
faithfulRes = 0,
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua) -- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60, fpsCap = 60,
-- graphics performance tier: auto | high | balanced | low. "auto" -- graphics performance tier: auto | high | balanced | low. "auto"
@@ -254,6 +266,15 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data. -- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default. -- Missing entries mean enabled so newly installed mods work by default.
mods = {}, mods = {},
-- Named setups the player can switch between (#593; src/mods/ModProfile.lua
-- owns the shape, src/mods/ManagerState.lua the UI): each row is
-- { name, enabled = {id=bool}, options = {id={k=v}}, slots = {version=slotId} }.
-- activeProfile names the row the live set currently matches (nil for
-- ad-hoc, so it has no default entry here; mergeOptions preserves it).
-- modProfilesSeeded records that the pre-profiles setup was already
-- migrated into PROFILE 1, so deleting every profile does not re-seed one.
modProfiles = {},
modProfilesSeeded = false,
-- GitHub release checks for mods with a manifest "github" field -- GitHub release checks for mods with a manifest "github" field
-- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours. -- (src/mods/ModUpdate.lua). Keyed by owner/repo; TTL is six hours.
modUpdateCache = {}, modUpdateCache = {},
@@ -269,8 +290,11 @@ function SaveData.defaultOptions()
modIndexCache = {}, modIndexCache = {},
-- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua). -- On-screen touch overlay (Android/iOS; see src/core/TouchControls.lua).
-- enabled=false hides it permanently (distinct from auto-hide-on-gamepad). -- enabled=false hides it permanently (distinct from auto-hide-on-gamepad).
-- positions are optional normalized centers {x=0..1, y=0..1} per control -- layouts.portrait / layouts.landscape each hold optional normalized
-- (dpad/a/b/start/select); nil means the default layout. -- centers {x=0..1, y=0..1} per control (dpad/a/b/start/select) plus a
-- size scale; nil positions mean that orientation draws the default
-- layout (#633). Pre-#633 files stored one top-level positions table;
-- TouchControls.normalizeConfig folds it into both orientations on load.
touchControls = { enabled = true }, touchControls = { enabled = true },
} }
end end
+42 -3
View File
@@ -82,6 +82,40 @@ local function isChipDef(def)
return type(def) == "table" and (def.chip ~= nil or def.address ~= nil) return type(def) == "table" and (def.chip ~= nil or def.address ~= nil)
end end
-- OpenAL only spatializes 1-channel Sources, and one left at the default
-- (0,0,0) position sits on top of the listener, which OpenAL renders as an
-- ambient sound spread over every output channel the device has: on an
-- interface with more than two outputs the SFX also came out of outputs 5+6
-- while the 2-channel music stayed on 1+2 (#626). A Source cannot change its
-- channel count after the fact, so a mono file def is re-decoded and its
-- sample duplicated into a stereo buffer, which OpenAL never spatializes.
-- Chip SFX and cries are already stereo at the source (ChipSynth
-- renderEffectData); this covers file defs, i.e. Yellow's 8-bit mono PCM
-- Pikachu clips (RomExtractor extractPikachuCries) and mod-supplied wav/ogg
-- SFX. Every step is guarded: a headless love stub without love.sound, or a
-- decoder that will not hand back SoundData, keeps the original Source.
local function widenMono(source, file)
if not (source and love.sound and love.sound.newSoundData) then
return source
end
local ok, channels = pcall(function() return source:getChannelCount() end)
if not ok or channels ~= 1 then return source end
local built, widened = pcall(function()
local mono = love.sound.newSoundData(file)
local frames = mono:getSampleCount()
local stereo = love.sound.newSoundData(frames, mono:getSampleRate(),
mono:getBitDepth(), 2)
for index = 0, frames - 1 do
local value = mono:getSample(index)
stereo:setSample(index, 1, value)
stereo:setSample(index, 2, value)
end
return love.audio.newSource(stereo, "static")
end)
if built and widened then return widened end
return source
end
-- a file def carries an optional playback rate; a bare string is shorthand -- a file def carries an optional playback rate; a bare string is shorthand
-- for { file = <string> } -- for { file = <string> }
local function newFileSource(def) local function newFileSource(def)
@@ -89,6 +123,7 @@ local function newFileSource(def)
if type(file) ~= "string" then return nil, "no chip program and no file" end if type(file) ~= "string" then return nil, "no chip program and no file" end
local ok, s = pcall(love.audio.newSource, file, "static") local ok, s = pcall(love.audio.newSource, file, "static")
if not ok or not s then return nil, ok and "no source" or tostring(s) end if not ok or not s then return nil, ok and "no source" or tostring(s) end
s = widenMono(s, file) -- keep mono defs off the surround channels (#626)
if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end if type(def) == "table" and def.pitch then pcall(s.setPitch, s, def.pitch) end
return s return s
end end
@@ -233,12 +268,16 @@ function Sound.playPikaCry(data, n)
local src = cache[key] local src = cache[key]
if src == false then return nil end if src == false then return nil end
if not src then if not src then
local ok, s = pcall(love.audio.newSource, local path = ("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n)
("assets/generated/audio/pika_cries/cry_%02d.wav"):format(n), "static") local ok, s = pcall(love.audio.newSource, path, "static")
if not ok then if not ok or not s then
cache[key] = false cache[key] = false
return nil return nil
end end
-- the importer writes these clips as 8-bit mono (RomExtractor
-- extractPikachuCries), so they need the same widening as the chip
-- effects to stay off a multi-output device's surround channels (#626)
s = widenMono(s, path)
s:setVolume(volumeFor(key)) s:setVolume(volumeFor(key))
cache[key] = s cache[key] = s
src = s src = s
+197
View File
@@ -0,0 +1,197 @@
-- Hardware frame budgets, in fixed 60Hz logic steps.
--
-- The original spends a large share of its running time inside DelayFrames
-- calls that produce no visible change -- the pause after a page break, the
-- beat before a status move resolves, the one-HP-at-a-time drain of an HP
-- bar. Porting the visible half of a sequence and dropping the wait is what
-- makes a port read as snappier than hardware, so every one of those waits
-- lives here with its asm citation instead of as a file-local constant.
--
-- See docs/timing-parity.md for the full catalog and the measurement method;
-- tools/scan_pokered_delays.ps1 regenerates the hardware side from a
-- disassembly checkout.
local Timing = {}
-- home/palettes.asm:14 -- three frames to let the bg map fully update
Timing.DELAY3 = 3
-- home/fade.asm: each fade is a loop of `ld c, 8 / call DelayFrames`
Timing.FADE_IN_FROM_BLACK = 32 -- fade.asm:21, b = 4
Timing.FADE_OUT_TO_BLACK = 32 -- fade.asm:43, b = 4
Timing.FADE_OUT_TO_WHITE = 24 -- fade.asm:26, b = 3
Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3
-- Overworld -----------------------------------------------------------------
-- home/overworld.asm:703 PlayMapChangeSound tail-calls GBFadeOutToBlack on
-- every map change. There is no matching fade in: the new map is drawn while
-- the palettes are still blacked out and LoadGBPal restores them in one write,
-- so the map appears instantly.
Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK
Timing.WARP_FADE_IN = 0
-- home/overworld.asm:351-352 -- after a battle, before EnterMap
Timing.POST_BATTLE_RETURN = 10
-- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly /
-- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite
Timing.SPECIAL_WARP_ENTRY = Timing.DELAY3 + Timing.FADE_IN_FROM_WHITE
-- player_animations.asm:43 -- dungeon warp holds before handing back control
Timing.DUNGEON_WARP_ARRIVAL = 50
-- Text ----------------------------------------------------------------------
-- home/text.asm:283-307 ScrollTextUpOneLine is `ld b, 5` of DelayFrame, and
-- its own comment notes it is "always called twice in a row"
Timing.TEXT_SCROLL_LINE = 5
Timing.TEXT_SCROLL_PAIR = Timing.TEXT_SCROLL_LINE * 2
-- Both _ContText (home/text.asm:262-277) and Paragraph (:230-243) print the
-- â–¼ and call ProtectedDelay3 *before* ManualTextScroll starts watching the
-- joypad, so three frames pass with the arrow up and the button ignored.
Timing.TEXT_PRE_ADVANCE = Timing.DELAY3
-- Paragraph / PageChar clear the box and then hold (home/text.asm:239-240,
-- :254-255) before the next page starts typing.
Timing.TEXT_PAGE_CLEAR = 20
-- Totals, for the catalog and the parity tests.
Timing.TEXT_CONT = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_SCROLL_PAIR
Timing.TEXT_PARAGRAPH = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_PAGE_CLEAR
Timing.TEXT_PAGE = Timing.TEXT_PARAGRAPH
Timing.TEXT_PAUSE = 30 -- home/text.asm:500 TextCommand_PAUSE
Timing.TEXT_DOT = 10 -- home/text.asm:576 TextCommand_DOTS, per dot
-- Menus ---------------------------------------------------------------------
-- engine/menus/text_box.asm:322-323 / :333-334 -- both branches of a
-- two-option (yes/no) menu hold before restoring the screen tiles
Timing.YES_NO_ANSWER = 15
Timing.LIST_MENU_OPEN = 10 -- home/list_menu.asm:55-56
Timing.LIST_MENU_REDRAW = Timing.DELAY3 -- home/list_menu.asm:64
-- engine/menus/start_sub_menus.asm:224-225
Timing.FIELD_TELEPORT = 60 + Timing.DELAY3
-- Battle --------------------------------------------------------------------
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:9-49):
-- the enemy comes in on BG SCX $90 -> $00 and the player's back pic on
-- decrementing OAM x, both 2 px per frame -- so 144 px over 72 frames. The
-- port ran 160 px at 4 px/frame (40 frames), a little under twice too fast.
Timing.BATTLE_SLIDE_IN_FRAMES = 72
Timing.BATTLE_SLIDE_PX_PER_FRAME = 2
-- PrintBeginningBattleText .trainerBattle (engine/battle/common_text.asm):
-- SFX_SILPH_SCOPE plays into a clear window (PlaySound then
-- WaitForSoundToFinish, which blocks), and only after `ld c, 20 /
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
Timing.TRAINER_INTRO_SFX_GAP = 20
Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
-- beat is paid on a large fraction of all turns.
Timing.MOVE_STATUS_OR_MISS = 30
-- PlayApplyingAttackAnimation's six types (AnimationTypePointerTable,
-- engine/battle/animations.asm:490-524). The two shake families are
-- `AnimationShakeScreenHorizontallySlow`, whose double push/pop makes each
-- outer pass cost 4b frames and run c times -- so c * 4b.
Timing.SHAKE_VERTICAL = 48 -- type 1, b=8: 8 x 6
Timing.SHAKE_HORIZ_HEAVY = 72 -- type 2, b=8: 8 x 9
Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6
Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9
Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3
-- Type 4 -- the player's damaging move with no added effect, and so the
-- single most common animation in the game -- is AnimationBlinkMon
-- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5
-- + show + DelayFrames 5. The asm's own comment calls it "a second or
-- two"; the port ran it in 20 frames, three times too fast, which is a
-- large part of why trading blows felt hurried.
Timing.BLINK_MON = 60
-- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT
-- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`.
-- This one the port ran SLOWER than hardware, at 30.
Timing.FAINT_SLIDE = 14
Timing.RESIDUAL_TICK = 20 -- core.asm:529-530 poison/burn/leech seed
Timing.CRIT_OHKO_TEXT = 20 -- core.asm:3813-3814
Timing.SWITCH_PLAYER_MON = 50 -- core.asm:2421-2422
Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
--
-- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each
-- iteration pays:
-- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only
-- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and
-- the party menu, never the enemy HUD; and
-- * 2 frames per pixel the bar actually moved, from
-- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148).
-- The drain closes with one more pixel step and a Delay3 (:133-135).
--
-- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames,
-- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon
-- losing everything takes 150 + 96 + 6 = 252 frames on hardware.
Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement
Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only
-- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the
-- 48ths and clamps the result to at least 1 for any nonzero HP
-- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0.
function Timing.hpBarPixels(hp, maxHP)
if not maxHP or maxHP <= 0 then return 0 end
if hp <= 0 then return 0 end
local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
if px < 1 then px = 1 end
return px
end
-- Frames one single-HP step of the drain costs: the per-HP number print
-- (player side only) plus two frames for every pixel that step moved.
function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide)
local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP)
- Timing.hpBarPixels(fromHP, maxHP))
local frames = pixels * Timing.HP_BAR_PIXEL_STEP
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
return frames
end
-- After the loop, .animateHPBarDone prints the number one last time, runs
-- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135)
-- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's.
function Timing.hpDrainClosingFrames(playerSide)
local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
return frames
end
-- Total cost of draining `fromHP` to `toHP`, for tests and for anything that
-- needs to budget the whole animation up front.
function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
local total = 0
local hp = fromHP
local dir = (toHP < fromHP) and -1 or 1
while hp ~= toHP do
local nextHP = hp + dir
total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide)
hp = nextHP
end
return total + Timing.hpDrainClosingFrames(playerSide)
end
return Timing
+144 -29
View File
@@ -14,8 +14,12 @@
-- --
-- Player preferences (options.touchControls) can permanently disable the -- Player preferences (options.touchControls) can permanently disable the
-- overlay and/or override per-control positions as normalized window -- overlay and/or override per-control positions as normalized window
-- fractions. The launcher editor (src/ui/TouchControlsEditor.lua) writes -- fractions. Positions and a size multiplier are stored per orientation
-- those; applyOptions reads them at boot and whenever options change. -- (#633): options.touchControls.layouts.portrait / .landscape, picked from
-- the safe rect's aspect, so laying the pad out in landscape never moves
-- the portrait one. The launcher editor (src/ui/TouchControlsEditor.lua)
-- writes those; applyOptions reads them at boot and whenever options
-- change.
-- --
-- Controls press GB buttons through Input:overlayPressed/Released -- their -- Controls press GB buttons through Input:overlayPressed/Released -- their
-- own input source, not a keyboard alias -- so a held overlay direction -- own input source, not a keyboard alias -- so a held overlay direction
@@ -47,6 +51,15 @@ local SLOP = { a = 1.3, b = 1.3, start = 1.4, select = 1.4 }
local BUTTONS = { "a", "b", "start", "select" } local BUTTONS = { "a", "b", "start", "select" }
local CONTROLS = { "dpad", "a", "b", "start", "select" } local CONTROLS = { "dpad", "a", "b", "start", "select" }
-- Per-orientation layout buckets (#633). Orientation comes from the safe
-- rect, not the device: sw > sh is landscape, so a resized desktop window
-- under POKEPORT_TOUCH exercises the same path a phone rotation does.
local ORIENTATIONS = { "portrait", "landscape" }
-- Control size multiplier bounds for the editor's -/+ (#633). 1.0 is the
-- historical size, so an install that never touches it draws as before.
local SCALE_MIN, SCALE_MAX, SCALE_STEP = 0.6, 1.6, 0.1
local IMAGES = { local IMAGES = {
dpad = "assets/touch/dpad.png", dpad = "assets/touch/dpad.png",
dpad_up = "assets/touch/dpad_up.png", dpad_up = "assets/touch/dpad_up.png",
@@ -65,6 +78,33 @@ local function clamp01(v)
return v return v
end end
local function clampScale(v)
if type(v) ~= "number" or v ~= v then return 1 end
if v < SCALE_MIN then return SCALE_MIN end
if v > SCALE_MAX then return SCALE_MAX end
return v
end
-- Copy a persisted positions table, dropping unknown / non-numeric entries.
-- Always a fresh table: two orientations seeded from the same pre-#633
-- layout must not alias, or dragging one would still move the other.
local function normalizePositions(src)
if type(src) ~= "table" then return nil end
local pos = {}
for _, name in ipairs(CONTROLS) do
local p = src[name]
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) }
end
end
if not next(pos) then return nil end
return pos
end
local function orientationFor(sw, sh)
return (sw or 0) > (sh or 0) and "landscape" or "portrait"
end
local function wantsOverlay() local function wantsOverlay()
local env = os.getenv("POKEPORT_TOUCH") local env = os.getenv("POKEPORT_TOUCH")
if env == "1" then return true end if env == "1" then return true end
@@ -73,22 +113,26 @@ local function wantsOverlay()
return osName == "Android" or osName == "iOS" return osName == "Android" or osName == "iOS"
end end
-- Normalize a persisted touchControls table into {enabled, positions}. -- Normalize a persisted touchControls table into
-- {enabled, layouts = {portrait = {positions, scale}, landscape = {...}}}.
-- Unknown / garbage keys are dropped so a bad options.lua cannot brick -- Unknown / garbage keys are dropped so a bad options.lua cannot brick
-- the overlay. -- the overlay. Pre-#633 files stored one top-level positions table and no
-- scale; that layout seeds both orientations, so an upgrading player keeps
-- what they had until they edit one of them.
function TouchControls.normalizeConfig(tc) function TouchControls.normalizeConfig(tc)
local out = { enabled = true, positions = nil } local out = { enabled = true, layouts = { portrait = {}, landscape = {} } }
if type(tc) ~= "table" then return out end -- a nil / garbage table still yields full buckets (scale defaulted), so
-- no caller ever has to nil-check a bucket's scale
if type(tc) ~= "table" then tc = {} end
if tc.enabled == false then out.enabled = false end if tc.enabled == false then out.enabled = false end
if type(tc.positions) == "table" then local saved = type(tc.layouts) == "table" and tc.layouts or nil
local pos = {} for _, o in ipairs(ORIENTATIONS) do
for _, name in ipairs(CONTROLS) do local b = saved and saved[o]
local p = tc.positions[name] if type(b) ~= "table" then b = { positions = tc.positions, scale = tc.scale } end
if type(p) == "table" and type(p.x) == "number" and type(p.y) == "number" then out.layouts[o] = {
pos[name] = { x = clamp01(p.x), y = clamp01(p.y) } positions = normalizePositions(b.positions),
end scale = clampScale(b.scale),
end }
if next(pos) then out.positions = pos end
end end
return out return out
end end
@@ -96,11 +140,14 @@ end
-- Pure default layout in LOVE units for a usable rect of size ww x wh at -- Pure default layout in LOVE units for a usable rect of size ww x wh at
-- origin (ox, oy). Shared by layout() and the editor's Reset path so -- origin (ox, oy). Shared by layout() and the editor's Reset path so
-- defaults stay in one place. ox/oy default to 0 for the headless tests -- defaults stay in one place. ox/oy default to 0 for the headless tests
-- and for callers that already pass a full-window size. -- and for callers that already pass a full-window size. scale (#633) is
function TouchControls.defaultLayout(ww, wh, ox, oy) -- the orientation's size multiplier: every width and the margin derive
-- from dpadW, so scaling it moves the default centers with the art
-- instead of letting bigger buttons hang off the edge.
function TouchControls.defaultLayout(ww, wh, ox, oy, scale)
ox, oy = ox or 0, oy or 0 ox, oy = ox or 0, oy or 0
local short = math.min(ww, wh) local short = math.min(ww, wh)
local dpadW = math.min(180, short * 0.34) local dpadW = math.min(180, short * 0.34) * clampScale(scale)
local abW = dpadW * 0.46 local abW = dpadW * 0.46
local ssW = dpadW * 0.30 local ssW = dpadW * 0.30
local margin = dpadW * 0.12 local margin = dpadW * 0.12
@@ -127,7 +174,13 @@ end
function TouchControls:init() function TouchControls:init()
self.active = wantsOverlay() self.active = wantsOverlay()
self.enabled = true self.enabled = true
-- per-orientation buckets (#633); self.positions / self.scale mirror the
-- one currently on screen so layout(), the editor and the tests keep a
-- single lookup
self.layouts = { portrait = {}, landscape = {} }
self.orientation = nil
self.positions = nil self.positions = nil
self.scale = 1
self.preview = false self.preview = false
self.controllerHidden = false self.controllerHidden = false
self.touches = {} self.touches = {}
@@ -157,20 +210,30 @@ end
function TouchControls:applyOptions(opts) function TouchControls:applyOptions(opts)
local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls)
self.enabled = cfg.enabled self.enabled = cfg.enabled
self.positions = cfg.positions self.layouts = cfg.layouts
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil self.layoutOx, self.layoutOy = nil, nil
-- prime positions/scale for the orientation on screen so callers that
-- read them before the next layout() (editor chrome, tests) see the file
self:currentBucket()
if not self.enabled then if not self.enabled then
self.controllerHidden = false self.controllerHidden = false
self:reset() self:reset()
end end
end end
-- Snapshot for the editor's save path: enabled plus both orientation
-- buckets, matching what options.lua stores (#633).
function TouchControls:config() function TouchControls:config()
return { local out = { enabled = self.enabled ~= false, layouts = {} }
enabled = self.enabled ~= false, for _, o in ipairs(ORIENTATIONS) do
positions = self.positions, local b = self.layouts and self.layouts[o] or nil
} out.layouts[o] = {
positions = b and b.positions or nil,
scale = clampScale(b and b.scale),
}
end
return out
end end
-- Preview mode: force-draw the overlay for the layout editor, ignoring -- Preview mode: force-draw the overlay for the layout editor, ignoring
@@ -197,13 +260,33 @@ local function clampZone(zone, x0, y0, x1, y1)
zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy)) zone.cy = math.max(y0 + half, math.min(y1 - half, zone.cy))
end end
-- The bucket for the orientation currently on screen (#633), created on
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket()
local _, _, sw, sh = SafeArea.rect()
local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o]
if type(b) ~= "table" then
b = {}
self.layouts[o] = b
end
b.scale = clampScale(b.scale)
self.orientation = o
self.positions = b.positions
self.scale = b.scale
return b
end
-- Layout in LOVE units (density-independent on mobile), recomputed when -- Layout in LOVE units (density-independent on mobile), recomputed when
-- the window or safe area changes (rotation, resize, notch insets). -- the window or safe area changes (rotation, resize, notch insets).
-- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy -- Default: d-pad bottom-left, B/A bottom-right with A above B (the Game Boy
-- diagonal), START/SELECT flanking the bottom center -- all inside the -- diagonal), START/SELECT flanking the bottom center -- all inside the
-- device safe area so thumbs clear the home indicator / cutouts. -- device safe area so thumbs clear the home indicator / cutouts.
-- Custom positions (normalized 0..1 within the safe rect) override centers -- Custom positions (normalized 0..1 within the safe rect) override centers
-- while sizes stay derived from the short edge. -- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633).
function TouchControls:layout() function TouchControls:layout()
local ox, oy, sw, sh = SafeArea.rect() local ox, oy, sw, sh = SafeArea.rect()
if self.layoutW == sw and self.layoutH == sh if self.layoutW == sw and self.layoutH == sh
@@ -212,10 +295,13 @@ function TouchControls:layout()
end end
self.layoutW, self.layoutH = sw, sh self.layoutW, self.layoutH = sw, sh
self.layoutOx, self.layoutOy = ox, oy self.layoutOx, self.layoutOy = ox, oy
self.L = TouchControls.defaultLayout(sw, sh, ox, oy) -- orientation picks which saved layout applies; rotating swaps buckets
if self.positions then -- because sw/sh swapped, which is already the cache key above (#633)
local bucket = self:currentBucket()
self.L = TouchControls.defaultLayout(sw, sh, ox, oy, bucket.scale)
if bucket.positions then
for _, name in ipairs(CONTROLS) do for _, name in ipairs(CONTROLS) do
local p = self.positions[name] local p = bucket.positions[name]
local zone = self.L[name] local zone = self.L[name]
if p and zone then if p and zone then
zone.cx = ox + p.x * sw zone.cx = ox + p.x * sw
@@ -242,19 +328,45 @@ function TouchControls:setControlCenter(name, cx, cy)
if not zone then return end if not zone then return end
zone.cx, zone.cy = cx, cy zone.cx, zone.cy = cx, cy
clampZone(zone, ox, oy, ox + sw, oy + sh) clampZone(zone, ox, oy, ox + sw, oy + sh)
self.positions = self.positions or {} -- writes land in the orientation on screen only (#633)
self.positions[name] = { local bucket = self:currentBucket()
bucket.positions = bucket.positions or {}
self.positions = bucket.positions
bucket.positions[name] = {
x = sw > 0 and (zone.cx - ox) / sw or 0, x = sw > 0 and (zone.cx - ox) / sw or 0,
y = sh > 0 and (zone.cy - oy) / sh or 0, y = sh > 0 and (zone.cy - oy) / sh or 0,
} }
end end
-- Editor Reset: defaults for the orientation on screen only (#633), so
-- resetting landscape never throws away the portrait layout.
function TouchControls:clearPositions() function TouchControls:clearPositions()
local bucket = self:currentBucket()
bucket.positions = nil
bucket.scale = 1
self.positions = nil self.positions = nil
self.scale = 1
self.layoutW, self.layoutH = nil, nil self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil self.layoutOx, self.layoutOy = nil, nil
end end
-- Control size multiplier for the orientation on screen (#633). Widths and
-- the default centers both derive from it in defaultLayout; custom centers
-- keep their normalized spot and re-clamp inside the safe rect on the next
-- layout().
function TouchControls:setScale(scale)
local bucket = self:currentBucket()
bucket.scale = clampScale(scale)
self.scale = bucket.scale
self.layoutW, self.layoutH = nil, nil
self.layoutOx, self.layoutOy = nil, nil
return self.scale
end
function TouchControls:nudgeScale(delta)
return self:setScale((self.scale or 1) + delta)
end
local function inCircle(zone, x, y, slop) local function inCircle(zone, x, y, slop)
local r = zone.w * 0.5 * slop local r = zone.w * 0.5 * slop
local dx, dy = x - zone.cx, y - zone.cy local dx, dy = x - zone.cx, y - zone.cy
@@ -446,5 +558,8 @@ function TouchControls:draw()
end end
TouchControls.CONTROLS = CONTROLS TouchControls.CONTROLS = CONTROLS
TouchControls.ORIENTATIONS = ORIENTATIONS
TouchControls.SCALE_MIN, TouchControls.SCALE_MAX = SCALE_MIN, SCALE_MAX
TouchControls.SCALE_STEP = SCALE_STEP
return TouchControls return TouchControls
+55
View File
@@ -495,6 +495,25 @@ function RomExtractor:extractSprites()
image = "assets/generated/sprites/red_bike.png", image = "assets/generated/sprites/red_bike.png",
frames = bikeFrames, walker = bikeFrames >= 6, frames = bikeFrames, walker = bikeFrames >= 6,
} }
-- Yellow-only: the surfing-Pikachu overworld sheet, loaded outside
-- SpriteSheetPointerTable (LoadSurfingPlayerSpriteGraphics2) -- same
-- extra-extract shape as RedBikeSprite. (RFC 0001)
local surfPika = self.manifest.sprites.surfPikachu
if surfPika and self.symbols[surfPika.label] then
local spSymbol = self:symbol(surfPika.label)
self:write2bpp(
self.rom:bytes(spSymbol.bank, spSymbol.address,
surfPika.imageWidth * surfPika.imageHeight / 4),
surfPika.imageWidth, surfPika.imageHeight,
"sprites/" .. surfPika.imageBase .. ".png", true)
local spFrames = surfPika.imageHeight / 16
out.SPRITE_SURFING_PIKACHU = {
id = "SPRITE_SURFING_PIKACHU",
source = ("ROM:%s"):format(surfPika.label),
image = "assets/generated/sprites/" .. surfPika.imageBase .. ".png",
frames = spFrames, walker = spFrames >= 6,
}
end
self:write("sprites", out) self:write("sprites", out)
self:tick("Overworld sprites", #order + 1, #order + 1) self:tick("Overworld sprites", #order + 1, #order + 1)
return out return out
@@ -1179,6 +1198,13 @@ function RomExtractor:extractPokemon()
completed = completed + 1 completed = completed + 1
self:tick("Pokemon", completed, #self.manifest.dexOrder + 10) self:tick("Pokemon", completed, #self.manifest.dexOrder + 10)
end end
-- Yellow-only third back pic: LoadPlayerBackPic (engine/battle/core.asm)
-- picks OldManPicBack for BATTLE_TYPE_OLD_MAN but ProfOakPicBack for
-- BATTLE_TYPE_PIKACHU, the Pallet Town catch scene (#557). Outside the
-- ticked loop so the progress denominator stays as it was.
if self.symbols["ProfOakPicBack"] then
self:writeCompressedPic("ProfOakPicBack", "battle/profoakb.png")
end
local balls = self:symbol("PokeballTileGraphics") local balls = self:symbol("PokeballTileGraphics")
self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64), self:write2bpp(self.rom:bytes(balls.bank, balls.address, 64),
32, 8, "battle/balls.png", true) 32, 8, "battle/balls.png", true)
@@ -1826,6 +1852,35 @@ function RomExtractor:extractField()
end end
end end
-- Yellow-only: TalkToPikachu's framed portrait, one 5x5 base frame per
-- PikaPicAnimScript -- each script's FIRST pikapic_loadgfx in
-- data/pikachu/pikachu_pic_animation.asm, the pic PikaPicAnimBGFrames_4
-- (PikaAnimTilemap_1, column order) paints. Scripts 18/22/23/24 have no
-- compressed base and take PikaPicAnimBGFrames_5 -> PikaAnimTilemap_9,
-- which is ROW order over a raw 25-tile sheet, hence no columns flag.
-- Script 26 shares script 11's base. The pikaframe overlays each script
-- draws ON TOP of the base are a second full pose out of the same blob and
-- stay unripped: PikachuFollower.picLift stands in for their motion
-- (#561, still on #407's stand-in).
local PIKAPIC_BASE = {
"Pic_e4000", "Pic_e411c", "Pic_e4272", "Pic_e4383", "Pic_e458b",
"Pic_e467b", "Pic_e476e", "Pic_e49d1", "Pic_e4b39", "Pic_e4c3e",
"Pic_e5000", "Pic_e523f", "Pic_e548e", "Pic_e56d1", "Pic_e5924",
"Pic_e5b7d", "Pic_e5ddd", "GFX_e6020", "Pic_e6340", "Pic_e6587",
"Pic_e67d6", "GFX_e6e6f", "GFX_e718f", "GFX_e74af", "Pic_e77cf",
"Pic_e5000", "Pic_f0abf", "Pic_f0cf4",
}
if self.symbols[PIKAPIC_BASE[1]] then
for script, label in ipairs(PIKAPIC_BASE) do
local path = "pikachu/pikapic_" .. script .. ".png"
if label:sub(1, 4) == "GFX_" then
self:raw2bpp(label, 40, 40, path, { matte = true })
else
self:writeCompressedPic(label, path)
end
end
end
self:raw1bpp("LedgeHoppingShadow", 8, 8, self:raw1bpp("LedgeHoppingShadow", 8, 8,
"fx/shadow.png", true); tick() "fx/shadow.png", true); tick()
for _, spec in ipairs({ for _, spec in ipairs({
+255 -20
View File
@@ -61,11 +61,22 @@ local REQUIRED_FILES = {
-- them re-imports on its own, without dragging the other versions through a -- them re-imports on its own, without dragging the other versions through a
-- CACHE_FORMAT bump. -- CACHE_FORMAT bump.
local VERSION_REQUIRED_FILES = { local VERSION_REQUIRED_FILES = {
yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439 yellow = {
"assets/generated/battle/trainers/jessie_james.png", -- #439
-- Oak's own back pic and the pikapic base frames only exist in caches
-- built after their manifest symbols landed, so an older Yellow cache
-- has to re-import to stop falling back to the old man's back pic and
-- to the battle front pic (#557, #561). Both are gated on manifest
-- symbols in RomExtractor, so these markers must only ever list files
-- tools/rom_manifest_yellow.json can actually produce -- otherwise the
-- cache reads as incomplete and re-importing cannot clear it.
"assets/generated/battle/profoakb.png",
"assets/generated/pikachu/pikapic_1.png",
},
} }
-- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from -- "Split-screen ROM selector" first-run palette (matches the FirstRun mockup):
-- the Claude Design project): a dark neon arcade panel, one column per game. -- a dark neon arcade panel, one column per game.
-- Red, Blue, and Yellow share the same importer flow once listed in -- Red, Blue, and Yellow share the same importer flow once listed in
-- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw. -- GameVersion.VERSIONS. Values are 0-255 RGB; alpha is applied per draw.
local PAL = { local PAL = {
@@ -637,6 +648,8 @@ function RomImporter.new(onComplete, opts)
-- Android SAF create-document: which game's SAVE FILES card should show -- Android SAF create-document: which game's SAVE FILES card should show
-- "Save exported." when export_done.flag appears on focus. -- "Save exported." when export_done.flag appears on focus.
androidPendingExportVersion = nil, androidPendingExportVersion = nil,
iosPendingKind = nil,
iosPendingVersion = nil,
-- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no -- Virtual pointer for handhelds / gamepads (Anbernic stock OS has no
-- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs; -- mouse). D-pad + left stick move it; A clicks; shoulders cycle tabs;
-- right stick scrolls the save-slot / mods lists. -- right stick scrolls the save-slot / mods lists.
@@ -818,7 +831,11 @@ local function resetPointerCursor(self)
if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then if not (love.mouse.isCursorSupported and love.mouse.isCursorSupported()) then
return return
end end
self.arrowCursor = self.arrowCursor or love.mouse.getSystemCursor("arrow") if not self.arrowCursor then
local ok, cursor = pcall(love.mouse.getSystemCursor, "arrow")
if not ok then return end
self.arrowCursor = cursor
end
love.mouse.setCursor(self.arrowCursor) love.mouse.setCursor(self.arrowCursor)
end end
@@ -1004,6 +1021,14 @@ end
-- which focus/Choose consumes on return. -- which focus/Choose consumes on return.
function RomImporter:chooseMod() function RomImporter:chooseMod()
if self.workState == "working" then return end if self.workState == "working" then return end
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "mod"
if not pickFile("mod") then
self.iosPendingKind = nil
self.modNotice = { ok = false, text = "Could not open the file picker." }
end
return
end
if self.android then if self.android then
local name = findPendingMod(true, self.pickSkip) local name = findPendingMod(true, self.pickSkip)
if name then if name then
@@ -1065,6 +1090,16 @@ end
-- Android mirrors ROM / mod import via love.system.pickFile("sav"). -- Android mirrors ROM / mod import via love.system.pickFile("sav").
function RomImporter:chooseSaveImport(version) function RomImporter:chooseSaveImport(version)
if self.workState == "working" then return end if self.workState == "working" then return end
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "sav"
self.iosPendingVersion = version
if not pickFile("sav") then
self.iosPendingKind = nil
self.iosPendingVersion = nil
self.saveNotice[version] = { ok = false, text = "Could not open the file picker." }
end
return
end
if self.android then if self.android then
local name = findPendingSav(true, self.pickSkip) local name = findPendingSav(true, self.pickSkip)
if name then if name then
@@ -1117,7 +1152,7 @@ function RomImporter:exportSave(version)
return return
end end
self.androidPendingExportVersion = version self.androidPendingExportVersion = version
if love.system.createFile and love.system.createFile(suggested) then if love.system.createFile and love.system.createFile(suggested, love.filesystem.getSaveDirectory()) then
self.pickPending = true self.pickPending = true
self.pickTimer = 0 self.pickTimer = 0
self.saveNotice[version] = { ok = true, self.saveNotice[version] = { ok = true,
@@ -1154,6 +1189,14 @@ end
function RomImporter:choose(version) function RomImporter:choose(version)
if self.workState == "working" then return end if self.workState == "working" then return end
self.chooseVersion = version or "red" self.chooseVersion = version or "red"
if self.ios and love.system.getPickedFile then
self.iosPendingKind = "rom"
if not pickFile("rom") then
self.iosPendingKind = nil
self:setError("Could not open the file picker.")
end
return
end
if self.android then if self.android then
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or -- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that -- a fresh SAF pick). Never reuse an already-imported cart's file -- that
@@ -1268,6 +1311,37 @@ end
function RomImporter:update(dt) function RomImporter:update(dt)
self.pulse = self.pulse + dt self.pulse = self.pulse + dt
self:_updatePadCursor(dt) self:_updatePadCursor(dt)
if self.ios and love.system.getPickedFile and self.workState ~= "working" then
local path = love.system.getPickedFile()
if path then
local kind = self.iosPendingKind or "rom"
local version = self.iosPendingVersion
self.iosPendingKind = nil
self.iosPendingVersion = nil
if kind == "mod" then
self:_installMod(path)
elseif kind == "sav" then
self:_importSave(version or self:_savedropTarget(), path)
else
self:startPath(path)
end
elseif love.system.getPickError then
local errorText = love.system.getPickError()
if errorText then
local kind = self.iosPendingKind or "rom"
local version = self.iosPendingVersion or self:_savedropTarget()
self.iosPendingKind = nil
self.iosPendingVersion = nil
if kind == "mod" then
self.modNotice = { ok = false, text = errorText }
elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText }
else
self:setError(errorText)
end
end
end
end
self:_pollPickedFiles(dt) self:_pollPickedFiles(dt)
if self.workState ~= "working" or not self.worker then return end if self.workState ~= "working" or not self.worker then return end
local started = love.timer.getTime() local started = love.timer.getTime()
@@ -1312,6 +1386,7 @@ function RomImporter:_cycleTab(delta)
self._slotPress = nil self._slotPress = nil
self._modPress = nil self._modPress = nil
self._findSearchFocus = false self._findSearchFocus = false
self:_disarmTextInput()
end end
function RomImporter:_updatePadCursor(dt) function RomImporter:_updatePadCursor(dt)
@@ -1415,15 +1490,25 @@ function RomImporter:gamepadaxis(_, axis, value)
end end
end end
-- Same gate as src/core/Input.lua's isMappedPad: a pad SDL can map already
-- reached gamepadpressed this frame, so re-entering it from the raw event
-- would fire the virtual cursor's click twice off one A press (#620).
local function isMappedPad(joystick)
return joystick ~= nil and joystick.isGamepad ~= nil and joystick:isGamepad()
end
function RomImporter:joystickpressed(joystick, button) function RomImporter:joystickpressed(joystick, button)
if isMappedPad(joystick) then return end
if button == 1 then self:gamepadpressed(joystick, "a") end if button == 1 then self:gamepadpressed(joystick, "a") end
end end
function RomImporter:joystickreleased(joystick, button) function RomImporter:joystickreleased(joystick, button)
if isMappedPad(joystick) then return end
if button == 1 then self:gamepadreleased(joystick, "a") end if button == 1 then self:gamepadreleased(joystick, "a") end
end end
function RomImporter:joystickaxis(joystick, axis, value) function RomImporter:joystickaxis(joystick, axis, value)
if isMappedPad(joystick) then return end
if axis == 1 then if axis == 1 then
self:gamepadaxis(joystick, "leftx", value) self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then elseif axis == 2 then
@@ -1431,7 +1516,8 @@ function RomImporter:joystickaxis(joystick, axis, value)
end end
end end
function RomImporter:joystickhat(_, hat, direction) function RomImporter:joystickhat(joystick, hat, direction)
if isMappedPad(joystick) then return end
for _, dir in ipairs(self._rawHatDirs[hat] or {}) do for _, dir in ipairs(self._rawHatDirs[hat] or {}) do
self._padDir[dir] = nil self._padDir[dir] = nil
end end
@@ -1697,6 +1783,11 @@ function RomImporter:_resetFrameRects()
self.modRects = nil self.modRects = nil
self.modDeleteRects = nil self.modDeleteRects = nil
self.modImportRect = nil self.modImportRect = nil
-- Enable all / Disable all share that header and the same rule (#647): they
-- are only drawn when the row is wide enough, so a stale rect would otherwise
-- stay clickable over whatever the next tab (or the next window size) draws.
self.modEnableAllRect = nil
self.modDisableAllRect = nil
-- Same rule as the toggles above, and it started to bite once FIND MODS gave -- Same rule as the toggles above, and it started to bite once FIND MODS gave
-- the mods tab a neighbour: these two were rebuilt by the mods panel but -- the mods tab a neighbour: these two were rebuilt by the mods panel but
-- never cleared, so switching tabs left the last mod row's Update / Versions -- never cleared, so switching tabs left the last mod row's Update / Versions
@@ -2190,7 +2281,7 @@ function RomImporter:draw()
col(PAL.bgBot, 0.72) col(PAL.bgBot, 0.72)
love.graphics.rectangle("fill", 0, 0, fullW, fullH) love.graphics.rectangle("fill", 0, 0, fullW, fullH)
local dw = math.min(appW - 32 * s, 520 * s) local dw = math.min(appW - 32 * s, 520 * s)
local dh = 168 * s local dh = 176 * s
local dx = appX + (appW - dw) / 2 local dx = appX + (appW - dw) / 2
local dy = oy + (height - dh) / 2 local dy = oy + (height - dh) / 2
local rr = 12 * s local rr = 12 * s
@@ -2234,6 +2325,14 @@ function RomImporter:draw()
fy + 7 * s, math.max(1, 1.5 * s), fh - 14 * s) fy + 7 * s, math.max(1, 1.5 * s), fh - 14 * s)
end end
-- PASTE under the field: a touch screen has no ctrl+V, and an index URL
-- is not something anyone retypes on a soft keyboard (#578). This rect
-- is the one click mousepressed honors while the prompt is up; pinned so
-- page-scroll banding never eats the tap.
self._indexPasteRect = self:_chipButton(fx + fw - 84 * s, fy + fh + 8 * s,
Strings("Paste"), { w = 84 * s, h = 28 * s, kind = "accent" })
self._indexPasteRect.pinned = true
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
col(PAL.warning) col(PAL.warning)
printfB(Strings("Enter to add - Esc to cancel"), printfB(Strings("Enter to add - Esc to cancel"),
@@ -2405,7 +2504,7 @@ function RomImporter:draw()
love.graphics.setFont(self.slotNameFont) love.graphics.setFont(self.slotNameFont)
col(PAL.white) col(PAL.white)
love.graphics.printf("Other versions " .. tostring(v.name), love.graphics.printf("Other versions: " .. tostring(v.name),
dx + pad, dy + 10 * s, dw - pad * 2, "left") dx + pad, dy + 10 * s, dw - pad * 2, "left")
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
local info = self:_modUpdateInfo(v.id) local info = self:_modUpdateInfo(v.id)
@@ -2512,8 +2611,11 @@ function RomImporter:draw()
if self._hoverEnabled and not self._padCursorActive if self._hoverEnabled and not self._padCursorActive
and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then and love.mouse.isCursorSupported and love.mouse.isCursorSupported() then
if self._anyHover then if self._anyHover then
self.handCursor = self.handCursor or love.mouse.getSystemCursor("hand") if not self.handCursor then
love.mouse.setCursor(self.handCursor) local ok, cursor = pcall(love.mouse.getSystemCursor, "hand")
if ok then self.handCursor = cursor end
end
if self.handCursor then love.mouse.setCursor(self.handCursor) end
else else
resetPointerCursor(self) resetPointerCursor(self)
end end
@@ -2572,7 +2674,14 @@ end
function RomImporter:mousepressed(x, y, button) function RomImporter:mousepressed(x, y, button)
if self._rename then return end -- the rename modal swallows all clicks if self._rename then return end -- the rename modal swallows all clicks
if self._indexPrompt then return end -- and so does the add-index prompt -- The add-index prompt swallows clicks too, except its PASTE button: a
-- touch screen has no ctrl+V, so the button is the only paste path (#578).
if self._indexPrompt then
if button == 1 and inside(self._indexPasteRect, x, y) then
self:_pasteIndexUrl()
end
return
end
-- Mod confirm / versions / release-notes modals swallow clicks too. -- Mod confirm / versions / release-notes modals swallow clicks too.
if self._modConfirm then if self._modConfirm then
if button ~= 1 then return end if button ~= 1 then return end
@@ -2586,6 +2695,8 @@ function RomImporter:mousepressed(x, y, button)
self:_findInstall(c.indexEntry) self:_findInstall(c.indexEntry)
elseif c.kind == "update" then elseif c.kind == "update" then
self:_confirmModUpdate(c.id, c.release) self:_confirmModUpdate(c.id, c.release)
elseif c.kind == "enableAll" then
self:_setAllMods(true, true)
else else
self:_toggleMod(c.id, true) self:_toggleMod(c.id, true)
end end
@@ -2679,6 +2790,7 @@ function RomImporter:mousepressed(x, y, button)
self._modPress = nil -- and any half-started mod toggle press self._modPress = nil -- and any half-started mod toggle press
self._pagePress = nil -- and any half-started page pan self._pagePress = nil -- and any half-started page pan
self._findSearchFocus = false -- and the search caret, now off screen self._findSearchFocus = false -- and the search caret, now off screen
self:_disarmTextInput()
-- Each tab is its own column of a different length; carrying one tab's -- Each tab is its own column of a different length; carrying one tab's
-- offset into another lands somewhere arbitrary. -- offset into another lands somewhere arbitrary.
self.pageScroll = 0 self.pageScroll = 0
@@ -2759,6 +2871,14 @@ function RomImporter:mousepressed(x, y, button)
if inside(self.modImportRect, x, y) then if inside(self.modImportRect, x, y) then
self:chooseMod(); return self:chooseMod(); return
end end
-- Enable all / Disable all sit in that same fixed header, so they dispatch on
-- press like the import button rather than arming a drag (#647).
if inside(self.modEnableAllRect, x, y) then
self:_setAllMods(true); return
end
if inside(self.modDisableAllRect, x, y) then
self:_setAllMods(false); return
end
for _, r in ipairs(self.modDeleteRects or {}) do for _, r in ipairs(self.modDeleteRects or {}) do
if inside(r, x, y) then if inside(r, x, y) then
if armedDelete(armed, "mod", r.id, nil) then if armedDelete(armed, "mod", r.id, nil) then
@@ -2800,11 +2920,14 @@ function RomImporter:mousepressed(x, y, button)
end end
if inside(self.findRefreshRect, x, y) then if inside(self.findRefreshRect, x, y) then
self._findSearchFocus = false self._findSearchFocus = false
self:_disarmTextInput()
self:_refreshFind(true) self:_refreshFind(true)
return return
end end
if inside(self.findSearchRect, x, y) then if inside(self.findSearchRect, x, y) then
self._findSearchFocus = true; return self._findSearchFocus = true
self:_armTextInput()
return
end end
for _, r in ipairs(self.findSourceRemoveRects or {}) do for _, r in ipairs(self.findSourceRemoveRects or {}) do
if inside(r, x, y) then self:_removeIndex(r.id); return end if inside(r, x, y) then self:_removeIndex(r.id); return end
@@ -2832,7 +2955,10 @@ function RomImporter:mousepressed(x, y, button)
end end
-- A press anywhere else on the tab drops the search caret, so the field does -- A press anywhere else on the tab drops the search caret, so the field does
-- not silently keep eating keystrokes once the player has moved on. -- not silently keep eating keystrokes once the player has moved on.
if self.tab == "find" then self._findSearchFocus = false end if self.tab == "find" and self._findSearchFocus then
self._findSearchFocus = false
self:_disarmTextInput()
end
-- Nothing was hit. On a scrolling page that is a press on empty background, -- Nothing was hit. On a scrolling page that is a press on empty background,
-- which is the natural place to grab and pan from. -- which is the natural place to grab and pan from.
if armDrag and (self._pageMax or 0) > 0 then if armDrag and (self._pageMax or 0) > 0 then
@@ -2848,6 +2974,7 @@ function RomImporter:keypressed(key)
self:_commitRename() self:_commitRename()
elseif key == "escape" then elseif key == "escape" then
self._rename = nil self._rename = nil
self:_disarmTextInput()
end end
return return
end end
@@ -2858,13 +2985,11 @@ function RomImporter:keypressed(key)
self:_commitAddIndex() self:_commitAddIndex()
elseif key == "escape" then elseif key == "escape" then
self._indexPrompt = nil self._indexPrompt = nil
self:_disarmTextInput()
elseif key == "v" and (love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui")) then elseif key == "v" and (love.keyboard.isDown("lctrl", "rctrl", "lgui", "rgui")) then
-- an index URL is long and comes from a browser: typing it out by hand -- an index URL is long and comes from a browser: typing it out by hand
-- is the difference between adding one and giving up -- is the difference between adding one and giving up
local ok, text = pcall(love.system.getClipboardText) self:_pasteIndexUrl()
if ok and type(text) == "string" then
self._indexPrompt.text = self._indexPrompt.text .. text:gsub("%s", "")
end
end end
return return
end end
@@ -2888,6 +3013,7 @@ function RomImporter:keypressed(key)
self.findScroll = 0 self.findScroll = 0
elseif key == "escape" or key == "return" or key == "kpenter" then elseif key == "escape" or key == "return" or key == "kpenter" then
self._findSearchFocus = false self._findSearchFocus = false
self:_disarmTextInput()
end end
return return
end end
@@ -3441,6 +3567,27 @@ local MAX_SLOT_LABEL = 24
local MAX_INDEX_URL = 200 local MAX_INDEX_URL = 200
local MAX_FIND_QUERY = 48 local MAX_FIND_QUERY = 48
-- Mobile LOVE only delivers love.textinput while setTextInput(true) is armed,
-- and arming it is also what raises the soft keyboard, so a cabled USB
-- keyboard is just as dead without it (#578). Every site that opens one of
-- the launcher's three text fields (_rename, _indexPrompt, _findSearchFocus)
-- arms through here, and every site that closes one disarms. Desktop has
-- text input on by default and the save editor hosted from this launcher
-- depends on it staying on (tools/save-editor/Kit.lua, #529), so disarm only
-- lowers on mobile -- setTextInput is global SDL state, not per-widget.
function RomImporter:_armTextInput()
if love.keyboard and love.keyboard.setTextInput then
pcall(love.keyboard.setTextInput, true)
end
end
function RomImporter:_disarmTextInput()
if not self.android then return end
if love.keyboard and love.keyboard.setTextInput then
pcall(love.keyboard.setTextInput, false)
end
end
function RomImporter:_beginRename(version, id) function RomImporter:_beginRename(version, id)
local label local label
for _, slot in ipairs(self.slots[version] or {}) do for _, slot in ipairs(self.slots[version] or {}) do
@@ -3448,12 +3595,14 @@ function RomImporter:_beginRename(version, id)
end end
self._rename = { version = version, id = id, text = label or "" } self._rename = { version = version, id = id, text = label or "" }
self._slotPress = nil -- cancel any armed click/drag on the list self._slotPress = nil -- cancel any armed click/drag on the list
self:_armTextInput()
end end
function RomImporter:_commitRename() function RomImporter:_commitRename()
local r = self._rename local r = self._rename
if not r then return end if not r then return end
self._rename = nil self._rename = nil
self:_disarmTextInput()
require("src.core.SaveData").renameSlot(r.version, r.id, r.text) require("src.core.SaveData").renameSlot(r.version, r.id, r.text)
self:_refreshSlots(r.version) self:_refreshSlots(r.version)
end end
@@ -3475,6 +3624,19 @@ function RomImporter:textinput(text)
self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL) self._rename.text = utf8Cap(self._rename.text .. text, MAX_SLOT_LABEL)
end end
-- Clipboard into the index prompt, shared by ctrl/cmd+V and the prompt's
-- on-screen PASTE button (#578). Same rule as typed input: URLs never
-- contain a literal space, and a pasted one usually arrives with a stray
-- newline attached.
function RomImporter:_pasteIndexUrl()
if not self._indexPrompt then return end
local ok, text = pcall(love.system.getClipboardText)
if ok and type(text) == "string" then
self._indexPrompt.text =
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
end
end
-- "+ New save slot": register an empty slot, make it active, relist, and pin the -- "+ New save slot": register an empty slot, make it active, relist, and pin the
-- scroll to the bottom (clamped next draw) so the new row is on screen. -- scroll to the bottom (clamped next draw) so the new row is on screen.
function RomImporter:_newSlot(version) function RomImporter:_newSlot(version)
@@ -3626,7 +3788,7 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged)
local metaH = self.labelFont:getHeight() local metaH = self.labelFont:getHeight()
local rowPadV = 10 * s local rowPadV = 10 * s
local chipBtnH = self.hintFont:getHeight() + 10 * s local chipBtnH = self.hintFont:getHeight() + 10 * s
-- LOADED sits top-right; Edit/Delete sit bottom-right row must fit both -- LOADED sits top-right; Edit/Delete sit bottom-right -- row must fit both
-- without stacking on the same y (chip buttons are taller than the old text). -- without stacking on the same y (chip buttons are taller than the old text).
local loadedH = self.warningFont:getHeight() + 6 * s local loadedH = self.warningFont:getHeight() + 6 * s
local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH, local rowH = math.max(rowPadV * 2 + nameH + 4 * s + metaH,
@@ -3938,6 +4100,49 @@ function RomImporter:_toggleMod(id, confirmed)
self:_refreshMods() self:_refreshMods()
end end
-- Enable all / Disable all (#647). One options write for the whole list
-- (LauncherMods.setAllEnabled) and one relist afterwards, so the count, the
-- switches and every status chip resolve together instead of per mod.
-- Enabling routes through the same experimental confirm _toggleMod arms: that
-- opt-in is the only warning an experimental mod ever gets, and a bulk button
-- must not be the way around it. Disabling needs no confirm -- it is the
-- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed)
local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do
if m.enabled ~= want then
ids[#ids + 1] = m.id
if want and m.experimental then experimental = true end
end
end
if #ids == 0 then
self.modNotice = { ok = true, text = want
and Strings("Every mod is already enabled.")
or Strings("Every mod is already disabled.") }
return
end
if want and experimental and not confirmed then
self._modConfirm = {
kind = "enableAll",
title = "Experimental mods",
yesLabel = "Enable all",
lines = {
"Some of these mods are marked experimental.",
"They may be unfinished or unstable.",
"Enable everything anyway?",
},
}
return
end
self._modConfirm = nil
LauncherMods.setAllEnabled(ids, want)
self:_refreshMods()
self.modNotice = { ok = true, text = want
and Strings("Enabled %d mods.", #ids)
or Strings("Disabled %d mods.", #ids) }
end
-- GitHub Update / Check for updates / Versions. Soft-fails into modNotice. -- GitHub Update / Check for updates / Versions. Soft-fails into modNotice.
-- Update button: when a newer release is known, confirm then install; when -- Update button: when a newer release is known, confirm then install; when
-- already current, force-refresh the 6h cache and report / offer update. -- already current, force-refresh the 6h cache and report / offer update.
@@ -4111,8 +4316,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end for _, m in ipairs(mods) do if m.enabled then enabledCount = enabledCount + 1 end end
love.graphics.setFont(self.hintFont) love.graphics.setFont(self.hintFont)
col(PAL.warning) col(PAL.warning)
love.graphics.print(Strings("%d of %d enabled", enabledCount, #mods), local countText = Strings("%d of %d enabled", enabledCount, #mods)
x + nameW + 14 * s, y + (headerH - self.hintFont:getHeight()) / 2) local countX = x + nameW + 14 * s
love.graphics.print(countText, countX,
y + (headerH - self.hintFont:getHeight()) / 2)
local btnLabel = "Import mod .zip" local btnLabel = "Import mod .zip"
local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s) local btnH = math.max(38 * s, self.saveBtnFont:getHeight() + 20 * s)
@@ -4122,6 +4329,32 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged)
self.modImportRect = self.modImportRect =
self:_glassyButton(btnX, btnY, btnW, btnH, btnLabel, self.saveBtnFont, true) self:_glassyButton(btnX, btnY, btnW, btnH, btnLabel, self.saveBtnFont, true)
-- Enable all / Disable all (#647): bulk switches beside Import mod .zip, so
-- coming back from a cable-club session (which asks for a vanilla fingerprint,
-- src/link/Fingerprint.lua) costs one click instead of one switch per mod.
-- The header is a single row shared with the count, so the chips are dropped
-- rather than overlapped when the column is too narrow to hold them (a
-- phone-width layout); the per-mod switches below are always the full path.
self.modEnableAllRect, self.modDisableAllRect = nil, nil
if #mods > 0 then
local enaLabel, disLabel = Strings("Enable all"), Strings("Disable all")
love.graphics.setFont(self.hintFont)
local bulkH = self.hintFont:getHeight() + 10 * s
local bulkY = y + (headerH - bulkH) / 2
local enaW = self.hintFont:getWidth(enaLabel) + 24 * s
local disW = self.hintFont:getWidth(disLabel) + 24 * s
local bulkGap = 8 * s
local room = btnX - (countX + self.hintFont:getWidth(countText) + bulkGap)
if room >= enaW + disW + 2 * bulkGap then
local disX = btnX - bulkGap - disW
local enaX = disX - bulkGap - enaW
self.modEnableAllRect =
self:_chipButton(enaX, bulkY, enaLabel, { w = enaW, h = bulkH })
self.modDisableAllRect =
self:_chipButton(disX, bulkY, disLabel, { w = disW, h = bulkH })
end
end
local top = y + headerH + 14 * s local top = y + headerH + 14 * s
-- notice line: the last install/delete result, else the platform hint -- notice line: the last install/delete result, else the platform hint
@@ -4518,11 +4751,13 @@ end
-- would make the launcher's choice look like an endorsement. -- would make the launcher's choice look like an endorsement.
function RomImporter:_promptAddIndex() function RomImporter:_promptAddIndex()
self._indexPrompt = { text = "" } self._indexPrompt = { text = "" }
self:_armTextInput()
end end
function RomImporter:_commitAddIndex() function RomImporter:_commitAddIndex()
local prompt = self._indexPrompt local prompt = self._indexPrompt
self._indexPrompt = nil self._indexPrompt = nil
self:_disarmTextInput()
if not prompt then return end if not prompt then return end
local ModIndex = require("src.mods.ModIndex") local ModIndex = require("src.mods.ModIndex")
local row, err = ModIndex.addSource(prompt.text or "") local row, err = ModIndex.addSource(prompt.text or "")
+74 -6
View File
@@ -40,7 +40,8 @@ function Handshake.mods(game)
if not ok or not status then return mods end if not ok or not status then return mods end
for _, manifest in ipairs(status.loaded or {}) do for _, manifest in ipairs(status.loaded or {}) do
mods[#mods + 1] = { id = manifest.id, version = manifest.version, mods[#mods + 1] = { id = manifest.id, version = manifest.version,
affectsLink = manifest.affects_link ~= false } affectsLink = manifest.affects_link ~= false,
language = manifest.language == true }
end end
table.sort(mods, function(a, b) return tostring(a.id) < tostring(b.id) end) table.sort(mods, function(a, b) return tostring(a.id) < tostring(b.id) end)
return mods return mods
@@ -67,15 +68,82 @@ function Handshake.linkModified(game)
return false return false
end end
-- #501: the registries a declared translation may write, and nothing else.
-- `text` is the ROM's dialogue, `strings` the engine's own authored text
-- (src/mods/Schemas.lua R.text / R.strings) and `font` the glyphs a
-- language with accents needs. None of the three is hashed into the
-- fingerprint (src/link/Fingerprint.lua header), so two peers reading the
-- same game in different languages stay in lockstep -- which is also what
-- the cable did: TradeCenter_PrintPartyListNames (pokered
-- engine/link/cable_club.asm) names the peer's party out of the local
-- ROM's table, only the trainer name and the party bytes travel.
-- text_pointers is deliberately out: its rows carry mart inventories and
-- nurse/pc flags, which are gameplay, not language.
local LANGUAGE_REGISTRIES = { text = true, strings = true, font = true }
Handshake.LANGUAGE_REGISTRIES = LANGUAGE_REGISTRIES
-- The manifest's `language = true` is the author's claim; this is the
-- check. A mod counts as a translation only if every op it appended
-- landed in a language registry, it subscribed no code (a hook or listener
-- runs inside the battle the two peers are lockstepping) and it asked for
-- no permission. Online play meets strangers, so nothing here may rest on
-- the manifest alone. A patched client can still lie about its own mods --
-- the fingerprint, not this, is what keeps the shared simulation honest;
-- this gate is what keeps an honest install from being told to turn its
-- language off.
local function translationOnly(mod, id)
local record = mod.mods and mod.mods[id]
local manifest = record and record.manifest
if manifest and #(manifest.permissions or {}) > 0 then return false end
for name, registry in pairs(mod.content or {}) do
if not LANGUAGE_REGISTRIES[name] then
for _, list in pairs(registry.ops or {}) do
for _, entry in ipairs(list) do
if entry.owner == id then return false end
end
end
end
end
for _, chain in pairs((mod.hooks and mod.hooks.chains) or {}) do
for _, entry in ipairs(chain) do
if entry.owner == id then return false end
end
end
for _, list in pairs((mod.events and mod.events.listeners) or {}) do
for _, entry in ipairs(list) do
if entry.owner == id then return false end
end
end
return true
end
-- the enabled mods that keep this install out of online play, in the
-- id order Handshake.mods sorts: everything except verified translations.
-- LinkState:offerVanillaRestart names these and switches off exactly these.
function Handshake.onlineBlockers(game)
local mod = loader(game)
local blockers = {}
for _, entry in ipairs(Handshake.mods(game)) do
local allowed = entry.language and not entry.affectsLink
and mod ~= nil and translationOnly(mod, entry.id)
if not allowed then blockers[#blockers + 1] = entry end
end
return blockers
end
-- online play (the relay-based online match / tournament flows in -- online play (the relay-based online match / tournament flows in
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it -- LinkState/Tournament) meets strangers, not a coordinating friend, so it
-- skips the LAN path's per-peer compatibility negotiation entirely and -- skips the LAN path's per-peer compatibility negotiation entirely and
-- just requires vanilla on both ends: no mod-added Pokemon, no surprises. -- just requires a vanilla simulation on both ends: no mod-added Pokemon, no
-- Mods only ever get baked in at boot (Loader:load), so this is a gate on -- surprises. #501 carves out translations, because a language is not a
-- attempting to go online, not a live mod toggle -- the player disables -- simulation: a mod that only rewrites text is invisible to the wire and
-- mods via the mod manager and relaunches. -- may stay on, so an English player and a Spanish one can meet the way two
-- regional carts always could. 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) function Handshake.onlineAllowed(game)
return #Handshake.mods(game) == 0 return #Handshake.onlineBlockers(game) == 0
end end
-- mode is nil on the guest: it pairs and announces itself before the host -- mode is nil on the guest: it pairs and announces itself before the host
+9 -7
View File
@@ -110,17 +110,19 @@ function LinkState:exitWith(message, reason)
end end
end end
-- Online play meets strangers, so it requires vanilla on both ends -- Online play meets strangers, so it requires a vanilla simulation on both
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at -- ends (Handshake.onlineAllowed). Mods merge into the shared Data
-- boot and there is no unmerge, so switching them off has to go through a -- registries at boot and there is no unmerge, so switching them off has to
-- relaunch -- but the player should not have to go find the mod manager and -- go through a relaunch -- but the player should not have to go find the
-- work out which mods count. This turns every enabled mod off, records -- mod manager and work out which mods count. This turns the blocking mods
-- them so the mod manager can put them back, and relaunches. The restart -- off, records them so the mod manager can put them back, and relaunches.
-- Verified translations are not blockers (#501), so a player keeps their
-- language across the restart and only the gameplay mods go. The restart
-- is confirmed rather than silent: it drops unsaved progress. -- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart() function LinkState:offerVanillaRestart()
local game = self.game local game = self.game
local loader = game.mods local loader = game.mods
local mods = Handshake.mods(game) local mods = Handshake.onlineBlockers(game)
local names = {} local names = {}
for i, mod in ipairs(mods) do for i, mod in ipairs(mods) do
if i > 2 then break end if i > 2 then break end
+15
View File
@@ -265,6 +265,21 @@ function LauncherMods.setEnabled(id, enabled)
return true return true
end end
-- setAllEnabled(ids, enabled): the launcher's Enable all / Disable all buttons
-- (#647). Writes exactly the options.mods shape setEnabled does, but loads and
-- saves once for the whole list: saveOptions rewrites the whole options file per
-- call, so looping setEnabled over a big mods folder is one disk write per mod
-- and leaves a half-applied state behind if one of them fails.
function LauncherMods.setAllEnabled(ids, enabled)
local options = SaveData.loadOptions()
options.mods = options.mods or {}
for _, id in ipairs(ids or {}) do
options.mods[id] = enabled and true or false
end
SaveData.saveOptions(options)
return true
end
-- ------- install (love.filesystem) -- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like -- Read a .zip source into bytes. A string is an external absolute path (like
+76 -8
View File
@@ -11,6 +11,7 @@ local Version = require("src.core.Version")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local ModProfile = require("src.mods.ModProfile")
local ManagerState = {} local ManagerState = {}
ManagerState.__index = ManagerState ManagerState.__index = ManagerState
@@ -180,6 +181,16 @@ end
function ManagerState:enter() function ManagerState:enter()
self:refresh() self:refresh()
-- #593: the setup that existed before profiles shipped becomes PROFILE 1
-- the first time the manager opens, so switching away from it is always a
-- round trip. Seeded here rather than at boot because profiles are only
-- ever read on this screen.
local seeded = ModProfile.ensureFirst(self:optionsTable(),
self.status.available, self:modOptionsTable())
if seeded then
self:persistOptions()
self:refresh()
end
if Runtime.safeMode then if Runtime.safeMode then
self.banner = "SAFE MODE - ALL MODS OFF" self.banner = "SAFE MODE - ALL MODS OFF"
end end
@@ -213,6 +224,12 @@ function ManagerState:optionsTable()
return (save and save.options) or {} return (save and save.options) or {}
end end
-- Per-mod option values as persisted (options.modOptions; setOption below is
-- the only writer). A profile captures this alongside the enable set.
function ManagerState:modOptionsTable()
return self:optionsTable().modOptions or {}
end
function ManagerState:manifestMap() function ManagerState:manifestMap()
return self.byId or {} return self.byId or {}
end end
@@ -283,6 +300,10 @@ function ManagerState:profileRows()
glyph = opts.activeProfile == p.name and GLYPH.errored or " " } glyph = opts.activeProfile == p.name and GLYPH.errored or " " }
end end
rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") } rows[#rows + 1] = { saveAs = true, label = Strings("SAVE CURRENT AS..") }
-- #593 sharing: EXPORT writes the focused profile to profiles/*.g1rmodlist
-- in the save dir, IMPORT reads back every file dropped in that folder.
rows[#rows + 1] = { exportProfile = true, label = Strings("EXPORT..") }
rows[#rows + 1] = { importProfile = true, label = Strings("IMPORT..") }
rows[#rows + 1] = { adhoc = true, rows[#rows + 1] = { adhoc = true,
label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" } label = opts.activeProfile and "[AD-HOC]" or "[AD-HOC] (LIVE)" }
return rows return rows
@@ -478,6 +499,10 @@ function ManagerState:activate()
self:applyProfile(row.profile) self:applyProfile(row.profile)
elseif row.saveAs then elseif row.saveAs then
self:saveCurrentAs() self:saveCurrentAs()
elseif row.exportProfile then
self:exportActiveProfile()
elseif row.importProfile then
self:importProfiles()
elseif row.adhoc then elseif row.adhoc then
self:optionsTable().activeProfile = nil self:optionsTable().activeProfile = nil
self:notify("AD-HOC SET ACTIVE") self:notify("AD-HOC SET ACTIVE")
@@ -712,9 +737,26 @@ function ManagerState:applyProfile(p)
end end
end end
self:commitToggle(combined) self:commitToggle(combined)
-- #593: a profile is the whole setup, not just the enable set. Options go
-- through setOption so loader.modOptions and mod.options_changed stay in
-- step; save slots move only to slots that version already registered
-- (SaveData.setActiveSlot would otherwise conjure one).
for modId, bucket in pairs(p.options or {}) do
if self.byId[modId] then
for key, value in pairs(bucket) do self:setOption(modId, key, value) end
end
end
for _, move in ipairs(ModProfile.slotMoves(p)) do
require("src.core.SaveData").setActiveSlot(move[1], move[2])
end
self:optionsTable().activeProfile = p.name self:optionsTable().activeProfile = p.name
self:persistOptions() self:persistOptions()
self:notify("PROFILE STAGED") local missing = ModProfile.missingIds(p, self.byId)
if #missing > 0 then
self:notify(#missing .. " MODS MISSING")
else
self:notify("PROFILE STAGED")
end
end end
function ManagerState:saveCurrentAs() function ManagerState:saveCurrentAs()
@@ -725,16 +767,15 @@ function ManagerState:saveCurrentAs()
onDone = function(name) onDone = function(name)
local opts = self:optionsTable() local opts = self:optionsTable()
opts.modProfiles = opts.modProfiles or {} opts.modProfiles = opts.modProfiles or {}
local enabled = {} local snap = ModProfile.capture(self.status.available,
for _, m in ipairs(self.status.available or {}) do self:modOptionsTable())
enabled[m.id] = m.enabled and true or false
end
local existing = self:findProfile(name) local existing = self:findProfile(name)
if existing then if existing then
existing.enabled = enabled existing.enabled, existing.options, existing.slots =
snap.enabled, snap.options, snap.slots
else else
opts.modProfiles[#opts.modProfiles + 1] = snap.name = name
{ name = name, enabled = enabled } opts.modProfiles[#opts.modProfiles + 1] = snap
end end
opts.activeProfile = name opts.activeProfile = name
self:persistOptions() self:persistOptions()
@@ -743,6 +784,33 @@ function ManagerState:saveCurrentAs()
})) }))
end end
-- #593 sharing. Export drops the active profile (or the focused one) in the
-- save directory's profiles/ folder as <NAME>.g1rmodlist; import reads every
-- file already there. Deliberately folder-based rather than a native file
-- picker: the same shape SaveFileIO uses for exports/, and it works on the
-- mobile builds where no picker exists.
function ManagerState:exportActiveProfile()
local row = self:focusedRow()
local p = (row and row.profile)
or self:findProfile(self:optionsTable().activeProfile)
if not p then return self:notify("NO PROFILE") end
local ok = ModProfile.export(p)
self:notify(ok and ("SAVED " .. p.name) or "EXPORT FAILED")
end
function ManagerState:importProfiles()
local opts = self:optionsTable()
opts.modProfiles = opts.modProfiles or {}
local n = 0
for _, path in ipairs(ModProfile.files()) do
if ModProfile.import(path, opts.modProfiles) then n = n + 1 end
end
if n == 0 then return self:notify("NO MODLISTS") end
self:persistOptions()
self:snapCursor()
self:notify(n .. " IMPORTED")
end
function ManagerState:renameProfile(p) function ManagerState:renameProfile(p)
local NamingScreen = require("src.ui.NamingScreen") local NamingScreen = require("src.ui.NamingScreen")
self.game.stack:push(NamingScreen.new(self.game, { self.game.stack:push(NamingScreen.new(self.game, {
+65 -2
View File
@@ -86,8 +86,58 @@ local function mergeConflictLists(conflicts, incompatible)
return out return out
end end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
-- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card.
local function scrubUtf8(s)
if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s
while i <= n do
local b = s:byte(i)
local len
if b < 0x80 then len = 1
elseif b >= 0xC2 and b <= 0xDF then len = 2
elseif b >= 0xE0 and b <= 0xEF then len = 3
elseif b >= 0xF0 and b <= 0xF4 then len = 4
end
local ok = len ~= nil and i + len - 1 <= n
if ok and len > 1 then
for j = i + 1, i + len - 1 do
local c = s:byte(j)
if c < 0x80 or c > 0xBF then ok = false; break end
end
if ok then
-- boundary lead bytes narrow their second byte: no overlongs
-- (E0/F0), no surrogates (ED), nothing past U+10FFFF (F4)
local b2 = s:byte(i + 1)
if (b == 0xE0 and b2 < 0xA0) or (b == 0xED and b2 > 0x9F)
or (b == 0xF0 and b2 < 0x90) or (b == 0xF4 and b2 > 0x8F) then
ok = false
end
end
end
if ok then
out[#out + 1] = s:sub(i, i + len - 1)
i = i + len
else
i = i + 1
end
end
return table.concat(out)
end
function Manifest.validate(raw, path) function Manifest.validate(raw, path)
assert(type(raw) == "table", "manifest must be an object") assert(type(raw) == "table", "manifest must be an object")
-- scrubbed in place so every later reader agrees, including the launcher's
-- badge derivation, which reads raw.category rather than the validated copy
raw.name = scrubUtf8(raw.name)
raw.version = scrubUtf8(raw.version)
raw.description = scrubUtf8(raw.description)
raw.category = scrubUtf8(raw.category)
assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"), assert(type(raw.id) == "string" and raw.id:match("^[%w_%-]+$"),
"manifest id must contain only letters, numbers, _ or -") "manifest id must contain only letters, numbers, _ or -")
assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required") assert(type(raw.name) == "string" and raw.name ~= "", "manifest name is required")
@@ -128,9 +178,21 @@ function Manifest.validate(raw, path)
"experimental must be a boolean") "experimental must be a boolean")
local experimental = raw.experimental == true local experimental = raw.experimental == true
-- #501: a translation declares itself here. Neither the ROM's dialogue
-- nor the engine's own strings are hashed into the link surface
-- (src/link/Fingerprint.lua header), so an English install and a Spanish
-- one run the same lockstep simulation, exactly as two regional carts on
-- a real cable did. The flag is only the author's claim;
-- Handshake.onlineBlockers checks it against the ops the mod actually
-- appended before online play trusts it.
assert(raw.language == nil or type(raw.language) == "boolean",
"language must be a boolean")
local language = raw.language == true
-- overhauls and total conversions are assumed to move the link -- overhauls and total conversions are assumed to move the link
-- fingerprint unless the manifest says otherwise; content packs are not -- fingerprint unless the manifest says otherwise; content packs and
local affectsLink = profile ~= "content" -- declared translations are not
local affectsLink = profile ~= "content" and not language
if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end if type(raw.affects_link) == "boolean" then affectsLink = raw.affects_link end
local function optionalFile(value, field) local function optionalFile(value, field)
@@ -161,6 +223,7 @@ function Manifest.validate(raw, path)
github = github, github = github,
experimental = experimental, experimental = experimental,
profile = profile, profile = profile,
language = language,
affects_link = affectsLink, affects_link = affectsLink,
permissions = permissions, permissions = permissions,
permissionSet = permissionSet, permissionSet = permissionSet,
+8 -25
View File
@@ -520,36 +520,19 @@ function ModIndex.writeCache(feed, index)
return ok return ok
end end
-- ------- host I/O (curl, via ModUpdate's shell plumbing) -- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
local function shq(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
-- Plain GET returning the body. No GitHub Accept header: the feed and the -- Plain GET returning the body. No GitHub Accept header: the feed and the
-- description markdown are static files on Pages, and the public feed is -- description markdown are static files on Pages, and the public feed is
-- explicitly unauthenticated. -- explicitly unauthenticated. The transport itself lives in
-- src/core/HostShell.lua because Android has no curl and has to fetch through
-- love.system.httpDownload instead (#597).
function ModIndex.httpGet(url) function ModIndex.httpGet(url)
local ModUpdate = require("src.mods.ModUpdate")
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell") local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " if not HostShell.canFetch() then
.. "-H " .. shq("User-Agent: gen1recomp-mod-index") .. " " return nil, "no network transport on this platform"
.. shq(url) end
local pipeOk, pipe = pcall(HostShell.popen, cmd) return HostShell.httpGet(url, "gen1recomp-mod-index")
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
end end
-- fetch(source, opts) -> index, err, meta -- fetch(source, opts) -> index, err, meta
+206
View File
@@ -0,0 +1,206 @@
-- Mod profiles (#593): a named snapshot of one whole setup -- which mods are
-- enabled, each mod's own options, and which save slot every game version
-- plays -- plus the shareable .g1rmodlist file the manager writes and reads.
-- Pure data and filesystem: src/mods/ManagerState.lua owns the UI and the
-- staged toggles, and is the only caller.
--
-- Records live in options.modProfiles (src/core/SaveData.lua defaultOptions),
-- so a profile survives New Game exactly like the mod enable-state does:
-- { name = "PROFILE 1",
-- enabled = { [modId] = true|false }, -- missing means enabled
-- options = { [modId] = { key = value } },-- mirrors options.modOptions
-- slots = { red = "slot1", blue = "slot2", yellow = nil } }
-- Exports are SaveSerializer-encoded (data-only parse, an imported file can
-- never execute code) under profiles/<NAME>.g1rmodlist in the save dir, the
-- same shape SaveFileIO uses for exports/ (src/import/SaveFileIO.lua).
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local ModProfile = {}
ModProfile.EXT = ".g1rmodlist"
ModProfile.DIR = "profiles"
ModProfile.FORMAT = "g1rmodlist"
ModProfile.FORMAT_VERSION = 1
-- deterministic order; GameVersion.VERSIONS is a map, and a profile file has
-- to encode the same way twice for a diff to mean anything
local VERSION_ORDER = { "red", "blue", "yellow" }
local function fsOr(fs)
return fs or (love and love.filesystem) or nil
end
-- Capture the live setup. `available` is ManagerState.status.available (the
-- loader's status manifests, m.enabled = the desired set including staged
-- flips); `modOptions` is options.modOptions.
function ModProfile.capture(available, modOptions)
local enabled, options = {}, {}
for _, m in ipairs(available or {}) do
enabled[m.id] = m.enabled and true or false
local bucket = modOptions and modOptions[m.id]
if type(bucket) == "table" then
local copy = {}
for k, v in pairs(bucket) do
local t = type(v)
if t == "string" or t == "number" or t == "boolean" then copy[k] = v end
end
options[m.id] = copy
end
end
local slots = {}
for _, id in ipairs(VERSION_ORDER) do
local ok, slot = pcall(SaveData.activeSlot, id)
if ok and slot then slots[id] = slot end
end
return { enabled = enabled, options = options, slots = slots }
end
-- The slot moves a profile may actually make: a slot id has to be registered
-- for that version already, because SaveData.setActiveSlot registers an
-- unknown id and would conjure a phantom slot the player never made.
function ModProfile.slotMoves(p)
local moves = {}
for _, version in ipairs(VERSION_ORDER) do
local want = p.slots and p.slots[version]
if want then
local ok, list = pcall(SaveData.listSlots, version)
if ok then
for _, row in ipairs(list or {}) do
if row.id == want then moves[#moves + 1] = { version, want } end
end
end
end
end
return moves
end
-- ids the profile wants enabled that are not installed here. Import cannot
-- fetch them (community-index install is deferred), so the manager reports
-- them rather than silently playing a different setup than the sharer's.
function ModProfile.missingIds(p, byId)
local out = {}
for id, on in pairs(p.enabled or {}) do
if on and not (byId or {})[id] then out[#out + 1] = id end
end
table.sort(out)
return out
end
function ModProfile.encode(p)
return SaveSerializer.encode({
format = ModProfile.FORMAT,
formatVersion = ModProfile.FORMAT_VERSION,
profile = { name = p.name, enabled = p.enabled,
options = p.options, slots = p.slots },
})
end
-- Shape-validate rather than trust: a shared file is untrusted input, and the
-- manager indexes p.enabled/p.options straight into its row models.
function ModProfile.decode(body)
if type(body) ~= "string" then return nil, "EMPTY FILE" end
local ok, data = pcall(SaveSerializer.decode, body)
if not ok or type(data) ~= "table" then return nil, "BAD FILE" end
if data.format ~= ModProfile.FORMAT then return nil, "NOT A MODLIST" end
local raw = data.profile
if type(raw) ~= "table" or type(raw.name) ~= "string" or raw.name == "" then
return nil, "BAD FILE"
end
local p = { name = raw.name:sub(1, 10), enabled = {}, options = {}, slots = {} }
for id, on in pairs(type(raw.enabled) == "table" and raw.enabled or {}) do
if type(id) == "string" then p.enabled[id] = on and true or false end
end
for id, bucket in pairs(type(raw.options) == "table" and raw.options or {}) do
if type(id) == "string" and type(bucket) == "table" then
local copy = {}
for k, v in pairs(bucket) do
local t = type(v)
if type(k) == "string" and (t == "string" or t == "number" or t == "boolean") then
copy[k] = v
end
end
p.options[id] = copy
end
end
for version, slot in pairs(type(raw.slots) == "table" and raw.slots or {}) do
if GameVersion.VERSIONS[version] and type(slot) == "string" then
p.slots[version] = slot
end
end
return p
end
-- profiles/<NAME>.g1rmodlist; letters, digits and dashes only so a shared
-- name can never escape the folder
local function fileFor(name)
return ModProfile.DIR .. "/" ..
tostring(name):upper():gsub("[^%w%-]", "_") .. ModProfile.EXT
end
ModProfile.fileFor = fileFor
function ModProfile.export(p, fs)
fs = fsOr(fs)
if not (fs and fs.write) then return false, "NO FILESYSTEM" end
if fs.createDirectory then fs.createDirectory(ModProfile.DIR) end
local rel = fileFor(p.name)
local ok, err = fs.write(rel, ModProfile.encode(p))
if not ok then return false, tostring(err) end
local base = fs.getSaveDirectory and fs.getSaveDirectory() or ""
if base ~= "" then return true, base .. "/" .. rel end
return true, rel
end
-- every .g1rmodlist sitting in profiles/, sorted, as relative paths
function ModProfile.files(fs)
fs = fsOr(fs)
local out = {}
if not (fs and fs.getDirectoryItems) then return out end
if fs.getInfo and not fs.getInfo(ModProfile.DIR) then return out end
for _, name in ipairs(fs.getDirectoryItems(ModProfile.DIR) or {}) do
if name:sub(-#ModProfile.EXT) == ModProfile.EXT then
out[#out + 1] = ModProfile.DIR .. "/" .. name
end
end
table.sort(out)
return out
end
-- Read one export and land it in `profiles` (options.modProfiles) under a
-- non-colliding name, so importing twice never overwrites what the player
-- already tuned. Returns the stored record, or nil + a short reason.
function ModProfile.import(path, profiles, fs)
fs = fsOr(fs)
if not (fs and fs.read) then return nil, "NO FILESYSTEM" end
local p, err = ModProfile.decode(fs.read(path))
if not p then return nil, err end
local taken = {}
for _, existing in ipairs(profiles) do taken[existing.name] = true end
local name, n = p.name, 1
while taken[name] do
n = n + 1
name = (p.name:sub(1, 8) .. n)
end
p.name = name
profiles[#profiles + 1] = p
return p
end
-- First run after this update: the setup the player already has becomes
-- PROFILE 1, then they can add more (#593). Seeded once and remembered by
-- options.modProfilesSeeded, so deleting every profile does not resurrect it.
function ModProfile.ensureFirst(opts, available, modOptions)
if opts.modProfilesSeeded then return nil end
opts.modProfilesSeeded = true
opts.modProfiles = opts.modProfiles or {}
if #opts.modProfiles > 0 then return nil end
local p = ModProfile.capture(available, modOptions)
p.name = "PROFILE 1"
opts.modProfiles[1] = p
opts.activeProfile = p.name
return p
end
return ModProfile
+19 -39
View File
@@ -254,39 +254,21 @@ function ModUpdate.statusFor(installedVersion, releases)
return "current", best return "current", best
end end
-- ------- host I/O (curl) -- ------- host I/O (HostShell's transport: curl, or the Android JNI bridge)
local function shq(s) -- GitHub's API wants a User-Agent and answers the versioned Accept.
s = tostring(s) local function apiFetch(url)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
local function curlCapture(url)
local HostShell = require("src.core.HostShell") local HostShell = require("src.core.HostShell")
local cmd = "curl -fsSL --connect-timeout 10 --max-time 40 " return HostShell.httpGet(url, "gen1recomp-mod-updater",
.. "-H " .. shq("User-Agent: gen1recomp-mod-updater") .. " " "application/vnd.github+json")
.. "-H " .. shq("Accept: application/vnd.github+json") .. " "
.. shq(url)
local pipeOk, pipe = pcall(HostShell.popen, cmd)
if not pipeOk or not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "curl read failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from GitHub" end
return out
end end
-- Still just the curl probe it always was. "Can we fetch at all" is
-- HostShell.canFetch, which also knows about the Android bridge (#597); every
-- gate below asks that instead.
function ModUpdate.haveCurl() function ModUpdate.haveCurl()
local HostShell = require("src.core.HostShell") local HostShell = require("src.core.HostShell")
local pipeOk, pipe = pcall(HostShell.popen, "curl --version") return HostShell.haveCurl()
if not pipeOk or not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end end
-- Fetch release list. opts.force bypasses the 6h cache. -- Fetch release list. opts.force bypasses the 6h cache.
@@ -302,15 +284,16 @@ function ModUpdate.fetchReleases(repo, modId, opts)
return cached.releases, nil, { fromCache = true } return cached.releases, nil, { fromCache = true }
end end
end end
if not ModUpdate.haveCurl() then local HostShell = require("src.core.HostShell")
if not HostShell.canFetch() then
-- Stale cache is better than nothing when offline -- Stale cache is better than nothing when offline
local cached = ModUpdate.readCache(repo) local cached = ModUpdate.readCache(repo)
if cached and cached.releases then if cached and cached.releases then
return cached.releases, nil, { fromCache = true, stale = true } return cached.releases, nil, { fromCache = true, stale = true }
end end
return nil, "curl is not available on this platform" return nil, "no network transport on this platform"
end end
local body, curlErr = curlCapture(ModUpdate.apiReleasesUrl(repo)) local body, curlErr = apiFetch(ModUpdate.apiReleasesUrl(repo))
if not body then if not body then
local cached = ModUpdate.readCache(repo) local cached = ModUpdate.readCache(repo)
if cached and cached.releases then if cached and cached.releases then
@@ -331,10 +314,10 @@ function ModUpdate.downloadZip(url, destName)
if not (love and love.filesystem) then if not (love and love.filesystem) then
return nil, "download needs LOVE" return nil, "download needs LOVE"
end end
if not ModUpdate.haveCurl() then
return nil, "curl is not available on this platform"
end
local HostShell = require("src.core.HostShell") local HostShell = require("src.core.HostShell")
if not HostShell.canFetch() then
return nil, "no network transport on this platform"
end
local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip") local name = destName or ("mod_update_" .. tostring(os.time()) .. ".zip")
name = tostring(name):gsub("[/\\]", "_") name = tostring(name):gsub("[/\\]", "_")
local saveOk, saveDir = pcall(function() local saveOk, saveDir = pcall(function()
@@ -344,12 +327,9 @@ function ModUpdate.downloadZip(url, destName)
return nil, "no save directory" return nil, "no save directory"
end end
local abs = saveDir .. "/" .. name local abs = saveDir .. "/" .. name
local cmd = "curl -fsSL --connect-timeout 15 --max-time 300 -o " -- Either transport can fail quietly; the getInfo size check below is what
.. shq(abs) .. " " .. shq(url) -- actually decides whether we got a file (#597).
local pipeOk, pipe = pcall(HostShell.popen, cmd) HostShell.httpDownload(url, abs, "gen1recomp-mod-updater")
if not pipeOk or not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
local infoOk, info = pcall(love.filesystem.getInfo, name) local infoOk, info = pcall(love.filesystem.getInfo, name)
if not infoOk or not info or (info.size or 0) == 0 then if not infoOk or not info or (info.size or 0) == 0 then
pcall(love.filesystem.remove, name) pcall(love.filesystem.remove, name)
+6 -4
View File
@@ -1186,11 +1186,13 @@ R.field = {
nest = f.opt(f.any) }, nest = f.opt(f.any) },
flyOrder = f.list(f.str), flyOrder = f.list(f.str),
-- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle -- the player's own trainer art (FieldDefaults.PLAYER_PICS): the battle
-- back pic, the catch tutorial's old man, and the front pic the intro, -- back pic, the catch tutorial's old man, Yellow's PROF.OAK variant of
-- trainer card and Hall of Fame share. Every key is optional so a -- it (#557), and the front pic the intro, trainer card and Hall of Fame
-- conversion can replace one pic and inherit the rest. -- share. Every key is optional so a conversion can replace one pic and
-- inherit the rest.
playerPics = f.rec{ playerPics = f.rec{
back = f.opt(f.str), demoBack = f.opt(f.str), front = f.opt(f.str) }, back = f.opt(f.str), demoBack = f.opt(f.str),
oakBack = f.opt(f.str), front = f.opt(f.str) },
-- the new-game and boot config a total conversion replaces -- the new-game and boot config a total conversion replaces
boot = f.rec{ boot = f.rec{
startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)), startMap = f.opt(f.str), startX = f.opt(f.int(0)), startY = f.opt(f.int(0)),
+13 -1
View File
@@ -46,20 +46,32 @@ end
-- opts.kind: "battle" | "intro" | "trainer_card" | "hof" -- opts.kind: "battle" | "intro" | "trainer_card" | "hof"
-- opts.demo: the catch tutorial, where the old man fights in the player's -- opts.demo: the catch tutorial, where the old man fights in the player's
-- place and stands in for the back pic -- place and stands in for the back pic
-- opts.oakDemo: the Yellow variant of that demo (BATTLE_TYPE_PIKACHU), where
-- PROF.OAK fights in the player's place behind his own back pic
-- opts.battle: the live battle, for kind == "battle" -- opts.battle: the live battle, for kind == "battle"
-- Returns path, trueColor. -- Returns path, trueColor.
function Sprites.playerPath(data, side, opts) function Sprites.playerPath(data, side, opts)
opts = opts or {} opts = opts or {}
side = side == "back" and "back" or "front" side = side == "back" and "back" or "front"
-- one key per pic, so a conversion can replace the back and inherit the -- one key per pic, so a conversion can replace the back and inherit the
-- rest; fieldValue folds data.field over FieldDefaults per key -- rest; fieldValue folds data.field over FieldDefaults per key. The two
-- demo keys mirror LoadPlayerBackPic's wBattleType branch (#557).
local key = side == "front" and "front" local key = side == "front" and "front"
or (opts.oakDemo and "oakBack")
or (opts.demo and "demoBack" or "back") or (opts.demo and "demoBack" or "back")
local path = FieldDefaults.fieldValue(data, "playerPics", key) local path = FieldDefaults.fieldValue(data, "playerPics", key)
-- ProfOakPicBack is a Yellow-only rip, so a cache imported before it
-- existed has no file there; fall back to the old man rather than hand a
-- missing path to getImage (#557)
if key == "oakBack" and path
and not require("src.render.Assets").exists(path) then
path = FieldDefaults.fieldValue(data, "playerPics", "demoBack")
end
local ctx = { local ctx = {
side = side, side = side,
kind = opts.kind or "battle", kind = opts.kind or "battle",
demo = opts.demo and true or false, demo = opts.demo and true or false,
oakDemo = opts.oakDemo and true or false,
battle = opts.battle, battle = opts.battle,
trueColor = false, trueColor = false,
data = data, data = data,
+158 -24
View File
@@ -32,10 +32,23 @@ local FLASH_CYCLES = 3
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and -- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between -- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port -- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
-- has no load to hide behind, so the hold has to be explicit (#315). 30 is a -- has no load to hide behind, so the hold has to be explicit (#315). This is
-- frame budget for that work, not a number pokered states; retune this one -- a frame budget for that work, not a number pokered states.
-- constant against a reference recording if it reads long or short. --
local BLACK_HOLD = 30 -- 60 comes from pokered-c (BTRANS_BLACK_HOLD_FRAMES), which itemized the
-- derivable floor at ~13 frames -- LoadHpBarAndStatusTilePatterns 4,
-- LoadHudTilePatterns 2, ClearScreen's Delay3 3, the DisableLCD LY wait 1,
-- Delay3 after EnableLCD 3 -- and then noted that the two sprite
-- decompressors (UncompressSpriteFromDE for the 7x7 front pic,
-- LoadPlayerBackPic's uncompress + ScaleSpriteByTwo) are bit-level RLE/delta
-- decoders whose cost cannot be cycle-counted from the asm at all. So the
-- derivation bottoms out around 25-30 with an unbounded remainder, and 60
-- was set by ear against the real ROM ("a solid second") and confirmed as
-- ~95% right rather than frame-matched. The credible range is 30-60; do NOT
-- "correct" this down toward the floor on the strength of the derivation,
-- because the omitted decompressors are exactly the unbounded part. It
-- wants a frame-by-frame capture against hardware to pin exactly.
local BLACK_HOLD = 60
local TILE = 8 local TILE = 8
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
@@ -106,11 +119,12 @@ end
-- HalfCircle2 continues (1,11) down under the bottom back to (18,11)). -- HalfCircle2 continues (1,11) down under the bottom back to (18,11)).
-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves -- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves
-- at once, so opposite arms) -- at once, so opposite arms)
local function sweepOrder(arms) local function sweepOrder(arms, cols, rows)
local cx, cy = COLS / 2, ROWS / 2 cols, rows = cols or COLS, rows or ROWS
local cx, cy = cols / 2, rows / 2
local tiles = {} local tiles = {}
for y = 0, ROWS - 1 do for y = 0, rows - 1 do
for x = 0, COLS - 1 do for x = 0, cols - 1 do
local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx) local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx)
if a < 0 then a = a + 2 * math.pi end if a < 0 then a = a + 2 * math.pi end
if arms == 2 then a = a % math.pi end if arms == 2 then a = a % math.pi end
@@ -121,18 +135,116 @@ local function sweepOrder(arms)
return tiles return tiles
end end
-- ---------------------------------------------------------------------
-- Arbitrary-grid wipes, for the surface OUTSIDE the classic letterbox
-- ---------------------------------------------------------------------
--
-- The builders above reproduce the ROM's exact walks on its 20x18 tilemap,
-- overrun and all, and stay the authority inside the 160x144 box. A zoomed
-- or windowed surface has more grid than the Game Boy ever had, and no
-- hardware behaviour to be faithful to out there -- but filling it with a
-- generic square cascade made a spiral read as "a spiral in a box, with
-- something else happening around it". These generalise the same shapes to
-- whatever grid the window works out to so the whole surface wipes as one
-- figure.
-- perimeter inward, counterclockwise, starting down the left edge -- the
-- direction BattleTransition_InwardSpiral walks
local function spiralInGrid(cols, rows)
local order = {}
local x0, y0, x1, y1 = 0, 0, cols - 1, rows - 1
while x0 <= x1 and y0 <= y1 do
for y = y0, y1 do order[#order + 1] = { x0, y } end
x0 = x0 + 1
if x0 > x1 then break end
for x = x0, x1 do order[#order + 1] = { x, y1 } end
y1 = y1 - 1
if y0 > y1 then break end
for y = y1, y0, -1 do order[#order + 1] = { x1, y } end
x1 = x1 - 1
if x0 > x1 then break end
for x = x1, x0, -1 do order[#order + 1] = { x, y0 } end
y0 = y0 + 1
end
return order
end
-- the outward spiral is the same walk read from the middle out
local function spiralOutGrid(cols, rows)
local inward = spiralInGrid(cols, rows)
local order = {}
for i = #inward, 1, -1 do order[#order + 1] = inward[i] end
return order
end
local GRID_BUILDERS = {
spiralin = spiralInGrid,
spiralout = spiralOutGrid,
circle = function(c, r) return sweepOrder(1, c, r) end,
doublecircle = function(c, r) return sweepOrder(2, c, r) end,
}
-- Tile order for `style` on an arbitrary cols x rows grid, or nil for the
-- styles whose shape is plain geometry (stripes / shrink / split) and which
-- the caller extends with rectangles instead. Cached per style+size: the
-- window grid only changes on a resize or a zoom step.
local orderFor -- defined below; the authentic 20x18 builders
local gridCache = {}
function BattleTransition.gridOrder(style, cols, rows)
local build = GRID_BUILDERS[style]
if not build or cols < 1 or rows < 1 then return nil end
-- At exactly the Game Boy's grid the ROM's own walk is the answer, overrun
-- and all -- so an unzoomed window is the classic wipe, not a lookalike.
if cols == COLS and rows == ROWS then return orderFor(style, nil) end
local key = style .. ":" .. cols .. "x" .. rows
local hit = gridCache[key]
if hit == nil then
hit = build(cols, rows) or false
gridCache[key] = hit
end
return hit or nil
end
-- Wipe lengths, taken from pokered-c's battle_transition.c frame budget --
-- derived from battle_transitions.asm and then checked on a live
-- side-by-side against the ROM. Each wipe is `steps x frames-per-step`:
--
-- DoubleCircle 10 x 3 = 30 SpiralOut 360 fills / 3 per frame = 120
-- Circle 20 x 3 = 60 HStripes 20 x 3 = 60
-- Shrink 9 x 6 = 54 VStripes 18 x 3 = 54
-- Split 9 x 6 = 54 (asm:386-392 and :418-424)
--
-- The port used a flat 40/24 for all eight, which ran every wipe between
-- 1.5x and 3x too fast -- the single biggest reason a battle used to open
-- so much more abruptly here than on hardware.
--
-- The inward spiral is the one that bites. It writes one tile per
-- iteration and calls BattleTransition_TransferDelay3 every seventh tile
-- (wInwardSpiralUpdateScreenCounter counts 7 down to 0), and that helper is
-- `ld a,1 / ldh [hAutoBGTransferEnabled] / call Delay3 / xor a / ldh [...]`
-- (battle_transitions.asm:619) -- THREE frames, not a one-frame transfer.
-- Reading it as one frame runs the whole spiral 3x too fast; pokered-c
-- caught that against the real ROM. Deriving the length from the path we
-- actually walk keeps the cadence right if the order ever changes.
local SPIRAL_IN_TILES_PER_STEP = 7
local SPIRAL_IN_STEP_FRAMES = 3 -- TransferDelay3
local SPIRAL_IN_FRAMES = math.ceil(#inwardSpiralOrder()
/ SPIRAL_IN_TILES_PER_STEP)
* SPIRAL_IN_STEP_FRAMES
-- The eight wipes as records: frames is the wipe length, flash marks the -- The eight wipes as records: frames is the wipe length, flash marks the
-- two circle wipes that call BattleTransition_FlashScreen first. new() -- two circle wipes that call BattleTransition_FlashScreen first. new()
-- reads them, and the transitions registry serves the same table. -- reads them, and the transitions registry serves the same table.
BattleTransition.STYLES = { BattleTransition.STYLES = {
doublecircle = { kind = "wipe", frames = 40, flash = true }, doublecircle = { kind = "wipe", frames = 30, flash = true },
spiralin = { kind = "wipe", frames = 40 }, spiralin = { kind = "wipe", frames = SPIRAL_IN_FRAMES },
circle = { kind = "wipe", frames = 40, flash = true }, circle = { kind = "wipe", frames = 60, flash = true },
spiralout = { kind = "wipe", frames = 40 }, spiralout = { kind = "wipe", frames = 120 },
hstripes = { kind = "wipe", frames = 24 }, hstripes = { kind = "wipe", frames = 60 },
shrink = { kind = "wipe", frames = 24 }, shrink = { kind = "wipe", frames = 54 },
vstripes = { kind = "wipe", frames = 24 }, vstripes = { kind = "wipe", frames = 54 },
split = { kind = "wipe", frames = 24 }, split = { kind = "wipe", frames = 54 },
} }
-- the eight wipes plus Transition's two warp fades: one registrant owns -- the eight wipes plus Transition's two warp fades: one registrant owns
@@ -164,7 +276,7 @@ local BUILTIN_ORDERS = {
-- A registered style may bring its own tile order (a list of {x, y}, or a -- A registered style may bring its own tile order (a list of {x, y}, or a
-- function returning one); the four built-in orders are the defaults for -- function returning one); the four built-in orders are the defaults for
-- the styles that have always had them. -- the styles that have always had them.
local function orderFor(style, def) function orderFor(style, def)
if ORDERS[style] == nil then if ORDERS[style] == nil then
local order = def and def.order local order = def and def.order
if type(order) == "function" then if type(order) == "function" then
@@ -235,6 +347,15 @@ function BattleTransition:draw()
local v = FLASH_STEPS[step] local v = FLASH_STEPS[step]
if v ~= 0 then if v ~= 0 then
local shade = v > 0 and 0 or 1 local shade = v > 0 and 0 or 1
-- The flash is a palette write (rBGP), so on hardware it tints every
-- pixel the LCD shows. Hand it to the renderer as a screen-space veil
-- so it covers the whole surface at any zoom; only the headless and
-- no-renderer paths fall back to filling the 160x144 box.
local r = self.game and self.game.renderer
if r then
r.screenVeil = { shade, math.abs(v) }
return
end
love.graphics.setColor(shade, shade, shade, math.abs(v)) love.graphics.setColor(shade, shade, shade, math.abs(v))
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -242,22 +363,35 @@ function BattleTransition:draw()
return return
end end
love.graphics.setColor(0, 0, 0, 1)
local prog = math.min(1, self.t / self.wipeLen) 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 local style = self.style
-- a registered style may draw itself; the eight built-ins do not -- a registered style may draw itself; the eight built-ins do not. A custom
-- draw owns the 160x144 UI canvas as it always has.
if self.def and self.def.draw then if self.def and self.def.draw then
love.graphics.setColor(0, 0, 0, 1)
self.def.draw(self, prog) self.def.draw(self, prog)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
return return
end end
-- With a renderer the wipe is drawn ONCE over the whole surface in screen
-- space (Renderer:drawBattleWipe), on a tile grid anchored to the letterbox
-- and extended outward at the same tile size. That makes it a single
-- continuous figure: the spiral starts at the outermost edge of the window
-- and works inward, instead of one spiral inside the letterbox running
-- alongside a second one outside it. Nothing is stretched -- the pattern is
-- continued with more tiles, not scaled-up pixels -- so at 1x the grid works
-- out to exactly 20x18 and this is the classic wipe unchanged.
local renderer = self.game and self.game.renderer
if renderer then
renderer.battleWipe = { style = style, prog = prog }
return
end
-- headless / no renderer: the classic 160x144 path
love.graphics.setColor(0, 0, 0, 1)
local order = orderFor(style, self.def) local order = orderFor(style, self.def)
if order then if order then
-- tile-order wipes: spiral / circle sweeps -- tile-order wipes: spiral / circle sweeps
+7 -4
View File
@@ -646,10 +646,13 @@ function PaletteFX.spriteObp(spriteDef, seed)
local src = spriteDef and (spriteDef.paletteSource or spriteDef.source) local src = spriteDef and (spriteDef.paletteSource or spriteDef.source)
if not (w and src) then return nil end if not (w and src) then return nil end
local idx = tonumber(src:match("%[(%d+)%]")) local idx = tonumber(src:match("%[(%d+)%]"))
-- RedBikeSprite loads outside SpriteSheetPointerTable -- RedBikeSprite and SurfingPikachuSprite load outside
-- (LoadBikePlayerSpriteGraphics), so its source carries no bracketed -- SpriteSheetPointerTable, so their source has no bracketed index;
-- index; it wears the player's own palette, same as SPRITE_RED -- they wear the player's OBP palette (spriteAssignment[0]).
if not idx and src:find("RedBikeSprite", 1, true) then idx = 0 end if not idx and (src:find("RedBikeSprite", 1, true)
or src:find("SurfingPikachuSprite", 1, true)) then
idx = 0
end
local group = idx and w.spriteAssignment[idx] local group = idx and w.spriteAssignment[idx]
if group == nil then return nil end if group == nil then return nil end
if group == "random" then if group == "random" then
+1 -1
View File
@@ -120,7 +120,7 @@ end
-- shape (love.graphics.newCanvas), so accept either and nothing else. -- shape (love.graphics.newCanvas), so accept either and nothing else.
local function isCanvas(v) local function isCanvas(v)
if type(v) == "userdata" then if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end end
if type(v) == "table" then if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function" return type(v.getWidth) == "function" and type(v.getHeight) == "function"
+351 -78
View File
@@ -32,7 +32,7 @@ Renderer.MAX_UI_HEIGHT = 576
-- would otherwise take the frame down with it. -- would otherwise take the frame down with it.
local function isCanvas(v) local function isCanvas(v)
if type(v) == "userdata" then if type(v) == "userdata" then
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true return type(v.getWidth) == "function" and type(v.getHeight) == "function"
end end
if type(v) == "table" then if type(v) == "table" then
return type(v.getWidth) == "function" and type(v.getHeight) == "function" return type(v.getWidth) == "function" and type(v.getHeight) == "function"
@@ -127,6 +127,44 @@ function Renderer:fitScale()
return math.max(1, math.floor(math.min(pw / w, ph / h))) return math.max(1, math.floor(math.min(pw / w, ph / h)))
end end
-- Integer framebuffer pixels per GB pixel for the UI pass.
--
-- Survey zoom only ever scaled the world: the UI kept blitting at fitScale,
-- so zooming out left a full-size dialogue box over a shrunken world, which
-- reads as the UI growing. Stepping the UI down with the zoom keeps the two
-- in proportion. Whole integers only, so a UI pixel stays a whole number of
-- screen pixels and the font does not resample; and never below half of
-- fitScale, because past that the text stops being readable.
--
-- Zooming IN does not scale the UI up -- a dialogue box larger than the
-- classic one has no reference to be faithful to, and the letterbox it sits
-- in does not grow either.
function Renderer:uiScale()
local S = self:fitScale()
local off = Zoom.offset or 0
-- Only follow the zoom when a world is actually behind the UI. Survey zoom
-- is an OVERWORLD control; the title screen, the intro and the credits show
-- no world at all, and shrinking them to match a zoom level the player set
-- for the map is meaningless. worldActive is this frame's answer --
-- beginFrame clears it and beginWorldPass sets it -- so a state that draws
-- no world keeps the full fit scale.
--
-- uiWorldHold (Game:draw) is the case worldActive cannot answer: a BATTLE
-- BG "world" battle stays the scene's backdrop while an OPAQUE state -- the
-- party menu, the bag -- covers it, whether or not a world pass ran under
-- that state. Reading worldActive alone drops the step-down there and
-- blits those menus a whole scale larger than the battle they cover.
-- Game.drawBaseInStack keeps the map drawing in the common case, so the two
-- normally agree; this holds the scale even when it cannot (a battle with
-- something other than the overworld beneath it).
if not (self.worldActive or self.uiWorldHold) then return S end
if off >= 0 then return S end
local floorS = math.ceil(S / 2) -- at most a 50% reduction
local s = S + off -- one integer step per zoom-out step
if s < floorS then s = floorS end
return math.max(1, s)
end
-- the native-pixel UI surface in use right now -- the native-pixel UI surface in use right now
function Renderer:uiSize() function Renderer:uiSize()
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
@@ -200,8 +238,14 @@ function Renderer:beginFrame(transparent)
-- warp-fade overlay from Transition (issue #121); cleared each frame so -- warp-fade overlay from Transition (issue #121); cleared each frame so
-- a popped transition cannot leave a sticky black veil -- a popped transition cannot leave a sticky black veil
self.worldFadeAlpha = nil self.worldFadeAlpha = nil
-- battle-transition cascade outside the 160x144 wipe (BattleTransition) -- battle-transition wipe, drawn over the whole surface (BattleTransition)
self.battleCascadeProg = nil self.battleWipe = nil
-- whole-surface veil in screen space (battle-transition flash, the
-- fade in from white after a battle) -- covers the window, not just the
-- 160x144 letterbox
self.screenVeil = nil
-- edge-anchored UI regions, re-declared by their elements each frame
self.uiAnchors = nil
-- last frame's trueColor rects and sprite redraws go before anything -- last frame's trueColor rects and sprite redraws go before anything
-- draws this one -- draws this one
PaletteFX.clearTrueColor() PaletteFX.clearTrueColor()
@@ -218,44 +262,94 @@ function Renderer:beginFrame(transparent)
end end
end end
-- Black 8x8 (scaled) blocks cascading outward from the classic GB letterbox -- The battle-transition wipe, drawn once over the WHOLE surface in screen
-- into the surrounding window, matching BattleTransition wipe progress. -- space rather than as a 160x144 wipe plus a separate fill around it.
-- Tiles that sit entirely inside the 160x144 square are left to the OG wipe. --
-- The tile grid is anchored on the letterbox and extended outward at the same
-- tile size, so the figure is continuous: a spiral begins at the outermost
-- edge of the window and works inward through the letterbox to the middle.
-- Nothing is stretched -- the pattern is continued with more tiles, not
-- scaled-up pixels -- and at 1x the grid works out to exactly 20x18, where
-- BattleTransition.gridOrder hands back the ROM's own walk, so an unzoomed
-- window is the classic wipe unchanged.
--
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces). -- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
function Renderer:drawBattleCascade(prog, ww, wh, ox, oy, vpw, vph, Sx, Sy) function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
if not prog or prog <= 0 then return end if not wipe or not wipe.prog or wipe.prog <= 0 then return end
Sy = Sy or Sx Sy = Sy or Sx
local TILE_W, TILE_H = 8 * Sx, 8 * Sy local TW, TH = 8 * Sx, 8 * Sy
if TILE_W < 1 then TILE_W = 1 end if TW < 1 then TW = 1 end
if TILE_H < 1 then TILE_H = 1 end if TH < 1 then TH = 1 end
local cols = math.ceil(ww / TILE_W) local prog = math.min(1, wipe.prog)
local rows = math.ceil(wh / TILE_H)
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_W, row * TILE_H
-- any tile with area outside the letterbox participates
if x < ox or y < oy or x + TILE_W > ox + vpw or y + TILE_H > oy + vph then
local dist = math.max(math.abs(x + TILE_W / 2 - cx),
math.abs(y + TILE_H / 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.setColor(0, 0, 0, 1)
love.graphics.setScissor(0, 0, ww, wh) love.graphics.setScissor(0, 0, ww, wh)
for i = 1, n do
local t = order[i] if prog >= 1 then
love.graphics.rectangle("fill", t[1], t[2], TILE_W, TILE_H) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- whole-tile padding out to each window edge, keeping the grid in phase
-- with the letterbox's tiles
local padL = math.max(0, math.ceil(ox / TW))
local padT = math.max(0, math.ceil(oy / TH))
local padR = math.max(0, math.ceil((ww - ox - vpw) / TW))
local padB = math.max(0, math.ceil((wh - oy - vph) / TH))
local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
local lbRows = math.max(1, math.floor(vph / TH + 0.5))
local cols, rows = padL + lbCols + padR, padT + lbRows + padB
local x0, y0 = ox - padL * TW, oy - padT * TH
-- required here rather than at the top: BattleTransition reaches the
-- renderer through game.renderer at draw time, and a module-level require
-- would put the two files in a load cycle
local BattleTransition = require("src.render.BattleTransition")
local order = BattleTransition.gridOrder(wipe.style, cols, rows)
if order then
local n = math.floor(#order * prog + 1e-6)
for i = 1, n do
local t = order[i]
love.graphics.rectangle("fill", x0 + t[1] * TW, y0 + t[2] * TH, TW, TH)
end
else
-- shrink / split / the stripes are geometry, not a walk: the same shapes
-- measured against the window instead of the letterbox
local style = wipe.style
if style == "hstripes" then
local w = ww * prog
for row = 0, rows - 1 do
local y = y0 + row * TH
if row % 2 == 0 then
love.graphics.rectangle("fill", 0, y, w, TH)
else
love.graphics.rectangle("fill", ww - w, y, w, TH)
end
end
elseif style == "vstripes" then
local h = wh * prog
for col = 0, cols - 1 do
local x = x0 + col * TW
if col % 2 == 0 then
love.graphics.rectangle("fill", x, 0, TW, h)
else
love.graphics.rectangle("fill", x, wh - h, TW, h)
end
end
elseif style == "shrink" then
local h, w = wh / 2 * prog, ww / 2 * prog
love.graphics.rectangle("fill", 0, 0, ww, h)
love.graphics.rectangle("fill", 0, wh - h, ww, h)
love.graphics.rectangle("fill", 0, 0, w, wh)
love.graphics.rectangle("fill", ww - w, 0, w, wh)
else -- split: a black cross growing out of the centre in both axes
local h, w = wh / 2 * prog, ww / 2 * prog
love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2)
love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh)
end
end end
love.graphics.setScissor() love.graphics.setScissor()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -474,6 +568,77 @@ local function withTrueColor(zoneList, pass)
return merged return merged
end end
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
-- render.compose mod can composite the world/UI canvases into its own layout.
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
if not shader then
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
end
-- Take a rect out of a list of rects, splitting each overlapped one into up
-- to four pieces. Used to vacate an anchored UI region from the letterbox
-- blit, so the element is drawn at its anchor and not also in place.
local function subtractRect(list, x, y, w, h)
local out = {}
local x2, y2 = x + w, y + h
for _, r in ipairs(list) do
local rx, ry, rw, rh = r[1], r[2], r[3], r[4]
local rx2, ry2 = rx + rw, ry + rh
if x2 <= rx or x >= rx2 or y2 <= ry or y >= ry2 then
out[#out + 1] = r -- disjoint
else
if ry < y then out[#out + 1] = { rx, ry, rw, y - ry } end
if y2 < ry2 then out[#out + 1] = { rx, y2, rw, ry2 - y2 } end
local ty, ty2 = math.max(ry, y), math.min(ry2, y2)
if rx < x then out[#out + 1] = { rx, ty, x - rx, ty2 - ty } end
if x2 < rx2 then out[#out + 1] = { x2, ty, rx2 - x2, ty2 - ty } end
end
end
return out
end
-- A UI element that should sit against a screen edge rather than inside the
-- centred letterbox. Declared during the element's own draw, in UI-canvas
-- pixels, and consumed by endFrame this frame only.
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
function Renderer:setUIAnchor(x, y, w, h, anchor)
-- uiAnchorHold (Game:draw): a state that composes its own screen -- a
-- battle -- keeps every element inside it, so the box blits where it was
-- drawn in the canvas instead of being pulled to the window edge.
if self.uiAnchorHold then return end
self.uiAnchors = self.uiAnchors or {}
self.uiAnchors[#self.uiAnchors + 1] =
{ x = x, y = y, w = w, h = h, anchor = anchor }
end
-- zones: optional list of SGB palette regions (see PaletteFX) in -- zones: optional list of SGB palette regions (see PaletteFX) in
-- 160x144 UI space, applied to the UI pass. worldZones: optional -- 160x144 UI space, applied to the UI pass. worldZones: optional
-- regions in world-canvas pixels (overworld survey zoom colors each -- regions in world-canvas pixels (overworld survey zoom colors each
@@ -494,6 +659,23 @@ function Renderer:endFrame(zones, worldZones)
-- Snap the letterbox origin to a framebuffer pixel, then convert to units. -- Snap the letterbox origin to a framebuffer pixel, then convert to units.
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
local oy = math.floor((ph - uih * Sp) / 2) / dpiY local oy = math.floor((ph - uih * Sp) / 2) / dpiY
-- The UI has its own scale: it steps down as the survey zoom goes out (see
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
-- are identical to Sp/ox/oy and every rect below is what it always was.
local Up = self:uiScale()
-- BATTLE SIZE "fill" (Renderer.uiFill, set per frame by Game:draw): scale
-- the surface to the window rather than to whole GB pixels, so the battle
-- fills vertically at any zoom or window size. Fractional by nature -- a
-- GB pixel stops being a whole number of screen pixels, which is the trade
-- the setting exists to offer. Clamped on the horizontal too, so a narrow
-- window scales to fit instead of overflowing off both sides.
if self.uiFill then
Up = math.min(ph / uih, pw / uiw)
end
local Ux, Uy = Up / dpiX, Up / dpiY
local uvpw, uvph = uiw * Ux, uih * Uy
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
-- Forced mono/Classic modes still need a whole-screen zone when a state -- Forced mono/Classic modes still need a whole-screen zone when a state
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap. -- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
@@ -506,6 +688,36 @@ function Renderer:endFrame(zones, worldZones)
zones = withTrueColor(zones, "ui") zones = withTrueColor(zones, "ui")
worldZones = withTrueColor(worldZones, "world") worldZones = withTrueColor(worldZones, "world")
-- render.compose: hand a mod the finished world + UI canvases (and their
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
-- stacked Game Boy screens, or driving one onto a second physical display.
-- The mod returns true to take over the whole window; anything else (or no
-- mod wrapping the hook) falls through to the normal composite below.
if Runtime.wantsHook("render.compose") then
local ctx = {
renderer = self,
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
worldOverride = self.worldOverride,
worldActive = self.worldActive and true or false,
zones = zones, worldZones = worldZones,
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
secondScreen = require("src.render.SecondScreen"),
}
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
self.worldActive = false
self.uprightActive = false
self.worldOverride = nil
PaletteFX.setPass(nil)
return {
width = ww, height = wh, gameX = ox, gameY = oy,
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
}
end
end
-- A post-process pipeline needs the whole composite in a canvas for the -- A post-process pipeline needs the whole composite in a canvas for the
-- same reason GBC FX does, so either one alone is enough to take the -- same reason GBC FX does, so either one alone is enough to take the
-- present path; with neither, the frame draws straight to the screen -- present path; with neither, the frame draws straight to the screen
@@ -541,7 +753,25 @@ function Renderer:endFrame(zones, worldZones)
local stack = ok and Game and Game.stack local stack = ok and Game and Game.stack
local base = stack and stack.visibleBase and stack:visibleBase() local base = stack and stack.visibleBase and stack:visibleBase()
local state = base and stack.states and stack.states[base] local state = base and stack.states and stack.states[base]
if state and state.letterboxWhite then -- A battle owns the surround it established until it leaves the stack.
-- Reading it off visibleBase alone loses that the moment the battle opens
-- an OPAQUE state -- the party menu, the bag -- because that state becomes
-- the base and answers no to letterboxWhite, flipping a white battle
-- surround to flat black for as long as the menu is up. Same whole-stack
-- hold as uiFill and the dim.
for i = #(stack and stack.states or {}), 1, -1 do
local s = stack.states[i]
if s and s.letterboxWhite then
state = s
break
end
end
-- BATTLE BG "black" keeps the default black clear; "white" (and any
-- non-battle state that opts in) uses the paper shade. "world" never
-- reaches here -- it makes the battle non-opaque, so the world pass is
-- active and this whole branch is skipped.
if state and state.letterboxWhite
and not (state.bgMode and state:bgMode() == "black") then
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
end end
end end
@@ -560,38 +790,12 @@ function Renderer:endFrame(zones, worldZones)
}) })
end end
-- blit `canvas` at (sx, sy) LOVE-unit scales into origin (bx, by), -- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
-- scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/zoneSy -- call sites below stay unchanged.
-- convert zone coords (canvas-space) into screen units.
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy, local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
bx, by, boxX, boxY, boxW, boxH) bx, by, boxX, boxY, boxW, boxH)
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
if not shader then bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
love.graphics.setScissor(boxX, boxY, boxW, boxH)
love.graphics.draw(canvas, bx, by, 0, sx, sy)
love.graphics.setScissor()
return
end
love.graphics.setShader(shader)
-- a colors == false zone is the trueColor opt-out: its rect draws with
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
-- list never toggles and issues exactly the calls it always did.
local bare = false
for _, z in ipairs(zoneList) do
local plain = z.colors == false
if plain ~= bare then
bare = plain
love.graphics.setShader(not plain and shader or nil)
end
if not plain then PaletteFX.sendColors(shader, z.colors) end
if scissorClamped(bx + z.x * zoneSx, by + z.y * zoneSy,
z.w * zoneSx, z.h * zoneSy,
boxX, boxY, boxW, boxH, dpiX, dpiY) then
love.graphics.draw(canvas, bx, by, 0, sx, sy)
end
end
love.graphics.setScissor()
love.graphics.setShader()
end end
if self.worldOverride then if self.worldOverride then
@@ -602,7 +806,12 @@ function Renderer:endFrame(zones, worldZones)
-- runs, so dialogs, menus and the HUD sit on top as usual. -- runs, so dialogs, menus and the HUD sit on top as usual.
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.setScissor(0, 0, ww, wh) love.graphics.setScissor(0, 0, ww, wh)
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY) local loveMajor = love.getVersion()
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
else
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
end
love.graphics.setScissor() love.graphics.setScissor()
-- the screen-space overlays the flat path draws over its composite -- the screen-space overlays the flat path draws over its composite
local fade = self.worldFadeAlpha local fade = self.worldFadeAlpha
@@ -611,9 +820,6 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
if self.battleCascadeProg then
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
elseif self.worldActive then elseif self.worldActive then
local sp = Zoom.scale(Sp) local sp = Zoom.scale(Sp)
local sx, sy = sp / dpiX, sp / dpiY local sx, sy = sp / dpiX, sp / dpiY
@@ -688,14 +894,81 @@ function Renderer:endFrame(zones, worldZones)
love.graphics.rectangle("fill", 0, 0, ww, wh) love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
-- Battle transition: cascade black blocks into the area outside the end
-- classic 160x144 wipe square (world still shows through until filled). -- BATTLE BG "world": the frozen overworld has just been composited and the
if self.battleCascadeProg then -- battle is about to blit over it. Dim the world first, so the battle
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, Sx, Sy) -- reads as the foreground instead of competing with a fully lit map. Goes
-- here rather than in the letterbox clear because with the world pass
-- active there is no clear -- the world already covers the surface.
if self.battleDim and self.battleDim > 0 then
love.graphics.setColor(0, 0, 0, self.battleDim)
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
-- UI: anchored regions against their screen edges, the rest in the classic
-- centred letterbox. With nothing anchored this is the single blit it has
-- always been.
local anchors = self.uiAnchors
if not anchors or #anchors == 0 then
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph)
else
local rest = { { uox, uoy, uvpw, uvph } }
local placed = {}
for _, a in ipairs(anchors) do
local dw, dh = a.w * Ux, a.h * Uy
-- Anchors are edge-RELATIVE: an element keeps its distance from the
-- canvas edge, measured against the screen edge instead. That is what
-- keeps a stack together -- the yes/no box sits 48px above the canvas
-- bottom, so bottom-anchoring lands it 48px above the screen bottom,
-- still directly over the dialogue box, rather than on top of it.
local gapR = (uiw - (a.x + a.w)) * Ux
local gapB = (uih - (a.y + a.h)) * Uy
local dx, dy
if a.anchor == "bottom" then
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
dy = wh - gapB - dh
elseif a.anchor == "topright" then
dx = ww - gapR - dw
dy = a.y * Uy
else -- unknown anchor: leave it where it is
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
end
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
end
for _, r in ipairs(rest) do
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
end
for _, p in ipairs(placed) do
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
-- The zone scissors are computed from the same origin, so an SGB
-- region travels with the element instead of staying in the letterbox.
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
end end
end end
-- UI stays in the classic centered GB letterbox
blit(self.canvas, Sx, Sy, zones, Sx, Sy, ox, oy, ox, oy, vpw, vph) -- The battle wipe covers the whole surface, letterbox included, so it goes
-- over the finished composite rather than under the UI blit. On hardware
-- it is the tilemap being overwritten -- there is nothing it does not cover.
if self.battleWipe then
self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
end
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
-- GBFadeInFromWhite after a battle) tint every pixel the LCD shows -- there
-- is no "outside the screen" on hardware for them to miss. So they are
-- painted here, over the finished composite, rather than into the 160x144
-- UI canvas: at any zoom above 1x a letterbox-only veil left the
-- surrounding window untouched, which read as the effect happening inside
-- a window rather than to the whole screen. { shade, alpha }.
local veil = self.screenVeil
if veil and veil[2] > 0 then
love.graphics.setColor(veil[1], veil[1], veil[1], veil[2])
love.graphics.rectangle("fill", 0, 0, ww, wh)
love.graphics.setColor(1, 1, 1, 1)
end
if present then if present then
love.graphics.setCanvas() love.graphics.setCanvas()
+59
View File
@@ -0,0 +1,59 @@
-- Bridge to native secondary-display output (Android Presentation). The C
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
-- this stays inert and the renderer keeps the in-window stacked layout.
local SecondScreen = {}
local C = nil
local function log(msg)
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
end
do
local ok, ffi = pcall(require, "ffi")
if not (ok and ffi) then
log("ffi unavailable (not LuaJIT); second display disabled")
else
pcall(ffi.cdef, [[
int love_android_secondary_ready();
void love_android_push_secondary(const void *rgba, int w, int h);
void love_android_secondary_enable(int on);
]])
local okLib, lib = pcall(ffi.load, "love")
if okLib and lib and pcall(function() return lib.love_android_secondary_ready end) then
C = lib
log("bridge linked via ffi.load('love')")
elseif pcall(function() return ffi.C.love_android_secondary_ready end) then
C = ffi.C
log("bridge linked via default namespace")
else
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
:format(tostring(okLib)))
end
end
end
function SecondScreen.usable()
return C ~= nil
end
function SecondScreen.available()
if not C then return false end
local ok, r = pcall(C.love_android_secondary_ready)
return ok and r ~= 0
end
function SecondScreen.push(imageData, w, h)
if not C or not imageData then return false end
return pcall(function()
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
end)
end
function SecondScreen.setEnabled(on)
if not C then return end
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
end
return SecondScreen
+56 -2
View File
@@ -8,6 +8,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Timing = require("src.core.Timing")
local TextBox = {} local TextBox = {}
TextBox.__index = TextBox TextBox.__index = TextBox
@@ -32,13 +33,22 @@ local MAX_COLS = 18
-- up: StateStack updates the top state only, so the overworld and its -- up: StateStack updates the top state only, so the overworld and its
-- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives -- ScriptRunner are frozen underneath (the Pewter JIGGLYPUFF dance drives
-- its spin off it, data/scripts/story5.lua, #249). -- its spin off it, data/scripts/story5.lua, #249).
-- opts.stay: text that ends in `done` rather than `prompt` returns from
-- PrintText with the box still on screen while the caller keeps running
-- (_ViridianSchoolBlackboardText2, data/text/text_2.asm:646). Such a box
-- waits for nothing, shows no blinking arrow, and never pops itself --
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
-- the last page finishes typing, which is where the caller pushes whatever
-- goes on top of it (#591).
function TextBox.new(game, text, onDone, opts) function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox) local self = setmetatable({}, TextBox)
self.game = game self.game = game
self.onDone = onDone self.onDone = onDone
self.choice = opts and opts.choice self.choice = opts and opts.choice
self.defaultNo = opts and opts.defaultNo self.defaultNo = opts and opts.defaultNo
self.choiceNoSound = opts and opts.noSound
self.auto = opts and opts.auto self.auto = opts and opts.auto
self.stay = opts and opts.stay
local box = Theme.textBox or {} local box = Theme.textBox or {}
self.boxTx = box.tx or BOX_TX self.boxTx = box.tx or BOX_TX
self.boxTy = box.ty or BOX_TY self.boxTy = box.ty or BOX_TY
@@ -177,7 +187,24 @@ end
function TextBox:update(dt) function TextBox:update(dt)
local input = self.game.input local input = self.game.input
self.blink = (self.blink + 1) % 60 self.blink = (self.blink + 1) % 60
-- A page or CONT advance blocks the whole box while the original's scroll
-- and clear run (src/core/Timing.lua TEXT_SCROLL_PAIR / TEXT_PAGE_CLEAR).
-- Nothing types and no input is read until it drains.
if (self.holdFrames or 0) > 0 then
self.holdFrames = self.holdFrames - 1
return
end
if self.done then if self.done then
-- opts.stay: the box is finished but stays up under whatever the caller
-- pushed over it; StateStack updates the top state only, so this runs
-- exactly once (#591)
if self.stay then
if not self.stayShown then
self.stayShown = true
if self.stay.onShown then self.stay.onShown() end
end
return
end
if self.auto then if self.auto then
if not self.autoStarted then if not self.autoStarted then
self.autoStarted = true self.autoStarted = true
@@ -232,7 +259,9 @@ function TextBox:update(dt)
self.game.stack:push(ChoiceBox.new(self.game, function(yes) self.game.stack:push(ChoiceBox.new(self.game, function(yes)
self.game.stack:pop() -- this text box, under the choice self.game.stack:pop() -- this text box, under the choice
self.choice(yes) self.choice(yes)
end, { defaultNo = self.defaultNo })) end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
-- this box is anchored below it; the pair moves together
anchor = "bottom" }))
end end
return return
end end
@@ -244,6 +273,13 @@ function TextBox:update(dt)
return return
end end
if self.waiting then if self.waiting then
-- _ContText and Paragraph both print the â–¼ and run ProtectedDelay3
-- before ManualTextScroll starts watching the joypad (home/text.asm:265,
-- :234), so the arrow is up for three frames that swallow the button.
if (self.preWait or 0) > 0 then
self.preWait = self.preWait - 1
return
end
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
require("src.core.Sound").play(self.game.data, "Press_AB") require("src.core.Sound").play(self.game.data, "Press_AB")
self.waiting = false self.waiting = false
@@ -252,11 +288,17 @@ function TextBox:update(dt)
self.contAdvance = false self.contAdvance = false
self.lineIndex = self.lineIndex + 1 self.lineIndex = self.lineIndex + 1
self:beginLine() self:beginLine()
-- ScrollTextUpOneLine is 5 blocking frames and, as its own comment
-- says, is "always called twice in a row" (home/text.asm:280-305)
self.holdFrames = Timing.TEXT_SCROLL_PAIR
else else
self.shown = {} self.shown = {}
self.pageIndex = self.pageIndex + 1 self.pageIndex = self.pageIndex + 1
self.lineIndex = 1 self.lineIndex = 1
self:beginLine() self:beginLine()
-- ClearScreenArea then DelayFrames 20: the box sits empty before the
-- next page starts typing (home/text.asm:236-240)
self.holdFrames = Timing.TEXT_PAGE_CLEAR
end end
end end
return return
@@ -283,6 +325,7 @@ function TextBox:update(dt)
if conts and conts[nextIdx] then if conts and conts[nextIdx] then
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll -- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
self.waiting = true self.waiting = true
self.preWait = Timing.TEXT_PRE_ADVANCE
self.contAdvance = true self.contAdvance = true
else else
self.lineIndex = nextIdx self.lineIndex = nextIdx
@@ -290,6 +333,7 @@ function TextBox:update(dt)
end end
elseif self.pageIndex < #self.pages then elseif self.pageIndex < #self.pages then
self.waiting = true self.waiting = true
self.preWait = Timing.TEXT_PRE_ADVANCE
self.contAdvance = false self.contAdvance = false
else else
self.done = true self.done = true
@@ -300,6 +344,15 @@ function TextBox:update(dt)
end end
function TextBox:draw() function TextBox:draw()
-- The dialogue box belongs against the bottom of the screen, not floating
-- in the middle of a zoomed-out letterbox. Declared per frame; the
-- renderer blits this region to the screen edge and the rest of the UI
-- where it always was (Renderer:setUIAnchor).
local r = self.game and self.game.renderer
if r and r.setUIAnchor then
r:setUIAnchor(self.boxTx * 8, self.boxTy * 8,
self.boxTw * 8, self.boxTh * 8, "bottom")
end
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh) Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
if self.scrollPx and self.scrollPx > 0 then if self.scrollPx and self.scrollPx > 0 then
@@ -321,7 +374,8 @@ function TextBox:draw()
Font.drawCode(code, self.textX + (j - 1) * 8, y) Font.drawCode(code, self.textX + (j - 1) * 8, y)
end end
end end
if (self.waiting or (self.done and not self.choice and not self.auto)) if (self.waiting or (self.done and not self.choice and not self.auto
and not self.stay))
and self.blink < 30 then and self.blink < 30 then
-- page-advance cursor: glyph $EE by default, the blinking down arrow -- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm) -- the original prints via `ld a, "▼"` (home/text.asm)
+121 -8
View File
@@ -1,17 +1,47 @@
-- Screen fade used for warps: fade out, run a callback (map switch), fade in. -- Screen fade used for warps: fade out, run a callback (map switch), fade in.
-- Pushed on the state stack above the overworld. -- Pushed on the state stack above the overworld.
local Timing = require("src.core.Timing")
local Transition = {} local Transition = {}
Transition.__index = Transition Transition.__index = Transition
local FRAMES = 12 -- PlayMapChangeSound tail-calls GBFadeOutToBlack on every map change
-- (home/overworld.asm:703), which is four palette steps of DelayFrames 8 --
-- 32 frames. There is no matching fade in: the new map is built while the
-- palettes are still blacked out and OverworldLoop's LoadGBPal
-- (home/overworld.asm:45) restores them in a single write, so the map pops
-- in. The old symmetric 12/12 fade was both too fast and a shape the
-- hardware never had.
local FRAMES = Timing.WARP_FADE_OUT
local FRAMES_IN = Timing.WARP_FADE_IN
local FLASH_FRAMES = 7 local FLASH_FRAMES = 7
-- Every fade in home/fade.asm holds each palette write with
-- `ld c, 8 / call DelayFrames`, so eight frames is the step of all of them.
local FADE_STEP_FRAMES = 8
-- Veil alpha `t` frames into a `len`-frame fade out. GBFadeOutToBlack
-- (home/fade.asm:43-46) walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1
-- with b = 4, and FadePal4 IS the normal palette while FadePal1 is solid
-- black (:64-67) -- so the screen sits untouched through the first eight
-- frames and then drops a whole shade per step, reaching black for the last
-- hold. The port tweened the veil instead, which reads as a dissolve rather
-- than a Game Boy fade (#607). A record retimed shorter than two steps
-- (mods) keeps the old ramp so it still reaches black.
local function fadeAlpha(t, len)
if not len or len <= 0 then return 1 end
local steps = math.floor(len / FADE_STEP_FRAMES)
if steps < 2 then return t / len end
local step = math.min(steps - 1, math.floor(t / (len / steps)))
return step / (steps - 1)
end
-- The two fades as transitions records, so a mod retimes a warp fade the -- The two fades as transitions records, so a mod retimes a warp fade the
-- same way it retimes a battle wipe. BattleTransition.registerInto pulls -- same way it retimes a battle wipe. BattleTransition.registerInto pulls
-- these in with its eight wipes -- one registrant owns the registry. -- these in with its eight wipes -- one registrant owns the registry.
Transition.STYLES = { Transition.STYLES = {
warp_fade = { kind = "fade", frames = FRAMES }, warp_fade = { kind = "fade", frames = FRAMES, framesIn = FRAMES_IN },
white_flash = { kind = "fade", frames = FLASH_FRAMES }, white_flash = { kind = "fade", frames = FLASH_FRAMES },
} }
@@ -36,27 +66,47 @@ function Transition.new(game, onMidpoint, onDone)
self.onDone = onDone self.onDone = onDone
self.t = 0 self.t = 0
self.phase = "out" self.phase = "out"
self.frames = styleOf(game, "warp_fade").frames or FRAMES local style = styleOf(game, "warp_fade")
self.frames = style.frames or FRAMES
-- a style may still ask for a fade in (mods, and the record is data-driven);
-- the built-in warp is 0, matching hardware
self.framesIn = style.framesIn or FRAMES_IN
return self return self
end end
function Transition:finish()
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function Transition:update(dt) function Transition:update(dt)
self.t = self.t + 1 self.t = self.t + 1
if self.t >= self.frames then local len = (self.phase == "out") and self.frames or self.framesIn
if self.t >= len then
self.t = 0 self.t = 0
if self.phase == "out" then if self.phase == "out" then
self.phase = "in" self.phase = "in"
if self.onMidpoint then self.onMidpoint() end if self.onMidpoint then self.onMidpoint() end
-- LoadGBPal restores the palettes in one write, so with no fade in the
-- map is simply there on the next frame
if (self.framesIn or 0) <= 0 then self:finish() end
else else
self.game.stack:pop() self:finish()
if self.onDone then self.onDone() end
end end
end end
end end
-- The veil alpha for this frame, exposed the way BattleReturn:alpha is so
-- the staircase can be asserted without stubbing love.graphics.
function Transition:alpha()
local len = (self.phase == "out") and self.frames or self.framesIn
local a = fadeAlpha(self.t, len)
if self.phase == "in" then a = 1 - a end
return a
end
function Transition:draw() function Transition:draw()
local alpha = self.t / self.frames local alpha = self:alpha()
if self.phase == "in" then alpha = 1 - alpha end
-- Survey zoom draws the overworld into a window-filling world canvas -- Survey zoom draws the overworld into a window-filling world canvas
-- while the UI pass stays the classic 160x144 letterbox. A rect on the -- while the UI pass stays the classic 160x144 letterbox. A rect on the
-- UI canvas only darkens that center box (issue #121); when the world -- UI canvas only darkens that center box (issue #121); when the world
@@ -101,4 +151,67 @@ function WhiteFlash:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
end end
-- Coming back to the overworld after a battle.
--
-- The battle screen is torn down with the palettes still whited out, the
-- caller spends `ld c, 10 / call DelayFrames` (home/overworld.asm:351-352),
-- and then EnterMap sees BIT_BATTLE_OVER_OR_BLACKOUT set and runs
-- MapEntryAfterBattle (:22, :749-753), which is GBFadeInFromWhite -- three
-- palette steps of DelayFrames 8, so 24 frames. The port had none of it and
-- simply cut from the battle to the map.
--
-- A dark map takes the other branch: wMapPalOffset is nonzero there, so
-- MapEntryAfterBattle does a plain LoadGBPal and the map is just there. Pass
-- opts.instant for that case.
local BattleReturn = {}
BattleReturn.__index = BattleReturn
BattleReturn.isOpaque = false -- the overworld draws underneath
function Transition.battleReturn(game, onDone, opts)
opts = opts or {}
return setmetatable({
game = game, onDone = onDone, t = 0,
hold = opts.hold or Timing.POST_BATTLE_RETURN,
frames = opts.instant and 0 or (opts.frames or Timing.FADE_IN_FROM_WHITE),
}, BattleReturn)
end
function BattleReturn:update(dt)
self.t = self.t + 1
if self.t >= self.hold + self.frames then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
-- GBFadeInFromWhite is a palette staircase, not a smooth ramp: GBFadeIncCommon
-- writes one palette then holds it with `ld c, 8 / call DelayFrames`
-- (home/fade.asm:30-41), three times over. Stepping the veil the same way
-- keeps the fade reading like a Game Boy palette fade rather than a tween.
-- The hold is the same eight frames the warp fade steps on, so both share
-- FADE_STEP_FRAMES at the top of the file.
function BattleReturn:alpha()
if self.t < self.hold then return 1 end
if self.frames <= 0 then return 0 end
local steps = math.max(1, math.floor(self.frames / FADE_STEP_FRAMES))
local step = math.floor((self.t - self.hold) / FADE_STEP_FRAMES)
if step >= steps then return 0 end
return (steps - step - 1) / steps
end
function BattleReturn:draw()
local a = self:alpha()
-- the fade is a palette write, so it covers the whole surface; the
-- renderer paints it in screen space (see Renderer.screenVeil)
local r = self.game and self.game.renderer
if r then
r.screenVeil = { 1, a }
return
end
love.graphics.setColor(1, 1, 1, a)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(1, 1, 1, 1)
end
return Transition return Transition
+45 -16
View File
@@ -78,7 +78,7 @@ end
-- play_cry's waitForButton form keeps that cry gate but hands the box back -- play_cry's waitForButton form keeps that cry gate but hands the box back
-- to the A/B path once the cry is over -- see TextBox's opts.auto.wait -- to the A/B path once the cry is over -- see TextBox's opts.auto.wait
-- (#247, #251). -- (#247, #251).
function Commands.show_text(ctx, textId, subs) function Commands.show_text(ctx, textId, subs, extraOpts)
local text = ctx.game.data.text[textId] local text = ctx.game.data.text[textId]
if not text and ctx.overworld then if not text and ctx.overworld then
text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId) text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId)
@@ -128,6 +128,12 @@ function Commands.show_text(ctx, textId, subs)
end end
end end
end end
if extraOpts then
opts = opts or {}
for k, v in pairs(extraOpts) do
opts[k] = v
end
end
ctx.game.stack:push(TextBox.new(ctx.game, text, function() ctx.game.stack:push(TextBox.new(ctx.game, text, function()
runner:resume() runner:resume()
end, opts)) end, opts))
@@ -139,16 +145,18 @@ function Commands.jump(ctx, target)
end end
-- ask <textId> [subs]: show text, then a YES/NO box; result lands in -- ask <textId> [subs]: show text, then a YES/NO box; result lands in
-- ctx.lastCheck. subs are forwarded to show_text's {token} filling. -- ctx.lastCheck. The box rides opts.choice (TextBox.lua), so the YES/NO
-- menu pops up over the still-visible text once it finishes typing, the
-- same as every hand-written prompt (OverworldController, BattleState,
-- OakSpeech) -- not a bare ChoiceBox after the text box has already been
-- cleared with A (DisplayTextID's WaitForTextScrollButtonPress never fires
-- ahead of a YES/NO box in the original; home/text.asm).
function Commands.ask(ctx, textId, subs) function Commands.ask(ctx, textId, subs)
Commands.show_text(ctx, textId, subs)
local ChoiceBox = require("src.ui.ChoiceBox")
local runner = ctx.runner local runner = ctx.runner
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) Commands.show_text(ctx, textId, subs, { choice = function(yes)
ctx.lastCheck = yes ctx.lastCheck = yes
runner:resume() runner:resume()
end)) end })
runner:yield()
end end
function Commands.face_player(ctx) function Commands.face_player(ctx)
@@ -281,7 +289,19 @@ function Commands.start_battle(ctx, kind, a, b)
end end
runner:resume() runner:resume()
end end
ctx.game.stack:push(battle) -- Every battle enters through the transition wipe, script-driven ones
-- included: BattleTransition (engine/battle/battle_transitions.asm:1) runs
-- from DoBattleTransitionAndInitBattleVariables for all of them, and
-- GetBattleTransitionID_WildOrTrainer picks the style from the battle kind.
-- Pushing the BattleState straight onto the stack skipped the wipe
-- entirely, so every scripted trainer -- gym leaders, the rival, Giovanni --
-- and every scripted wild battle simply cut to the battle screen. The
-- trainer-sight path already went through pushBattle; this one did not.
if ctx.overworld and ctx.overworld.pushBattle then
ctx.overworld:pushBattle(battle)
else
ctx.game.stack:push(battle)
end
runner:yield() runner:yield()
end end
@@ -584,13 +604,16 @@ local function askNickname(ctx, mon)
and ctx.game.data.pokemon[mon.species].name) and ctx.game.data.pokemon[mon.species].name)
or mon.species or mon.species
-- Prefer the real text label; fall back to the BattleState wording. -- Prefer the real text label; fall back to the BattleState wording.
local textId, subs
if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then if ctx.game.data.text and ctx.game.data.text._DoYouWantToNicknameText then
Commands.show_text(ctx, "_DoYouWantToNicknameText", { RAM = name }) textId, subs = "_DoYouWantToNicknameText", { RAM = name }
else else
Commands.show_text(ctx, Strings("Do you want to\ngive a nickname\nto %s?", name)) textId = Strings("Do you want to\ngive a nickname\nto %s?", name)
end end
local ChoiceBox = require("src.ui.ChoiceBox") -- opts.choice (TextBox.lua): the YES/NO box rides over the still-visible
ctx.game.stack:push(ChoiceBox.new(ctx.game, function(yes) -- question instead of a bare ChoiceBox popping up after an A press
-- already cleared it (same fix as Commands.ask).
Commands.show_text(ctx, textId, subs, { choice = function(yes)
if not yes then if not yes then
ctx.lastCheck = success ctx.lastCheck = success
runner:resume() runner:resume()
@@ -604,9 +627,7 @@ local function askNickname(ctx, mon)
runner:resume() runner:resume()
end, end,
}) })
end)) end })
runner:yield()
ctx.lastCheck = success
end end
-- give_pokemon <species> <level>: _GivePokemon (engine/events/ -- give_pokemon <species> <level>: _GivePokemon (engine/events/
@@ -770,7 +791,15 @@ function Commands.old_man_demo(ctx)
local battle = BattleState.newWild(ctx.game, om.species, om.level) local battle = BattleState.newWild(ctx.game, om.species, om.level)
battle:makeOldManDemo() battle:makeOldManDemo()
battle.onFinish = function() runner:resume() end battle.onFinish = function() runner:resume() end
ctx.game.stack:push(battle) -- InitWildBattle calls DoBattleTransitionAndInitBattleVariables
-- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN
-- special case -- so the catch tutorial gets the wipe like any other
-- wild battle
if ctx.overworld and ctx.overworld.pushBattle then
ctx.overworld:pushBattle(battle)
else
ctx.game.stack:push(battle)
end
runner:yield() runner:yield()
end end
+176 -27
View File
@@ -6,6 +6,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local ListMenu = require("src.ui.ListMenu") local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local Input = require("src.core.Input") local Input = require("src.core.Input")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
@@ -13,16 +14,18 @@ local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu 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 -- `pad` mirrors DEFAULT_GAMEPAD_BINDINGS in src/core/Input.lua row for
-- on the SELECT row so controller Back/View is discoverable (#73). -- row; keep the two in sync. Every row shows its key and pad so the
-- controller side is discoverable (#73, #589), and the swap in
-- storeBinding leans on each row holding a value in both slots.
local BUTTONS = { local BUTTONS = {
{ id = "up", label = "UP", key = "up" }, { id = "up", label = "UP", key = "up", pad = "dpup" },
{ id = "down", label = "DOWN", key = "down" }, { id = "down", label = "DOWN", key = "down", pad = "dpdown" },
{ id = "left", label = "LEFT", key = "left" }, { id = "left", label = "LEFT", key = "left", pad = "dpleft" },
{ id = "right", label = "RIGHT", key = "right" }, { id = "right", label = "RIGHT", key = "right", pad = "dpright" },
{ id = "a", label = "A", key = "z" }, { id = "a", label = "A", key = "z", pad = "a" },
{ id = "b", label = "B", key = "x" }, { id = "b", label = "B", key = "x", pad = "b" },
{ id = "start", label = "START", key = "escape" }, { id = "start", label = "START", key = "escape", pad = "start" },
{ id = "select", label = "SELECT", key = "tab", pad = "back" }, { id = "select", label = "SELECT", key = "tab", pad = "back" },
} }
@@ -41,14 +44,33 @@ local function boundPad(overlay, def)
return def.pad return def.pad
end end
-- Key column for every row. SELECT also appends "/PAD" (default BACK) -- The right column is KEY/PAD (e.g. "Z/A"). The row is 20 tiles and the
-- so controller Select/View is visible without opening a second legend. -- widest label ("SELECT") ends at x=64, so each half is clamped to 5
-- glyphs: 5+1+5 right-aligned at x=152 starts no further left than x=64.
-- SDL names longer than 5 get a fixed short form before the clamp.
local KEY_SHORT = {
escape = "ESC", backspace = "BKSP", ["return"] = "ENTER",
kpenter = "ENTER", space = "SPACE",
}
local PAD_SHORT = {
dpup = "D-UP", dpdown = "D-DN", dpleft = "D-LT", dpright = "D-RT",
leftshoulder = "LB", rightshoulder = "RB",
leftstick = "LS", rightstick = "RS", guide = "GUIDE",
}
local function shortName(name, shorts)
local s = shorts[name]
if s then return s end
s = name:upper()
return #s > 5 and s:sub(1, 5) or s
end
-- Right column for every row: effective key and pad together, so a
-- controller player can read the whole map without a second legend (#589).
local function boundRight(overlay, def) local function boundRight(overlay, def)
local key = boundKey(overlay, def) local key = shortName(boundKey(overlay, def), KEY_SHORT)
if def.id ~= "select" then return key:upper() end
local pad = boundPad(overlay, def) local pad = boundPad(overlay, def)
if pad then return (key .. "/" .. pad):upper() end if pad then return key .. "/" .. shortName(pad, PAD_SHORT) end
return key:upper() return key
end end
function BindingsMenu.new(game) function BindingsMenu.new(game)
@@ -61,9 +83,17 @@ function BindingsMenu.new(game)
items[i] = { label = Strings(def.label), items[i] = { label = Strings(def.label),
right = boundRight(overlay, def), button = def } right = boundRight(overlay, def), button = def }
end end
local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {}), local self = setmetatable(ListMenu.new(game, "CONTROLS", items, {
BindingsMenu) -- 6 rows leaves the bottom two lines free for the hint; a clear or
-- reset nobody can see on screen may as well not exist (#589)
rows = 6,
footer = Strings("SELECT:CLEAR ROW\nSTART:RESET ALL"),
}), BindingsMenu)
self.onChoose = function(item) self:beginCapture(item) end self.onChoose = function(item) self:beginCapture(item) end
-- SELECT deletes one row's rebind: dropping the overlay entry is enough
-- because Input:applyBindings rebuilds the whole map from the defaults
-- on every call (#589)
self.onSelectKey = function(item) self:clearBinding(item) end
-- A rebind reaches Input only when this screen closes (#510). The menu -- A rebind reaches Input only when this screen closes (#510). The menu
-- steers by the live map, so applying "B = Z" the instant it was captured -- steers by the live map, so applying "B = Z" the instant it was captured
-- turned the player's next confirm press into a cancel and shut the -- turned the player's next confirm press into a cancel and shut the
@@ -82,12 +112,32 @@ function BindingsMenu:commitBindings()
if opts then Input:applyBindings(opts.bindings) end if opts then Input:applyBindings(opts.bindings) end
end end
-- the capture handlers are per-instance slots, so Game's raw-input -- The capture handlers are per-instance slots, so Game's raw-input
-- routing only ever sees this screen while a capture is armed -- routing only ever sees this screen while a capture is armed. A capture
-- no longer commits on the press: it commits when that press is RELEASED,
-- and a second key or pad button going down while the first is still held
-- cancels instead. That gives a bare controller a way to back out of an
-- armed row, where Escape cannot help (#589).
function BindingsMenu:beginCapture(item) function BindingsMenu:beginCapture(item)
self.capture = item self.capture = item
self.pending = nil
self.onKeyPressed = BindingsMenu.captureKey self.onKeyPressed = BindingsMenu.captureKey
self.onGamepadPressed = BindingsMenu.capturePad self.onGamepadPressed = BindingsMenu.capturePad
self.onJoystickPressed = BindingsMenu.captureJoy
self.onKeyReleased = BindingsMenu.captureKeyRelease
self.onGamepadReleased = BindingsMenu.capturePadRelease
self.onJoystickReleased = BindingsMenu.captureJoyRelease
end
function BindingsMenu:endCapture()
self.capture = nil
self.pending = nil
self.onKeyPressed = nil
self.onGamepadPressed = nil
self.onJoystickPressed = nil
self.onKeyReleased = nil
self.onGamepadReleased = nil
self.onJoystickReleased = nil
end end
-- Escape is the capture's way out, so it is never captured: every other -- Escape is the capture's way out, so it is never captured: every other
@@ -95,23 +145,85 @@ end
-- to bind something (#510). Escape stays START in Input's default map, -- to bind something (#510). Escape stays START in Input's default map,
-- which no rebind removes, so reserving it costs the player nothing. -- which no rebind removes, so reserving it costs the player nothing.
function BindingsMenu:captureKey(key) function BindingsMenu:captureKey(key)
if key == "escape" then return self:storeBinding("key", nil) end if key == "escape" or self.pending then return self:endCapture() end
self:storeBinding("key", key) self.pending = { slot = "key", value = key }
end end
function BindingsMenu:capturePad(button) function BindingsMenu:capturePad(button)
self:storeBinding("pad", button) if self.pending then return self:endCapture() end
self.pending = { slot = "pad", value = button }
end
-- Game forwards every release to Input BEFORE these hooks (see
-- Game:keyreleased): the capture observes releases, it never owns them,
-- so Input's held-state stays honest for keys it saw go down before the
-- capture armed. A release that does not match the pending input (the
-- press that armed the row, a cancelled capture's stragglers) is noise.
function BindingsMenu:captureKeyRelease(key)
local p = self.pending
if p and p.slot == "key" and p.value == key then
self:storeBinding("key", key)
end
end
function BindingsMenu:capturePadRelease(button)
local p = self.pending
if p and p.slot == "pad" and p.value == button then
self:storeBinding("pad", button)
end
end
-- A stick SDL has no game-controller-database entry for reports plain
-- button numbers instead of names, so its capture stores "joyN" in the
-- SAME pad slot every other controller uses (#632). The row's right
-- column, storeBinding's swap and START's reset-all then need no special
-- case, and src/core/Input.lua's applyBindings is the only place that has
-- to decode the name. `raw` keeps the two release hooks apart: p.value is
-- "joy3" here and an SDL button name there, so neither can claim the
-- other's release. Game only routes a raw stick to these hooks, so a
-- recognized pad still records its readable SDL name.
function BindingsMenu:captureJoy(button)
if self.pending then return self:endCapture() end
self.pending = { slot = "pad", value = "joy" .. button, raw = true }
end
function BindingsMenu:captureJoyRelease(button)
local p = self.pending
if p and p.raw and p.value == "joy" .. button then
self:storeBinding("pad", p.value)
end
end end
function BindingsMenu:storeBinding(slot, value) function BindingsMenu:storeBinding(slot, value)
local item = self.capture local item = self.capture
self.capture = nil self:endCapture()
self.onKeyPressed = nil
self.onGamepadPressed = nil
local game = self.game local game = self.game
if not (item and value and game.save and game.save.options) then return end if not (item and value and game.save and game.save.options) then return end
local opts = game.save.options local opts = game.save.options
opts.bindings = opts.bindings or {} opts.bindings = opts.bindings or {}
-- Swap, never steal (#589): when the captured input is another row's
-- effective binding in this slot, that row inherits this row's previous
-- binding. Every BUTTONS row has a default in both slots, so `prev`
-- always exists: no row goes empty and no input serves two rows.
-- Default key ALIASES (W beside Up, Space beside Z; DEFAULT_BINDINGS in
-- Input.lua) are not effective bindings, so capturing one costs the
-- other row a spare alias, never its shown key.
local effective = (slot == "key") and boundKey or boundPad
local prev = effective(opts.bindings, item.button)
if value ~= prev then
for _, other in ipairs(self.items) do
if other ~= item and effective(opts.bindings, other.button) == value then
local ob = opts.bindings[other.button.id]
if type(ob) ~= "table" then
ob = { key = type(ob) == "string" and ob or nil }
end
ob[slot] = prev
opts.bindings[other.button.id] = ob
other.right = boundRight(opts.bindings, other.button)
break
end
end
end
local b = opts.bindings[item.button.id] local b = opts.bindings[item.button.id]
if type(b) ~= "table" then if type(b) ~= "table" then
-- keep a direct-edited plain key string when only the pad changes -- keep a direct-edited plain key string when only the pad changes
@@ -123,18 +235,55 @@ function BindingsMenu:storeBinding(slot, value)
if game.writeOptions then game:writeOptions() end if game.writeOptions then game:writeOptions() end
end end
-- SELECT: forget one row's rebind and fall back to the defaults. #510's
-- deferral still holds: only options change here, the live map catches up
-- in commitBindings on close.
function BindingsMenu:clearBinding(item)
local game = self.game
local opts = game and game.save and game.save.options
if not (opts and opts.bindings and opts.bindings[item.button.id]) then
return
end
opts.bindings[item.button.id] = nil
item.right = boundRight(opts.bindings, item.button)
if game.writeOptions then game:writeOptions() end
end
-- START: confirm, then drop the whole overlay (#589). The footer doubles
-- as the prompt while the YES/NO box is up, the same bottom-line pattern
-- the mart and PC screens use.
function BindingsMenu:confirmReset()
local game = self.game
local hint = self.footer
self.footer = Strings("RESET ALL BINDINGS?")
game.stack:push(ChoiceBox.new(game, function(yes)
self.footer = hint
if not yes then return end
local opts = game.save and game.save.options
if opts then opts.bindings = nil end
for _, it in ipairs(self.items) do
it.right = boundRight(nil, it.button)
end
if game.writeOptions then game:writeOptions() end
end, { defaultNo = true }))
end
function BindingsMenu:update(dt) function BindingsMenu:update(dt)
if self.capture then return end -- the raw capture owns the input if self.capture then return end -- the raw capture owns the input
if self.game.input:wasPressed("start") then
return self:confirmReset()
end
ListMenu.update(self, dt) ListMenu.update(self, dt)
end end
function BindingsMenu:draw() function BindingsMenu:draw()
ListMenu.draw(self) ListMenu.draw(self)
if self.capture then if self.capture then
Font.drawBox(1, 6, 18, 5) Font.drawBox(1, 6, 18, 6)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PRESS A BUTTON"), 24, 60) Font.draw(Strings("PRESS A BUTTON"), 24, 60)
Font.draw(Strings("ESC TO CANCEL"), 24, 72) Font.draw(Strings("RELEASE TO SET"), 24, 72)
Font.draw(Strings("ESC/2ND CANCELS"), 24, 84)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
end end
+14 -10
View File
@@ -66,6 +66,7 @@ local function withdraw(game)
end end
game.stack:push(ListMenu.new(game, game.stack:push(ListMenu.new(game,
Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, { Strings("BOX %d (WITHDRAW)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list) onChoose = function(item, list)
local mon = box[item.value] local mon = box[item.value]
if not mon then return end if not mon then return end
@@ -112,6 +113,7 @@ local function deposit(game)
table.insert(items, { label = monLabel(game, mon), value = i }) table.insert(items, { label = monLabel(game, mon), value = i })
end end
game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, { game.stack:push(ListMenu.new(game, "PARTY (DEPOSIT)", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list) onChoose = function(item, list)
local mon = game.save.party[item.value] local mon = game.save.party[item.value]
if not mon then return end if not mon then return end
@@ -157,22 +159,23 @@ local function release(game)
end end
game.stack:push(ListMenu.new(game, game.stack:push(ListMenu.new(game,
Strings("BOX %d (RELEASE)", game.save.currentBox), items, { Strings("BOX %d (RELEASE)", game.save.currentBox), items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(_, list) onChoose = function(_, list)
local mon = box[list.index] local mon = box[list.index]
if not mon then return end if not mon then return end
local name = monName(game, mon) local name = monName(game, mon)
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("Once released,\n%s is\ngone forever. OK?", name), function() Strings("Once released,\n%s is\ngone forever. OK?", name), nil, {
game.stack:push(ChoiceBox.new(game, function(yes) defaultNo = true, noSound = true,
choice = function(yes)
if not yes then return end if not yes then return end
table.remove(box, list.index) table.remove(box, list.index)
require("src.core.Sound").playCry(game.data, mon.species) require("src.core.Sound").playCry(game.data, mon.species)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("%s was\nreleased outside.\fBye %s!", name, name))) Strings("%s was\nreleased outside.\fBye %s!", name, name)))
list:removeCurrent() list:removeCurrent()
end, { defaultNo = true, noSound = true })) end,
end)) }))
end, end,
})) }))
end end
@@ -189,19 +192,20 @@ local function changeBox(game)
}) })
end end
game.stack:push(ListMenu.new(game, "CHANGE BOX", items, { game.stack:push(ListMenu.new(game, "CHANGE BOX", items, {
noSound = true, -- PCMainMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list) onChoose = function(item, list)
-- the original asks BEFORE switching ("When you change a #MON -- the original asks BEFORE switching ("When you change a #MON
-- BOX, data will be saved. OK?"); declining aborts the change -- BOX, data will be saved. OK?"); declining aborts the change
local ChoiceBox = require("src.ui.ChoiceBox")
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), function() Strings("When you change a\nPOKéMON BOX, data\nwill be saved. OK?"), nil, {
game.stack:push(ChoiceBox.new(game, function(yes) noSound = true,
choice = function(yes)
if not yes then return end if not yes then return end
game.save.currentBox = item.value game.save.currentBox = item.value
if game.writeSave then game:writeSave() end if game.writeSave then game:writeSave() end
list:close() list:close()
end, { noSound = true })) end,
end)) }))
end, end,
})) }))
end end
+33 -4
View File
@@ -3,6 +3,7 @@
local Font = require("src.render.Font") local Font = require("src.render.Font")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local ChoiceBox = {} local ChoiceBox = {}
ChoiceBox.__index = ChoiceBox ChoiceBox.__index = ChoiceBox
@@ -15,6 +16,12 @@ function ChoiceBox.new(game, onChoose, opts)
self.index = (opts and opts.defaultNo) and 2 or 1 self.index = (opts and opts.defaultNo) and 2 or 1
-- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent -- BIT_NO_MENU_BUTTON_SOUND: PC-session prompts stay silent
self.noSound = (opts and opts.noSound) or false self.noSound = (opts and opts.noSound) or false
-- Only a choice box sitting on top of an ANCHORED dialogue box rides the
-- anchor with it (TextBox passes it). A bare one -- the battle's switch
-- offer, a shop or PC confirm -- belongs over the screen that pushed it,
-- and docking it to the window edge instead tears it off that screen by
-- however far the letterbox sits from the edge.
self.anchor = opts and opts.anchor or nil
local box = Theme.choiceBox local box = Theme.choiceBox
self.tx = (opts and opts.tx) or box.tx self.tx = (opts and opts.tx) or box.tx
self.ty = (opts and opts.ty) or box.ty self.ty = (opts and opts.ty) or box.ty
@@ -25,6 +32,19 @@ end
function ChoiceBox:update(dt) function ChoiceBox:update(dt)
local input = self.game.input local input = self.game.input
-- Both branches of DisplayTwoOptionMenu hold 15 frames with the menu still
-- on screen before TwoOptionMenu_RestoreScreenTiles hands control back
-- (engine/menus/text_box.asm:322-323, :333-334).
if self.pending ~= nil then
self.holdFrames = self.holdFrames - 1
if self.holdFrames <= 0 then
local yes = self.pending
self.pending = nil
self.game.stack:pop()
self.onChoose(yes)
end
return
end
if input:wasPressed("up") or input:wasPressed("down") then if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1 self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
@@ -32,19 +52,28 @@ function ChoiceBox:update(dt)
if not self.noSound then if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB") require("src.core.Sound").play(self.game.data, "Press_AB")
end end
self.game.stack:pop() self.pending = (self.index == 1)
self.onChoose(self.index == 1) self.holdFrames = Timing.YES_NO_ANSWER
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
if not self.noSound then if not self.noSound then
require("src.core.Sound").play(self.game.data, "Press_AB") require("src.core.Sound").play(self.game.data, "Press_AB")
end end
self.game.stack:pop() -- .choseSecondMenuItem writes wCurrentMenuItem = 1 before the hold, so
self.onChoose(false) -- the cursor visibly snaps to NO for those 15 frames
self.index = 2
self.pending = false
self.holdFrames = Timing.YES_NO_ANSWER
end end
end end
function ChoiceBox:draw() function ChoiceBox:draw()
local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th local tx, ty, tw, th = self.tx, self.ty, self.tw, self.th
-- rides the same bottom anchor as the dialogue box it sits above, so the
-- pair travels together (the anchor keeps each element's gap from the edge)
local r = self.anchor and self.game and self.game.renderer
if r and r.setUIAnchor then
r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor)
end
Font.drawBox(tx, ty, tw, th) Font.drawBox(tx, ty, tw, th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8) Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
+4
View File
@@ -33,6 +33,10 @@ local IntroMovie = {}
IntroMovie.__index = IntroMovie IntroMovie.__index = IntroMovie
IntroMovie.isOpaque = true IntroMovie.isOpaque = true
-- Same as the title screen: full-bleed art, no world behind it, no player zoom
-- to respect, so fill the window rather than sit at the fixed integer scale.
function IntroMovie:wantsFillScale() return true end
-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo -- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract -- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars) -- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars)
+18
View File
@@ -78,6 +78,10 @@ function ListMenu.new(game, title, items, opts)
-- bottom text box -- same row budget as the mart, without the money box. -- bottom text box -- same row budget as the mart, without the money box.
self.messageBox = opts.messageBox self.messageBox = opts.messageBox
self.money = opts.money -- () -> current money for the box self.money = opts.money -- () -> current money for the box
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): both PC sessions hold the flag
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
-- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS) self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self return self
end end
@@ -101,6 +105,17 @@ local function syncScroll(self)
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end end
-- HandleMenuInput_ (home/window.asm) replays SFX_PRESS_AB for any watched
-- A or B press; DisplayListMenuID's mask is PAD_A | PAD_B | PAD_SELECT
-- (home/list_menu.asm), so the SELECT swap key stays silent (#570)
local function beep(self)
-- game.data is nil under the UI harnesses that drive a list with a stub
-- game (tests/engine/rebind_capture_bug510.lua), where there is no audio
-- cache to play out of
if self.noSound or not (self.game and self.game.data) then return end
require("src.core.Sound").play(self.game.data, "Press_AB")
end
-- edge press or key-repeat tick for a held direction -- edge press or key-repeat tick for a held direction
local function navPressed(self, dir) local function navPressed(self, dir)
if dir == "up" then if dir == "up" then
@@ -126,6 +141,7 @@ function ListMenu:update(dt)
local input = self.game.input local input = self.game.input
if #self.items == 0 then if #self.items == 0 then
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
beep(self)
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end if self.onCancel then self.onCancel() end
end end
@@ -148,10 +164,12 @@ function ListMenu:update(dt)
elseif self.onSelectKey and input:wasPressed("select") then elseif self.onSelectKey and input:wasPressed("select") then
self.onSelectKey(self.items[self.index], self) self.onSelectKey(self.items[self.index], self)
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
beep(self)
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end if self.onCancel then self.onCancel() end
return return
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
beep(self)
local item = self.items[self.index] local item = self.items[self.index]
if self.onChoose then if self.onChoose then
self.onChoose(item, self) self.onChoose(item, self)
+29 -4
View File
@@ -50,6 +50,9 @@ function Menu.new(game, items, opts)
-- only menus whose real mask includes PAD_START -- the start menu -- only menus whose real mask includes PAD_START -- the start menu
-- (engine/menus/draw_start_menu.asm) -- opt in here. -- (engine/menus/draw_start_menu.asm) -- opt in here.
self.startCloses = opts.startCloses or false self.startCloses = opts.startCloses or false
-- screen-edge anchor for this menu (see Menu:draw); nil keeps it in the
-- classic centred letterbox
self.anchor = opts.anchor
self.onCancel = opts.onCancel self.onCancel = opts.onCancel
-- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its -- BIT_NO_MENU_BUTTON_SOUND (wMiscFlags): the PC session runs its
-- menus silent (home/window.asm HandleMenuInput_) -- menus silent (home/window.asm HandleMenuInput_)
@@ -104,24 +107,46 @@ function Menu:update(dt)
end end
function Menu:draw() function Menu:draw()
-- opts.anchor opts a menu out of the centred letterbox and onto a screen
-- edge (the START menu asks for "topright"). Only menus that ask for it
-- move; every other menu is placed exactly as before.
local r = self.anchor and self.game and self.game.renderer
if r and r.setUIAnchor then
r:setUIAnchor(self.tx * 8, self.ty * 8,
self.tw * 8, self.th * 8, self.anchor)
end
Font.drawBox(self.tx, self.ty, self.tw, self.th) Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items or #self.items
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
-- row and let any slack fall as a blank row under the top edge --
-- draw_start_menu.asm (TextBoxBorder 10,0, then hlcoord 12,2 /
-- wTopMenuItemY 2), players_pc.asm and bills_pc.asm all do the same. The
-- bottom border is ty + th - 1, so the last choice sits on ty + th - 2 and
-- row r counts back up from there. Anchoring from the top instead only
-- agrees when th is exactly visible * rowStep + 2, which is Menu.new's
-- default but NOT what a caller sizing its own box passes: BagMenu's
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
-- the bottom border (#564, #572).
for row = 1, visible do for row = 1, visible do
local item = self.items[self.scroll + row] local item = self.items[self.scroll + row]
if not item then break end if not item then break end
Font.draw(item.label, (self.tx + 2) * 8, Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + row * self.rowStep - (self.rowStep - 1)) * 8) (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
end end
local cursorRow = self.index - self.scroll local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + cursorRow * self.rowStep - (self.rowStep - 1)) * 8) (self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState -- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor -- use, sat on the bottom border like TextBox's page-advance cursor. It
-- has to be the border row, not ty + th - 2: that is the last interior
-- row, which the last choice now occupies, and Menu.new widens the box to
-- widest + 3 so tx + tw - 2 is exactly that label's final glyph (#564).
if self.maxVisible and self.scroll + self.maxVisible < #self.items then if self.maxVisible and self.scroll + self.maxVisible < #self.items then
Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8, Font.drawCode(Theme.moreArrow, (self.tx + self.tw - 2) * 8,
(self.ty + self.th - 2) * 8) (self.ty + self.th - 1) * 8)
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+60
View File
@@ -19,6 +19,7 @@ local TileRenderer = require("src.render.TileRenderer")
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local VideoMode = require("src.core.VideoMode") local VideoMode = require("src.core.VideoMode")
local FaithfulRes = require("src.core.FaithfulRes")
local FrameCap = require("src.core.FrameCap") local FrameCap = require("src.core.FrameCap")
local Performance = require("src.core.Performance") local Performance = require("src.core.Performance")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
@@ -156,6 +157,40 @@ local function buildRows(game)
o.battleLayout = o.battleLayout == "wide" and "og" or "wide" o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
return true return true
end }, end },
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
-- whole number of screen pixels and the battle is the same size at any
-- zoom. FILL scales the battle surface to the window so it fills
-- vertically; that needs a fractional scale, so pixels stop being evenly
-- sized. Battle only: the overworld is untouched either way.
{ id = "battleFit", label = Strings("BATTLE SIZE"),
value = function(g)
return g.save.options.battleFit == "fill" and Strings("FILL")
or Strings("FIXED")
end,
step = function(g)
local o = g.save.options
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
return true
end },
-- What sits behind and around the battle. WHITE is the classic paper
-- field; BLACK swaps it for black bars; WORLD leaves the frozen overworld
-- visible underneath, dimmed (the battle stops being opaque, so the map
-- shows through everywhere the battle does not paint).
{ id = "battleBg", label = Strings("BATTLE BG"),
value = function(g)
local m = g.save.options.battleBg
if m == "black" then return Strings("BLACK") end
if m == "world" then return Strings("WORLD") end
return Strings("WHITE")
end,
step = function(g, dir)
local o = g.save.options
local order = { "white", "black", "world" }
local cur = 1
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1]
return true
end },
{ id = "ruleset", label = Strings("RULESET"), { id = "ruleset", label = Strings("RULESET"),
value = function(g) return rulesetName(g) end, value = function(g) return rulesetName(g) end,
step = function(g, dir) step = function(g, dir)
@@ -298,6 +333,20 @@ local function buildRows(game)
VideoMode.apply(o.videoMode) VideoMode.apply(o.videoMode)
return true return true
end }, end },
-- Lock the window to an exact 160x144 multiple, so the surface IS the
-- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE
-- because it overrides it: holding an exact size means dropping
-- fullscreen.
{ id = "faithfulRes", label = Strings("FAITHFUL RES"),
value = function(g)
return FaithfulRes.label(g.save.options.faithfulRes)
end,
step = function(g, dir)
local o = g.save.options
o.faithfulRes = FaithfulRes.cycle(o.faithfulRes, dir)
FaithfulRes.apply(o.faithfulRes)
return true
end },
-- hard render cap (issue #88): bounds the present rate so a -- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic -- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only. -- is fixed-step off dt, so this touches presentation only.
@@ -448,10 +497,21 @@ function OptionsMenu:update(dt)
elseif row and row.step then elseif row and row.step then
changed = row.step(self.game, dir) and true or false changed = row.step(self.game, dir) and true or false
elseif input:wasPressed("a") then -- CANCEL elseif input:wasPressed("a") then -- CANCEL
-- DisplayOptionMenu .exitMenu (engine/menus/main_menu.asm) is the
-- only spot in this menu that plays SFX_PRESS_AB: A on a setting row
-- and the Left/Right toggles stay silent (#570). game.data is nil
-- under the stub games the UI harnesses drive.
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end if self.onCancel then self.onCancel() end
end end
elseif input:wasPressed("b") or input:wasPressed("start") then elseif input:wasPressed("b") or input:wasPressed("start") then
-- .exitMenu again: B and START leave the same way, sound and all
if self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.game.stack:pop() self.game.stack:pop()
if self.onCancel then self.onCancel() end if self.onCancel then self.onCancel() end
end end
+19 -3
View File
@@ -310,6 +310,14 @@ function PartyMenu:update(dt)
local input = self.game.input local input = self.game.input
local party = self.party or self.game.save.party local party = self.party or self.game.save.party
-- HandlePartyMenuInput (home/pokemon.asm) and the field-move submenu
-- (engine/menus/start_sub_menus.asm .chosePokemon) both run through
-- HandleMenuInput_, which beeps SFX_PRESS_AB on any A or B press (#570).
-- game.data is nil under the stub games the UI harnesses drive.
if self.game.data and (input:wasPressed("a") or input:wasPressed("b")) then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if self.submenu then if self.submenu then
local n = #self.subItems local n = #self.subItems
if input:wasPressed("up") then if input:wasPressed("up") then
@@ -365,9 +373,17 @@ function PartyMenu:update(dt)
or Strings("A blinding FLASH\nlights the area!"), function() or Strings("A blinding FLASH\nlights the area!"), function()
self:close() self:close()
-- setDark, not a bare field write: ADVANCED carries the darkness -- setDark, not a bare field write: ADVANCED carries the darkness
-- in a baked atlas, so lighting the cave rebuilds it (#383) -- in a baked atlas, so lighting the cave drops every resident map
self.game.stack:push(Transition.whiteFlash(self.game, nil, -- and rebakes this one (#383). It runs HERE, before the blink,
function() ow:setDark(false) end)) -- because start_sub_menus.asm .flash clears wMapPalOffset before
-- PrintText and blinks last of all: the cave is already lit by the
-- time GBPalWhiteOutWithDelay3 runs. Hanging the rebuild off the
-- blink's completion instead left that rebuild's whole cost --
-- seconds of per-pixel atlas baking on a phone -- on screen as a
-- solid white frame with nothing under it, which reads as a
-- lockup (#610).
ow:setDark(false)
self.game.stack:push(Transition.whiteFlash(self.game))
end)) end))
return return
elseif action == "surf" then elseif action == "surf" then
+3
View File
@@ -73,6 +73,7 @@ local function withdraw(game)
local pc = game.save.pcItems local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), { game.stack:push(ListMenu.new(game, "WITHDRAW ITEM", buildItems(game, pc), {
messageBox = true, messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty) askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
local Bag = require("src.inventory.Bag") local Bag = require("src.inventory.Bag")
@@ -110,6 +111,7 @@ local function deposit(game)
end end
game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), { game.stack:push(ListMenu.new(game, "DEPOSIT ITEM", buildItems(game, depositable), {
messageBox = true, messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list) onChoose = function(item, list)
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty) askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
if pcFull(game, pc, item.value) then if pcFull(game, pc, item.value) then
@@ -130,6 +132,7 @@ local function toss(game)
local pc = game.save.pcItems local pc = game.save.pcItems
game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), { game.stack:push(ListMenu.new(game, "TOSS ITEM", buildItems(game, pc), {
messageBox = true, messageBox = true,
noSound = true, -- PlayerPCMenu holds BIT_NO_MENU_BUTTON_SOUND (#570)
onChoose = function(item, list) onChoose = function(item, list)
local def = game.data.items[item.value] local def = game.data.items[item.value]
if (def and def.keyItem) or item.value:find("^HM_") then if (def and def.keyItem) or item.value:find("^HM_") then
+11 -3
View File
@@ -50,11 +50,13 @@ function PokedexMenu.new(game, opts)
footer = Strings("SEEN %d OWNED %d", seen, owned), footer = Strings("SEEN %d OWNED %d", seen, owned),
pageJump = true, -- Left/Right page jumps like the original pageJump = true, -- Left/Right page jumps like the original
onCancel = opts.onCancel, -- B returns to the start menu when opened from it onCancel = opts.onCancel, -- B returns to the start menu when opened from it
onChoose = function(item) onChoose = function(item, dexList)
if not item.value then return end if not item.value then return end
-- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm -- the DATA / CRY / AREA / QUIT choice (engine/menus/pokedex.asm
-- PokedexMenuItemsText); CRY keeps the side menu open like the -- PokedexMenuItemsText); CRY keeps the side menu open like the
-- original, QUIT returns to the list -- original. B is what returns to the list: HandlePokedexSideMenu
-- hands back b=2 for B and b=1 for QUIT, and ShowPokedexMenu sends
-- b=1 to .exitPokedex, so QUIT closes the whole Pokédex (#571)
local Menu = require("src.ui.Menu") local Menu = require("src.ui.Menu")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local entries = { local entries = {
@@ -91,7 +93,13 @@ function PokedexMenu.new(game, opts)
or Strings("Printer error!\n%s", tostring(err)))) or Strings("Printer error!\n%s", tostring(err))))
end } end }
end end
entries[#entries + 1] = { label = Strings("QUIT") } entries[#entries + 1] = { label = Strings("QUIT"), onSelect = function()
-- .exitPokedex: drop the dex list too and hand back to whoever
-- opened it (the start menu, whose saved cursor is still on
-- POKéDEX -- wBattleAndStartSavedMenuItem)
dexList:close()
if opts.onCancel then opts.onCancel() end
end }
game.stack:push(Menu.new(game, entries, game.stack:push(Menu.new(game, entries,
{ tx = 12, ty = 8, tw = 8, { tx = 12, ty = 8, tw = 8,
th = #entries * 2 + 2 })) th = #entries * 2 + 2 }))
+25 -14
View File
@@ -53,7 +53,6 @@ function StartMenu.new(game)
-- (PrintSaveScreenText) -- (PrintSaveScreenText)
table.insert(items, { label = Strings("SAVE"), onSelect = function() table.insert(items, { label = Strings("SAVE"), onSelect = function()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local badges = require("src.inventory.Badges").count(game.data, game.save) local badges = require("src.inventory.Badges").count(game.data, game.save)
local owned = 0 local owned = 0
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
@@ -64,8 +63,8 @@ function StartMenu.new(game)
game.save.player.name or "RED", badges, owned, game.save.player.name or "RED", badges, owned,
math.floor(t / 3600), math.floor(t / 60) % 60) math.floor(t / 3600), math.floor(t / 60) % 60)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
panel .. Strings("\fWould you like to\nSAVE the game?"), function() panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
game.stack:push(ChoiceBox.new(game, function(yes) choice = function(yes)
if not yes then return end if not yes then return end
-- "Now saving..." beat before the write (save.asm -- "Now saving..." beat before the write (save.asm
-- NowSavingString), then GameSavedText + SFX_SAVE -- NowSavingString), then GameSavedText + SFX_SAVE
@@ -75,8 +74,8 @@ function StartMenu.new(game)
game.stack:push(TextBox.new(game, game.stack:push(TextBox.new(game,
Strings("%s saved\nthe game!", game.save.player.name or "RED"))) Strings("%s saved\nthe game!", game.save.player.name or "RED")))
end)) end))
end)) end,
end)) }))
end }) end })
table.insert(items, { label = Strings("OPTION"), onSelect = function() table.insert(items, { label = Strings("OPTION"), onSelect = function()
@@ -105,12 +104,12 @@ function StartMenu.new(game)
-- to the title after a confirm (defaultNo guards accidental quits) -- to the title after a confirm (defaultNo guards accidental quits)
table.insert(items, { label = Strings("QUIT"), onSelect = function() table.insert(items, { label = Strings("QUIT"), onSelect = function()
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox") game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), nil, {
game.stack:push(TextBox.new(game, Strings("RETURN TO MAIN\nMENU?"), function() defaultNo = true,
game.stack:push(ChoiceBox.new(game, function(yes) choice = function(yes)
if yes then game:returnToTitle() end if yes then game:returnToTitle() end
end, { defaultNo = true })) end,
end)) }))
end }) end })
local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items) local hooked = Runtime.call("ui.start_menu.items", sameItems, game, items)
@@ -133,7 +132,12 @@ function StartMenu.new(game)
local rowStep = 2 local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep) local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items, local menu = Menu.new(game, items,
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true }) -- the START menu hugs the top-right corner of the SCREEN, not of a
-- centred letterbox: at 9,0 x 11 it is already flush with the top and
-- right of the 20x18 grid, so the anchor keeps it flush when the view
-- is zoomed out and the letterbox no longer fills the window
{ tx = 9, ty = 0, tw = 11, maxVisible = maxVisible, startCloses = true,
anchor = "topright" })
-- the cursor position survives closing the menu -- the cursor position survives closing the menu
-- (wBattleAndStartSavedMenuItem, home/start_menu.asm) -- (wBattleAndStartSavedMenuItem, home/start_menu.asm)
menu.index = math.min(game.save.startMenuIndex or 1, #items) menu.index = math.min(game.save.startMenuIndex or 1, #items)
@@ -145,9 +149,16 @@ function StartMenu.new(game)
end end
-- inside the Safari Zone the start menu also shows remaining steps and -- inside the Safari Zone the start menu also shows remaining steps and
-- SAFARI BALLs (PrintSafariZoneSteps): a 9x5 border at the top-left with -- SAFARI BALLs (PrintSafariZoneSteps, engine/overworld/player_state.asm:
-- "steps/500" and "BALL xx" -- 219-224): a 9x5 border at the top-left with "steps/500" and "BALL xx".
if game.save.safari then -- It opens with `cp SAFARI_ZONE_EAST / ret c`, so only the nine interior
-- maps ($D9..$E1) get it -- SAFARI_ZONE_GATE is $9C and falls under that
-- early out, and used to show "502/500" while the player was still
-- standing at the counter (#540). Same map set as the step counter's
-- (FieldDefaults safari.stepMaps via OverworldState:inSafariStepZone).
local ow = game.overworld
if game.save.safari and ow and ow.map and ow.inSafariStepZone
and ow:inSafariStepZone() then
local baseDraw = menu.draw local baseDraw = menu.draw
menu.draw = function(self) menu.draw = function(self)
baseDraw(self) baseDraw(self)
+6
View File
@@ -14,6 +14,12 @@ local TitleState = {}
TitleState.__index = TitleState TitleState.__index = TitleState
TitleState.isOpaque = true TitleState.isOpaque = true
-- Fill the window (aspect preserved, bars on the long axis) instead of sitting
-- at the fixed integer scale. The title screen is a full-bleed picture with no
-- world behind it, so a small centred box in a large window is just wasted
-- glass -- and unlike the overworld it has no zoom the player chose to respect.
function TitleState:wantsFillScale() return true end
-- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2, -- SGB title zones (PalPacket_Titlescreen): the logo rows get LOGO2,
-- the version-ribbon band LOGO1, the rest MEWMON. -- the version-ribbon band LOGO1, the rest MEWMON.
-- --
+55 -7
View File
@@ -1,8 +1,13 @@
-- Launcher touch-controls editor (#327): drag each on-screen button to a -- Launcher touch-controls editor (#327): drag each on-screen button to a
-- new spot, toggle the overlay off entirely, Reset to defaults, Done to -- new spot, resize them, toggle the overlay off entirely, Reset to
-- persist into options.lua. Opened from the game panel's "Touch Controls" -- defaults, Done to persist into options.lua. Opened from the game
-- button; main.lua suspends the launcher the same way it does for the -- panel's "Touch Controls" button; main.lua suspends the launcher the same
-- save editor. -- way it does for the save editor.
--
-- Everything edited here belongs to the orientation currently on screen
-- (#633): portrait and landscape keep separate positions and sizes, so a
-- player lays out each rotation once. Rotate the device (or resize the
-- desktop window past square) and the editor switches buckets live.
-- --
-- Draws in full window LOVE units -- the same space TouchControls uses -- Draws in full window LOVE units -- the same space TouchControls uses
-- after Renderer:endFrame -- so what you drag here is what you get in play. -- after Renderer:endFrame -- so what you drag here is what you get in play.
@@ -63,9 +68,11 @@ end
local function persist() local function persist()
local opts = SaveData.loadOptions() local opts = SaveData.loadOptions()
local cfg = TouchControls:config() local cfg = TouchControls:config()
-- replaces the whole table, so a pre-#633 top-level positions key is
-- dropped once the player saves under the new shape
opts.touchControls = { opts.touchControls = {
enabled = cfg.enabled, enabled = cfg.enabled,
positions = cfg.positions, layouts = cfg.layouts,
} }
SaveData.saveOptions(opts) SaveData.saveOptions(opts)
end end
@@ -113,6 +120,8 @@ function Editor.draw()
local fullW, fullH = love.graphics.getDimensions() local fullW, fullH = love.graphics.getDimensions()
local ox, oy, ww, wh = SafeArea.rect() local ox, oy, ww, wh = SafeArea.rect()
local s = math.max(0.75, math.min(1.4, wh / 768)) local s = math.max(0.75, math.min(1.4, wh / 768))
-- resolve the orientation bucket before any chrome reads scale (#633)
local bucket = TouchControls:currentBucket()
Editor.rects = {} Editor.rects = {}
-- radial-ish navy field (two stacked fills; matches launcher atmosphere) -- radial-ish navy field (two stacked fills; matches launcher atmosphere)
@@ -186,13 +195,41 @@ function Editor.draw()
chromeBtn(toggle, on and "Disable" or "Enable", chromeBtn(toggle, on and "Disable" or "Enable",
on and PAL.red or PAL.green) on and PAL.red or PAL.green)
-- size card (#633): -/+ resize every control in the orientation on
-- screen; the heading names it so it is plain the other one is untouched
local sizeY = cardY + cardH + 10 * s
col(PAL.card, 0.88)
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
col(PAL.stroke, 0.4)
roundRect("line", cardX, sizeY, cardW, cardH, 12 * s)
love.graphics.setFont(Editor.fonts.body)
col(PAL.label)
local orient = TouchControls.orientation == "landscape"
and "Landscape" or "Portrait"
love.graphics.print("Button size (" .. orient .. ")",
cardX + 16 * s, sizeY + 12 * s)
love.graphics.setFont(Editor.fonts.btn)
col(PAL.white)
love.graphics.print(
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
cardX + 16 * s, sizeY + 34 * s)
local stepW = 52 * s
local plus = { x = cardX + cardW - 16 * s - stepW,
y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
Editor.rects.sizeUp, Editor.rects.sizeDown = plus, minus
chromeBtn(minus, "-", { 60, 70, 110 })
chromeBtn(plus, "+", { 60, 70, 110 })
-- hint -- hint
love.graphics.setFont(Editor.fonts.body) love.graphics.setFont(Editor.fonts.body)
col(PAL.label, 0.9) col(PAL.label, 0.9)
local hint = on local hint = on
and "Drag each button to reposition. Layout is saved when you tap Done." and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
or "Controls are hidden in-game. Enable them to show and edit the layout." or "Controls are hidden in-game. Enable them to show and edit the layout."
love.graphics.printf(hint, ox + pad, cardY + cardH + 12 * s, ww - 2 * pad, "left") love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
-- the overlay itself (preview mode; dimmed when disabled) -- the overlay itself (preview mode; dimmed when disabled)
TouchControls:draw() TouchControls:draw()
@@ -214,6 +251,12 @@ local function beginDrag(id, x, y)
if inside(Editor.rects.done, x, y) then close(); return end if inside(Editor.rects.done, x, y) then close(); return end
if inside(Editor.rects.reset, x, y) then resetLayout(); return end if inside(Editor.rects.reset, x, y) then resetLayout(); return end
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
if inside(Editor.rects.sizeDown, x, y) then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
end
if inside(Editor.rects.sizeUp, x, y) then
TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
end
local name = TouchControls:hitTest(x, y) local name = TouchControls:hitTest(x, y)
if not name then return end if not name then return end
@@ -271,6 +314,11 @@ function Editor.keypressed(key)
close() close()
elseif key == "r" then elseif key == "r" then
resetLayout() resetLayout()
-- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
elseif key == "-" or key == "kp-" then
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
elseif key == "=" or key == "+" or key == "kp+" then
TouchControls:nudgeScale(TouchControls.SCALE_STEP)
end end
end end
+12 -1
View File
@@ -77,6 +77,12 @@ local SAFARI = {
local PLAYER_SPRITES = { local PLAYER_SPRITES = {
walk = "SPRITE_RED", surf = "SPRITE_SEEL", walk = "SPRITE_RED", surf = "SPRITE_SEEL",
bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD", bike = "SPRITE_RED_BIKE", fly = "SPRITE_BIRD",
-- Yellow's IsSurfingPikachuInParty: when the SURF-mon is a Pikachu,
-- the player rides this sheet. GFX loads via
-- LoadSurfingPlayerSpriteGraphics2, not SpriteSheetPointerTable, so
-- it needs a manifest extract (RFC 0001). Guarded in Player.new so
-- before extraction lands the ride keeps the Seel.
surfPikachu = "SPRITE_SURFING_PIKACHU",
} }
-- The player's own trainer art: RedPicBack (the battle back pic, up until -- The player's own trainer art: RedPicBack (the battle back pic, up until
@@ -89,6 +95,11 @@ local PLAYER_SPRITES = {
local PLAYER_PICS = { local PLAYER_PICS = {
back = "assets/generated/battle/redb.png", back = "assets/generated/battle/redb.png",
demoBack = "assets/generated/battle/oldmanb.png", demoBack = "assets/generated/battle/oldmanb.png",
-- LoadPlayerBackPic keys the demo pic off wBattleType, not off "is a
-- demo": BATTLE_TYPE_PIKACHU (Yellow's Pallet catch scene) puts PROF.OAK
-- in the player's place with his own back pic, not the old man's (#557).
-- Red/Blue never reach it -- only the Yellow script names that thrower.
oakBack = "assets/generated/battle/profoakb.png",
front = "assets/generated/trainer_card/red.png", front = "assets/generated/trainer_card/red.png",
} }
@@ -185,7 +196,7 @@ FieldDefaults.CONSTANTS = {
neighborHops = 2, -- connection hops drawn around the current map neighborHops = 2, -- connection hops drawn around the current map
stepFrames = 16, -- 1px per frame, 16 frames per tile stepFrames = 16, -- 1px per frame, 16 frames per tile
bikeStepFrames = 8, -- the bicycle doubles walking speed bikeStepFrames = 8, -- the bicycle doubles walking speed
turnFrames = 2, -- the extra OverworldLoop pass after a turn turnFrames = 4, -- tap window before a turn commits to a step
}, },
-- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm) -- cumulative slot thresholds out of 256 (engine/battle/wild_encounters.asm)
encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 }, encounterBuckets = { 51, 102, 141, 166, 191, 216, 229, 242, 253, 256 },
+105 -11
View File
@@ -389,6 +389,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
and not self.map:isWalkableCell(x, y) and not self.map:isWalkableCell(x, y)
and self.map:isWaterCell(x, y) and self.map:isWaterCell(x, y)
end end
-- re-derive from the live party: a reloaded save with the SURF-Pikachu
-- since deposited should not render the Pikachu sheet.
-- ponytail: re-derived rather than persisted.
self:syncSurfingPikachu()
end end
-- crossConnection re-arms this after setMap; clear so a warp/reload -- crossConnection re-arms this after setMap; clear so a warp/reload
-- cannot leave a stale deferred PlayMapMusic pending -- cannot leave a stale deferred PlayMapMusic pending
@@ -717,6 +721,11 @@ function OverworldState:pushBattle(battle)
if battle.computeMusicKind then if battle.computeMusicKind then
require("src.core.Music").playBattle(Game.data, battle:computeMusicKind()) require("src.core.Music").playBattle(Game.data, battle:computeMusicKind())
end end
-- The fade back in from white on the way out is BattleState:finish()'s
-- job now -- the one choke point every battle passes through on exit,
-- guaranteed regardless of which caller pushed the battle -- so this
-- function only owns the entry wipe.
Game.stack:push(BattleTransition.new(Game, function() Game.stack:push(BattleTransition.new(Game, function()
Game.stack:push(battle) Game.stack:push(battle)
end, { end, {
@@ -1155,6 +1164,13 @@ function OverworldState:handleInput()
end end
end end
-- .noDirectionButtonsPressed (home/overworld.asm) is the only place that
-- sets wCheckFor180DegreeTurn, so reaching this line -- a poll that found
-- no direction held -- is what re-arms the next turn in place. The early
-- returns above (mid-step, A, START) skip it exactly as the original's
-- jumps to .moveAhead and .displayDialogue do (#415).
self.player.turnArmed = true
-- Cycling Road's downhill pull: with no d-pad held the bike rolls -- Cycling Road's downhill pull: with no d-pad held the bike rolls
-- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN). -- south (home/overworld.asm JoypadOverworld's simulated PAD_DOWN).
-- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B -- The mask there is PAD_CTRL_PAD | PAD_B | PAD_A, so HOLDING A or B
@@ -1477,6 +1493,21 @@ function OverworldState:partyKnows(moveId)
return partyKnowsVanilla(moveId) return partyKnowsVanilla(moveId)
end end
-- IsSurfingPikachuInParty (home/map_objects.asm): when the SURF-mon
-- is a Pikachu, pose() renders the Pikachu surf sprite. Called at
-- every surf-state change so a reloaded save picks the right sheet
-- after a party change. No-op when not surfing.
function OverworldState:syncSurfingPikachu()
local p = self.player
if not p then return end
if not p.surfing then
p.surfingPikachu = false
return
end
local mon = self:partyKnows("SURF")
p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false
end
-- The rejection loop shared by the Good and Super Rods -- The rejection loop shared by the Good and Super Rods
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an -- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it -- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
@@ -1573,6 +1604,7 @@ function OverworldState:flyTo(mapId)
Game.save.onBike = false Game.save.onBike = false
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
self.player.surfing = false self.player.surfing = false
self:syncSurfingPikachu()
-- the bird carries the player off westward before the warp -- the bird carries the player off westward before the warp
-- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics) -- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics)
self.flyAnim = { frames = 48 } self.flyAnim = { frames = 48 }
@@ -1600,6 +1632,7 @@ function OverworldState:beginTeleportOut(onDone)
end end
require("src.core.Sound").play(Game.data, "Teleport_Exit1") require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false self.player.surfing = false
self:syncSurfingPikachu()
self.player.inputLocked = true self.player.inputLocked = true
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so -- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down -- spinRise lifts the sprite (Player:pose) while spinFrames counts down
@@ -1891,9 +1924,20 @@ function OverworldState:tryHiddenObject(fx, fy)
end end
end end
-- bench guys (data/events/bench_guys.asm) -- Bench guys (data/events/bench_guys.asm). A hidden_event's fourth byte
-- is wHiddenEventFunctionArgument, not a facing gate -- pokered's own
-- macro comment says the SPRITE_FACING_* values parked there "do not
-- actually prevent the player from interacting with them in any
-- direction" (data/events/hidden_events.asm). The facing that does decide
-- a bench guy is PrintBenchGuyText's own test against BenchGuyTextPointers,
-- SPRITE_FACING_LEFT for all twelve seats, which the manifest carries as
-- `textFacing`. Gating on the hidden_event byte instead silenced the four
-- seats that store SPRITE_FACING_UP (Vermilion, Saffron, Fuchsia,
-- Cinnabar): (0,4) is the bench wall cell and can only ever be faced from
-- the right (#488).
for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do for _, h in ipairs(extras.benchGuys[self.map.id] or {}) do
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then local want = h.textFacing or h.facing
if h.x == fx and h.y == fy and (not want or want == facing) then
local text = OverworldState.benchGuyText(Game.data, save, h.text) local text = OverworldState.benchGuyText(Game.data, save, h.text)
if text then if text then
Game.stack:push(TextBox.new(Game, text)) Game.stack:push(TextBox.new(Game, text))
@@ -2252,6 +2296,7 @@ function OverworldState:trySurf(fx, fy, onClose)
Game.stack:push(TextBox.new(Game, text, function() Game.stack:push(TextBox.new(Game, text, function()
if onClose then onClose() end if onClose then onClose() end
p.surfing = true p.surfing = true
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true) require("src.core.Music").setSurfing(Game.data, true)
Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil, Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil,
function() self:stepForwardOrCrossEdge(p.facing) end)) function() self:stepForwardOrCrossEdge(p.facing) end))
@@ -2558,8 +2603,7 @@ function OverworldState:openPC(onDone)
table.insert(items, { table.insert(items, {
label = Strings("PROF.OAK's PC"), label = Strings("PROF.OAK's PC"),
onSelect = function() onSelect = function()
self:dexRating() self:openOaksPC(done)
done()
end, end,
}) })
end end
@@ -2586,11 +2630,41 @@ function OverworldState:openPC(onDone)
noSound = true })) noSound = true }))
end end
-- The PROF. OAK's PC session (engine/menus/oaks_pc.asm OpenOaksPC): the
-- access text, "Want to get your #DEX rated?" with a YES/NO, then the
-- rating, and "Closed link to PROF.OAK's PC." before control returns -- the
-- intro and closing links the launcher skipped, jingle ordering aside (#576).
function OverworldState:openOaksPC(onDone)
local done = onDone or function() end
local text = Game.data.text or {}
local accessed = text._AccessedOaksPCText
or Strings("Accessed PROF.\nOAK's PC.\fAccessed POKéDEX\nRating System.")
local rated = text._GetDexRatedText
or Strings("Want to get your\nPOKéDEX rated?")
local closed = text._ClosedOaksPCText
or Strings("Closed link to\nPROF.OAK's PC.")
local function close()
Game.stack:push(TextBox.new(Game, closed, done))
end
Game.stack:push(TextBox.new(Game, accessed, function()
-- _GetDexRatedText ends with `done`, so the YES/NO pops as soon as the
-- text has typed out, with no button wait in between (YesNoChoice)
Game.stack:push(TextBox.new(Game, rated, nil, {
choice = function(yes)
if not yes then
close()
return
end
self:dexRating(close)
end,
}))
end))
end
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm): -- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
-- the completion line with seen AND owned counts, then the per-decade -- the completion line with seen AND owned counts, then the per-decade
-- rating text. -- rating text.
function OverworldState:dexRating(onDone) function OverworldState:dexRating(onDone)
require("src.core.Sound").play(Game.data, "Pokedex_Rating")
local seen, owned = 0, 0 local seen, owned = 0, 0
for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end for _ in pairs(Game.save.pokedex.seen or {}) do seen = seen + 1 end
for _ in pairs(Game.save.pokedex.owned or {}) do owned = owned + 1 end for _ in pairs(Game.save.pokedex.owned or {}) do owned = owned + 1 end
@@ -2607,7 +2681,15 @@ function OverworldState:dexRating(onDone)
completion = completion completion = completion
:gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen)) :gsub("{NUM:hDexRatingNumMonsSeen[^}]*}", tostring(seen))
:gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned)) :gsub("{NUM:hDexRatingNumMonsOwned[^}]*}", tostring(owned))
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone)) -- DisplayDexRating prints the completion line, then the tier text, and
-- only then plays the rating jingle and waits for a button -- the fanfare
-- must not pre-empt the evaluation it celebrates (#576). auto.wait hands
-- the box to the plain A/B path once the jingle has sounded.
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, {
auto = { wait = true, sound = function()
return require("src.core.Sound").play(Game.data, "Pokedex_Rating")
end },
}))
end end
-- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls -- AnimateHealingMachine (engine/overworld/healing_machine.asm): balls
@@ -3136,6 +3218,7 @@ function OverworldState:onStepComplete()
-- dismounting a surf: landing on a walkable cell ends it -- dismounting a surf: landing on a walkable cell ends it
if p.surfing and self.map:isWalkableCell(p.cellX, p.cellY) then if p.surfing and self.map:isWalkableCell(p.cellX, p.cellY) then
p.surfing = false p.surfing = false
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, false) require("src.core.Music").setSurfing(Game.data, false)
end end
@@ -3437,6 +3520,7 @@ function OverworldState:checkForcedMovement()
end end
elseif tile.mode == "surf" then elseif tile.mode == "surf" then
p.surfing = true p.surfing = true
self:syncSurfingPikachu()
require("src.core.Music").setSurfing(Game.data, true) require("src.core.Music").setSurfing(Game.data, true)
end end
return false return false
@@ -3715,6 +3799,7 @@ end
function OverworldState:warpToHealPoint(onDone, opts) function OverworldState:warpToHealPoint(onDone, opts)
local heal = self:healPoint() local heal = self:healPoint()
self.player.surfing = false self.player.surfing = false
self:syncSurfingPikachu()
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear -- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm) -- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil Game.save.forcedBike = nil
@@ -3829,7 +3914,15 @@ end
-- dev-mode hot reload). The neighbors go too: their strips render the -- dev-mode hot reload). The neighbors go too: their strips render the
-- same tileset. When the active map is the one that changed, the player -- same tileset. When the active map is the one that changed, the player
-- is clamped back in bounds, the NPC pool is reused so runtime handles -- is clamped back in bounds, the NPC pool is reused so runtime handles
-- survive, and the tile-pair table is re-read. -- survive, and the tile-pair table is re-read. keepMusic: a reload is not a
-- map entry. Its counterpart ReloadMapData (home/reload_tiles.asm) only
-- re-reads the map view and the tileset tile patterns after the Pokedex /
-- start menu / PC clobbered VRAM; map music starts from LoadMapData alone
-- (home/overworld.asm, gated on BIT_NO_MAP_MUSIC). Whatever is playing
-- belongs to the state on top, so a COLORS cycle during a battle
-- (PaletteFX.setMode reloads the live map to rebuild its baked atlas) must
-- not drop the route theme over the battle song (#484). The out-of-bounds
-- fallback below is a real map change and keeps its map music.
function OverworldState:reloadMap(mapId, reason) function OverworldState:reloadMap(mapId, reason)
MapLoader.invalidate(mapId) MapLoader.invalidate(mapId)
for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end for _, nb in ipairs(self.neighbors or {}) do MapLoader.invalidate(nb.map.id) end
@@ -3837,7 +3930,8 @@ function OverworldState:reloadMap(mapId, reason)
local p = self.player local p = self.player
local x, y, facing = p.cellX, p.cellY, p.facing local x, y, facing = p.cellX, p.cellY, p.facing
Collision.load(Game.data) Collision.load(Game.data)
self:setMap(mapId, x, y, facing, { seamless = true, via = "reload" }) self:setMap(mapId, x, y, facing,
{ seamless = true, via = "reload", keepMusic = true })
if not self.map:inBounds(x, y) then if not self.map:inBounds(x, y) then
local heal = self:healPoint() local heal = self:healPoint()
Logger.warn("map %s reloaded out from under the player; sending to %s", Logger.warn("map %s reloaded out from under the player; sending to %s",
@@ -4568,9 +4662,9 @@ function OverworldState:drawUI()
-- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm -- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a -- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a
-- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The -- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The
-- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the -- script's base frame is ripped as pikachu/pikapic_N.png (#561) but the
-- front pic stands in for every frame of the script; PikachuFollower -- pikaframe overlays on top of it are not, so PikachuFollower
-- .picLift lifts it on the runs that draw the alternate pose, and the -- .picLift lifts the base on the runs that draw the alternate pose, and the
-- script's own duration times the beat (#407, #424). Palette zone -- script's own duration times the beat (#407, #424). Palette zone
-- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above. -- PAL_PIKACHU_PORTRAIT covers (7,6)-(11,10) via sgbPalettes above.
if self.emote and self.emote.pikaPic then if self.emote and self.emote.pikaPic then
+79 -8
View File
@@ -518,9 +518,12 @@ end
-- pikachu_emotions.asm): pick a scripted emotion, then play its bubble -- pikachu_emotions.asm): pick a scripted emotion, then play its bubble
-- and voiced PCM clip, and raise the framed Pikachu picture the original -- and voiced PCM clip, and raise the framed Pikachu picture the original
-- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm -- puts over the map (pikaemotion_pikapic -> pikachu_pic_animation.asm
-- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. The -- PlacePikapicTextBoxBorder), drawn by OverworldController:drawUI. Each
-- per-emotion animation frames (gfx/pikachu/unknown_*) are not extracted, -- script's BASE 5x5 frame is ripped as pikachu/pikapic_N.png (#561); the
-- so the front pic stands in for all twenty of them (#407). -- pikaframe overlays it alternates with are a second full-body pose out of
-- the same blob and are still unported, so picLift below stands in for
-- their motion and the battle front pic covers caches built before the
-- rip (#407).
-- --------------------------------------------------------------------- -- ---------------------------------------------------------------------
-- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm -- PikachuEmotionTable, reduced to each entry's bubble + pikaemotion_pcm
@@ -715,9 +718,17 @@ function PikachuFollower.talk(game, ow, npc, done)
-- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame; -- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame;
-- Sprites.path keeps a mod's replacement skin in play. -- Sprites.path keeps a mod's replacement skin in play.
local Sprites = require("src.pokemon.Sprites") local Sprites = require("src.pokemon.Sprites")
local pic = Sprites.path(game.data, "PIKACHU", "front", -- The chosen script's own base frame (its first pikapic_loadgfx, ripped as
{ kind = "overworld" }) -- pikachu/pikapic_N.png). Red/Blue have no such art and Yellow caches
local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1] -- built before the rip do not carry it, so both fall back to the battle
-- front pic that stood in for every script before (#561).
local script = PIKAPIC_SCRIPT[emotion] or emotion
local pic = "assets/generated/pikachu/pikapic_" .. script .. ".png"
if not require("src.render.Assets").exists(pic) then
pic = Sprites.path(game.data, "PIKACHU", "front",
{ kind = "overworld" })
end
local anim = PIKAPIC[script] or PIKAPIC[1]
local hold = anim.dur * PIKAPIC_TICK local hold = anim.dur * PIKAPIC_TICK
ow.emote = { ow.emote = {
npc = npc, frames = hold, bubble = bi or false, pikaPic = pic, npc = npc, frames = hold, bubble = bi or false, pikaPic = pic,
@@ -778,6 +789,14 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
return return
end end
if game.save.flags.EVENT_MET_BILL_2 then return end if game.save.flags.EVENT_MET_BILL_2 then return end
-- BillsHouseScript0 (scripts/BillsHouse.asm:41-47) only runs the confused
-- walk while CheckPikachuStatusCondition comes back clear
-- (engine/pikachu/pikachu_status.asm:140, carry when the starter's status
-- byte is nonzero); a statused starter keeps trailing the player instead,
-- and that is the one state that lets onBillWalksAroundPlayer below fire,
-- since the confused beat ends in DisablePikachuFollowingPlayer (#455).
local starter = PikachuFollower.starterInParty(game.save)
if starter and starter.status then return end
local npc = findFollower(ow) local npc = findFollower(ow)
if not npc then return end if not npc then return end
ow.pikachuBillsScene = true ow.pikachuBillsScene = true
@@ -786,14 +805,66 @@ function PikachuFollower.onBillsHouseEnter(game, ow)
end) end)
end end
-- BillsHousePikachuWatchPlayer (scripts/BillsHouse_2.asm:133-156), reached
-- from BillsHouseScript2 (scripts/BillsHouse.asm:58-69) only when the player
-- faces down -- so Bill has to walk around them -- and Pikachu is still
-- following, i.e. the confused beat above was skipped. Both tables go
-- through TryApplyPikachuMovementData
-- (engine/events/try_pikachu_movement.asm:1-15), which keeps the data only
-- when the caller's b equals GetPikachuFacingDirectionAndReturnToE
-- (engine/pikachu/pikachu_follow.asm:1110-1151). That byte is not the
-- sprite's facing: it is Pikachu's position relative to the player, UP when
-- Pikachu's MapY sits above the player's, DOWN below, and on a shared row
-- LEFT when west, RIGHT when east ($ff on the same cell). So WatchPlayer1
-- (b = SPRITE_FACING_UP) means "Pikachu stands above the player" and
-- WatchPlayer2 (b = SPRITE_FACING_RIGHT) means "Pikachu stands level with
-- and east of the player". ApplyPikachuMovementData_ walks a whole table
-- synchronously (engine/pikachu/pikachu_movement.asm:9-18), so the second
-- call does observe the finished walk; the two are mutually exclusive by
-- geometry rather than by timing, since both routes end at
-- (playerX - 1, playerY), west of the player, where the RIGHT test fails.
-- Each closes on PIKAMOVEMENT_LOOK_RIGHT, leaving Pikachu watching the
-- player while Bill talks; PIKAMOVEMENT_DELAY is dropped here as in the
-- beats above (#455).
function PikachuFollower.onBillWalksAroundPlayer(game, ow)
if not (GameVersion.isYellow() and ow.map and ow.map.id == "BILLS_HOUSE")
or ow.pikachuBillsScene then
return
end
local npc = findFollower(ow)
if not npc or not ow.player then return end
local steps
if npc.cellY < ow.player.cellY then -- PikachuMovement_WatchPlayer1
steps = { { "left", 1 }, { "down", 1 } }
elseif npc.cellY == ow.player.cellY
and npc.cellX > ow.player.cellX then -- PikachuMovement_WatchPlayer2
steps = { { "up", 1 }, { "left", 2 }, { "down", 1 } }
else
return
end
movePikachu(ow, npc, steps, function() npc.facing = "right" end)
end
function PikachuFollower.onBillEnteredMachine(game, ow) function PikachuFollower.onBillEnteredMachine(game, ow)
if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end if not (GameVersion.isYellow() and ow.pikachuBillsScene) then return end
local npc = findFollower(ow) local npc = findFollower(ow)
if not npc then return end if not npc then return end
-- BillsHouseScript3 (pokeyellow scripts/BillsHouse.asm:100-115). The two
-- movement tables are named the wrong way round in the cartridge source:
-- hl is seeded with ..._EnterCellSeparatorDown, then
-- and a ; cp SPRITE_FACING_DOWN (SPRITE_FACING_DOWN is 0)
-- jr nz, .applyPikachuMovement
-- keeps that seeded table when the player is NOT facing down, and only the
-- fallthrough -- facing down -- swaps in ..._EnterCellSeparatorNotDown.
-- So facing down takes the long way round the cell separator and every
-- other facing walks straight up. Following the label names instead of the
-- branch inverts the scene (#455).
local steps = ow.player.facing == "down" local steps = ow.player.facing == "down"
and { { "up", 3 } } and { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } }
or { { "up", 1 }, { "left", 1 }, { "up", 2 }, { "right", 1 } } or { { "up", 3 } }
movePikachu(ow, npc, steps, function() movePikachu(ow, npc, steps, function()
-- PIKAMOVEMENT_LOOK_UP closes the detour table before the bubble
npc.facing = "up"
billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE") billsHouseEmotion(game, ow, npc, "QUESTION_BUBBLE")
end) end)
end end
+51 -7
View File
@@ -15,11 +15,21 @@ local STEP_FRAMES = 16
-- a turn in place blocks movement for the one extra OverworldLoop pass the -- a turn in place blocks movement for the one extra OverworldLoop pass the
-- original spends after a direction change: .handleDirectionButtonPress ends -- original spends after a direction change: .handleDirectionButtonPress ends
-- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two -- `jp OverworldLoop` (home/overworld.asm), and OverworldLoop burns two
-- DelayFrame calls before the next JoypadOverworld, so the sample that can -- DelayFrame calls before the next JoypadOverworld, so the step can only
-- commit to a step lands exactly 2 fixed steps after the turn -- the same -- commit at the following poll -- the same 2-frames-per-iteration cadence
-- 2-frames-per-iteration cadence that makes STEP_FRAMES 16 above -- that makes STEP_FRAMES 16 above (wWalkCounter = 8, 2px per
-- (wWalkCounter = 8, 2px per AdvancePlayerSprite) (#415) -- AdvancePlayerSprite).
local TURN_FRAMES = 2 -- Those polls sit on a 2-frame grid, so the hardware samples a press 0 or 1
-- frames after the d-pad physically goes down and the release deadline lands
-- 2 or 3 frames after that. We sample on the frame the button goes down
-- with none of that poll latency, so a flat 2 handed every tap the tightest
-- case the original could produce; 4 covers the grid instead of
-- undercutting it (#415).
local TURN_FRAMES = 4
-- The on-screen d-pad cannot produce a 60ms tap: a finger press and release
-- run well past it even before the OS batches the touch events, so the
-- overlay gets a longer window than a physical pad (#415).
local TOUCH_TURN_FRAMES = 8
function Player.new(data, cx, cy, facing) function Player.new(data, cx, cy, facing)
local self = setmetatable({}, Player) local self = setmetatable({}, Player)
@@ -31,11 +41,18 @@ function Player.new(data, cx, cy, facing)
-- LoadSurfingPlayerSpriteGraphics, home/overworld.asm) -- LoadSurfingPlayerSpriteGraphics, home/overworld.asm)
local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk") local walkId = FieldDefaults.fieldValue(data, "playerSprites", "walk")
local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf") local surfId = FieldDefaults.fieldValue(data, "playerSprites", "surf")
local surfPikaId = FieldDefaults.fieldValue(data, "playerSprites", "surfPikachu")
local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike") local bikeId = FieldDefaults.fieldValue(data, "playerSprites", "bike")
self.sprite = SpriteRenderer.new(data.sprites[walkId], "player") self.sprite = SpriteRenderer.new(data.sprites[walkId], "player")
if surfId and data.sprites[surfId] then if surfId and data.sprites[surfId] then
self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player") self.surfSprite = SpriteRenderer.new(data.sprites[surfId], "player")
end end
-- Yellow's surfing-Pikachu ride (Yellow LoadSurfingPlayerSpriteGraphics2,
-- paired with field.playerSprites.surfPikachu). rotated in at pose()
-- when the SURF-mon is a Pikachu.
if surfPikaId and data.sprites[surfPikaId] then
self.surfPikachuSprite = SpriteRenderer.new(data.sprites[surfPikaId], "player")
end
if bikeId and data.sprites[bikeId] then if bikeId and data.sprites[bikeId] then
self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player") self.bikeSprite = SpriteRenderer.new(data.sprites[bikeId], "player")
end end
@@ -67,6 +84,11 @@ function Player.new(data, cx, cy, facing)
self.progress = 0 self.progress = 0
self.stepFlip = false self.stepFlip = false
self.turnTimer = 0 self.turnTimer = 0
-- wCheckFor180DegreeTurn (home/overworld.asm): the original only lets a
-- turn in place happen on a poll whose previous pass found no direction
-- held. It starts armed, tryMove spends it, and OverworldState:handleInput
-- re-arms it from a standstill.
self.turnArmed = true
self.inputLocked = false self.inputLocked = false
return self return self
end end
@@ -75,14 +97,35 @@ function Player:position()
return self.cellX, self.cellY return self.cellX, self.cellY
end end
-- How long a fresh turn holds the step off for; see TURN_FRAMES. The
-- overlay is detected per source rather than by whether the touch controls
-- are on screen, so a phone with a controller attached still gets the
-- physical pad's window (Input:isTouchDown, src/core/Input.lua).
function Player:turnWindow()
local frames = self.turnFrames or TURN_FRAMES
local input = require("src.core.Game").input
if input and input.isTouchDown and input:isTouchDown(self.facing) then
return math.max(frames, TOUCH_TURN_FRAMES)
end
return frames
end
-- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil. -- Attempt to start a step; returns "moved"|"turned"|"blocked"|nil.
function Player:tryMove(dir, map, entities) function Player:tryMove(dir, map, entities)
if self.moving or self.inputLocked then return nil end if self.moving or self.inputLocked then return nil end
if self.facing ~= dir then if self.facing ~= dir then
self.facing = dir self.facing = dir
self.turnTimer = self.turnFrames or TURN_FRAMES
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
return "turned" -- .handleDirectionButtonPress only reaches the turn while
-- wCheckFor180DegreeTurn is still set, and .noDirectionButtonsPressed is
-- the one place that sets it (home/overworld.asm), so a facing change
-- made without the d-pad ever coming up steps straight away rather than
-- paying the turn delay at every corner (#415)
if self.turnArmed then
self.turnArmed = false
self.turnTimer = self:turnWindow()
return "turned"
end
end end
if self.turnTimer > 0 then return nil end if self.turnTimer > 0 then return nil end
local ok, why = Collision.canMove(map, entities, self, dir) local ok, why = Collision.canMove(map, entities, self, dir)
@@ -252,6 +295,7 @@ function Player:pose()
-- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState -- RodResponse (engine/items/item_effects.asm) zeroes wWalkBikeSurfState
-- across FishingAnim, so casting from the water shows the on-foot sheet -- across FishingAnim, so casting from the water shows the on-foot sheet
local sprite = (self.fishing and self.sprite) local sprite = (self.fishing and self.sprite)
or (self.surfing and self.surfingPikachu and self.surfPikachuSprite)
or (self.surfing and self.surfSprite) or (self.surfing and self.surfSprite)
or (self.onBike and self.bikeSprite) or self.sprite or (self.onBike and self.bikeSprite) or self.sprite
return sprite, self.px, py, facing, phase, flip, hopping return sprite, self.px, py, facing, phase, flip, hopping

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