Compare commits

..

244 Commits

Author SHA1 Message Date
bryanthaboi f2ea250364 Merge pull request #739 from bryanthaboi/dev
ui updates and responsiveness - and bug fixes
2026-08-03 12:04:47 -04:00
bryanthaboi a0ab949399 Merge pull request #738 from johnjohto/fix-yellow-route18-gate-trade
Wire up Yellow's Route 18 gate trade cook (#651)
2026-08-03 12:00:09 -04:00
bryanthaboi 30ed68ae22 Merge pull request #736 from johnjohto/fix-flavor-talk-double-text
Show only one branch of the gated flavor talks (#719)
2026-08-03 11:59:36 -04:00
bryanthaboi f2aa376304 Merge pull request #733 from johnjohto/fix-fuchsia-exhibit-pokedex
Show the exhibited species' Pokédex entries in Fuchsia City (#646)
2026-08-03 11:58:43 -04:00
bryanthaboi 1b48862923 Merge pull request #732 from jherediagu/fix/rom-text-move-effects
Extend ROM-text messages to move effects and the overworld
2026-08-03 11:58:26 -04:00
bryanthaboi 25417ffac8 Update IntroMovie.lua 2026-08-03 11:56:56 -04:00
johnjohto 4dfbc5a828 Wire up Yellow's Route 18 gate trade cook (#651) 2026-08-03 11:52:48 -04:00
bryanthaboi f0f5bc7551 big ui moment 2026-08-03 11:50:49 -04:00
johnjohto f002db2929 Show only one branch of the gated flavor talks (#719) 2026-08-03 11:22:40 -04:00
johnjohto c5791574f5 Show the exhibited species' Pokédex entries in Fuchsia City (#646) 2026-08-03 11:01:39 -04:00
Juan Heredia 8c1fbfb429 Extend ROM-text messages to move effects and the overworld 2026-08-03 16:18:44 +02:00
bryanthaboi 8e5501a23b Merge pull request #728 from hernan0078/ios-picker-dismiss
iOS: a file picker dismissed by swiping locks out every later picker
2026-08-03 09:43:02 -04:00
bryanthaboi 6ed41c3b87 Merge pull request #725 from castdrian/metal 2026-08-03 08:57:49 -04:00
hernan 7414c176d9 ios: a picker dismissed by swiping locks out every later picker
UIDocumentPickerViewController reports an interactive dismissal only through
UIAdaptivePresentationControllerDelegate. PickerDelegate implemented
didPickDocumentsAt and documentPickerWasCancelled, so a sheet swiped away
reached neither, onFinish never ran, and the delegate stayed in
liveDelegates -- after which the re-present guard refused every later picker
while still answering true.

Implements the dismissal callback, and self-heals when UIKit is presenting
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 08:34:35 -04:00
Adrian Castro 01f68d4038 fix(ios): consolidate mobile integration changes 2026-08-03 11:09:30 +02:00
bryanthaboi 21276e20d1 Merge pull request #707 from ShaneMcGovernIE/fix/champion-theme-timing 2026-08-02 21:26:03 -04:00
bryanthaboi 9fb9648e4c Merge pull request #708 from ShaneMcGovernIE/fix/picker-path-sanitizing 2026-08-02 21:25:43 -04:00
bryanthaboi af7689a1bc Merge pull request #710 from ShaneMcGovernIE/fix/faint-cry-parity 2026-08-02 21:25:20 -04:00
Shane McGovern 175bff4b29 Match the original faint sound sequence per side (#709)
pokered plays no 'pitched-down faint cry': the player mon's faint is its
ordinary species cry (RemoveFaintedPlayerMon -> PlayCry) with no
Faint_Fall, and the enemy faint plays no species cry at all -- trainer
battles get SFX_FAINT_FALL then SFX_FAINT_THUD, wild battles go straight
to the victory music (FaintEnemyPokemon core.asm:732-796).

The port played the species cry AND Faint_Fall on every faint, so a
fainted enemy sounded its full battle cry and a fainted player mon got
the fall whistle the hardware never plays.

BattleState.onFaint now:
- player: Sound.playCry only
- enemy trainer: Faint_Fall then Faint_Thud (after the slide)
- enemy wild: no faint sfx (victory music already queued)

Adds tests/parity_faint_cry_bug709.lua asserting the per-side sequence.

Fixes #709
2026-08-03 01:55:52 +01:00
Shane McGovern 7c1cdea2eb Sanitize picker file paths (spaces / non-ANSI) and shell prompts
Issue #665: the Windows ROM and save pickers returned the raw chosen
path. io.open on Windows needs ANSI bytes, so a path with accented
characters (Pokemon -> Pok\x82mon, or a folder like 'Pokemon Gen1')
could never be opened -- the same bug #325 already fixed for mod zips
by copying to a plain-ASCII temp name.

Apply that fix to the ROM and .sav pickers: each copies its pick to an
ASCII temp name (pokeport_rom_pick.gb / pokeport_sav_pick.sav) before
answering, exactly like chooseZip does.

Also sanitize the prompt strings interpolated into the picker shell
commands: '%' would be eaten as a string.format directive, and quotes
would break the AppleScript/zenity argument or the surrounding shell
string.

Fixes #665
2026-08-03 01:04:59 +01:00
Shane McGovern 2bfa491b6a Start champion theme when rival dialogue ends, not at battle
The Elite Four champion script showed the rival's intro text and then
jumped straight into the battle; the wipe-time playBattle('final')
started Music_FinalBattle only when the battle began. The original
(ChampionsRoomRivalReadyToBattleScript) plays MUSIC_FINAL_BATTLE right
after the dialogue ends, before the battle.

Add a play_music Music_FinalBattle row between the intro text and the
battle, and bump the two past-the-end jump targets 25 -> 26 for the new
row count. pushBattle's wipe-time playBattle('final') no-ops on the
already-playing song, so the theme stays continuous into the fight.

Fixes #706
2026-08-03 00:52:46 +01:00
github-actions 41fbfc9ba6 chore(ios): update app-repo.json [skip ci] 2026-08-02 19:15:43 -04:00
bryanthaboi 1fa7c0eb43 Merge pull request #705 from bryanthaboi/dev 2026-08-02 19:11:23 -04:00
bryanthaboi 839b238e74 Merge pull request #699 from ShaneMcGovernIE/feature/3x-speed-and-shoulder-hotkeys 2026-08-02 19:08:25 -04:00
Shane McGovern ec22d57cd1 Document GAME SPEED hotkeys in README
Maintainer review on #699 asked to document the hotkeys. Added key 1
(cycle speed up) and controller R2/L2 to the README Hotkeys table, and
GAME SPEED to the Options-menu note.
2026-08-03 00:07:31 +01:00
bryanthaboi 23ef283272 Merge pull request #701 from ShaneMcGovernIE/fix/revive-exp-participants 2026-08-02 19:02:16 -04:00
bryanthaboi 327579f9b6 Merge pull request #700 from ShaneMcGovernIE/fix/pp-up-stats-display 2026-08-02 19:02:06 -04:00
Shane McGovern e5f9d2903d Fix revived Pokemon not receiving experience share
When a Pokemon faints in battle, onFaint() clears it from the battle
participant set. The revive item restored HP but never re-added the
mon to self.participants, so at awardExp() time the revived mon passed
the HP check but failed the participant gate -- getting no exp.

Fix: re-add the revived mon to battle.participants in the revive item
effect, so it's counted as a participant and receives its share of
experience at battle end.

Fixes #648
2026-08-02 23:22:34 +01:00
Shane McGovern 1f3aa4e111 Fix PP UP max PP not shown on stats/summary screen
The SummaryMenu (pause menu stats screen and in-battle stats screen)
displayed the base PP from the move definition as the max value,
ignoring PP Up bonuses. After using a PP UP, the screen would show
e.g. 6/5 instead of 6/6.

Fix: calculate maxPP with the PP Up bonus (basePP + ppUps * basePP/5),
matching the formula used everywhere else -- battle fight menus,
ETHER restore, Pokemon Center heal, link protocol, and save editor.

Fixes #641
2026-08-02 23:16:10 +01:00
Shane McGovern 3f88d1c49a Add 3X game speed and R2/L2 shoulder button speed hotkeys
Add 3X as a speed option between 2X and 4X in GameSpeed.LEVELS (#677).

Add controller hotkeys: rightshoulder (R2) cycles speed up through the
level list, leftshoulder (L2) cycles speed down. Keyboard equivalent
is hotkey 1 (cycles up). All hotkeys are gated during transitions,
scripted cutscenes, and link play (same guards as the color hotkey
at key 2).

The _cycleSpeed helper wraps the save-options update with the same
busy/overworld guard used by the existing color-cycle hotkey.

Fixes #677
2026-08-02 23:10:06 +01:00
bryanthaboi 5348ba1c65 Merge pull request #690 from spiritsnails/fix/faithful-ratio-mobile 2026-08-02 17:58:41 -04:00
bryanthaboi 2f35fd44a1 Merge pull request #693 from ShaneMcGovernIE/fix/celadon-diner-table-alias 2026-08-02 17:58:17 -04:00
bryanthaboi 470fe70f07 Merge pull request #696 from ShaneMcGovernIE/fix/oaks-lab-rival-exit-music 2026-08-02 17:57:58 -04:00
bryanthaboi 399bf557f3 Merge pull request #698 from ShaneMcGovernIE/fix/pc-back-and-hole-sfx 2026-08-02 17:57:44 -04:00
Shane McGovern fc2ca6cc26 Fix PC B-button navigation and hole-fall sound effect
Issue #695: Pressing B in PC submenus (BoxMenu, PlayerPC) was exiting
the entire PC session instead of returning to the main PC menu. The
three main-menu items (Bill's PC, player's PC, Prof. Oak's PC) were
missing keepOpen=true, so selecting one popped the main menu off the
stack. Added keepOpen to all three, matching the pattern already used
by BoxMenu and PlayerPC's own rows.

Issue #694: Falling through boulder holes in Seafoam Islands, Victory
Road, and Pokemon Mansion played no sound effect. Added Faint_Fall sfx
before every hole warp -- the scripted onStep holes in seafoam.lua,
story.lua, and story6.lua, plus the warp-tile-based hole detection in
OverworldController takeWarp. Faint_Fall is the companion to Faint_Thud
(already played when boulders fall into holes).

Fixes #695
Fixes #694
2026-08-02 22:11:15 +01:00
Shane McGovern d9d42ec956 Play rival encounter music when rival leaves Oak's Lab after battle
The parcel scene in Oak's Lab plays Music_MeetRival on both the rival's
arrival and departure (lines 144-146 in oaks_lab.lua), but the post-battle
onStep exit sequence only played the fanfare when the rival approached
(fixed in #596). It was missing when the rival walks out after the battle.

Add stop_music + play_music Music_MeetRival before the rival's exit
walk-out in both oaks_lab.lua and oaks_lab_yellow.lua, matching the
parcel scene's double-fanfare pattern from the original ROM.

Fixes #683
2026-08-02 21:49:56 +01:00
Shane McGovern 5e41c74682 Add tile aliases for LOBBY table blocks 45 and 49
The Celadon Diner uses three LOBBY table blocks that share tile 0x37 on
their flat surfaces. Only block 29 had the 0x37->0x5a BROWN alias; blocks
45 and 49 showed raw tile 0x37 in ROOF (blue-gray), creating a blue
square on the second/third tables with the Advanced Colors preset.

Add alias entries for blocks 45 (cells 13/14) and 49 (cells 1/2).

Fixes #689
2026-08-02 21:20:28 +01:00
spiritsnails 02ad846dfa fix: FAITHFUL RATIO works on Android and iOS
apply() returned false on its first line for mobile, so the option did
nothing there. A phone has no window to resize, so the lock caps the
render scale instead: the largest whole multiple of 160x144 the display
holds, centred, black around it.

Two parts beyond that. The scale is read off the display rather than from
the desktop's 1X-4X ladder, which named a different fraction of every
device and left the useful levels off the list; mobile shows ON or OFF.
And the world pass, which expands to cover the whole display so letterbox
becomes more map, is now sized against the locked viewport, so the lock
reaches the overworld instead of showing more of it.

Pixel perfect throughout, whole multiples only. Desktop and OFF are
unchanged. Renames the row to FAITHFUL RATIO on both platforms; the saved
key stays faithfulRes so existing settings carry over.
2026-08-02 12:44:38 -06:00
bryanthaboi 4e7eda65ed Merge pull request #685 from castdrian/metal 2026-08-02 14:34:08 -04:00
Adrian Castro 3fabe4f591 fix(ios): use square mobile icon in AltSource 2026-08-02 19:27:55 +02:00
github-actions 81234ef2ab chore(ios): update app-repo.json [skip ci] 2026-08-02 13:21:15 -04:00
bryanthaboi 4bd28390ea Merge pull request #680 from bryanthaboi/dev 2026-08-02 13:13:50 -04:00
bryanthaboi fa886899fc Merge pull request #653 from jherediagu/fix/oak-intro-name-confirmation 2026-08-02 13:11:51 -04:00
bryanthaboi a81126a03a Merge pull request #667 from castdrian/metal 2026-08-02 13:11:27 -04:00
bryanthaboi 164c555bb4 Merge pull request #669 from jherediagu/fix/battle-messages-use-rom-text 2026-08-02 13:10:15 -04:00
bryanthaboi e5926893a9 Merge pull request #670 from ShaneMcGovernIE/fix/oak-starter-jingle-668 2026-08-02 13:07:38 -04:00
bryanthaboi dbcaf705c2 Merge pull request #672 from ShaneMcGovernIE/fix/faint-animation-671 2026-08-02 13:05:23 -04:00
bryanthaboi f6809be81d Merge pull request #678 from spiritsnails/feat/ui-layout-option 2026-08-02 13:03:29 -04:00
spiritsnails 3ddf70888e test: ui_layout_option runs ROM-free
The row assertions called Data:load(), which needs data/generated/. The
T1/T2 tier runs without a ROM in CI, so the suite died on the import
rather than failing an assertion. Use T.fixtures.load() like the other
engine suites do.

Verified by moving data/generated aside and re-running: 20/20 with no
imported data present.
2026-08-02 10:54:04 -06:00
spiritsnails fe7dcf33ec feat: UI LAYOUT option, centered by default
Edge docking and zoom-linked UI scaling shipped as unconditional
behaviour. Both are departures from how the port composed the screen, so
they become a setting instead: UI LAYOUT = CENTERED (the default) or
DYNAMIC.

CENTERED is a fixed letterbox. Elements stay where they were drawn in the
160x144 canvas, and the UI does not follow the survey zoom, so screen
furniture neither moves nor resizes under the player. That is what the
pre-anchoring builds did. DYNAMIC is the current behaviour, unchanged.

Both halves matter together: gating only the anchoring would stop the
dialogue box moving but leave it resizing with the zoom, which is the same
complaint in a different form.

Gated at Renderer:setUIAnchor and Renderer:uiScale rather than at each
caller, so one switch covers the dialogue box, its YES/NO, the START menu
and anything anchored later, and no caller knows the option exists.
Game.dynamicUI answers true only for an explicit "dynamic", so a save
written before this keeps the layout it already had.

Independent of it, deliberately: BATTLE SIZE still works under either mode
(uiFill overrides the scale later, in endFrame), and a battle still holds
its own prompts inside its screen under DYNAMIC.

Also includes the Oak intro fix (previously #674): the speech fills white
over the UI canvas while its dialogue box docks to the window edge, so
under DYNAMIC black showed between the two. letterboxWhite closes it, and
the shrink beat's replica box rides the same anchor as the real box it
stands in for.
2026-08-02 10:47:27 -06:00
Shane McGovern 733450bf86 Fix faint slide starting partway down (#671)
The faint slide was shortened from 30 to Timing.FAINT_SLIDE (14) frames
in the timing-parity pass, but fxFaintOffset still computed the offset
with a stale (30 - frames) * 2.  With frames starting at 14 the sprite
teleported 32px down on the first frame and only slid the remaining
28px, cutting the animation short.

SlideDownFaintedMonPic drops the pic one 8px row per 2-frame step, so
the offset advances Timing.FAINT_SLIDE_STEP (4px) per frame at 1x and
covers the full 56px PIC_HEIGHT over the 14-frame budget.
2026-08-02 16:34:02 +01:00
Shane McGovern 1dae9622e1 Play the jingle when Oak hands over the starter (#668)
The starter balls' scripts showed the received-mon text but never played
the sound_get_key_item fanfare that the text carries in the original
(scripts/OaksLab.asm OaksLabReceivedMonText / OaksLabRivalReceivedMonText).
Add play_sound Get_Key_Item before each received text, mirroring the
Yellow starter port.
2026-08-02 16:21:49 +01:00
Juan Heredia da0fa5c9ad Use the ROM's own battle text instead of paraphrasing it 2026-08-02 17:09:08 +02:00
Adrian Castro e576dea676 fix(ios): silence remaining build warnings and refresh artifact comments 2026-08-02 17:07:13 +02:00
github-actions 4cac51a831 chore(ios): update app-repo.json [skip ci] 2026-08-02 08:55:51 -04:00
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
Juan Heredia b6a397460e fix: Play Oak's name confirmation lines 2026-08-02 08:18:33 +02: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
bryanthaboi 898bf0c71e Merge pull request #560 from bryanthaboi/dev 2026-07-31 23:55:26 -04:00
bryanthaboi 5a7add8eaa Merge pull request #554 from andrewqsantos/feature/mobile-safe-area 2026-07-31 23:54:11 -04:00
bryanthaboi 66334dfacd Merge pull request #558 from erereck/fix/mod-configurable-bag-capacity 2026-07-31 23:53:54 -04:00
bryanthaboi a33f3b1ceb mobile fixes 2026-07-31 23:47:28 -04:00
erereck 6e724cedf7 Honor modded bag capacity 2026-08-01 00:32:00 -03:00
bryanthaboi e949c78639 Merge pull request #556 from bryanthaboi/dev 2026-07-31 23:03:09 -04:00
bryanthaboi 9bcfdb1f0d mobile fixes 2026-07-31 23:01:25 -04:00
Andrew Quenehen fccb122c59 Respect iOS/Android safe areas in launcher and touch chrome.
Layout interactive UI against love.window.getSafeArea so notch, Dynamic Island, and home-indicator insets no longer clip controls, while keeping the game framebuffer edge-to-edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 23:53:26 -03:00
bryanthaboi e2b2a5fb9f Merge pull request #555 from bryanthaboi/dev 2026-07-31 22:41:53 -04:00
bryanthaboi f2a3e5f05b mobile updates CLOSES #482, CLOSES #553 2026-07-31 22:39:01 -04:00
bryanthaboi 5f855568c8 Merge pull request #539 from hernan0078/ios-support 2026-07-31 22:16:24 -04:00
bryanthaboi 24696e3be2 Merge pull request #524 from kaosregulator/claude/multi-game-low-end-support-o16gz5
Claude/multi game low end support  and some fixes o16gz5
2026-07-31 20:26:09 -04:00
bryanthaboi b17dce232e Merge pull request #547 from bryanthaboi/dev
bug squashing and what not
2026-07-31 20:21:37 -04:00
bryanthaboi e1b20723fd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-31 20:20:38 -04:00
bryanthaboi fdab15a6dc CLOSES #503, CLOSES #511, CLOSES #515, CLOSES #518, CLOSES #522, CLOSES #523, CLOSES #525, CLOSES #528, CLOSES #529, CLOSES #533, CLOSES #535, CLOSES #536 2026-07-31 20:20:37 -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
hernan0078 9c5f33e187 Fix #482: guard love.system.pickFile so Import ROM cannot crash
Pressing Import ROM on iOS takes the whole app down:

  src/import/RomImporter.lua: attempt to call field 'pickFile' (a nil value)

love.system.pickFile is a NATIVE BRIDGE, not part of LOVE. It exists only on
builds that compiled one -- Android, and iOS builds patched by
mobile/ios/patch_love_src.py -- so on a build without it the field is simply
nil. RomImporter:546 routes iOS down the same path as Android
(`mobileOS == "Android" or mobileOS == "iOS"`), and all three mobile pick
sites called the field unguarded.

That is why the reports say "any version": nothing about it is version
specific. Red, Blue and Yellow all reach the same call.

Every one of those call sites already handles a device with no document
picker -- Choose falls back to "No picker available, copy your ROM into:"
plus the save directory, and the mod / save rows have their own notices --
and love.system.createFile at its single call site was already guarded this
way. These three were not, so the fallback that was written for exactly this
case could never be reached.

Route them through one small helper that answers false when the bridge is
absent. A build without a picker now degrades to the copy-into-the-save-folder
flow, which on iOS is a working path: the Files app exposes the app's
Documents folder and GRBootstrap sweeps what lands there into the save dir.

tests/rom_importer_no_picker_test.lua covers Import ROM, Import mod and
Import save with the bridge missing, and asserts the picker is still used
when it is present. Reverting the fix reproduces the reported error exactly.

Reported in #482 (confirmed by three people) and #512.
2026-07-31 16:38:15 -04: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
bryanthaboi 4916fc8f4b Merge pull request #532 from TitaniteScale/symlink-mod-support
Recognize symlinked mod dirs on Linux (mods/ dev workflow)
2026-07-31 15:22:48 -04:00
Jake Eaker fc19e58678 added symlink support for mods 2026-07-31 13:47:53 -04:00
bryanthaboi 9610bcfb72 Merge pull request #527 from bryanthaboi/dev
bug fixes and new features
2026-07-31 12:04:18 -04:00
bryanthaboi 1bc252741a rom finder 2026-07-31 11:58:21 -04: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
bryanthaboi 97e3fb296c Update tool_mod_hooks.lua 2026-07-31 10:45:12 -04:00
Claude 74eeb411ba Add luacheck config + lint script
Introduce a tuned .luacheckrc and scripts/lint.sh so the engine has a
standing static-analysis baseline -- the tool that would have caught both
bugs in the previous commit before they shipped.

The config is high-signal by design: it keeps the categories that catch
real defects (undefined globals/locals, unused values, unreachable code)
and mutes the cosmetic ones the codebase deliberately lives with (a self/dt
an interface requires but a method ignores, documented empty fall-through
branches, long lines). It marks `love` mutable (games assign callbacks onto
it) and teaches it LuaJIT's table.unpack.

.luacheckrc is tracked via a .gitignore exception, matching how .github and
.gitignore opt out of the blanket dotfile ignore.

`luacheck src` now reports 7 benign warnings and 0 errors, down from 185.
Also drop one dead `require` (ItemEffects loaded src.pokemon.Pokemon and
never used it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:35:40 +00:00
Claude 040ca3f332 Fix two latent bugs surfaced by static analysis
Both are code paths that never run in a green test today but crash or
misbehave the moment a mod or a link failure exercises them.

1. Music.lua: applyVolume built its `music.volume` hook context from the
   private `state` table, but was defined *above* `local state = {...}`, so
   those reads bound to the nil global `state`. Any mod registering the
   music.volume hook crashed with "attempt to index a nil value (global
   'state')" the first time a volume was applied. Forward-declare `state`
   above applyVolume. Regression test drives a file-backed song through the
   hook and asserts the context resolves.

2. Tournament.lua: `local battle, why = isHost and newHost() or newGuest()`
   had two defects. The and/or idiom truncates a call to its first result,
   so `why` (the specific failure reason) was always dropped and every link
   failure showed the generic "Link battle can't start" instead of e.g.
   "same mods on both games". Worse, when a host's newHost() returned nil,
   the `or` fell through and wrongly called newGuest() as the host. Split
   into an explicit if/else so the reason is preserved and each role calls
   its own constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 14:34:58 +00:00
bryanthaboi 5ec440792e Merge pull request #347 from kevindjacobson/agent/tool-mod-hooks
Add lifecycle and HUD hooks for tool mods
2026-07-31 10:29:04 -04:00
bryanthaboi 9d3ee6a597 mod manager now supports auto updates 2026-07-31 10:19:51 -04:00
bryanthaboi 6c4c9d14e2 Merge pull request #520 from johnjohto/fix-true-color-evolution-trade-494
Fix true-color evolution and trade sprites
2026-07-31 10:18:44 -04:00
bryanthaboi d165189032 Merge pull request #521 from johnjohto/fix-fighting-dojo-master-gate-495
Fix Karate Master gate
2026-07-31 10:08:02 -04:00
johnjohto 84bf25fcd5 Fix Karate Master gate 2026-07-31 09:47:53 -04:00
bryanthaboi 3945b9d078 on screen controller editing CLOSES #327 2026-07-31 09:23:38 -04:00
bryanthaboi 6796804593 CLOSES #505, CLOSES #510, CLOSES #513 2026-07-31 09:08:56 -04:00
johnjohto 858c5bc9b8 Fix true-color evolution and trade sprites 2026-07-31 09:05:19 -04:00
bryanthaboi 71bdbb7e48 CLOSES #519 2026-07-31 08:27:29 -04:00
bryanthaboi c346c78697 Merge pull request #499 from johnjohto/fix-battle-menu-cursor
Fix battle menu cursor edges
2026-07-31 08:14:55 -04:00
bryanthaboi ba2bac5bed Merge branch 'dev' into fix-battle-menu-cursor 2026-07-31 08:14:08 -04:00
bryanthaboi bcfc94d448 Merge pull request #366 from pmarcus93/android-respect-rotation-lock
Respect the device rotation lock on Android
2026-07-31 08:08:19 -04:00
bryanthaboi bebfc87cd4 yellow palette / pikachu yellow 2026-07-31 08:07:23 -04:00
Claude c8e035d332 Add graphics performance tier for low-end devices
Introduce an OPTIONS -> PERFORMANCE setting that scales the port's
optional presentation extras down for weaker hardware, so older/lower-end
devices can run the game smoothly.

The tier governs the three heaviest non-faithful extras -- the 3D TILT,
the GBC FX post-process shader, and survey ZOOM (which renders connected
neighbor maps) -- plus a hard FPS ceiling. It never touches game logic,
which is fixed-step off dt, so every tier plays identically.

- src/core/Performance.lua: tiers (auto/high/balanced/low), a conservative
  device auto-detect (ARM handhelds -> low, phones -> balanced, normal
  desktops -> high), per-tier caps, and the option-row cycle. Zero
  requires, like GameVersion.
- Game:applyOptions clamps the *live* presentation state against the tier
  without rewriting stored options, so a lower tier hides the player's
  TILT/GBC FX/ZOOM/FPS choices and a higher tier restores them exactly.
- Zoom.offsetRange floors the range at FIT when survey is disallowed, so
  the option row, hotkey, and mouse wheel all stop at close-up on LOW.
- New save.options.performance default "auto"; OPTIONS row heads the
  display group and re-applies live.
- Tests: tests/engine/performance_tiers.lua (ROM-free); mod_ui_tests row
  golden updated for the spliced row.

AUTO resolves to HIGH on a normal desktop and on every options.lua that
predates the option, so the common case is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q6bFAiQyZ5jDmewsbB4LG9
2026-07-31 01:44:17 +00:00
bryanthaboi e58929428c Merge pull request #498 from johnjohto/fix-cerulean-badge-house-exit 2026-07-30 18:24:26 -04:00
johnjohto a389416b7b Fix battle menu cursor edges
Battle commands toggled directly between rows and columns. An outward press therefore moved the cursor to the opposite command instead of leaving it at the edge.
2026-07-30 17:53:53 -04:00
johnjohto 0a8bca1a6d Fix Cerulean badge menu exit
The selected badge list stayed on the stack while its description opened. Cancelling the next list only closed that newer menu, leaving the NPC conversation open.
2026-07-30 17:49:02 -04:00
bryanthaboi efad8b325b Merge pull request #490 from bryanthaboi/dev
ios and some bugs
2026-07-30 16:12:24 -04:00
bryanthaboi 4c5f776128 Update README.md 2026-07-30 16:04:34 -04:00
bryanthaboi c1495e0d5e ios 2026-07-30 16:03:35 -04:00
bryanthaboi a97168a4f0 Update ios-install.md 2026-07-30 15:53:49 -04:00
bryanthaboi a50ad30176 Merge pull request #464 from mresnick67/pokewalker-0.2.0-followup
iOS: stop fusing mods; Pokewalker moves to its own repo
2026-07-30 15:51:55 -04:00
bryanthaboi c1f65106de Merge pull request #457 from johnjohto/fix-trainer-evolution-order
Delay evolution until trainer text ends
2026-07-30 14:56:24 -04:00
johnjohto 4250e3aa32 Delay evolution until trainer text ends 2026-07-30 14:03:36 -04:00
bryanthaboi cecb24bb0e Merge pull request #480 from bryanthaboi/dev
johnny boys fixes
2026-07-30 13:55:19 -04:00
bryanthaboi b99b497893 Merge pull request #479 from johnjohto/fix-save-editor-items
Fix save editor item crash on Android
2026-07-30 13:53:24 -04:00
johnjohto e16a45d6ba Avoid invalid save editor clips
On compact Android viewports the item picker can end up with no list
space. Passing its negative height to setScissor crashes LÖVE.
Use an empty clip region instead.
2026-07-30 13:28:18 -04:00
bryanthaboi 0e29046c31 Merge pull request #458 from johnjohto/fix-yellow-bills-pikachu
Add Bill's House Pikachu scenes
2026-07-30 13:02:50 -04:00
johnjohto 5cbada493a Add Bill's House Pikachu scenes 2026-07-30 12:52:17 -04:00
bryanthaboi 4c79db3d5d Merge pull request #472 from bryanthaboi/dev
stupid ass android
2026-07-30 12:28:57 -04:00
bryanthaboi c7e46b6563 stupid ass android 2026-07-30 12:26:27 -04:00
Myles Resnick fe1714d50e iOS: stop fusing mods; Pokewalker moves to its own repo
Follow-up to #452, from dogfooding the merged build on a real iPhone.

A mod fused into game.love sits in the read-only app bundle: the mod
manager's Delete can't remove it and it reappears every launch, and an
imported newer version can never fully replace it. iOS now packs no
mods, matching every other platform - mods install as .zips at runtime
with a real install/upgrade/delete lifecycle.

With nothing fusing it, mods/pokewalker moves out of this tree to its
canonical home, https://github.com/mresnick67/Gen1ReComp-Pokewalker
(already at 0.2.0 there with move learning and a paged credit dialog;
in-tree 0.1.0 was stale). docs/ios-install.md points at it. The
event:save.loaded coverage-debt entry returns to gate_meta_coverage:
its coverage came from the mod's test suite and leaves with it.

Local reproduction of the ci workflow on this branch: all tiers, mod
lint, fingerprint + meta-coverage gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:44:16 -04:00
bryanthaboi 2765e3d145 Merge pull request #463 from bryanthaboi/dev
big bug squash party
2026-07-30 11:40:02 -04:00
bryanthaboi c62f1334bc merge main into dev 2026-07-30 11:35:57 -04:00
bryanthaboi eea0dec4a6 Merge pull request #459 from johnjohto/fix-generic-joystick-input
Handle generic joystick input
2026-07-30 11:27:41 -04:00
bryanthaboi 25d241f12c Merge pull request #456 from johnjohto/fix-yellow-npc-trades
Fix Yellow NPC trades
2026-07-30 11:26:56 -04:00
bryanthaboi 0b55ab083a Merge pull request #452 from mresnick67/ios-native-bridge
iOS: native document-picker + Apple Health bridges, working device builds
2026-07-30 11:26:16 -04:00
bryanthaboi 6b9d4f2470 Merge pull request #450 from RetroRulez/feat/i18n-enabling
i18n: make more UI reachable for translation mods (Strings wraps, menu box auto-size, optional metric dex)
2026-07-30 11:24:59 -04:00
bryanthaboi b9a89c1fd2 Merge pull request #434 from johnjohto/fuchsia-swamp-trainer-support-upstream
Support custom trainer palettes and base portraits
2026-07-30 11:23:56 -04:00
bryanthaboi 8a8340ffc8 Merge pull request #461 from bryanthaboi/bug-squashing-jul-30
Bug squashing jul 30
2026-07-30 11:23:08 -04:00
bryanthaboi 0af69e2df6 pikachu volume 2026-07-30 11:20:21 -04:00
bryanthaboi 4b9a326f6b CLOSES #339, CLOSES #354, CLOSES #360, CLOSES #372, CLOSES #373, CLOSES #374, CLOSES #375, CLOSES #378, CLOSES #379, CLOSES #383, CLOSES #384, CLOSES #385, CLOSES #391, CLOSES #392, CLOSES #393, CLOSES #394, CLOSES #395, CLOSES #396, CLOSES #397, CLOSES #398, CLOSES #413, CLOSES #420, CLOSES #423, CLOSES #424, CLOSES #425, CLOSES #426, CLOSES #427, CLOSES #429, CLOSES #430, CLOSES #431, CLOSES #433, CLOSES #435, CLOSES #436, CLOSES #438, CLOSES #439, CLOSES #441, CLOSES #442, CLOSES #444 2026-07-30 11:14:51 -04:00
Myles Resnick a92761dc13 Make the Pokewalker suite CI-runnable (fixture dataset) + pay save.loaded debt
T4 auto-runs shipped-mod suites ROM-free: the test now uses
T.fixtures.fresh() (FIXMON_A) with the level assertion derived from the
growth curve, and the now-covered event:save.loaded DEBT entry is
removed as gate_meta_coverage instructs. All four CI jobs reproduced
green on a ROM-free checkout.
2026-07-30 10:46:34 -04:00
johnjohto 8a649e8d95 Handle generic joystick input
Unmapped handheld controllers emit joystick events, leaving the launcher
cursor and in-game input inactive.
2026-07-30 10:38:24 -04:00
Cyberboy 5c5c970e81 i18n: draw naming-grid cells through Strings()
The name-entry keyboard drew each cell literally, so the case-switch labels
("lower case" / "UPPER CASE") and the "ED" confirm cell could not be
localized. Wrapping the draw in Strings() makes them translatable; the grid
data keeps the original strings, so the case-switch detection is unaffected.
Behavior-neutral for the base game (Strings identity without a catalog).
2026-07-30 16:35:03 +02:00
johnjohto 7b208043b9 Fix Yellow NPC trades 2026-07-30 10:17:30 -04:00
johnjohto 26dfad0682 Merge remote-tracking branch 'upstream/main' 2026-07-30 10:13:03 -04:00
Myles Resnick 902f0d73d4 iOS: native document-picker + Apple Health bridges, working device builds
Makes the iOS build a first-class citizen: ROM/mod/save import through
the system document picker (the README's missing "UIDocumentPicker
handoff"), Files-app drop-in support, and an opt-in Apple Health
step-sync seam consumed by a new gallery mod (Pokewalker).

Native layer (mobile/ios/native/, wired by mobile/ios/patch_love_src.py
on every build, so the fetched love-src tree stays pristine + re-patchable):
- GRPickerBridge.swift: love.system.pickFile("rom"|"mod"|"sav") and
  love.system.createFile on iOS with the same contract as love-android's
  SAF picker (picked_rom.gb / picked_mod.zip / picked_save.sav /
  export_done.flag in the save dir). Reached from wrap_System.cpp via the
  ObjC runtime, so liblove needs no Swift interop.
- GRBootstrap.m: sweeps .gb/.gbc/.zip/.sav dropped in Documents (Files
  app / Finder sharing) into the save dir on every activation;
  UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace in the plist
  overlay. Drop a ROM, open the app, it imports with zero taps.
- GRHealthBridge.swift: love.system.syncHealthSteps() -> read-only
  HealthKit step query anchored to the last sync, delivered as
  steps_pending.json (merge-not-overwrite). HealthKit entitlement +
  usage description included.

Lua:
- RomImporter: iOS rides the Android mobile flows; a 0.5s poll consumes
  picker deliveries (iOS pickers are in-process modals, so the Android
  refocus rescan never fires); failed pick copies surface as an
  on-screen notice via pick_error.txt.
- main.lua: on iOS, stop forwarding touchpressed to the Importer - LOVE
  already synthesizes a mousepressed for the primary touch, and the
  resulting same-frame double-present made the document picker
  auto-dismiss with zero documents (silent import failure).
- mods/pokewalker: opt-in Pokewalker mod (manifest v2, MECHANIC,
  permissions declared, mod.card, CHANGELOG, headless test suite 9/9,
  modkit validate --base imported + lint clean). Fused into iOS
  game.love only; loads dormant anywhere without the bridge.

Build (scripts/build_ios.sh):
- Fix Xcode 26: the global PRODUCT_NAME override also renamed liblove.a
  and broke the app link; the app bundle is renamed after the build
  instead.
- Fix nondeterministic pack failures: grep -q + pipefail races SIGPIPE
  on the game.love content checks.
- Simulator builds sign ad-hoc so entitlements embed (HealthKit works in
  the simulator).
- Device builds: signing team auto-detected from the keychain,
  CODE_SIGN_STYLE=Automatic + -allowProvisioningUpdates for CLI-only
  provisioning, per-team derived bundle ID (explicit App IDs are
  globally unique, so third parties can't sign the project default),
  gitignored mobile/ios/bundle_id.local pin, and --install to push to a
  connected iPhone.
- docs/ios-install.md: a zero-knowledge walkthrough from bare Mac to
  playing on an iPhone.

Backward compatibility: no behavior change on desktop or Android. The
new love.system functions exist only under LOVE_IOS; RomImporter's
mobile flag simply includes iOS alongside Android; the main.lua change
is iOS-gated; the Pokewalker mod is packed only by the iOS build script
and its option defaults off.

Verified on an iPhone 17 Pro simulator and an iPhone 16 Pro device:
scripted ROM import to title screen, Files-drop zero-tap import,
picker-driven mod install and save import/export, HealthKit permission
sheet + step credit (4000 steps -> +200 EXP at the default rate through
the engine growth curve).
2026-07-30 09:46:48 -04:00
Cyberboy f6ae967796 i18n: route SummaryMenu stat labels through Strings()
The ATTACK/DEFENSE/SPEED/SPECIAL labels on the status screen were drawn
literally; wrap them like the other UI so a language mod can translate them.
Behavior-neutral for the base game (Strings identity without a catalog).
2026-07-30 15:37:10 +02:00
bryanthaboi 29afbdaec7 Update README with additional media badges
Added new badges for Digital Foundry, Android Authority, and XDA Developers to the README.
2026-07-30 09:08:58 -04:00
Cyberboy 350402b179 i18n: route hardcoded UI strings through Strings(), auto-size menu boxes, optional metric dex fields
Makes several UI spots reachable for translation mods without forking the
engine. All behavior-neutral for the base game: Strings(x) is the identity
function when no catalog is loaded, and the metric dex fields are opt-in.

- ListMenu: draw the title via Strings() (covers bag/PC/box/fly/move lists)
- Menu / (see follow-up ChoiceBox): grow the box to the widest label so longer
  localized labels don't overflow the frame
- OptionsMenu: wrap toggle values (ON/OFF, SET/SHIFT, WIDE/OG, text speed) and
  the ruleset display name in Strings()
- Schemas: add optional dexEntry.heightM / weightKg
- DexEntryMenu: render metric height/weight when those fields are present,
  otherwise unchanged (ft/in + lb)
2026-07-30 15:03:35 +02:00
bryanthaboi 0f581e2f69 so many bugs i cannot even breathe 2026-07-30 07:41:41 -04:00
johnjohto 17eee1ca0e Test custom trainer portrait support 2026-07-30 00:00:36 -04:00
johnjohto 7aa06942be Support custom trainer palettes and base portraits 2026-07-29 23:59:59 -04:00
johnjohto d3010dc060 Merge pull request #1 from johnjohto/fuchsia-swamp-trainer-support
Support custom trainer palettes and base portraits
2026-07-29 23:52:41 -04:00
johnjohto 53e1a535dd Support custom trainer palettes and base portraits 2026-07-29 23:51:10 -04:00
bryanthaboi e63fa70700 Update README.md 2026-07-29 21:22:25 -04:00
bryanthaboi 27fb6eedc6 Change update video link and thumbnail
Updated the YouTube video link and thumbnail in the README.
2026-07-29 16:48:54 -04:00
bryanthaboi 5c0a7d1456 Merge pull request #422 from bryanthaboi/dev
big android energy
2026-07-29 16:07:31 -04:00
bryanthaboi e10f6e2032 Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-29 16:01:06 -04:00
bryanthaboi c61643edaa big android energy 2026-07-29 16:01:04 -04:00
bryanthaboi 0b7b0b48d1 Add Kotaku badge to README 2026-07-29 15:33:43 -04:00
bryanthaboi 2ff459e2c4 Update README to include Poke Yellow 2026-07-29 15:17:24 -04:00
bryanthaboi 8c877a64f0 Merge pull request #421 from bryanthaboi/dev
yellow fixes and othe random shit
2026-07-29 15:11:17 -04:00
bryanthaboi 8fdfd5c843 Merge pull request #416 from johnjohto/fix-true-color-dex-intro
fix true color UI Pokemon sprites

Two conflicts against dev, both structural rather than behavioural:

DexEntryMenu.render became a static function on dev so the printer could
share the entry page; the branch still marked true color from the method
form.  Threaded the flag through as a render() parameter.  The printer
caller leaves it nil on purpose -- it renders to its own PNG canvas, and a
mark left there would bleed into the next real frame.

TitleState moved the cycling mon and Red's OAM into the non-Yellow branch
on dev (title_yellow.asm has neither); the branch still drew them at top
level.  Kept dev's structure and folded the true color marking into it.
2026-07-29 15:10:03 -04:00
bryanthaboi 579c1744bd Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev 2026-07-29 15:06:37 -04:00
bryanthaboi 118c45f8f6 CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417
CLOSES #407, CLOSES #408, CLOSES #409, CLOSES #410, CLOSES #411, CLOSES #417
2026-07-29 15:06:33 -04:00
bryanthaboi beaa502ff9 Merge pull request #419 from johnjohto/fix-exported-save-layout
keep imported save cache on export
2026-07-29 14:20:05 -04:00
bryanthaboi 730b7b842c Add video update section to README
Added a section to watch the latest update video.
2026-07-29 14:16:56 -04:00
bryanthaboi 784832ca95 Revise README for support and announcements
Updated support section with new announcements and logos.
2026-07-29 14:09:27 -04:00
johnjohto bbe894fb4f keep imported save cache on export
Red restores the current-map cache from SRAM before Continue. Dropping the import template made a later export boot with zeroed map data.
2026-07-29 14:01:19 -04:00
johnjohto 4c17e606fd fix true color UI Pokemon sprites
The UI drew sprite paths but discarded their trueColor flag, so the SGB palette pass recolored full-color Pokemon.
2026-07-29 13:31:50 -04:00
bryanthaboi 73d81a72b8 Merge pull request #412 from johnjohto/fix-wide-option-battle-screens
Keep wide layout through battle menus
2026-07-29 13:26:54 -04:00
johnjohto 8b0f770c57 Keep wide layout through battle menus 2026-07-29 13:14:56 -04:00
bryanthaboi a89d43f315 Merge pull request #405 from bryanthaboi/dev
Update build.sh
2026-07-29 12:17:11 -04:00
bryanthaboi 96a64d439e Update build.sh 2026-07-29 12:15:56 -04:00
Marcus Pereira cd33858020 Respect the device rotation lock on Android
The manifest asks for android:screenOrientation="fullUser", but SDL
overrides it at window creation: SDLActivity.setOrientationBis, given a
resizable window and no SDL_HINT_ORIENTATIONS -- which is what conf.lua
produces on Android -- requests SCREEN_ORIENTATION_FULL_SENSOR. The
*_SENSOR constants follow the accelerometer even when the player has
turned auto-rotate off, so the game rotated anyway on a locked device.

Override setOrientationBis in GameActivity to remap SDL's request onto
the matching *_USER constant after super has run. The same orientations
stay allowed and SDL keeps deciding which ones those are; only the
tie-break changes, from the sensor to the system rotation setting.
2026-07-28 23:22:35 -03:00
Kevin Jacobson fb58fa788b Expose window margins to tool HUDs 2026-07-28 14:09:46 -07:00
Kevin Jacobson d09cf80da5 Add persistent HUD hook for tool mods 2026-07-28 13:58:42 -07:00
Kevin Jacobson aab980b05a Add lifecycle hooks for tool mods 2026-07-28 13:33:53 -07:00
463 changed files with 80212 additions and 4388 deletions
+1
View File
@@ -40,6 +40,7 @@ body:
- Windows
- Linux
- Android
- iOS
- Multiple platforms
validations:
required: true
+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
# data/generated/ is absent and skips it rather than failing.
#
# Runs alongside release.yml, which is untouched by this file.
on:
push:
# Integration branch + release branch. PRs already run via pull_request
@@ -24,12 +22,91 @@ concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
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$)'; 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:
name: headless suites (no ROM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# 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
@@ -47,7 +124,7 @@ jobs:
name: fixture dataset integrity
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: sudo apt-get update && sudo apt-get install -y luajit
- run: python3 -m pip install --upgrade pillow
@@ -89,7 +166,7 @@ jobs:
name: screenshot differ (capture not yet wired)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- run: python3 -m pip install --upgrade pillow
# 21-testing-and-ci §"Testing & acceptance criteria": compare_shots
@@ -137,7 +214,7 @@ jobs:
name: mod lint (no ROM-derived content)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
# the MK305 dump check key-diffs shipped tables through luajit, and
# modkit treats a missing interpreter as a fatal MK100 -- without
@@ -0,0 +1,61 @@
name: iOS artifact comment
on:
workflow_run:
workflows: [ci]
types: [completed]
permissions:
contents: read
issues: write
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: Delete existing comment
if: steps.artifact.outputs.pr_number != ''
uses: izhangzhihao/delete-comment@master
with:
github_token: ${{ github.token }}
delete_user_name: github-actions[bot]
issue_number: ${{ steps.artifact.outputs.pr_number }}
- 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 }}
+105 -6
View File
@@ -1,8 +1,8 @@
name: Release
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, and the
# Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on the self-hosted
# Mac runner, and publishes them as a GitHub Release.
# Builds the macOS, Windows, and Linux desktop apps, an Android APK, an iOS
# IPA, and the Anbernic RG34XXSP (Stock OS 64-bit MOD / PortMaster) port on
# the self-hosted Mac runner, and publishes them as a GitHub Release.
#
# Versioning:
# - First ever release is 0.1.0.
@@ -24,6 +24,7 @@ on:
paths-ignore:
- '.github/**'
- '**.md'
- 'mobile/ios/app-repo.json'
workflow_dispatch:
inputs:
version:
@@ -42,11 +43,17 @@ concurrency:
jobs:
release:
runs-on: [self-hosted, macOS]
runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }}
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
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
@@ -121,6 +128,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Import signing certificate into a temporary keychain
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
KEYCHAIN_PATH="$RUNNER_TEMP/pokemon-signing.keychain-db"
@@ -170,6 +178,24 @@ jobs:
set -euo pipefail
scripts/build_android.sh --version "${{ steps.ver.outputs.version }}"
- name: Install xcbeautify
run: |
set -euo pipefail
brew list xcbeautify >/dev/null 2>&1 || brew install xcbeautify
- name: Build iOS
env:
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
run: |
set -euo pipefail
@@ -178,6 +204,7 @@ jobs:
./build-rg34xxsp.sh --version "${{ steps.ver.outputs.version }}"
- name: Notarize & staple macOS app
if: github.repository == 'bryanthaboi/gen1recomp'
run: |
set -euo pipefail
ci_dir="${POKEMON_CI_DIR:-$HOME/.config/pokemon-ci}"
@@ -212,6 +239,7 @@ jobs:
echo "Notarized + stapled ✓"
- name: Stage release assets
if: github.repository == 'bryanthaboi/gen1recomp'
id: assets
run: |
set -euo pipefail
@@ -226,6 +254,10 @@ jobs:
[ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; }
cp "$apk" "$outdir/gen1recomp-${v}-android.apk"
ipa="dist/ios/gen1recomp.ipa"
[ -f "$ipa" ] || { echo "::error::$ipa not found (expected from scripts/build_ios.sh --device)"; exit 1; }
cp "$ipa" "$outdir/gen1recomp-${v}-ios.ipa"
# Anbernic handheld port (suffix names the CFW it targets, so a
# future RG35XX/other-CFW pack can ship alongside it).
rg34="dist/rg34xxsp/gen1recomp-rg34xxsp-stockos64-mod.zip"
@@ -247,6 +279,7 @@ jobs:
cat "$outdir/sha256sums.txt"
- name: Publish GitHub Release
if: github.repository == 'bryanthaboi/gen1recomp'
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -344,13 +377,79 @@ jobs:
"dist/release/gen1recomp-${v}-windows.zip" \
"dist/release/gen1recomp-${v}-linux.zip" \
"dist/release/gen1recomp-${v}-android.apk" \
"dist/release/gen1recomp-${v}-ios.ipa" \
"dist/release/gen1recomp-${v}-rg34xxsp-stockos64-mod.zip" \
"dist/release/gen1recomp-${v}.love" \
"dist/release/sha256sums.txt"
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"
localized_description="Gen1Recomp - A native Lua / LÖVE2D recreation of Gen 1 Poke"
release_notes="$(GH_TOKEN="${{ github.token }}" gh release view "v${v}" --json body --jq '.body // ""' 2>/dev/null || true)"
if [ -n "$release_notes" ]; then
localized_description="$release_notes"
fi
entry="$(jq -n \
--arg version "$v" \
--arg date "$date" \
--arg download_url "$download_url" \
--arg localized_description "$localized_description" \
--argjson size "$size" \
'{version: $version, date: $date, size: $size, downloadURL: $download_url, localizedDescription: $localized_description}')"
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
if: always()
if: ${{ always() && github.repository == 'bryanthaboi/gen1recomp' }}
run: |
security delete-keychain "$RUNNER_TEMP/pokemon-signing.keychain-db" 2>/dev/null || true
rm -f "$RUNNER_TEMP/release-deploy-key"
+4
View File
@@ -16,6 +16,7 @@ __pycache__/
.*
!.github/
!.gitignore
!.luacheckrc
# Android build outputs / local SDK path / packaged payload (keep love-android sources)
mobile/android/app/build/
@@ -39,3 +40,6 @@ mobile/dist/
# Tiled map-editing workspace (tools/tiled_export.py). Derived from the imported
# ROM cache exactly like data/generated/, so it is never committable.
/build/tiled/
# per-machine iOS bundle-id pin (see scripts/build_ios.sh)
mobile/ios/bundle_id.local
+43
View File
@@ -0,0 +1,43 @@
-- Static-analysis config for `luacheck` (https://luacheck.readthedocs.io).
--
-- Run it over the engine with: luacheck src (or scripts/lint.sh)
--
-- The point is a high-signal baseline: the categories left on are the ones
-- that catch real defects -- undefined globals/locals (the class that hid a
-- `music.volume` crash: applyVolume read a `state` that was still the nil
-- global), unused values, unreachable code, redefinitions. The cosmetic
-- categories the codebase deliberately lives with (a `self`/`dt` an
-- interface requires but a given method ignores, documented empty
-- fall-through branches, the odd long line) are muted so they don't drown
-- the signal.
std = "luajit"
-- LÖVE exposes `love` as a mutable table: games assign their callbacks onto
-- it (love.wheelmoved, love.run, ...), so it is a regular global, not
-- read-only -- otherwise every callback registration reads as a violation.
globals = { "love" }
read_globals = {
"jit",
-- LuaJIT 2.1 ships table.unpack even though the bare 5.1 `table` std lacks
-- it; without this, every `table.unpack` reads as an undefined field.
table = { fields = { "unpack" } },
}
-- Vendored/native trees and the test suites have their own conventions.
exclude_files = {
"mobile/",
"tests/",
"tools/save-editor/",
}
ignore = {
"212", -- unused argument -- self/dt kept for a shared method signature
"213", -- unused loop variable -- `for _, v in` where only v is wanted
"421", -- shadowing a local -- deliberate re-use in a few tight scopes
"431", -- shadowing an upvalue
"432", -- shadowing an argument
"542", -- empty if branch -- documented fall-throughs, not gaps
"631", -- line is too long
}
+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 |
| `MECHANIC` | New or changed battle/field mechanics via hooks/effects | overhaul |
| `GRAPHICS` | Sprite / tileset / palette / font changes | content |
| `LANGUAGE` | A translation: `text`, `strings` and the glyphs it needs | content |
| `AUDIO` | Music, sfx, cries | content |
| `UI` | New or modified screens, menus, overlays | content / overhaul |
| `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
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`
The manifest is the *engine's* contract: identity, load order, dependencies,
+7
View File
@@ -0,0 +1,7 @@
Copyright 2026 BOIS CLUB GAMES, LLC
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+107 -16
View File
@@ -1,36 +1,96 @@
# Gen1Recomp
A native LÖVE2D recreation of Poke Red and Blue. The engine and map
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
supplied by the player.
SUPPORT AND ANNOUNCEMENTS: [Discord](https://bois.icu)
> [!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>
**SUPPORT / ANNOUNCEMENTS / MODS:** [Discord](https://bois.icu)
<p align="center">
<a href="https://www.youtube.com/@bryanthaboi">
<img src="https://img.shields.io/badge/YouTube-FF0000?style=for-the-badge&logo=youtube&logoColor=white" alt="YouTube">
</a>
<a href="https://www.tiktok.com/@bryanthaboi">
<img src="https://img.shields.io/badge/TikTok-000000?style=for-the-badge&logo=tiktok&logoColor=white" alt="TikTok">
</a>
<a href="https://x.com/bryanthaboi">
<img src="https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white" alt="X">
</a>
<a href="https://bsky.app/profile/bryanthaboi.live">
<img src="https://img.shields.io/badge/Bluesky-0285FF?style=for-the-badge&logo=bluesky&logoColor=white" alt="Bluesky">
</a>
<a href="https://www.instagram.com/bryanthaboi">
<img src="https://img.shields.io/badge/Instagram-E4405F?style=for-the-badge&logo=instagram&logoColor=white" alt="Instagram">
</a>
</p>
<p align="center"> <a href="https://www.polygon.com/pokemon-red-blue-3d-voxel-mod-battle-pixels-gameplay-footage-remake/"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-POLYGON-ea2e49?style=for-the-badge" alt="As seen on Polygon"> </a>
<a href="https://kotaku.com/pokemon-red-blue-recompilation-project-voxel-3d-mod-2000720281"> <img src="https://img.shields.io/badge/AS%20SEEN%20ON-KOTAKU-ea2e49?style=for-the-badge" alt="As seen on KOTAKU"> </a>
<a href="https://www.digitalfoundry.net/news/2026/07/pokemon-yellow-voxel-mod-turns-the-original-gameboy-code-into-a-stunning-world">
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-DIGITAL%20FOUNDRY-ea2e49?style=for-the-badge" alt="As seen on Digital Foundry">
</a>
<a href="https://www.androidauthority.com/unofficial-android-port-pokemon-red-blue-yellow-3692724/">
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-ANDROID%20AUTHORITY-ea2e49?style=for-the-badge" alt="As seen on Android Authority">
</a>
<a href="https://www.xda-developers.com/this-amazing-pokemon-red-and-blue-voxel-mod-adds-a-3d-perspective-without-an-emulator/">
<img src="https://img.shields.io/badge/AS%20SEEN%20ON-XDA%20DEVELOPERS-ea2e49?style=for-the-badge" alt="As seen on XDA Developers">
</a>
</p>
### Watch the latest update video
[![Watch the latest update video](https://img.youtube.com/vi/8IOgqbe4YvA/maxresdefault.jpg)](https://www.youtube.com/watch?v=8IOgqbe4YvA)
This project does not include a ROM, emulate the Game Boy, transpile assembly,
or download a disassembly. A canonical US Poke Red or Blue ROM is the only
game content input.
or download a disassembly. A canonical US Poke Red, Blue, or Yellow ROM is the
only game content input.
The ROM is verified, used during import, and then released from memory. It is
not copied into the cache. Later launches load the private generated cache and
do not ask for the ROM again. Red and Blue can both be imported and played
side by side.
do not ask for the ROM again. Red, Blue, and Yellow can all be imported and
played side by side.
## Quick Start
Open the desktop app. On first boot, choose your legally obtained `.gb` file
or drop it onto the window. Import takes a few seconds and the game starts
automatically.
Open the desktop app. On first boot, choose your legally obtained `.gb` /
`.gbc` file or drop it onto the window. Import takes a few seconds and the
game starts automatically.
Only the canonical 1 MiB US Red and Blue ROMs are accepted. The importer
verifies SHA-1 before creating any game data:
Only the canonical 1 MiB US Red, Blue, and Yellow ROMs are accepted. The
importer verifies SHA-1 before creating any game data:
- Red: `ea9bcae617fdf159b045185467ae58b2e4a48b9a`
- Blue: `d7037c83e1ae5b39bde3c30787637ba1d4c48ce2`
- Yellow: `cc7d03262ebfaf2f06772c1a480c7d9d5f4a38e1`
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
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
@@ -52,6 +112,7 @@ supported out of the box.
| Key | What it does |
| --------- | ---------------------------------------------------- |
| `-` / `=` | Zoom out / in (overworld; also mouse wheel) |
| `1` | Cycle GAME SPEED up (controller: R2 faster, L2 slower) |
| `2` | Cycle COLORS |
| `3` | Cycle TILT (free-roam overworld) |
| `4` | Cycle ZOOM through every level (free-roam overworld) |
@@ -61,8 +122,19 @@ supported out of the box.
| `F10` | Open / close the mod manager |
COLORS, TILT, ZOOM, GBC FX, and VOID FILL are also in the Options menu
and persist in `options.lua`.
COLORS, TILT, ZOOM, GBC FX, GAME SPEED, and VOID FILL are also in the
Options menu and persist in `options.lua`.
### Low-end devices
**OPTIONS → PERFORMANCE** scales the port's optional extras for weaker
hardware: **HIGH** (everything on), **BALANCED** (no 3D tilt or GBC FX),
**LOW** (also no survey zoom, FPS capped), or **AUTO** — the default, which
picks a tier from your device (ARM handhelds → LOW, phones → BALANCED,
normal desktops → HIGH, unchanged). It only scales presentation; the
fixed-step game logic is identical on every tier, and a lower tier hides
your tilt/zoom/GBC-FX preferences without forgetting them. Details in
[docs/new-features.md](docs/new-features.md#performance-tier-low-end-devices).
### Rulesets
@@ -98,11 +170,11 @@ famous bugs included:
## Running From Source
Requires LÖVE 11.x. Place a Red or Blue ROM in the project folder and
Requires LÖVE 11.x. Place a Red, Blue, or Yellow ROM in the project folder and
double-click `Play-Mac.command` or `Play-Windows.bat`, or run:
```sh
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Poke Blue.gb
scripts/setup.sh --rom "/path/to/Poke Red.gb" # or Blue.gb / Yellow.gbc
scripts/run.sh
```
@@ -133,6 +205,23 @@ even on a different computer, as long as the same folder comes along.
already written to either location is touched automatically, so copy files
over yourself if you want to carry existing progress across the switch.
## iOS
Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore
(Windows or Mac) — see [docs/ios-sideload.md](docs/ios-sideload.md). To
build and install from source on a Mac instead, see
[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
A PortMaster-style port for the **Anbernic RG34XXSP** on Stock OS 64-bit MOD
@@ -185,4 +274,6 @@ request with real detail is one that can actually get built.
This project would not be possible without [pret](https://github.com/pret) >
the pret band of decompiling maniacs > and their
[pokered](https://github.com/pret/pokered) disassembly.
[pokered](https://github.com/pret/pokered) disassembly.
<p align="center"><a href="https://boisclub.games"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/bcg.png"></a></p>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+1 -1
View File
@@ -89,7 +89,7 @@ mkdir -p "$GAME_SRC"
# tools/save-editor is part of that payload: the launcher's Edit button on a
# save row opens it in-process (main.lua).
(cd "$ROOT" && zip -q -9 -r "$WORK/game-payload.zip" \
main.lua conf.lua src data assets tools/save-editor \
main.lua conf.lua src libs data assets tools/save-editor \
tools/rom_manifest.json tools/rom_manifest_blue.json \
-x '*.DS_Store' 'data/generated/*' 'assets/generated/*')
if unzip -Z1 "$WORK/game-payload.zip" \
+18 -2
View File
@@ -1,4 +1,9 @@
function love.conf(t)
-- PhysFS ignores symlinks unless told otherwise, so a mod dev-linked into
-- mods/ (ln -s, matching the mklink /J workflow on Windows) is invisible
-- to love.filesystem.getDirectoryItems without this.
love.filesystem.setSymlinksEnabled(true)
local editor = os.getenv("POKEPORT_EDITOR") == "1"
local developer = os.getenv("POKEPORT_DEV") == "1"
if arg then
@@ -44,7 +49,7 @@ function love.conf(t)
t.window.minwidth = 480
t.window.minheight = 360
end
t.version = "11.5"
t.version = love._os == "iOS" and "12.0" or "11.5"
t.window.vsync = 1
t.modules.joystick = true
t.modules.physics = false
@@ -62,7 +67,10 @@ function love.conf(t)
-- the game follow the device. The renderer letterboxes the 160x144
-- viewport into whatever size results, and the on-screen touch controls
-- re-lay themselves out from the new window size, so both orientations
-- just work. iOS follows the Info.plist orientations
-- just work. FULL_SENSOR ignores the device's rotation lock, so
-- GameActivity.setOrientationBis remaps it to FULL_USER after SDL has
-- run: same orientations allowed, but auto-rotate being off now wins.
-- iOS follows the Info.plist orientations
-- (see mobile/ios/overlays/love-ios.plist, now portrait + landscape).
t.window.resizable = true
-- Starting size is a tall portrait hint; the OS resizes to the real
@@ -78,6 +86,14 @@ function love.conf(t)
-- player to copy their ROM there instead of needing a native file
-- picker (LOVE 11.5 on Android has none -- see src/import/RomImporter.lua).
t.externalstorage = osName == "Android"
-- LOVE 11.x exposes the phone's accelerometer as a 3-axis joystick by
-- default. Gravity keeps one axis pinned near +/-1.0 whenever the phone
-- is held upright, so it streams past-deadzone joystickaxis events that
-- both hid the touch overlay every instant (it reads as "a controller is
-- in use") and, via the generic-joystick axis mapping (#459), walked the
-- player around by tilt. Nothing in the game reads the accelerometer,
-- so drop the device entirely (#468).
t.accelerometerjoystick = false
else
t.window.resizable = true
end
+165 -151
View File
@@ -1683,158 +1683,172 @@ return {
{ 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 = {
ABRA = "ABRA",
AERODACTYL = "AERODACTYL",
ALAKAZAM = "ALAKAZAM",
ARBOK = "ARBOK",
ARCANINE = "ARCANINE",
ARTICUNO = "ARTICUNO",
BEEDRILL = "BEEDRILL",
BELLSPROUT = "BELLSPROUT",
BLASTOISE = "BLASTOISE",
BULBASAUR = "BULBASAUR",
BUTTERFREE = "BUTTERFREE",
CATERPIE = "CATERPIE",
CHANSEY = "CHANSEY",
CHARIZARD = "CHARIZARD",
CHARMANDER = "CHARMANDER",
CHARMELEON = "CHARMELEON",
CLEFABLE = "CLEFABLE",
CLEFAIRY = "CLEFAIRY",
CLOYSTER = "CLOYSTER",
CUBONE = "CUBONE",
DEWGONG = "DEWGONG",
DIGLETT = "DIGLETT",
DITTO = "DITTO",
DODRIO = "DODRIO",
DODUO = "DODUO",
DRAGONAIR = "DRAGONAIR",
DRAGONITE = "DRAGONITE",
DRATINI = "DRATINI",
DROWZEE = "DROWZEE",
DUGTRIO = "DUGTRIO",
EEVEE = "EEVEE",
EKANS = "EKANS",
ELECTABUZZ = "ELECTABUZZ",
ELECTRODE = "ELECTRODE",
EXEGGCUTE = "EXEGGCUTE",
EXEGGUTOR = "EXEGGUTOR",
FARFETCHD = "FARFETCH_D",
FEAROW = "FEAROW",
FLAREON = "FLAREON",
GASTLY = "GASTLY",
GENGAR = "GENGAR",
GEODUDE = "GEODUDE",
GLOOM = "GLOOM",
GOLBAT = "GOLBAT",
GOLDEEN = "GOLDEEN",
GOLDUCK = "GOLDUCK",
GOLEM = "GOLEM",
GRAVELER = "GRAVELER",
GRIMER = "GRIMER",
GROWLITHE = "GROWLITHE",
GYARADOS = "GYARADOS",
HAUNTER = "HAUNTER",
HITMONCHAN = "HITMONCHAN",
HITMONLEE = "HITMONLEE",
HORSEA = "HORSEA",
HYPNO = "HYPNO",
IVYSAUR = "IVYSAUR",
JIGGLYPUFF = "JIGGLYPUFF",
JOLTEON = "JOLTEON",
JYNX = "JYNX",
KABUTO = "KABUTO",
KABUTOPS = "KABUTOPS",
KADABRA = "KADABRA",
KAKUNA = "KAKUNA",
KANGASKHAN = "KANGASKHAN",
KINGLER = "KINGLER",
KOFFING = "KOFFING",
KRABBY = "KRABBY",
LAPRAS = "LAPRAS",
LICKITUNG = "LICKITUNG",
MACHAMP = "MACHAMP",
MACHOKE = "MACHOKE",
MACHOP = "MACHOP",
MAGIKARP = "MAGIKARP",
MAGMAR = "MAGMAR",
MAGNEMITE = "MAGNEMITE",
MAGNETON = "MAGNETON",
MANKEY = "MANKEY",
MAROWAK = "MAROWAK",
MEOWTH = "MEOWTH",
METAPOD = "METAPOD",
MEW = "MEW",
MEWTWO = "MEWTWO",
MOLTRES = "MOLTRES",
MR_MIME = "MR_MIME",
MUK = "MUK",
NIDOKING = "NIDOKING",
NIDOQUEEN = "NIDOQUEEN",
NIDORAN_F = "NIDORAN_F",
NIDORAN_M = "NIDORAN_M",
NIDORINA = "NIDORINA",
NIDORINO = "NIDORINO",
NINETALES = "NINETALES",
ODDISH = "ODDISH",
OMANYTE = "OMANYTE",
OMASTAR = "OMASTAR",
ONIX = "ONIX",
PARAS = "PARAS",
PARASECT = "PARASECT",
PERSIAN = "PERSIAN",
PIDGEOT = "PIDGEOT",
PIDGEOTTO = "PIDGEOTTO",
PIDGEY = "PIDGEY",
PIKACHU = "PIKACHU",
PINSIR = "PINSIR",
POLIWAG = "POLIWAG",
POLIWHIRL = "POLIWHIRL",
POLIWRATH = "POLIWRATH",
PONYTA = "PONYTA",
PORYGON = "PORYGON",
PRIMEAPE = "PRIMEAPE",
PSYDUCK = "PSYDUCK",
RAICHU = "RAICHU",
RAPIDASH = "RAPIDASH",
RATICATE = "RATICATE",
RATTATA = "RATTATA",
RHYDON = "RHYDON",
RHYHORN = "RHYHORN",
SANDSHREW = "SANDSHREW",
SANDSLASH = "SANDSLASH",
SCYTHER = "SCYTHER",
SEADRA = "SEADRA",
SEAKING = "SEAKING",
SEEL = "SEEL",
SHELLDER = "SHELLDER",
SLOWBRO = "SLOWBRO",
SLOWPOKE = "SLOWPOKE",
SNORLAX = "SNORLAX",
SPEAROW = "SPEAROW",
SQUIRTLE = "SQUIRTLE",
STARMIE = "STARMIE",
STARYU = "STARYU",
TANGELA = "TANGELA",
TAUROS = "TAUROS",
TENTACOOL = "TENTACOOL",
TENTACRUEL = "TENTACRUEL",
VAPOREON = "VAPOREON",
VENOMOTH = "VENOMOTH",
VENONAT = "VENONAT",
VENUSAUR = "VENUSAUR",
VICTREEBEL = "VICTREEBEL",
VILEPLUME = "VILEPLUME",
VOLTORB = "VOLTORB",
VULPIX = "VULPIX",
WARTORTLE = "WARTORTLE",
WEEDLE = "WEEDLE",
WEEPINBELL = "WEEPINBELL",
WEEZING = "WEEZING",
WIGGLYTUFF = "WIGGLYTUFF",
ZAPDOS = "ZAPDOS",
ZUBAT = "ZUBAT",
ABRA = "YELLOWMON",
AERODACTYL = "GRAYMON",
ALAKAZAM = "YELLOWMON",
ARBOK = "PURPLEMON",
ARCANINE = "REDMON",
ARTICUNO = "BLUEMON",
BEEDRILL = "YELLOWMON",
BELLSPROUT = "GREENMON",
BLASTOISE = "CYANMON",
BULBASAUR = "GREENMON",
BUTTERFREE = "CYANMON",
CATERPIE = "GREENMON",
CHANSEY = "PINKMON",
CHARIZARD = "REDMON",
CHARMANDER = "REDMON",
CHARMELEON = "REDMON",
CLEFABLE = "PINKMON",
CLEFAIRY = "PINKMON",
CLOYSTER = "GRAYMON",
CUBONE = "GRAYMON",
DEWGONG = "BLUEMON",
DIGLETT = "BROWNMON",
DITTO = "GRAYMON",
DODRIO = "BROWNMON",
DODUO = "BROWNMON",
DRAGONAIR = "BLUEMON",
DRAGONITE = "BROWNMON",
DRATINI = "GRAYMON",
DROWZEE = "YELLOWMON",
DUGTRIO = "BROWNMON",
EEVEE = "GRAYMON",
EKANS = "PURPLEMON",
ELECTABUZZ = "YELLOWMON",
ELECTRODE = "YELLOWMON",
EXEGGCUTE = "PINKMON",
EXEGGUTOR = "GREENMON",
FARFETCHD = "BROWNMON",
FEAROW = "BROWNMON",
FLAREON = "REDMON",
GASTLY = "PURPLEMON",
GENGAR = "PURPLEMON",
GEODUDE = "GRAYMON",
GLOOM = "REDMON",
GOLBAT = "BLUEMON",
GOLDEEN = "REDMON",
GOLDUCK = "CYANMON",
GOLEM = "GRAYMON",
GRAVELER = "GRAYMON",
GRIMER = "PURPLEMON",
GROWLITHE = "BROWNMON",
GYARADOS = "BLUEMON",
HAUNTER = "PURPLEMON",
HITMONCHAN = "BROWNMON",
HITMONLEE = "BROWNMON",
HORSEA = "CYANMON",
HYPNO = "YELLOWMON",
IVYSAUR = "GREENMON",
JIGGLYPUFF = "PINKMON",
JOLTEON = "YELLOWMON",
JYNX = "MEWMON",
KABUTO = "BROWNMON",
KABUTOPS = "BROWNMON",
KADABRA = "YELLOWMON",
KAKUNA = "YELLOWMON",
KANGASKHAN = "BROWNMON",
KINGLER = "REDMON",
KOFFING = "PURPLEMON",
KRABBY = "REDMON",
LAPRAS = "CYANMON",
LICKITUNG = "PINKMON",
MACHAMP = "GRAYMON",
MACHOKE = "GRAYMON",
MACHOP = "GRAYMON",
MAGIKARP = "REDMON",
MAGMAR = "REDMON",
MAGNEMITE = "GRAYMON",
MAGNETON = "GRAYMON",
MANKEY = "BROWNMON",
MAROWAK = "GRAYMON",
MEOWTH = "YELLOWMON",
METAPOD = "GREENMON",
MEW = "MEWMON",
MEWTWO = "MEWMON",
MOLTRES = "REDMON",
MR_MIME = "PINKMON",
MUK = "PURPLEMON",
NIDOKING = "PURPLEMON",
NIDOQUEEN = "BLUEMON",
NIDORAN_F = "BLUEMON",
NIDORAN_M = "PURPLEMON",
NIDORINA = "BLUEMON",
NIDORINO = "PURPLEMON",
NINETALES = "YELLOWMON",
ODDISH = "GREENMON",
OMANYTE = "BLUEMON",
OMASTAR = "BLUEMON",
ONIX = "GRAYMON",
PARAS = "REDMON",
PARASECT = "REDMON",
PERSIAN = "YELLOWMON",
PIDGEOT = "BROWNMON",
PIDGEOTTO = "BROWNMON",
PIDGEY = "BROWNMON",
PIKACHU = "YELLOWMON",
PINSIR = "BROWNMON",
POLIWAG = "BLUEMON",
POLIWHIRL = "BLUEMON",
POLIWRATH = "BLUEMON",
PONYTA = "REDMON",
PORYGON = "GRAYMON",
PRIMEAPE = "BROWNMON",
PSYDUCK = "YELLOWMON",
RAICHU = "YELLOWMON",
RAPIDASH = "REDMON",
RATICATE = "GRAYMON",
RATTATA = "GRAYMON",
RHYDON = "GRAYMON",
RHYHORN = "GRAYMON",
SANDSHREW = "BROWNMON",
SANDSLASH = "BROWNMON",
SCYTHER = "GREENMON",
SEADRA = "CYANMON",
SEAKING = "REDMON",
SEEL = "BLUEMON",
SHELLDER = "GRAYMON",
SLOWBRO = "PINKMON",
SLOWPOKE = "PINKMON",
SNORLAX = "PINKMON",
SPEAROW = "BROWNMON",
SQUIRTLE = "CYANMON",
STARMIE = "GRAYMON",
STARYU = "REDMON",
TANGELA = "BLUEMON",
TAUROS = "GRAYMON",
TENTACOOL = "CYANMON",
TENTACRUEL = "CYANMON",
VAPOREON = "CYANMON",
VENOMOTH = "PURPLEMON",
VENONAT = "PURPLEMON",
VENUSAUR = "GREENMON",
VICTREEBEL = "GREENMON",
VILEPLUME = "REDMON",
VOLTORB = "YELLOWMON",
VULPIX = "REDMON",
WARTORTLE = "CYANMON",
WEEDLE = "YELLOWMON",
WEEPINBELL = "GREENMON",
WEEZING = "PURPLEMON",
WIGGLYTUFF = "PINKMON",
ZAPDOS = "YELLOWMON",
ZUBAT = "BLUEMON",
},
source = "pokered-gbc data/super_palettes.asm + data/mon_palettes.asm + color/**",
world = {
+686
View File
@@ -0,0 +1,686 @@
-- Generated by tools/build_data.py. DO NOT EDIT.
-- Pokemon Yellow palettes (COLORS=OG YELLOW): SuperPalettes +
-- CGBBasePalettes (authentic GBC look) as 4 8-bit RGB colors
-- per palette (color 0 first), plus MonsterPalettes.
return {
cgbBase = {
["0F"] = {
{ 255, 255, 255 },
{ 107, 8, 255 },
{ 0, 74, 255 },
{ 8, 8, 8 },
},
BADGE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
BLACK = {
{ 255, 255, 255 },
{ 25, 25, 25 },
{ 25, 25, 25 },
{ 25, 25, 25 },
},
BLUEMON = {
{ 255, 255, 255 },
{ 132, 148, 255 },
{ 0, 8, 206 },
{ 25, 25, 25 },
},
BROWNMON = {
{ 255, 255, 255 },
{ 239, 148, 82 },
{ 140, 74, 41 },
{ 25, 25, 25 },
},
CAVE = {
{ 255, 255, 255 },
{ 189, 66, 0 },
{ 140, 115, 90 },
{ 25, 25, 25 },
},
CELADON = {
{ 255, 255, 255 },
{ 41, 255, 41 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CERULEAN = {
{ 255, 255, 255 },
{ 41, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CINNABAR = {
{ 255, 255, 255 },
{ 255, 66, 66 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
CYANMON = {
{ 255, 255, 255 },
{ 132, 214, 255 },
{ 0, 140, 255 },
{ 25, 25, 25 },
},
FUCHSIA = {
{ 255, 255, 255 },
{ 255, 123, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
GAMEFREAK = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 156, 156, 0 },
{ 25, 25, 25 },
},
GRAYMON = {
{ 255, 255, 255 },
{ 165, 189, 82 },
{ 90, 90, 41 },
{ 25, 25, 25 },
},
GREENBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 0, 255, 0 },
{ 25, 25, 25 },
},
GREENMON = {
{ 255, 255, 255 },
{ 140, 255, 90 },
{ 8, 181, 49 },
{ 25, 25, 25 },
},
INDIGO = {
{ 255, 255, 255 },
{ 90, 66, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LAVENDER = {
{ 255, 255, 255 },
{ 206, 33, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
LOGO1 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 255, 0, 0 },
},
LOGO2 = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 58, 58, 206 },
{ 0, 0, 140 },
},
MEWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 8, 8 },
{ 25, 25, 25 },
},
PALLET = {
{ 255, 255, 255 },
{ 189, 140, 255 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PEWTER = {
{ 255, 255, 255 },
{ 148, 148, 123 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 255 },
{ 74, 74, 74 },
{ 255, 173, 0 },
{ 25, 25, 25 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 255 },
{ 255, 148, 0 },
{ 156, 58, 8 },
{ 25, 25, 25 },
},
PINKMON = {
{ 255, 255, 255 },
{ 255, 123, 148 },
{ 255, 0, 49 },
{ 25, 25, 25 },
},
PURPLEMON = {
{ 255, 255, 255 },
{ 206, 123, 255 },
{ 156, 0, 181 },
{ 25, 25, 25 },
},
REDBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
REDMON = {
{ 255, 255, 255 },
{ 255, 140, 0 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
ROUTE = {
{ 255, 255, 255 },
{ 132, 255, 33 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SAFFRON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
SLOTS1 = {
{ 255, 255, 255 },
{ 206, 8, 255 },
{ 255, 0, 0 },
{ 25, 25, 25 },
},
SLOTS2 = {
{ 255, 255, 255 },
{ 255, 33, 156 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS3 = {
{ 255, 255, 255 },
{ 66, 255, 0 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
SLOTS4 = {
{ 255, 255, 255 },
{ 0, 255, 255 },
{ 255, 255, 0 },
{ 25, 25, 25 },
},
TOWNMAP = {
{ 255, 255, 255 },
{ 0, 173, 255 },
{ 82, 230, 0 },
{ 8, 8, 8 },
},
VERMILION = {
{ 255, 255, 255 },
{ 255, 156, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
VIRIDIAN = {
{ 255, 255, 255 },
{ 156, 255, 0 },
{ 90, 189, 255 },
{ 25, 25, 25 },
},
YELLOWBAR = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 255, 148, 0 },
{ 25, 25, 25 },
},
YELLOWMON = {
{ 255, 255, 255 },
{ 255, 255, 0 },
{ 230, 115, 0 },
{ 25, 25, 25 },
},
},
order = {
"ROUTE",
"PALLET",
"VIRIDIAN",
"PEWTER",
"CERULEAN",
"LAVENDER",
"VERMILION",
"CELADON",
"FUCHSIA",
"CINNABAR",
"INDIGO",
"SAFFRON",
"TOWNMAP",
"LOGO1",
"LOGO2",
"0F",
"MEWMON",
"BLUEMON",
"REDMON",
"CYANMON",
"PURPLEMON",
"BROWNMON",
"GREENMON",
"PINKMON",
"YELLOWMON",
"GRAYMON",
"SLOTS1",
"SLOTS2",
"SLOTS3",
"SLOTS4",
"BLACK",
"GREENBAR",
"YELLOWBAR",
"REDBAR",
"BADGE",
"CAVE",
"GAMEFREAK",
"PIKACHUS_BEACH",
"PIKACHU_PORTRAIT",
"PIKACHUS_BEACH_TITLE",
},
palettes = {
["0F"] = {
{ 255, 255, 247 },
{ 197, 165, 247 },
{ 90, 165, 247 },
{ 25, 16, 16 },
},
BADGE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
BLACK = {
{ 255, 255, 247 },
{ 49, 49, 49 },
{ 49, 49, 49 },
{ 49, 49, 49 },
},
BLUEMON = {
{ 255, 255, 247 },
{ 173, 181, 255 },
{ 74, 82, 165 },
{ 49, 49, 49 },
},
BROWNMON = {
{ 255, 255, 247 },
{ 214, 189, 148 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
CAVE = {
{ 255, 255, 247 },
{ 165, 123, 90 },
{ 181, 173, 165 },
{ 49, 49, 49 },
},
CELADON = {
{ 255, 255, 247 },
{ 181, 255, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CERULEAN = {
{ 255, 255, 247 },
{ 181, 189, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CINNABAR = {
{ 255, 255, 247 },
{ 255, 123, 115 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
CYANMON = {
{ 255, 255, 247 },
{ 214, 230, 255 },
{ 58, 197, 230 },
{ 49, 49, 49 },
},
FUCHSIA = {
{ 255, 255, 247 },
{ 255, 214, 214 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
GAMEFREAK = {
{ 255, 255, 247 },
{ 230, 197, 115 },
{ 165, 165, 90 },
{ 49, 49, 49 },
},
GRAYMON = {
{ 255, 255, 247 },
{ 206, 206, 148 },
{ 132, 132, 115 },
{ 49, 49, 49 },
},
GREENBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 0, 173, 0 },
{ 49, 49, 49 },
},
GREENMON = {
{ 255, 255, 247 },
{ 197, 230, 148 },
{ 107, 173, 123 },
{ 49, 49, 49 },
},
INDIGO = {
{ 255, 255, 247 },
{ 140, 140, 206 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LAVENDER = {
{ 255, 255, 247 },
{ 222, 189, 239 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
LOGO1 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 173, 0, 33 },
{ 173, 0, 33 },
},
LOGO2 = {
{ 255, 255, 247 },
{ 247, 247, 140 },
{ 148, 148, 197 },
{ 58, 58, 132 },
},
MEWMON = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 222, 132, 132 },
{ 49, 49, 49 },
},
PALLET = {
{ 255, 255, 247 },
{ 230, 222, 255 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PEWTER = {
{ 255, 255, 247 },
{ 189, 189, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH = {
{ 255, 255, 247 },
{ 255, 247, 181 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
PIKACHUS_BEACH_TITLE = {
{ 255, 255, 247 },
{ 132, 132, 132 },
{ 255, 206, 74 },
{ 49, 49, 49 },
},
PIKACHU_PORTRAIT = {
{ 255, 255, 247 },
{ 230, 189, 74 },
{ 148, 115, 82 },
{ 49, 49, 49 },
},
PINKMON = {
{ 255, 255, 247 },
{ 255, 197, 214 },
{ 255, 148, 173 },
{ 49, 49, 49 },
},
PURPLEMON = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 181, 123, 189 },
{ 49, 49, 49 },
},
REDBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
REDMON = {
{ 255, 255, 247 },
{ 255, 197, 90 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
ROUTE = {
{ 255, 255, 247 },
{ 189, 214, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SAFFRON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
SLOTS1 = {
{ 255, 255, 247 },
{ 222, 181, 247 },
{ 214, 74, 49 },
{ 49, 49, 49 },
},
SLOTS2 = {
{ 255, 255, 247 },
{ 255, 189, 214 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS3 = {
{ 255, 255, 247 },
{ 189, 255, 165 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
SLOTS4 = {
{ 255, 255, 247 },
{ 189, 239, 255 },
{ 239, 239, 66 },
{ 49, 49, 49 },
},
TOWNMAP = {
{ 255, 255, 247 },
{ 165, 214, 255 },
{ 140, 189, 82 },
{ 25, 16, 16 },
},
VERMILION = {
{ 255, 255, 247 },
{ 255, 206, 132 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
VIRIDIAN = {
{ 255, 255, 247 },
{ 214, 255, 173 },
{ 189, 222, 255 },
{ 49, 49, 49 },
},
YELLOWBAR = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
YELLOWMON = {
{ 255, 255, 247 },
{ 255, 255, 156 },
{ 230, 189, 74 },
{ 49, 49, 49 },
},
},
pokemon = {
ABRA = "YELLOWMON",
AERODACTYL = "GRAYMON",
ALAKAZAM = "YELLOWMON",
ARBOK = "PURPLEMON",
ARCANINE = "REDMON",
ARTICUNO = "BLUEMON",
BEEDRILL = "YELLOWMON",
BELLSPROUT = "GREENMON",
BLASTOISE = "CYANMON",
BULBASAUR = "GREENMON",
BUTTERFREE = "CYANMON",
CATERPIE = "GREENMON",
CHANSEY = "PINKMON",
CHARIZARD = "REDMON",
CHARMANDER = "REDMON",
CHARMELEON = "REDMON",
CLEFABLE = "PINKMON",
CLEFAIRY = "PINKMON",
CLOYSTER = "GRAYMON",
CUBONE = "GRAYMON",
DEWGONG = "BLUEMON",
DIGLETT = "BROWNMON",
DITTO = "GRAYMON",
DODRIO = "BROWNMON",
DODUO = "BROWNMON",
DRAGONAIR = "BLUEMON",
DRAGONITE = "BROWNMON",
DRATINI = "GRAYMON",
DROWZEE = "YELLOWMON",
DUGTRIO = "BROWNMON",
EEVEE = "GRAYMON",
EKANS = "PURPLEMON",
ELECTABUZZ = "YELLOWMON",
ELECTRODE = "YELLOWMON",
EXEGGCUTE = "PINKMON",
EXEGGUTOR = "GREENMON",
FARFETCHD = "BROWNMON",
FEAROW = "BROWNMON",
FLAREON = "REDMON",
GASTLY = "PURPLEMON",
GENGAR = "PURPLEMON",
GEODUDE = "GRAYMON",
GLOOM = "REDMON",
GOLBAT = "BLUEMON",
GOLDEEN = "REDMON",
GOLDUCK = "CYANMON",
GOLEM = "GRAYMON",
GRAVELER = "GRAYMON",
GRIMER = "PURPLEMON",
GROWLITHE = "BROWNMON",
GYARADOS = "BLUEMON",
HAUNTER = "PURPLEMON",
HITMONCHAN = "BROWNMON",
HITMONLEE = "BROWNMON",
HORSEA = "CYANMON",
HYPNO = "YELLOWMON",
IVYSAUR = "GREENMON",
JIGGLYPUFF = "PINKMON",
JOLTEON = "YELLOWMON",
JYNX = "MEWMON",
KABUTO = "BROWNMON",
KABUTOPS = "BROWNMON",
KADABRA = "YELLOWMON",
KAKUNA = "YELLOWMON",
KANGASKHAN = "BROWNMON",
KINGLER = "REDMON",
KOFFING = "PURPLEMON",
KRABBY = "REDMON",
LAPRAS = "CYANMON",
LICKITUNG = "PINKMON",
MACHAMP = "GRAYMON",
MACHOKE = "GRAYMON",
MACHOP = "GRAYMON",
MAGIKARP = "REDMON",
MAGMAR = "REDMON",
MAGNEMITE = "GRAYMON",
MAGNETON = "GRAYMON",
MANKEY = "BROWNMON",
MAROWAK = "GRAYMON",
MEOWTH = "YELLOWMON",
METAPOD = "GREENMON",
MEW = "MEWMON",
MEWTWO = "MEWMON",
MOLTRES = "REDMON",
MR_MIME = "PINKMON",
MUK = "PURPLEMON",
NIDOKING = "PURPLEMON",
NIDOQUEEN = "BLUEMON",
NIDORAN_F = "BLUEMON",
NIDORAN_M = "PURPLEMON",
NIDORINA = "BLUEMON",
NIDORINO = "PURPLEMON",
NINETALES = "YELLOWMON",
ODDISH = "GREENMON",
OMANYTE = "BLUEMON",
OMASTAR = "BLUEMON",
ONIX = "GRAYMON",
PARAS = "REDMON",
PARASECT = "REDMON",
PERSIAN = "YELLOWMON",
PIDGEOT = "BROWNMON",
PIDGEOTTO = "BROWNMON",
PIDGEY = "BROWNMON",
PIKACHU = "YELLOWMON",
PINSIR = "BROWNMON",
POLIWAG = "BLUEMON",
POLIWHIRL = "BLUEMON",
POLIWRATH = "BLUEMON",
PONYTA = "REDMON",
PORYGON = "GRAYMON",
PRIMEAPE = "BROWNMON",
PSYDUCK = "YELLOWMON",
RAICHU = "YELLOWMON",
RAPIDASH = "REDMON",
RATICATE = "GRAYMON",
RATTATA = "GRAYMON",
RHYDON = "GRAYMON",
RHYHORN = "GRAYMON",
SANDSHREW = "BROWNMON",
SANDSLASH = "BROWNMON",
SCYTHER = "GREENMON",
SEADRA = "CYANMON",
SEAKING = "REDMON",
SEEL = "BLUEMON",
SHELLDER = "GRAYMON",
SLOWBRO = "PINKMON",
SLOWPOKE = "PINKMON",
SNORLAX = "PINKMON",
SPEAROW = "BROWNMON",
SQUIRTLE = "CYANMON",
STARMIE = "GRAYMON",
STARYU = "REDMON",
TANGELA = "BLUEMON",
TAUROS = "GRAYMON",
TENTACOOL = "CYANMON",
TENTACRUEL = "CYANMON",
VAPOREON = "CYANMON",
VENOMOTH = "PURPLEMON",
VENONAT = "PURPLEMON",
VENUSAUR = "GREENMON",
VICTREEBEL = "GREENMON",
VILEPLUME = "REDMON",
VOLTORB = "YELLOWMON",
VULPIX = "REDMON",
WARTORTLE = "CYANMON",
WEEDLE = "YELLOWMON",
WEEPINBELL = "GREENMON",
WEEZING = "PURPLEMON",
WIGGLYTUFF = "PINKMON",
ZAPDOS = "YELLOWMON",
ZUBAT = "BLUEMON",
},
source = "pokeyellow data/sgb/sgb_palettes.asm + data/pokemon/palettes.asm",
}
+71 -4
View File
@@ -11,8 +11,70 @@
-- equivalent, kept for save compatibility: rows 1-4 also self-heal
-- older saves (flag set before the port hid the ball) by hiding the
-- leftover ball on the next interaction.
--
-- The room's two readables (data/events/hidden_events.asm,
-- hidden_events_for CELADON_MANSION_ROOF_HOUSE):
-- hidden_text_predef 3, 0 / 4, 0 PrintBlackboardLinkCableText, LinkCableHelp
-- hidden_text_predef 3, 4 PrintNotebookText, TMNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so no
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#391). Same hook shape as the bedroom SNES in
-- data/scripts/flavor/reds_house_2f.lua (#135). hidden_text_predef puts
-- the predef id in the facing byte, so neither tile gates on facing.
local Menu = require("src.ui.Menu")
local TextBox = require("src.render.TextBox")
-- TMNotebookText (data/text/text_2.asm) has no leading underscore, so the
-- extractor never collects it and the pamphlet's text is inlined.
local TM_NOTEBOOK_TEXT = "It's a pamphlet\non TMs.\f...\f"
.. "There are 50 TMs\nin all.\f"
.. "There are also 5\nHMs that can be\vused repeatedly.\f"
.. "SILPH CO."
-- LinkCableHelp (engine/events/hidden_events/school_blackboard.asm):
-- HowToLinkText's four headings in the 15x10 box at the top left; picking
-- a heading prints its LinkCableInfoText and returns to the menu, B or
-- STOP READING closes.
local LINK_HEADINGS = { "HOW TO LINK", "COLOSSEUM", "TRADE CENTER" }
local function linkCableHelp(game)
local text = game.data.text or {}
local items, showMenu, askHeading
function showMenu()
game.stack:push(Menu.new(game, items,
{ tx = 0, ty = 0, tw = 15, th = 10, rowStep = 1 }))
end
function askHeading()
game.stack:push(TextBox.new(game,
text._LinkCableHelpText2 or "Which heading do\nyou want to read?",
showMenu))
end
items = {}
for i, label in ipairs(LINK_HEADINGS) do
items[i] = { label = label, onSelect = function()
game.stack:push(TextBox.new(game,
text["_LinkCableInfoText" .. i] or label, askHeading))
end }
end
items[#items + 1] = { label = "STOP READING" }
game.stack:push(TextBox.new(game,
text._LinkCableHelpText1 or "TRAINER TIPS\fUsing a Game Link\nCable",
askHeading))
end
return {
onInteract = function(game, ow, fx, fy)
if fy == 0 and (fx == 3 or fx == 4) then
linkCableHelp(game)
return true
end
if fx == 3 and fy == 4 then
game.stack:push(TextBox.new(game, TM_NOTEBOOK_TEXT))
return true
end
return false
end,
talk = {
TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL = {
{ "check_flag", "EVENT_GOT_EEVEE" }, -- 1
@@ -22,11 +84,16 @@ return {
{ "jump", 13 }, -- 4
{ "give_pokemon", "EEVEE", 25 }, -- 5
{ "jump_if_false", 12 }, -- 6 (party+box full)
{ "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 9
-- flag + HideObject before the jingle and GotMonText: the nickname
-- prompt inside give_pokemon and the text row both yield, and a
-- script that dies there would leave the EEVEE taken with the ball
-- still on the table and the gift claimable again (#426). The row
-- count is unchanged, so the numeric jump targets still hold.
{ "set_flag", "EVENT_GOT_EEVEE" }, -- 7
{ "hide_object", "CELADON_MANSION_ROOF_HOUSE",
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10
"CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8
{ "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle)
{ "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10
{ "jump", 13 }, -- 11
{ "show_text", "_BoxIsFullText" }, -- 12
},
+9 -7
View File
@@ -1,10 +1,9 @@
-- BikeShop (BIKE_SHOP) flavor dialogue
-- Source: pokered/scripts/BikeShop.asm, pokered/text/BikeShop.asm
--
-- TEXT_BIKESHOP_CLERK is skipped: it drives the actual voucher-for-bicycle
-- exchange (YesNoChoice purchase menu, GiveItem, RemoveItemByID, SetEvent
-- EVENT_GOT_BICYCLE). That is a significant standalone feature outside the
-- scope of these two flavor NPCs and is left unported here.
-- TEXT_BIKESHOP_CLERK lives in data/scripts/story2.lua (M.BIKE_SHOP): the
-- voucher exchange and the BICYCLE/CANCEL price window need more than
-- command rows (#568).
return {
BIKE_SHOP = {
@@ -20,12 +19,15 @@ return {
-- CheckEvent EVENT_GOT_BICYCLE ; jr nz, .gotBike
-- before the player owns a bike -> TheseBikesAreExpensiveText
-- 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 = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_BICYCLE" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "check_item", "BICYCLE" }, -- 2
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_BikeShopYoungsterTheseBikesAreExpensiveText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_BikeShopYoungsterCoolBikeText" }, -- 6
},
},
@@ -35,13 +35,25 @@ local function middleAgedMan(game, ow, npc, done)
-- .loop: print WhichBadgeText, then show the badge list menu again
push(game, t._CeruleanBadgeHouseMiddleAgedManWhichBadgeText, function()
local ListMenu = require("src.ui.ListMenu")
local Strings = require("src.core.Strings")
local items = {}
for _, id in ipairs(BADGE_ORDER) do
local idef = game.data.items[id]
items[#items + 1] = { label = idef and idef.name or id, value = id }
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, {
onChoose = function(item)
game.stack:pop()
if not item.value then
push(game, t._CeruleanBadgeHouseMiddleAgedManVisitAnyTimeText, done)
return
end
push(game, t[BADGE_TEXT[item.value]], loop)
end,
onCancel = function()
+33 -18
View File
@@ -3,6 +3,10 @@
-- Both NPCs use text_asm with an hRandomAdd roll to pick one of several
-- flavor lines (no flags, no branching outcome) -- ported as a weighted
-- math.random pick each time the NPC is talked to.
--
-- Yellow renames Slowbro -> Electrode in the text labels (see
-- tools/yellow_symbol_aliases.py); look up both so Red/Blue and Yellow
-- share this script.
local M = {}
@@ -12,6 +16,29 @@ local function push(game, ow, npc, done, text)
game.stack:push(TextBox.new(game, text, done))
end
-- CeruleanCitySlowbroText / CeruleanCityElectrodeText
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
local function talkMon(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
or t._CeruleanCityElectrodeTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
or t._CeruleanCityElectrodeIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
or t._CeruleanCityElectrodeTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
or t._CeruleanCityElectrodeIgnoredOrdersText
end
push(game, ow, npc, done, text)
end
M.CERULEAN_CITY = {
talk = {
-- CeruleanCityCooltrainerF1Text (scripts/CeruleanCity.asm:362-393)
@@ -23,32 +50,20 @@ M.CERULEAN_CITY = {
local text
if roll >= 180 then
text = t._CeruleanCityCooltrainerF1SlowbroUseSonicboomText
or t._CeruleanCityCooltrainerF1ElectrodeUseSonicboomText
elseif roll >= 100 then
text = t._CeruleanCityCooltrainerF1SlowbroPunchText
or t._CeruleanCityCooltrainerF1ElectrodePunchText
else
text = t._CeruleanCityCooltrainerF1SlowbroWithdrawText
or t._CeruleanCityCooltrainerF1ElectrodeWithdrawText
end
push(game, ow, npc, done, text)
end,
-- CeruleanCitySlowbroText (scripts/CeruleanCity.asm:395-436)
-- cp 180 -> 76/256 chance of 1st; cp 120 -> 60/256 chance of 2nd;
-- cp 60 -> 60/256 chance of 3rd; else 60/256 chance of 4th.
TEXT_CERULEANCITY_SLOWBRO = function(game, ow, npc, done)
local t = game.data.text
local roll = math.random(0, 255)
local text
if roll >= 180 then
text = t._CeruleanCitySlowbroTookASnoozeText
elseif roll >= 120 then
text = t._CeruleanCitySlowbroIsLoafingAroundText
elseif roll >= 60 then
text = t._CeruleanCitySlowbroTurnedAwayText
else
text = t._CeruleanCitySlowbroIgnoredOrdersText
end
push(game, ow, npc, done, text)
end,
TEXT_CERULEANCITY_SLOWBRO = talkMon,
-- Yellow map object uses TEXT_CERULEANCITY_ELECTRODE
TEXT_CERULEANCITY_ELECTRODE = talkMon,
},
}
@@ -14,9 +14,9 @@ return {
-- else -> .WhatsLostIsLostText (player has TM_DIG)
TEXT_CERULEANTRASHEDHOUSE_FISHING_GURU = {
{ "check_item", "TM_DIG" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_CeruleanTrashedHouseFishingGuruTheyStoleATMText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_CeruleanTrashedHouseFishingGuruWhatsLostIsLostText" },
},
},
+80
View File
@@ -0,0 +1,80 @@
-- Fuchsia City (pokered/scripts/FuchsiaCity.asm)
--
-- The exhibit signs are text_asm bodies: PrintText of a single text_far,
-- then DisplayPokedex for the exhibited species. The preview marks the
-- species seen but not owned, same as the S.S. Anne passenger's Snorlax.
--
-- The fossil sign branches on the Mt. Moon fossil events. The exhibit
-- holds the fossil the player did NOT take: taking the Dome Fossil puts
-- Omanyte on display, taking the Helix Fossil puts Kabuto. With neither
-- event set the sign only prints its undetermined line and no dex entry
-- opens.
--
-- The other text pointers (city sign, Safari Game signs, mart/center/gym
-- and warden signs, the four NPCs, the exhibited-mon FuchsiaCityPokemonText
-- rows) are plain text_far wrappers that resolve through Data:resolveText,
-- so they are not ported here.
return {
FUCHSIA_CITY = {
talk = {
-- FuchsiaCityChanseySignText: PrintText(_FuchsiaCityChanseySignText),
-- then DisplayPokedex CHANSEY.
TEXT_FUCHSIACITY_CHANSEY_SIGN = {
{ "show_text", "_FuchsiaCityChanseySignText" },
{ "mark_seen", "CHANSEY" },
{ "push_screen", "DexEntryMenu", "CHANSEY" },
},
-- FuchsiaCityVoltorbSignText: PrintText(_FuchsiaCityVoltorbSignText),
-- then DisplayPokedex VOLTORB.
TEXT_FUCHSIACITY_VOLTORB_SIGN = {
{ "show_text", "_FuchsiaCityVoltorbSignText" },
{ "mark_seen", "VOLTORB" },
{ "push_screen", "DexEntryMenu", "VOLTORB" },
},
-- FuchsiaCityKangaskhanSignText: PrintText(_FuchsiaCityKangaskhanSignText),
-- then DisplayPokedex KANGASKHAN.
TEXT_FUCHSIACITY_KANGASKHAN_SIGN = {
{ "show_text", "_FuchsiaCityKangaskhanSignText" },
{ "mark_seen", "KANGASKHAN" },
{ "push_screen", "DexEntryMenu", "KANGASKHAN" },
},
-- FuchsiaCitySlowpokeSignText: PrintText(_FuchsiaCitySlowpokeSignText),
-- then DisplayPokedex SLOWPOKE.
TEXT_FUCHSIACITY_SLOWPOKE_SIGN = {
{ "show_text", "_FuchsiaCitySlowpokeSignText" },
{ "mark_seen", "SLOWPOKE" },
{ "push_screen", "DexEntryMenu", "SLOWPOKE" },
},
-- FuchsiaCityLaprasSignText: PrintText(_FuchsiaCityLaprasSignText),
-- then DisplayPokedex LAPRAS.
TEXT_FUCHSIACITY_LAPRAS_SIGN = {
{ "show_text", "_FuchsiaCityLaprasSignText" },
{ "mark_seen", "LAPRAS" },
{ "push_screen", "DexEntryMenu", "LAPRAS" },
},
-- FuchsiaCityFossilSignText: CheckEvent EVENT_GOT_DOME_FOSSIL /
-- CheckEventReuseA EVENT_GOT_HELIX_FOSSIL pick the text and the
-- displayed entry; with neither set only the undetermined line prints.
TEXT_FUCHSIACITY_FOSSIL_SIGN = {
{ "check_flag", "EVENT_GOT_DOME_FOSSIL" }, -- 1
{ "jump_if_true", 7 }, -- 2
{ "check_flag", "EVENT_GOT_HELIX_FOSSIL" }, -- 3
{ "jump_if_true", 11 }, -- 4
{ "show_text", "_FuchsiaCityFossilSignUndeterminedText" }, -- 5
{ "jump", "end" }, -- 6
{ "show_text", "_FuchsiaCityFossilSignOmanyteText" }, -- 7
{ "mark_seen", "OMANYTE" }, -- 8
{ "push_screen", "DexEntryMenu", "OMANYTE" }, -- 9
{ "jump", "end" }, -- 10
{ "show_text", "_FuchsiaCityFossilSignKabutoText" }, -- 11
{ "mark_seen", "KABUTO" }, -- 12
{ "push_screen", "DexEntryMenu", "KABUTO" }, -- 13
},
},
},
}
+41
View File
@@ -85,6 +85,47 @@ return {
alreadyGotLabel = "_GameCornerGentlemanCloselyWatchTheReelsText",
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!",
}),
},
},
}
+2 -2
View File
@@ -8,9 +8,9 @@ return {
TEXT_LAVENDERMART_COOLTRAINER_M = {
{ "face_player" },
{ "check_flag", "EVENT_RESCUED_MR_FUJI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_LavenderMartCooltrainerMReviveText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_LavenderMartCooltrainerMNuggetText" },
},
},
+4 -4
View File
@@ -13,9 +13,9 @@ M.MR_FUJIS_HOUSE = {
TEXT_MRFUJISHOUSE_SUPER_NERD = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_MrFujisHouseSuperNerdMrFujiIsntHereText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_MrFujisHouseSuperNerdMrFujiHadBeenPrayingText" }, -- 6
},
@@ -25,9 +25,9 @@ M.MR_FUJIS_HOUSE = {
TEXT_MRFUJISHOUSE_LITTLE_GIRL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_RESCUED_MR_FUJI" }, -- 2
{ "jump_if_true", 5 }, -- 3
{ "jump_if_true", 6 }, -- 3
{ "show_text", "_MrFujisHouseLittleGirlThisIsMrFujisHouseText" }, -- 4
{ "jump", 6 }, -- 5
{ "jump", "end" }, -- 5
{ "show_text", "_MrFujisHouseLittleGirlPokemonAreNiceToHugText" }, -- 6
},
+2 -2
View File
@@ -12,9 +12,9 @@ return {
TEXT_ROUTE16GATE1F_GUARD = {
{ "face_player" },
{ "check_item", "BICYCLE" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_Route16Gate1FGuardNoPedestriansAllowedText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_Route16Gate1FGuardCyclingRoadExplanationText" },
},
},
+2 -2
View File
@@ -14,9 +14,9 @@ return {
TEXT_ROUTE18GATE1F_GUARD = {
{ "face_player" },
{ "check_item", "BICYCLE" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_Route18Gate1FGuardYouNeedABicycleText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_Route18Gate1FGuardCyclingRoadUphillText" },
},
},
+2 -2
View File
@@ -12,9 +12,9 @@ return {
TEXT_SILPHCO10F_SILPH_WORKER_F = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo10FSilphWorkerFImScaredText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo10FSilphWorkerFQuietAboutMyCryingText" },
},
},
+2 -2
View File
@@ -10,9 +10,9 @@ return {
-- not set: _SilphCo3FSilphWorkerMWhatShouldIDoText
TEXT_SILPHCO3F_SILPH_WORKER_M = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo3FSilphWorkerMWhatShouldIDoText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo3FSilphWorkerMYouSavedUsText" },
},
},
+2 -2
View File
@@ -7,9 +7,9 @@ return {
TEXT_SILPHCO4F_SILPH_WORKER_M = {
{"face_player"},
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
{"jump_if_true", 5},
{"jump_if_true", 6},
{"show_text", "_SilphCo4FSilphWorkerMImHidingText"},
{"jump", 6},
{"jump", "end"},
{"show_text", "_SilphCo4FSilphWorkerMTeamRocketIsGoneText"},
},
},
+2 -2
View File
@@ -7,9 +7,9 @@ return {
TEXT_SILPHCO5F_SILPH_WORKER_M = {
{"face_player"},
{"check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI"},
{"jump_if_true", 5},
{"jump_if_true", 6},
{"show_text", "_SilphCo5FSilphWorkerMThatsYouRightText"},
{"jump", 6},
{"jump", "end"},
{"show_text", "_SilphCo5FSilphWorkerMYoureOurHeroText"},
},
},
+10 -10
View File
@@ -11,9 +11,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_M1 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerM1TookOverTheBuildingText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerM1BackToWorkText" },
},
@@ -21,9 +21,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_M2 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerMHelpMePleaseText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerMWeGotEngagedText" },
},
@@ -31,9 +31,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_F1 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerF1SuchACowardText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerF1HaveToMarryHimText" },
},
@@ -41,9 +41,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_F2 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketConquerWorldText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerF2TeamRocketRanText" },
},
@@ -51,9 +51,9 @@ return {
TEXT_SILPHCO6F_SILPH_WORKER_M3 = {
{ "face_player" },
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 5 },
{ "jump_if_true", 6 },
{ "show_text", "_SilphCo6FSilphWorkerM3TargetedSilphText" },
{ "jump", 6 },
{ "jump", "end" },
{ "show_text", "_SilphCo6FSilphWorkerM3WorkForSilphText" },
},
},
+6 -6
View File
@@ -10,9 +10,9 @@ return {
-- set: _SilphCo7FSilphWorkerM2CancelledMasterBallText
TEXT_SILPHCO7F_SILPH_WORKER_M2 = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo7FSilphWorkerM2AfterTheMasterBallText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo7FSilphWorkerM2CancelledMasterBallText" },
},
@@ -22,9 +22,9 @@ return {
-- set: _SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText
TEXT_SILPHCO7F_SILPH_WORKER_M3 = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo7FSilphWorkerM3ItWouldBeBadText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo7FSilphWorkerM3YouChasedOffTeamRocketText" },
},
@@ -34,9 +34,9 @@ return {
-- set: _SilphCo7FSilphWorkerM4SafeAtLastText
TEXT_SILPHCO7F_SILPH_WORKER_M4 = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo7FSilphWorkerM4ItsReallyDangerousHereText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo7FSilphWorkerM4SafeAtLastText" },
},
},
+2 -2
View File
@@ -10,9 +10,9 @@ return {
-- set: _SilphCo8FSilphWorkerMThanksForSavingUsText
TEXT_SILPHCO8F_SILPH_WORKER_M = {
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" },
{ "jump_if_true", 4 },
{ "jump_if_true", 5 },
{ "show_text", "_SilphCo8FSilphWorkerMSilphIsFinishedText" },
{ "jump", 5 },
{ "jump", "end" },
{ "show_text", "_SilphCo8FSilphWorkerMThanksForSavingUsText" },
},
},
@@ -0,0 +1,175 @@
-- Viridian School House's two readables (data/events/hidden_events.asm,
-- hidden_events_for VIRIDIAN_SCHOOL_HOUSE):
-- hidden_text_predef 3, 0 PrintBlackboardLinkCableText, ViridianSchoolBlackboard
-- hidden_text_predef 3, 4 PrintNotebookText, ViridianSchoolNotebook
-- tools/extract/field.py only parses `hidden_event` rows, so neither
-- hidden_text_predef row reaches data/generated/field.lua and both tiles
-- were dead A presses (#503). Same hook shape, and the same sibling asm
-- file, as the Celadon roof house in data/scripts/celadon_eevee.lua (#391);
-- hidden_text_predef spends the facing byte on the tx_pre id, so neither
-- tile gates on facing.
local Font = require("src.render.Font")
local Theme = require("src.ui.Theme")
local TextBox = require("src.render.TextBox")
-- ViridianSchoolBlackboard (engine/events/hidden_events/school_blackboard.asm):
-- StatusAilmentText1/2 are the two columns of the 12x8 box at the top left
-- (hlcoord 0, 0 + `lb bc, 6, 10`); picking a status prints its
-- ViridianBlackboardStatusPointers entry and jumps back to .blackboardLoop,
-- QUIT or B falls through to .exitBlackboard.
local STATUS_LABELS = {
{ " SLP", "_ViridianBlackboardSleepText" },
{ " PSN", "_ViridianBlackboardPoisonText" },
{ " PAR", "_ViridianBlackboardPrlzText" },
{ " BRN", "_ViridianBlackboardBurnText" },
{ " 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 text = game.data.text or {}
local openBoard
-- wCurrentMenuItem / wMenuItemOffset are zeroed once, above .blackboardLoop,
-- and nothing inside the loop clears them again: after a status blurb
-- `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
local function pick(i)
closeBoard()
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,
text._ViridianSchoolBlackboardText2 or "Which heading do\nyou want to read?",
nil, { stay = { onShown = function()
board = board or StatusBoard.new(game, pick, closeBoard)
game.stack:push(board)
end } }))
end
game.stack:push(TextBox.new(game,
text._ViridianSchoolBlackboardText1
or "The blackboard\ndescribes POKéMON\vSTATUS changes\vduring battles.",
openBoard))
end
-- ViridianSchoolNotebook (engine/events/hidden_events/school_notebooks.asm):
-- pages 1-3 each end in TurnPageSchoolNotebook (TurnPageText + YesNoChoice)
-- and NO stops the read; page 4 turns without asking and runs straight into
-- page 5, the girl catching you at it.
local function notebook(game)
local text = game.data.text or {}
local function page(n, after)
return TextBox.new(game, text["_ViridianSchoolNotebookText" .. n] or "", after)
end
local function turnPage(nextPage)
return function()
game.stack:push(TextBox.new(game, text._TurnPageText or "Turn the page?",
nil, { choice = function(yes)
if yes then game.stack:push(nextPage()) end
end }))
end
end
local function page5() return page(5) end
local function page4() return page(4, function() game.stack:push(page5()) end) end
local function page3() return page(3, turnPage(page4)) end
local function page2() return page(2, turnPage(page3)) end
game.stack:push(page(1, turnPage(page2)))
end
return {
VIRIDIAN_SCHOOL_HOUSE = {
onInteract = function(game, ow, fx, fy)
if fx == 3 and fy == 0 then
blackboard(game)
return true
end
if fx == 3 and fy == 4 then
notebook(game)
return true
end
return false
end,
},
}
+2
View File
@@ -15,6 +15,7 @@ local files = {
"data.scripts.flavor.cerulean_trashed_house",
"data.scripts.flavor.copycats_house_1f",
"data.scripts.flavor.copycats_house_2f",
"data.scripts.flavor.fuchsia_city",
"data.scripts.flavor.game_corner",
"data.scripts.flavor.lavender_cubone_house",
"data.scripts.flavor.lavender_mart",
@@ -60,6 +61,7 @@ local files = {
"data.scripts.flavor.victory_road_2f",
"data.scripts.flavor.viridian_city",
"data.scripts.flavor.viridian_nickname_house",
"data.scripts.flavor.viridian_school_house", -- #503
"data.scripts.flavor.wardens_house",
}
+2 -1
View File
@@ -48,7 +48,8 @@ end
if GameVersion.isYellow() then
for _, file in ipairs({ "data.scripts.yellow_gifts",
"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
MapScripts.attachBase(mapId, mod)
end
+67 -18
View File
@@ -6,6 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- 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
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
@@ -20,10 +22,10 @@ local function starterBall(askText, species, choseFlag, ownBall,
rivalBallX, rivalBall)
return {
{ "check_flag", "EVENT_GOT_STARTER" }, -- 1
{ "jump_if_true", 20 }, -- 2
{ "jump_if_true", 22 }, -- 2
-- no picking until Oak has walked you in (OaksLabScript gating)
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" }, -- 3
{ "jump_if_false", 20 }, -- 4
{ "jump_if_false", 25 }, -- 4
-- the Pokédex "new species" entry shows before the ask (predef
-- StarterDex ahead of OaksLabYouWant...Text). StarterDex temporarily
-- sets the owned bits so ShowPokedexData prints height/weight/text;
@@ -31,30 +33,45 @@ local function starterBall(askText, species, choseFlag, ownBall,
{ "push_screen", "DexEntryMenu",
{ species = species, forceOwned = true } }, -- 5
{ "ask", askText }, -- 6
{ "jump_if_false", 21 }, -- 7
{ "jump_if_false", "end" }, -- 7
-- OaksLab.asm prints ReceivedMon then AddPartyMon (AskName lives
-- inside give_pokemon). Show the received text first so the
-- nickname prompt follows "you got X", matching Gen1.
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 8
{ "give_pokemon", species, 5 }, -- 9
{ "set_flag", "EVENT_GOT_STARTER" }, -- 10
{ "set_flag", choseFlag }, -- 11
-- The received text carries sound_get_key_item (OaksLab.asm
-- OaksLabReceivedMonText); the jingle plays as the box opens
-- (same beat as the Yellow port's starter, #668).
{ "play_sound", "Get_Key_Item" }, -- 8
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 9
{ "give_pokemon", species, 5 }, -- 10
{ "set_flag", "EVENT_GOT_STARTER" }, -- 11
{ "set_flag", choseFlag }, -- 12
-- POKé BALLs are not handed out here in the original -- Oak gives
-- them later, at OaksLabOak1Text's .give_poke_balls beat once the
-- player has beaten the Route 22 rival (see TEXT_OAKSLAB_OAK1 below)
{ "hide_object", "OAKS_LAB", ownBall }, -- 12
{ "hide_object", "OAKS_LAB", ownBall }, -- 13
-- the rival walks to the countering ball (around the furniture)
{ "move_npc_to", 1, rivalBallX, 4 }, -- 13
{ "face_object", 1, "up" }, -- 14
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 15
{ "hide_object", "OAKS_LAB", rivalBall }, -- 16
{ "move_npc_to", 1, rivalBallX, 4 }, -- 14
{ "face_object", 1, "up" }, -- 15
{ "show_text", "_OaksLabRivalIllTakeThisOneText" }, -- 16
{ "hide_object", "OAKS_LAB", rivalBall }, -- 17
{ "play_sound", "Get_Key_Item" }, -- 18 (sound_get_key_item)
{ "show_text", "_OaksLabRivalReceivedMonText",
{ RAM = rivalBall == "OAKSLAB_CHARMANDER_POKE_BALL" and "CHARMANDER"
or rivalBall == "OAKSLAB_SQUIRTLE_POKE_BALL" and "SQUIRTLE"
or "BULBASAUR" } }, -- 17
{ "jump", 21 }, -- 18
{ "jump", 21 }, -- 19 (spacer)
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 20
or "BULBASAUR" } }, -- 19
{ "jump", "end" }, -- 20
{ "jump", "end" }, -- 21 (spacer)
-- 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" }, -- 22
{ "show_text", "That's PROF.OAK's\nlast Pokémon!" }, -- 23
-- OaksLabLastMonScript ends at TextScriptEnd; the port used to fall
-- through into the pre-pick line below (#601 remnant, reported on #600)
{ "jump", "end" }, -- 24
{ "show_text", "_OaksLabThoseArePokeBallsText" }, -- 25
}
end
@@ -62,10 +79,22 @@ return {
talk = {
-- Oak: OaksLabOak1Text. Parcel delivery kicks SCRIPT_OAKSLAB_RIVAL_
-- ARRIVES_AT_OAKS_REQUEST + OaksLabOakGivesPokedexScript (rival walk-
-- in, full Pokédex speech, rival exit, Route 22 arm). Dex-rating
-- (DisplayDexRating) is still skipped.
-- in, full Pokédex speech, rival exit, Route 22 arm).
TEXT_OAKSLAB_OAK1 = {
{ "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" },
{ "jump_if_true", "come_see" },
{ "check_flag", "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE" },
@@ -151,6 +180,14 @@ return {
{ "label", "come_see" },
{ "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 =
@@ -245,7 +282,12 @@ return {
and y >= 6 then
local rival = ow:npcByIndex(1)
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 = {
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "show_text", "_OaksLabRivalIllTakeYouOnText" }, -- 1
}
-- the rival routes to a free cell beside the player
@@ -283,8 +325,15 @@ return {
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player. The fanfare was dropped here (#683) -- the
-- parcel scene above already plays Music_MeetRival on both arrival and
-- departure (lines 144-146), and this exit should match (#596).
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
ow.runner:run(rows, { npc = rival })
return true
end
+25 -11
View File
@@ -71,8 +71,12 @@ return {
{ "show_text", "_OaksLabRivalLeaveItAllToMeText" },
{ "set_flag", "EVENT_GOT_POKEDEX" },
{ "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" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN" },
{ "show_object", "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN2" },
{ "stop_music" },
{ "play_music", "Music_MeetRival" },
{ "move_npc_to", RIVAL, 4, 7 },
@@ -143,17 +147,25 @@ return {
-- OaksLabRivalExclamationScript: "!" over the rival
{ "emote", RIVAL, "shock" },
}
-- .RivalPushesPlayerAwayFromEeveeBall + the PAD_RIGHT x2 shove:
-- the rival cuts across to the ball WHILE the player standing
-- below it is bumped two tiles right (both movements run in the
-- same beat, so the walk overlaps the shove like the original)
-- .RivalPushesPlayerAwayFromEeveeBall is DOWN then RIGHT x3 (the $07
-- bytes are Yellow's own step-right encoding, decoded by
-- engine/overworld/movement.asm Func_5288 -> Func_532b), and the
-- 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
rows[#rows + 1] = { "walk_npc", RIVAL,
{ "down", "right", "right", "right" }, { wait = false } }
rows[#rows + 1] = { "walk_npc", RIVAL, { "down", "right", "right" } }
-- 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] = { "move_player", "right", 2 }
-- let the rival finish the last stretch to (7,4)
rows[#rows + 1] = { "wait", 40 }
-- move_player blocks for both tiles, so the rival has already
-- landed on (7,4); this is just the beat before he turns up
rows[#rows + 1] = { "wait", 20 }
else
rows[#rows + 1] = { "move_npc_to", RIVAL, 7, 4 }
end
@@ -275,10 +287,12 @@ return {
table.insert(rows, { "label", "lost_lab" })
table.insert(rows, { "set_field", "rivalStarter", 3 })
table.insert(rows, { "label", "exit" })
-- OaksLabRivalStartsExitScript: parting shot, walk out past the
-- player, restore the lab theme
-- OaksLabRivalStartsExitScript: parting shot, rival exit fanfare, then
-- walk out past the player (#683).
table.insert(rows, { "wait", 20 })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "stop_music" })
table.insert(rows, { "play_music", "Music_MeetRival" })
table.insert(rows, { "move_npc_to", RIVAL, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
table.insert(rows, { "play_music", "Music_OaksLab" })
+71 -29
View File
@@ -4,9 +4,11 @@
-- (.PlayerNextToSafariZoneWorker1CoordsArray). Paying ¥500 hands over
-- 30 SAFARI BALLs and starts the 502-step game
-- (SafariZoneGateWouldYouLikeToJoinScript: wSafariSteps = 502,
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED). Declining walks you back
-- so you can't slip past. Returning to the gate ends the game and the
-- worker takes the leftover balls back.
-- wNumSafariBalls = SAFARI_BALLS_RECEIVED), then auto-walks the player
-- up through the north warp into the zone. Declining walks you 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
-- (safariStep/safariGameOver, from
@@ -19,7 +21,33 @@ local FEE = 500
local BALLS = 30
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.safariNags = nil
local TextBox = require("src.render.TextBox")
@@ -31,7 +59,10 @@ local function startGame(game, t, done, balls, introText)
local pa = t._SafariZoneGateSafariZoneWorker1CallYouOnThePAText
or "\fWe'll call you on\nthe PA when you\nrun out of time\nor SAFARI BALLs!"
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
-- 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.")
.. "\f" .. (t._SafariZoneLowCostText2
or "But, I can't give\nyou all 30 BALLs.")
startGame(game, t, done, balls, intro)
startGame(game, ow, t, done, balls, intro)
return
end
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._SafariZoneLowCostText3
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
end
local nags = {
@@ -100,7 +131,7 @@ local function joinPrompt(game, ow, done)
end
else
game.save.money = game.save.money - FEE
startGame(game, t, done)
startGame(game, ow, t, done)
end
end))
end))
@@ -135,27 +166,38 @@ M.SAFARI_ZONE_GATE = {
-- no walks you back into the zone
onEnter = function(game, ow)
if not game.save.safari or ow.player.cellY > 1 then return end
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local t = game.data.text
game.stack:push(TextBox.new(game,
t._SafariZoneGateSafariZoneWorker1LeavingEarlyText or "Leaving early?",
function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
-- back into the zone through the entrance warp
local w = game.data.maps.SAFARI_ZONE_CENTER.warps[1]
ow:startWarpTo("SAFARI_ZONE_CENTER", w.x, w.y, "up")
return
end
game.save.safari = nil
game.stack:push(TextBox.new(game,
(t._SafariZoneGateSafariZoneWorker1ReturnSafariBallsText
or "Please return any\nSAFARI BALLs you\nhave left.")
.. "\f" .. (t._SafariZoneGateSafariZoneWorker1GoodHaulComeAgainText
or "Did you get a\ngood haul?\fCome again!")))
end))
end))
-- QUEUED, never pushed: onEnter runs inside the arriving warp's
-- Transition midpoint, and Transition:finish pops whatever is on top
-- the same frame (Timing.WARP_FADE_IN is 0) -- so a box pushed here is
-- swallowed, and on a build where it survived it drew over a screen
-- still faded to black (#540). Same contract as M.HALL_OF_FAME in
-- data/scripts/story.lua.
--
-- SafariZoneGateLeavingSafariScript .leaving_early: YES prints the
-- return-balls text, faces the player down and runs
-- SafariZoneEntranceAutoWalk with `PAD_DOWN, c = 3`, landing on the
-- counter row 3 cells below the warp you came in by; NO prints
-- "Good Luck!" and walks one step back up through that same warp.
local rightSide = ow.player.cellX ~= 3
local dest = game.data.maps.SAFARI_ZONE_CENTER.warps[rightSide and 2 or 1]
ow:queueScript({
{ "ask", "_SafariZoneGateSafariZoneWorker1LeavingEarlyText" },
{ "jump_if_false", "stay" },
-- the port never reaches SafariZoneGateLeavingSafariScript's own
-- GOOD_HAUL_COME_AGAIN branch (safariGameOver warps straight to the
-- counter), so the sign-off rides on this path
{ "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,
}
+43 -1
View File
@@ -11,7 +11,7 @@
-- field.seafoam (SEAFOAM_ISLANDS_1F/B1F/B3F holes+holeDestination and
-- B3F's pluggedByHolesOn) plus the generic
-- 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 = {}
@@ -23,4 +23,46 @@ M.VERMILION_GYM = {
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
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
return true
end
end
return false
end
end
return M
+147 -25
View File
@@ -183,11 +183,17 @@ M.BILLS_HOUSE = {
overworld = ow },
"BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON")
game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true
require("src.world.PikachuFollower").onBillEnteredMachine(game, ow)
done()
end
if ow.player.facing == "down" then
-- 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, "up", 2, function()
ow:scriptMove(npc, "left", 1, function()
@@ -273,6 +279,9 @@ M.BILLS_HOUSE = {
Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1")
Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2")
end
if not flags.EVENT_MET_BILL_2 then
require("src.world.PikachuFollower").onBillsHouseEnter(game, ow)
end
end,
}
@@ -596,12 +605,34 @@ local function snorlaxWake(mapId, objName, beatFlag, wokeUpText, calmedText)
}
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
-- 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
-- already been beaten before allowing the wake.
M.ROUTE_12 = {
talk = { TEXT_ROUTE12_SNORLAX = { { "show_text", "_Route12SnorlaxText" } } },
onEnter = hideBeatenSnorlax("ROUTE_12", "ROUTE12_SNORLAX",
"EVENT_BEAT_ROUTE12_SNORLAX"),
snorlaxWake = {
objName = "ROUTE12_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE12_SNORLAX",
script = snorlaxWake("ROUTE_12", "ROUTE12_SNORLAX", "EVENT_BEAT_ROUTE12_SNORLAX",
@@ -610,6 +641,8 @@ M.ROUTE_12 = {
}
M.ROUTE_16 = {
talk = { TEXT_ROUTE16_SNORLAX = { { "show_text", "_Route16Text7" } } },
onEnter = hideBeatenSnorlax("ROUTE_16", "ROUTE16_SNORLAX",
"EVENT_BEAT_ROUTE16_SNORLAX"),
snorlaxWake = {
objName = "ROUTE16_SNORLAX", beatFlag = "EVENT_BEAT_ROUTE16_SNORLAX",
script = snorlaxWake("ROUTE_16", "ROUTE16_SNORLAX", "EVENT_BEAT_ROUTE16_SNORLAX",
@@ -641,11 +674,18 @@ M.SAFARI_ZONE_SECRET_HOUSE = {
M.WARDENS_HOUSE = {
talk = {
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
{ "check_flag", "EVENT_GOT_HM04" }, -- 2
{ "jump_if_true", 13 }, -- 3
-- #535: previously jumped to the same silent-end target as the
-- give-then-thank fallthrough, so the warden said nothing on every
-- visit after the trade. pokered's .got_item branch
-- (scripts/WardensHouse.asm) instead prints HM04ExplanationText.
{ "jump_if_true", "got_hm04" }, -- 3
{ "check_item", "GOLD_TEETH" }, -- 4
{ "jump_if_false", 15 }, -- 5
{ "jump_if_false", "no_teeth" }, -- 5
{ "show_text", "_WardensHouseWardenGaveTheGoldTeethText" }, -- 6
{ "take_item", "GOLD_TEETH", 1 }, -- 7
{ "set_flag", "EVENT_GAVE_GOLD_TEETH" }, -- 8
@@ -654,9 +694,27 @@ M.WARDENS_HOUSE = {
{ "give_item", "HM_STRENGTH", 1, false }, -- 10
{ "show_text", "_WardensHouseWardenReceivedHM04Text" }, -- 11
{ "set_flag", "EVENT_GOT_HM04" }, -- 12
{ "jump", 16 }, -- 13 (already got it)
{ "jump", 16 }, -- 14 (unused)
{ "show_text", "_WardensHouseWardenGibberish1Text" }, -- 15
{ "jump", "end" }, -- 13 (jp .done)
-- #645: WardensHouseWardenText prints Gibberish1, then YesNoChoice,
-- 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) --
-- printed on every subsequent talk once EVENT_GOT_HM04 is set.
-- Text is _WardensHouseWardenHM04ExplanationText (text/WardensHouse.asm):
-- HM04 teaches Strength, and hints at the Safari Zone secret house.
{ "label", "got_hm04" }, -- 22
{ "show_text", "_WardensHouseWardenHM04ExplanationText" }, -- 23
},
},
}
@@ -666,6 +724,48 @@ M.WARDENS_HOUSE = {
-- generic OPP_GIOVANNI#2 battle)
-- -------------------------------------------------------------------
-- SilphCo11FTeamRocketLeavesScript (scripts/SilphCo11F.asm) hides every
-- ROCKET and rocket-aligned SCIENTIST toggle on 2F-11F the moment Giovanni
-- falls; the item balls, the 2F/10F rescued workers and the 7F rival keep
-- theirs. Names and order are data/maps/toggleable_objects.asm's
-- TOGGLE_SILPH_CO_<floor>_n entries. Saffron City's half of the same
-- script lives in M.SAFFRON_CITY (story4.lua) (#392).
local SILPH_ROCKET_OBJECTS = {
{ "SILPH_CO_2F", { "SILPHCO2F_SCIENTIST1", "SILPHCO2F_SCIENTIST2",
"SILPHCO2F_ROCKET1", "SILPHCO2F_ROCKET2" } },
{ "SILPH_CO_3F", { "SILPHCO3F_ROCKET", "SILPHCO3F_SCIENTIST" } },
{ "SILPH_CO_4F", { "SILPHCO4F_ROCKET1", "SILPHCO4F_SCIENTIST",
"SILPHCO4F_ROCKET2" } },
{ "SILPH_CO_5F", { "SILPHCO5F_ROCKET1", "SILPHCO5F_SCIENTIST",
"SILPHCO5F_ROCKER", "SILPHCO5F_ROCKET2" } },
{ "SILPH_CO_6F", { "SILPHCO6F_ROCKET1", "SILPHCO6F_SCIENTIST",
"SILPHCO6F_ROCKET2" } },
{ "SILPH_CO_7F", { "SILPHCO7F_ROCKET1", "SILPHCO7F_SCIENTIST",
"SILPHCO7F_ROCKET2", "SILPHCO7F_ROCKET3" } },
{ "SILPH_CO_8F", { "SILPHCO8F_ROCKET1", "SILPHCO8F_SCIENTIST",
"SILPHCO8F_ROCKET2" } },
{ "SILPH_CO_9F", { "SILPHCO9F_ROCKET1", "SILPHCO9F_SCIENTIST",
"SILPHCO9F_ROCKET2" } },
{ "SILPH_CO_10F", { "SILPHCO10F_ROCKET", "SILPHCO10F_SCIENTIST" } },
{ "SILPH_CO_11F", { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1",
"SILPHCO11F_ROCKET2" } },
}
-- hide_object writes save.objectToggles, so the single pass at the win
-- covers floors the player is not standing on; onlyMap is the per-floor
-- repair path below.
local function silphRocketsLeave(game, ow, onlyMap)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
if not onlyMap or onlyMap == floor[1] then
for _, name in ipairs(floor[2]) do
Commands.hide_object(ctx, floor[1], name)
end
end
end
end
M.SILPH_CO_11F = {
-- Giovanni's battle is a COORDINATE TRIGGER, not a talk.
-- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks
@@ -692,13 +792,11 @@ M.SILPH_CO_11F = {
ow:scriptMove(gio, "down", 3, function()
gio:facePlayer(ow.player)
ow:engageTrainer(gio, function()
-- SilphCo11FTeamRocketLeavesScript: Giovanni leaves the floor
-- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves
-- after the loss (the street rockets are handled by
-- M.SAFFRON_CITY.onEnter in story4.lua).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
silphRocketsLeave(game, ow)
end
end)
end)
@@ -706,28 +804,46 @@ M.SILPH_CO_11F = {
end,
onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
Commands.hide_object(ctx, "SILPH_CO_11F", "SILPHCO11F_GIOVANNI")
silphRocketsLeave(game, ow)
end
end,
talk = {
-- SilphCo11FSilphPresidentText branches on EVENT_GOT_MASTER_BALL only:
-- the teleport pads reach him without passing Giovanni's trigger, and
-- afterwards he describes the ball forever. The old rows fell off the
-- end of the script on both branches, so a second talk was silence
-- (#392).
TEXT_SILPHCO11F_SILPH_PRESIDENT = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, -- 2
{ "jump_if_false", 10 }, -- 3
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 4
{ "jump_if_true", 10 }, -- 5
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 6
{ "check_flag", "EVENT_GOT_MASTER_BALL" }, -- 2
{ "jump_if_true", 9 }, -- 3
{ "show_text", "_SilphCo11FSilphPresidentText" }, -- 4
-- give-then-print like scripts/SilphCo11F.asm
{ "give_item", "MASTER_BALL", 1, false }, -- 7
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 8
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 9
{ "jump", 11 }, -- 10
{ "give_item", "MASTER_BALL", 1, false }, -- 5
{ "show_text", "_SilphCo11FSilphPresidentReceivedMasterBallText" }, -- 6
{ "set_flag", "EVENT_GOT_MASTER_BALL" }, -- 7
{ "jump", "end" }, -- 8
{ "show_text",
"_SilphCo11FSilphPresidentMasterBallDescriptionText" }, -- 9
},
},
}
-- Floors 2F-10F carry no other onEnter, so this repairs saves that beat
-- Giovanni before the hide list existed (same shape as
-- M.POKEMON_TOWER_7F.onEnter); 11F hides its own inside the table above.
for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do
local mapId = floor[1]
if mapId ~= "SILPH_CO_11F" then
M[mapId] = M[mapId] or {}
M[mapId].onEnter = function(game, ow)
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
silphRocketsLeave(game, ow, mapId)
end
end
end
end
-- -------------------------------------------------------------------
-- Victory Road boulder switches (scripts/VictoryRoad1F/2F/3F.asm):
-- a boulder resting on a switch removes a barrier block; the 3F hole
@@ -826,6 +942,7 @@ M.VICTORY_ROAD_3F = {
-- fall is onStep, not a collision block.
onStep = function(game, ow, x, y)
if x == 23 and y == 15 then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo("VICTORY_ROAD_2F", 22, 16, ow.player.facing)
return true
end
@@ -864,10 +981,15 @@ M.VICTORY_ROAD_3F = {
local championsRoomRivalScript = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2
{ "jump_if_true", 25 }, -- 3 past end
{ "jump_if_true", 26 }, -- 3 past end
{ "show_text", "_ChampionsRoomRivalIntroText" }, -- 4
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 5
{ "jump_if_false", 25 }, -- 6 past end
-- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after
-- the intro text, before the battle itself (#706); pushBattle's wipe-time
-- playBattle("final") then no-ops on the same song, so the theme stays
-- continuous into the fight
{ "play_music", "Music_FinalBattle" }, -- 5
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
{ "jump_if_false", 26 }, -- 6 past end
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
+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 = {
talk = {
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")
if (game.save.inventory.BICYCLE or 0) > 0 then
game.stack:push(TextBox.new(game, "How's the\nBICYCLE treating\nyou?", done))
elseif (game.save.inventory.BIKE_VOUCHER or 0) > 0 then
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))
local t = game.data.text
local function say(text, after, opts)
game.stack:push(TextBox.new(game, text, after, opts))
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,
},
}
+180 -127
View File
@@ -111,8 +111,9 @@ M.POKEMON_TOWER_5F = {
--
-- PokemonTower6FDefaultScript starts the RESTLESS SOUL battle with NO
-- Silph Scope check at the trigger -- the scope only decides whether the
-- battle is disguised (IsGhostBattle -> makeGhost: "too scared to move",
-- balls dodged). An earlier version of this port turned the player back
-- disguise sticks (IsGhostBattle -> makeGhost: "too scared to move", balls
-- 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
-- impassable on any route that skips Rocket Hideout; vanilla lets the
-- battle open and a POKE_DOLL end it (see wBattleResult below).
@@ -128,7 +129,18 @@ M.POKEMON_TOWER_6F = {
t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function()
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "MAROWAK", 30)
if not game.save.inventory.SILPH_SCOPE then
-- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball
-- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether
-- or not the scope revealed it, so the "can't be caught" state rides
-- the battle instead of IsGhostBattle alone (#444)
battle.noCatch = true
-- 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()
end
battle.onFinish = function(result)
@@ -182,17 +194,16 @@ M.POKEMON_TOWER_6F = {
-- preFrames: the shake's lead-in delays -- ShakeElevator's own Delay3s
-- come to 9 frames; Silph/Rocket's ...ShakeScript prefixes another
-- Delay3 (12) while CeladonMartElevatorShakeScript farjps straight in
-- The panel is a bg_event, not a map-entry script: data/maps/objects/
-- CeladonMartElevator.asm has `bg_event 3, 0, TEXT_CELADONMARTELEVATOR`
-- and CeladonMartElevatorText is what runs DisplayElevatorFloorMenu, so
-- the menu waits for the player to face the panel and press A (#395).
-- Map entry only stores the car's exit warps (CeladonMartElevator
-- StoreWarpEntriesScript), which is elevatorSeedExit below.
-- After the ride the original does NOT jump-cut to the floor: choosing a
-- floor in engine/events/elevator.asm DisplayElevatorFloorMenu rewrites
-- the elevator car's own warp entries (wWarpEntries, via .UpdateWarp) to
-- the chosen floor's exit warp, then the player walks out of the car onto
-- that warp themselves (scripts/SilphCoElevator.asm etc.). Reproduce
-- that here: rewrite the car's exit warps, then drive a short scripted
-- walk-out onto an exit tile and take the (now rewritten) warp, reusing
-- ow:scriptMove / ow:takeWarp (the Oak-escort primitives).
local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" }
local WALK_ORDER = { "up", "down", "left", "right" }
-- floor rewrites the elevator car's own warp entries (wWarpEntries, via
-- .UpdateWarp) to the chosen floor's exit warp and returns control, and
-- the player walks out of the car onto that warp themselves.
-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator
-- door (warp id, map id). Shared generated map data, but the car's
@@ -205,46 +216,6 @@ local function elevatorSetExit(ow, floor)
end
end
local function elevatorWalkOut(ow, floor)
local m, p = ow.map, ow.player
elevatorSetExit(ow, floor)
-- leave by the exit tile under the player (they warped in onto one),
-- else the nearest
local door
for _, w in ipairs(m.def.warps) do
if w.x == p.cellX and w.y == p.cellY then door = w break end
end
if not door then
local best
for _, w in ipairs(m.def.warps) do
local d = math.abs(w.x - p.cellX) + math.abs(w.y - p.cellY)
if not best or d < best then best, door = d, w end
end
end
-- the car interior sits on the door's walkable side; "out" is the
-- doorway direction (the map edge for Silph/Celadon, the top doorway
-- for the Rocket car). Fixed direction order keeps the step
-- deterministic when a door tile has several walkable neighbours
-- (Silph/Celadon step up into the car, the Rocket car steps down).
local into
for _, dir in ipairs(WALK_ORDER) do
local v = WALK_DIRVEC[dir]
local nx, ny = door.x + v[1], door.y + v[2]
if m:inBounds(nx, ny) and m:isWalkableCell(nx, ny) then into = dir break end
end
into = into or "up"
local out = WALK_OPP[into]
local function leave()
ow:takeWarp(door) -- door SFX + warp to the rewritten floor target
end
-- step one tile into the car, then walk back through the doorway onto
-- the exit tile and take the warp: a visible walk-out on valid tiles
-- for either door orientation, instead of a jump cut
ow:scriptMove(p, into, 1, function()
ow:scriptMove(p, out, 1, leave)
end)
end
local function elevatorFloors(elevatorMapId, game)
local floors = {}
for mapId, def in pairs(game.data.maps) do
@@ -272,9 +243,9 @@ local function elevatorFloors(elevatorMapId, game)
end
local function elevatorSeedExit(ow, floors, fromMapId)
-- Seed a walk-out destination before the menu (or key-gate text):
-- entry floor when known, else the first listed floor (1F). Choosing
-- a floor still rewrites via elevatorWalkOut; B-cancel / no-key leave
-- Seed a walk-out destination on entry (before the panel is ever
-- read): entry floor when known, else the first listed floor (1F).
-- Choosing a floor rewrites it again; B-cancel / no-key leave
-- keeps this seed so walking out of the car cannot hit a missing ROM
-- placeholder (#123) or the car's static default floor (#90: Rocket
-- Hideout defaults to B1F even when entered from B2F/B4F).
@@ -288,68 +259,68 @@ local function elevatorSeedExit(ow, floors, fromMapId)
return exitFloor
end
local function elevator(elevatorMapId, keyGate, preFrames)
local function elevator(elevatorMapId, panelText, keyGate, preFrames)
-- the panel bg_event: DisplayElevatorFloorMenu runs from this text
-- script, never from map entry (#395)
local function panel(game, ow, npc, done)
done = done or function() end
local floors = elevatorFloors(elevatorMapId, game)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line and shows no floor menu
-- (scripts/RocketHideoutElevator.asm).
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key.", done))
return
end
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
end
local ListMenu = require("src.ui.ListMenu")
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
onChoose = function(item, list)
list:close()
-- the whole ShakeElevator ride runs in place -- music stop, 100
-- collision-thud scroll bounces, the PA chime -- and only then
-- does .UpdateWarp's rewrite land, with the player still stood
-- at the panel: they walk out to the car door themselves
local ElevatorShake = require("src.world.ElevatorShake")
game.stack:push(ElevatorShake.new(game, ow, {
preFrames = preFrames,
onDone = function()
elevatorSetExit(ow, item.value)
done()
end,
}))
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded on entry)
done()
end,
}))
end
return {
-- fromMapId: the floor the player just left (setMap passes it), so a
-- B-cancel can still walk out onto a real map. Silph's ROM car warps
-- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve
-- asserted and hard-crashed (#123).
onEnter = function(game, ow, fromMapId)
local floors = elevatorFloors(elevatorMapId, game)
elevatorSeedExit(ow, floors, fromMapId)
-- Rocket Hideout: without LIFT_KEY the panel only prints the need-
-- a-key line (scripts/RocketHideoutElevator.asm). Exit warps are
-- still seeded above so walking out returns to the entry floor
-- instead of the car's ROM default (B1F) -- #90 / #105.
if keyGate and not game.save.inventory[keyGate.item] then
local TextBox = require("src.render.TextBox")
game.stack:push(TextBox.new(game,
game.data.text[keyGate.text] or "It appears to\nneed a key."))
return
end
local items = {}
for _, f in ipairs(floors) do
table.insert(items, { label = f.token, value = f })
end
local ListMenu = require("src.ui.ListMenu")
game.stack:push(ListMenu.new(game, "WHICH FLOOR?", items, {
onChoose = function(item, list)
list:close()
-- the map-entry Transition (startWarpTo) calls onEnter from
-- its OWN midpoint callback, so this menu was pushed on top
-- of that still-active Transition; only the top state
-- updates, so it froze mid-fade instead of finishing. Left
-- alone it would resurface after the ride and play a stray
-- fade at the wrong time -- pop it now, before it can happen.
local Transition = require("src.render.Transition")
if getmetatable(game.stack:top()) == Transition then
game.stack:pop()
end
-- the whole ShakeElevator ride runs in place -- music stop,
-- 100 collision-thud scroll bounces, the PA chime -- and only
-- then does the player walk out of the car onto the chosen
-- floor (elevatorWalkOut rewrites the car's exit warps first)
local ElevatorShake = require("src.world.ElevatorShake")
game.stack:push(ElevatorShake.new(game, ow, {
preFrames = preFrames,
onDone = function()
elevatorWalkOut(ow, item.value)
end,
}))
end,
onCancel = function()
-- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing
-- happens, the player just stays in the car (exit warps were
-- already seeded to the entry floor above)
end,
}))
elevatorSeedExit(ow, elevatorFloors(elevatorMapId, game), fromMapId)
end,
talk = { [panelText] = panel },
}
end
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR")
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR", nil, 9)
M.SILPH_CO_ELEVATOR = elevator("SILPH_CO_ELEVATOR",
"TEXT_SILPHCOELEVATOR_ELEVATOR")
M.CELADON_MART_ELEVATOR = elevator("CELADON_MART_ELEVATOR",
"TEXT_CELADONMARTELEVATOR", nil, 9)
M.ROCKET_HIDEOUT_ELEVATOR = elevator("ROCKET_HIDEOUT_ELEVATOR",
"TEXT_ROCKETHIDEOUTELEVATOR",
{ item = "LIFT_KEY", text = "_RocketHideoutElevatorAppearsToNeedKeyText" })
-- -------------------------------------------------------------------
@@ -389,6 +360,38 @@ M.ROCKET_HIDEOUT_B4F = {
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)
-- Giovanni has no trainer-header row (def_trainers 2); his text_asm
-- owns both the engage and the BeatGiovanniScript aftermath.
@@ -538,45 +541,56 @@ M.GAME_CORNER = {
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)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
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,
(t._GameCornerClerk1DoYouNeedSomeGameCoinsText
or "Do you need some\ngame coins?\f¥1000 for 50."), function()
line("DoYouNeedSomeGameCoinsText",
"Do you need some\ngame coins?\f¥1000 for 50."), function()
game.stack:push(ChoiceBox.new(game, function(yes)
if not yes then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1PleaseComePlaySometimeText
or "No? Please come\nplay sometime!", done))
line("PleaseComePlaySometimeText",
"No? Please come\nplay sometime!"), done))
return
end
-- scripts/GameCorner.asm GameCornerClerk1Text: coins need
-- the COIN CASE and room for at least 9 coins (Has9990Coins)
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1DontHaveCoinCaseText
or "You don't have a\nCOIN CASE!", done))
line("DontHaveCoinCaseText",
"You don't have a\nCOIN CASE!"), done))
return
end
if (game.save.coins or 0) >= 9990 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CoinCaseIsFullText
or "Oops! Your COIN\nCASE is full.", done))
line("CoinCaseIsFullText",
"Oops! Your COIN\nCASE is full."), done))
return
end
if game.save.money < 1000 then
game.stack:push(TextBox.new(game,
t._GameCornerClerk1CantAffordTheCoinsText
or "You can't afford\nthe coins!", done))
line("CantAffordTheCoinsText",
"You can't afford\nthe coins!"), done))
return
end
game.save.money = game.save.money - 1000
game.save.coins = math.min(9999, (game.save.coins or 0) + 50)
game.stack:push(TextBox.new(game,
(t._GameCornerClerk1ThanksHereAre50CoinsText
or "Thanks! Here are\nyour 50 coins!")
line("ThanksHereAre50CoinsText",
"Thanks! Here are\nyour 50 coins!")
.. ("\fCOINS: %d"):format(game.save.coins), done))
end))
end))
@@ -584,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).
-- 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.
@@ -695,6 +716,16 @@ local DOCK_SHIP_BLOCKS = {
{ bx = 7, by = 2, water = 13 }, { bx = 8, by = 2, water = 13 },
}
-- her four hull columns bow-to-stern (upper-half / lower-half block ids)
-- and the open-water ids of the rows she sits in
local DOCK_SHIP_COLUMNS = {
{ bx = 5, top = 4, bottom = 8 },
{ bx = 6, top = 5, bottom = 9 },
{ bx = 7, top = 6, bottom = 10 },
{ bx = 8, top = 7, bottom = 11 },
}
local DOCK_WATER_TOP, DOCK_WATER_BOTTOM = 1, 13
M.VERMILION_DOCK = {
onEnter = function(game, ow)
local Flags = require("src.script.Flags")
@@ -729,17 +760,39 @@ M.VERMILION_DOCK = {
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
end
puff(3, 15)
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
-- player alone ("south of the player and won't be redrawn"), so skip
-- his own block: he must not spend the walk-out standing on water
local pbx = math.floor(ow.player.cellX / 2)
local pby = math.floor(ow.player.cellY / 2)
local rows = {}
rows[#rows + 1] = { "wait", 100 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
for _, b in ipairs(DOCK_SHIP_BLOCKS) do
rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water }
local function setBlock(bx, by, block)
if bx < 1 or bx > 8 then return end
if bx == pbx and by == pby then return end
rows[#rows + 1] = { "replace_block", bx, by, block }
end
rows[#rows + 1] = { "wait", 30 }
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
-- .shift_columns_up slides her tile columns west behind a mid-frame
-- rSCX split; with no split scroll here she sails one block per beat
-- and the water closes in astern (#360)
for step = 1, 8 do
for _, col in ipairs(DOCK_SHIP_COLUMNS) do
setBlock(col.bx - step, 1, col.top)
setBlock(col.bx - step, 2, col.bottom)
end
setBlock(9 - step, 1, DOCK_WATER_TOP)
setBlock(9 - step, 2, DOCK_WATER_BOTTOM)
rows[#rows + 1] = { "wait", 20 }
end
-- the second horn as she clears the dock, then EraseSSAnne's 120
-- frames before the walk out
rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" }
rows[#rows + 1] = { "wait", 120 }
rows[#rows + 1] = { "move_player", "up", 2 }
-- keep Music_Surfing across the city warp (pokered stays on it
-- through the exit-ship walk)
rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } }
-- no keepMusic on this warp: Music_Surfing belongs to the dock's
-- cutscene, and VERMILION_CITY's own theme has to take over as the
-- player crosses in (EnterMap's PlayDefaultMusic)
rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" }
rows[#rows + 1] = { "move_player", "up", 2 }
ow:queueScript(rows)
+23
View File
@@ -171,6 +171,28 @@ local function dojoBall(species, ownBall, otherBall, askKey)
end
end
-- FightingDojoDefaultScript does not use the Master's facing direction for
-- this battle. It checks exactly the cell left of him, turns both sprites
-- toward one another, and then displays his trainer text. Keeping this out
-- of trainer sight also lets him keep his original downward-facing pose.
local function dojoMasterGate(game, ow, x, y)
if x ~= 4 or y ~= 3 or game.save.flags.EVENT_BEAT_KARATE_MASTER then
return false
end
local master
for _, npc in ipairs(ow.npcs) do
if npc.def and npc.def.name == "FIGHTINGDOJO_KARATE_MASTER" then
master = npc
break
end
end
if not master or ow:trainerDefeated(master) then return false end
ow.player.facing = "right"
master:facePlayer(ow.player)
ow:engageTrainer(master)
return true
end
M.FIGHTING_DOJO = {
talk = {
TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL =
@@ -197,6 +219,7 @@ M.FIGHTING_DOJO = {
end
return false
end,
onStep = dojoMasterGate,
}
-- -------------------------------------------------------------------
+46 -4
View File
@@ -135,6 +135,9 @@ M.SILPH_CO_2F = {
talk = {
TEXT_SILPHCO2F_SILPH_WORKER_F = gift({
flag = "EVENT_GOT_TM36", item = "TM_SELFDESTRUCT",
-- the label carries no leading underscore: pokered keeps this one in
-- the script bank, not the far-text bank (#393)
pre = "SilphCo2FSilphWorkerFPleaseTakeThisText",
received = "_SilphCo2FSilphWorkerFReceivedTM36Text",
explain = "_SilphCo2FSilphWorkerFTM36ExplanationText",
noRoom = "_SilphCo2FSilphWorkerFTM36NoRoomText",
@@ -173,6 +176,20 @@ M.ROUTE_18_GATE_2F = {
{ "face_player" },
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- MARC
},
-- Yellow replaces the youngster with a cook trading SPIKE
-- (TANGELA -> PARASECT): pokeyellow/scripts/Route18Gate2F.asm
-- Route18Gate2FCookText runs TRADE_FOR_SPIKE, index 6 in the Yellow
-- TradeMons table that Data:applyVersionedFieldData swaps in. Red
-- maps have no COOK object here and Yellow maps have no YOUNGSTER,
-- so each version only ever fires its own row (#651). Both rows
-- share the Red-flavoured done flag on purpose: a .sav tracks
-- "trade slot 6 completed" in one wCompletedInGameTradeFlags bit
-- either version reads, and the save codec maps that bit to this
-- flag name (src/save_convert/GenSave.lua EXTRA_FLAG_BITS).
TEXT_ROUTE18GATE2F_COOK = {
{ "face_player" },
{ "trade", 6, "EVENT_TRADED_SLOWBRO_FOR_LICKITUNG" }, -- SPIKE (Yellow)
},
},
}
@@ -787,8 +804,19 @@ local function bikeGateGuard(coords, stopText, explainText)
local t = text(game)
push(game, t[stopText] or "Hey! Wait up!", function()
push(game, t[explainText] or "You need a\nBICYCLE for\nCYCLING ROAD!", function()
-- pokered's Route16Gate1FGuardScript / Route18Gate1FGuardScript
-- (scripts/Route16Gate1F.asm, Route18Gate1F.asm) simulate one
-- PAD_RIGHT step after the refusal text, and only clear
-- wJoyIgnore / hand control back once that step finishes
-- (PlayerMovingRightScript). Without it the player was left
-- parked beside the guard's counter with no way past. #518
local function shoveRight()
ow:scriptMove(ow.player, "right", 1)
end
if dist > 0 then
ow:scriptMove(ow.player, "up", dist)
ow:scriptMove(ow.player, "up", dist, shoveRight)
else
shoveRight()
end
end)
end)
@@ -836,6 +864,19 @@ M.SILPH_CO_7F = {
end,
}
-- SSAnne2FRivalAfterBattleScript's exit walk is keyed on the player's X,
-- not on where the rival stopped: from (37,8) the rival stands below him on
-- (36,8) and takes .RivalDownFourMovement straight down out of the room;
-- from (36,8) he stands above him on (36,7) and takes
-- .RivalWalkAroundPlayerMovement, which steps RIGHT and then falls through
-- into the same four DOWNs, so five downs in all (#360).
local function ssAnne2FRivalExitDirs(onLeft)
if onLeft then
return { "right", "down", "down", "down", "down", "down" }
end
return { "down", "down", "down", "down" }
end
-- S.S. Anne 2F rival ambush (scripts/SSAnne2F.asm; coords 36/37,8)
M.SS_ANNE_2F = {
onStep = function(game, ow, x, y)
@@ -848,11 +889,12 @@ M.SS_ANNE_2F = {
{ "face_object", 2, onLeft and "down" or "right" }, -- 3
{ "show_text", "_SSAnne2FRivalText" }, -- 4
{ "rival_battle", "OPP_RIVAL2", 1 }, -- 5
{ "jump_if_false", 10 }, -- 6
{ "jump_if_false", 11 }, -- 6
{ "set_flag", "EVENT_BEAT_SS_ANNE_RIVAL" }, -- 7
{ "show_text", "_SSAnne2FRivalDefeatedText" }, -- 8
{ "move_npc_to", 2, 36, 4 }, -- 9
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 10
{ "show_text", "_SSAnne2FRivalCutMasterText" }, -- 9
{ "walk_npc", 2, ssAnne2FRivalExitDirs(onLeft) }, -- 10
{ "hide_object", "SS_ANNE_2F", "SSANNE2F_RIVAL" }, -- 11
}, onLeft and "up" or "left")
end,
}
+1
View File
@@ -118,6 +118,7 @@ local MANSION_HOLES = {
M.POKEMON_MANSION_3F.onStep = function(game, ow, x, y)
for _, h in ipairs(MANSION_HOLES) do
if x == h[1] and y == h[2] then
require("src.core.Sound").play(game.data, "Faint_Fall")
ow:startWarpTo(h[3], h[4], h[5], ow.player.facing)
return true
end
+15 -4
View File
@@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = {
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 }
rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full
rows[#rows + 1] = { "show_text", "MelanieText3" }
-- Event and HideObject before MelanieText3: give_pokemon's nickname
-- prompt and the received text both yield, and a script that dies
-- in that window used to leave the BULBASAUR given with the event
-- clear, so Melanie offered a second one (#426). The flag is only
-- read at script entry, so the order is invisible in play.
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE",
"CERULEANMELANIESHOUSE_BULBASAUR" }
rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" }
rows[#rows + 1] = { "show_text", "MelanieText3" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "MelanieText5" }
@@ -60,8 +65,13 @@ M.ROUTE_24 = {
{ "jump_if_false", "declined" },
{ "give_pokemon", "CHARMANDER", 10 },
{ "jump_if_false", "end" },
{ "show_text", "_Route24DamianText2" },
-- SetEvent hoisted ahead of the received text (Route24.asm writes it
-- after Route24Text_515e3, where nothing in between can fail): the
-- nickname prompt and the text both yield, and a script death in that
-- window left the CHARMANDER given with EVENT_54F clear, so Damian
-- handed out a second one on the next talk (#426).
{ "set_flag", "EVENT_54F" },
{ "show_text", "_Route24DamianText2" },
{ "jump", "end" },
{ "label", "declined" },
{ "show_text", "_Route24DamianText3" },
@@ -87,9 +97,10 @@ M.VERMILION_CITY = {
rows[#rows + 1] = { "jump_if_false", "declined" }
rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 }
rows[#rows + 1] = { "jump_if_false", "end" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
-- event before the received text, as above (#426)
rows[#rows + 1] = { "set_flag",
"EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText3" }
rows[#rows + 1] = { "jump", "end" }
rows[#rows + 1] = { "label", "declined" }
rows[#rows + 1] = { "show_text", "_OfficerJennyText4" }
+15 -1
View File
@@ -48,7 +48,19 @@ M.MT_MOON_B2F = {
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "show_text", "_MtMoonJessieJamesText1" },
{ "face_player_dir", "right" },
{ "face_player_dir", "up" },
{ "emote", "player", "shock", 30 },
-- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then
-- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six
-- $06) and James (object 6, f9e66: five $06); both objects' movement
-- byte 2 is LEFT, so .determineDirection makes those LEFT steps --
-- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4)
-- beside him. #423
{ "walk_npc", "player", { "up" } },
{ "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } },
{ "face_object", 2, "down" },
{ "walk_npc", 6, { "left", "left", "left", "left", "left" } },
{ "face_object", 6, "left" },
{ "show_text", "_MtMoonJessieJamesText2" },
{ "start_battle", "trainer", "OPP_ROCKET", 42 },
{ "check_battle_result", "win" },
@@ -57,8 +69,10 @@ M.MT_MOON_B2F = {
{ "show_text", "_MtMoonJessieJamesText4" },
{ "stop_music" },
{ "play_music", "Music_MeetJessieJames" },
{ "fade", "out" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" },
{ "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" },
{ "fade", "in" },
{ "play_default_music" },
{ "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" },
}, {})
+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
+7
View File
@@ -65,6 +65,13 @@ display option — COLORS, TILT, ZOOM, VOID FILL, MAX FPS — works normally. If
your device turns out to handle the pass, launch with `POKEPORT_GBCFX=1` to
put the row back.
**PERFORMANCE defaults to LOW here.** The OPTIONS → PERFORMANCE tier defaults
to AUTO, which reads this device as an ARM Linux handheld and resolves to
**LOW**: the 3D tilt and survey zoom stay off and the frame rate is capped,
so the overworld runs smoothly on the H700 out of the box. Bump it to
BALANCED or HIGH from OPTIONS if you want the extras and your device keeps
up; see [Performance tier](new-features.md#performance-tier-low-end-devices).
The pack bundles the LÖVE 11.5 aarch64 runtime from
[PortMaster](https://portmaster.games/), so the device does not need a
separate `love_11.5` runtime download on first launch. The launcher resolves
+5 -1
View File
@@ -12,7 +12,7 @@ this port.
| # | Mechanic | Value | Why the Game Boy had this limit | Where it lives here |
|---|---|---|---|---|
| 1 | Bag capacity | 20 item slots | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `src/inventory/Bag.lua:8` (`Bag.CAPACITY = 20`) |
| 1 | Bag capacity | 20 item slots by default | `wNumBagItems` save block was a fixed 20-entry id/quantity array in SRAM | `Data.constants.bagSize`, read by `src/inventory/Bag.lua` (`Bag.capacity`) |
| 2 | Party size | 6 Pokémon | `wPartyMon1..6` were 6 fixed save-RAM slots | `src/pokemon/Party.lua:5` (`Party.MAX = 6`) |
| 3 | PC storage | 12 boxes × 20 Pokémon | `wBoxDataStart` / Bill's PC allocated a fixed 12×20 SRAM block | `src/pokemon/Boxes.lua:7-8` |
| 4 | Moves per Pokémon | 4 | Fixed 4-move-slot field in the party/box Pokémon struct | `src/pokemon/Pokemon.lua:20`, enforced again in `src/battle/BattleState.lua:1941` |
@@ -38,6 +38,10 @@ this port.
## Notes
- Mods may patch `constants.bagSize` through the public content registry. The
native `save.lua` format keeps every existing item when the configured
limit changes; exporting to a cartridge `.sav` still writes only the first
20 bag slots because the original SRAM layout has no room for more.
- PC Box **overflow handling** was deliberately changed even though the
20×12 box *shape* was kept faithful: instead of Gen 1's "full box discards
or blocks the deposit," this port spills into the next box with room.
+116
View File
@@ -0,0 +1,116 @@
# Build & install on your iPhone — step by step
Don't want to install Xcode? Download the release IPA and sideload it with
AltStore instead — see [ios-sideload.md](ios-sideload.md).
This guide assumes **zero** programming experience. Follow it top to
bottom and you'll have the game running on your own iPhone in roughly an
hour (most of it is waiting for downloads).
## What you need
- A **Mac** (any Apple-silicon or recent Intel Mac on macOS 14 or newer)
- An **iPhone** and its **charging cable**
- A free **Apple ID** (the same account you use for the App Store)
- Your **own, legally obtained** Pokémon Red or Blue ROM file (a 1 MB
`.gb` file). This project ships no game data — the app rebuilds
everything from *your* cartridge dump and verifies it before use.
- About **15 GB free disk space** (Xcode is enormous; the game itself is
tiny)
> **Free vs. paid Apple account:** a free Apple ID works. The only catch:
> apps signed with a free account stop launching after **7 days** — just
> re-run the install command (step 5) to re-sign; your save data is kept.
> A paid Apple Developer account ($99/yr) extends that to a year.
---
## Step 1 — Install Xcode (once)
1. On the Mac, open the **App Store**, search **Xcode**, click **Get**.
It's a ~10 GB download — go make tea.
2. Open Xcode once. Accept the license. If it offers to install extra
components or the **iOS platform**, say yes and let it finish.
## Step 2 — Sign Xcode into your Apple ID (once)
1. In Xcode's menu bar: **Xcode → Settings → Accounts**.
2. Click the **+** in the bottom-left → **Apple Account** → sign in.
3. Close the window. (This quietly creates the "signing certificate" the
build uses — you never have to touch it again.)
## Step 3 — Get this project onto the Mac
If you received it as a folder, put it somewhere easy like your home
folder. If it's on GitHub, click the green **Code** button → **Download
ZIP**, then double-click the zip to unpack it.
## Step 4 — Build it (one command)
1. Open the **Terminal** app (press ⌘-space, type `terminal`, press
return).
2. Type `cd ` (c, d, space — don't press return yet), then **drag the
project folder** from Finder onto the Terminal window — it fills in
the path — and press **return**.
3. Paste this and press return:
```sh
scripts/build_ios.sh --fetch
```
The first run downloads the LÖVE engine and compiles everything
(515 minutes). Lines of build output scrolling by is normal. You're
done when you see **`==> done`**.
## Step 5 — Put it on your iPhone
1. Plug the iPhone into the Mac with the cable. **Unlock it** and keep it
unlocked. If it asks **"Trust This Computer?" → Trust**.
2. Paste this and press return:
```sh
scripts/build_ios.sh --device --install
```
The script finds your signing identity and your phone by itself. If it
complains, it tells you exactly what to fix (usually: the phone was
locked — unlock and re-run).
3. First time only, the iPhone will want two approvals:
- **Developer Mode**: Settings → **Privacy & Security** → scroll to
**Developer Mode** → turn on → restart the phone → confirm.
- **Trust the developer**: Settings → **General** → **VPN & Device
Management** → tap the entry under *Developer App***Trust**.
Then re-run the command in step 5.2 if the install had failed.
## Step 6 — Give it your ROM and play
1. Get your `.gb` file onto the phone — AirDrop it to yourself, or save
it in iCloud Drive / Files.
2. Open the app. On the **RED** tab, tap **Import ROM** and pick your
`.gb` file in the file browser that appears. (Alternative: in the
**Files** app, drop the `.gb` into **On My iPhone → the game's
folder** and just reopen the app — it imports automatically.)
3. Import takes ~10 seconds, the button turns into **Play Red** — tap it.
## Optional goodies
- **Mods**: launcher → **MODS** tab → **Import mod .zip**.
- **Save import/export**: buttons on each game's tab, using the normal
iOS file picker.
## When things go wrong
| Symptom | Fix |
|---|---|
| `xcodebuild not found` | Xcode isn't installed or wasn't opened once — do Step 1 |
| `no Apple signing identity found` | Do Step 2, then re-run |
| `no iPhone/iPad found` | Cable in? Phone unlocked? Tapped "Trust"? |
| Install fails with "locked" | Unlock the phone, keep it unlocked, re-run |
| App icon appears then won't open | Do the two approvals in Step 5.3 |
| App stops launching after a week | Free-account 7-day limit — re-run Step 5.2 |
| "That ROM could not be imported" | The file isn't a canonical 1 MB US Red/Blue dump — the importer checks its fingerprint |
Every build/install command is safe to re-run; your saves live on the
phone and survive reinstalls.
+48
View File
@@ -0,0 +1,48 @@
# Sideload the iOS build with AltStore
Every GitHub Release ships an IPA (`gen1recomp-*-ios.ipa`). Install it on
your iPhone or iPad with [AltStore Classic](https://altstore.io/) — AltStore
re-signs the app with **your** free Apple ID so you do not need a Mac or
Xcode.
## 1. Install AltStore
Follow the official guide for your computer:
- [How to Install (Windows)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-windows)
- [How to Install (macOS)](https://faq.altstore.io/altstore-classic/how-to-install-altstore-macos)
You will install **AltServer** on the computer, then use it to put AltStore
on the phone. What AltServer is and why it needs to stay running:
- [AltServer](https://faq.altstore.io/altstore-classic/altserver)
Stuck? Start here:
- [Troubleshooting Guide](https://faq.altstore.io/altstore-classic/troubleshooting-guide)
## 2. Install the game
1. Download `gen1recomp-*-ios.ipa` from
[Releases](https://github.com/bryanthaboi/gen1recomp/releases).
2. Open **AltStore** on the phone (AltServer must be running on the same
WiFi, or keep the phone plugged into the computer).
3. Tap **My Apps → +** (or share the IPA into AltStore) and pick the file.
4. Sign in with your Apple ID when prompted. Wait for the install to finish.
5. On first launch: Settings → **Privacy & Security → Developer Mode** (iOS
16+), and Settings → **General → VPN & Device Management** → Trust your
Apple ID if asked.
Then open the app, import your own legal `.gb` ROM on the Red/Blue tab, and
play.
## Refresh / 7-day limit
With a free Apple ID, sideloaded apps stop launching after **7 days**. Keep
AltServer running so AltStore can refresh them, or open AltStore and refresh
manually before they expire. Saves on the phone are kept across refreshes.
## Prefer building it yourself?
Building from source on a Mac (no AltStore) is covered in
[ios-install.md](ios-install.md).
+33
View File
@@ -167,5 +167,38 @@ The console understands these verbs (anything else is evaluated as Lua):
- `trace PAT | trace off` — trace events/hooks matching a glob pattern.
- `help` — list the verbs.
## Tool input and title-menu hooks
Tool mods that need to act once per game logic tick can wrap `input.step`.
It runs immediately before queued button edges are promoted, so input added by
the wrapper is visible during that same fixed step. The callback receives
`(next, game, dt)` and must call `next(game, dt)`.
`ui.title_menu.items` receives `(next, game, items)` and follows the same
decorate-after-`next` convention as `ui.start_menu.items`. It is the safe place
for a tool to offer a fresh-session action before gameplay begins.
Ephemeral tools can wrap `save.write(next, game)` and return `false` to veto a
progress write before world state is captured or any bytes reach disk.
`render.hud` receives `(next, game, viewport)` after the finished game frame is
composited and before touch controls draw. The window-space viewport contains
`width`, `height`, `gameX`, `gameY`, `gameWidth`, `gameHeight`, `scale`, `dpiX`,
and `dpiY`, so a tool can use the letterbox margins without drawing over the
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
that reach outside their permission set.
+158 -12
View File
@@ -86,14 +86,20 @@ Game Boy equivalent:
## Colors mode
The `2` key (and the Options menu COLORS row) cycles the display mode
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**.
through **OG RED → SGB → ADVANCED → OG → OG INV → SGB INV → CLASSIC → OG RED**
(on Blue the first slot labels **OG BLUE**; on Yellow, **OG YELLOW**).
The first three are the real colorizations; the rest are DMG-shade novelties:
- **OG RED**: the Game Boy Color boot-ROM look for Pokemon Red -- one global
red BG palette + one green OBJ palette, every map, no per-map variation
(Pokemon Red has no CGB code, so on a GBC the boot ROM colors it globally).
The player/NPCs stay green over the red terrain via the OBP bake +
post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`).
- **OG RED** / **OG BLUE**: the Game Boy Color boot-ROM look for that cart --
one global BG palette + one OBJ palette, every map, no per-map variation
(Red/Blue ship no CGB code, so on a GBC the boot ROM colors them globally).
The player/NPCs keep the boot-ROM OBJ color over the terrain via the OBP
bake + post-zone redraw (`PaletteFX.GBC_BG` / `GBC_OBJ`, or Blue's blue/pink
pair).
- **OG YELLOW** (Yellow playthrough, same `ogred` save id): Pokemon Yellow's
authentic GBC look from `CGBBasePalettes` (`data/palettes_yellow.lua`,
sourced from pret/pokeyellow). Per-map / per-species colors, not a single
boot-ROM ramp -- Yellow was CGB-enhanced.
- **SGB** (default): the per-map Super Game Boy region palettes
(`data/sgb/sgb_palettes.asm`). Sprites tint with the region palette, as on
real SGB. (This is the mode formerly mislabeled "GBC".)
@@ -137,6 +143,41 @@ effect, `=1` forces it available. The Anbernic handheld pack exports `0` from
its launcher because the device reports `"Linux"` while its GPU is in the
phone class (see [Anbernic RG34XXSP](anbernic-rg34xxsp.md)).
## Performance tier (low-end devices)
The Options **PERFORMANCE** row scales the port's optional presentation
extras down for weaker hardware. The extras it governs are the three
heaviest things the port adds on top of the original -- the whole-screen 3D
**TILT** (transforms the entire map as a ground plane), the **GBC FX**
post-process shader (a fullscreen pass), and survey **ZOOM** (zooming out
renders the connected neighbor maps, a lot of extra overdraw) -- plus a hard
FPS ceiling. None of this touches game logic, which is fixed-step off `dt`
(`src/core/FixedStep.lua`), so every tier plays identically; they differ
only in how much eye-candy the renderer is allowed to do.
| Tier | TILT | GBC FX | Survey ZOOM | Extra FPS ceiling |
| ------------ | ---- | ------ | ----------- | ----------------- |
| **HIGH** | on | on | on | none |
| **BALANCED** | off | off | on | none |
| **LOW** | off | off | off | 60 |
| **AUTO** | picks a default from the device (below) |||
- **AUTO** (the default) reads the device once at boot: ARM Linux handhelds
(e.g. the RG34XXSP) resolve to **LOW**, phones/tablets and very-low-core
desktops to **BALANCED**, and everything else -- a normal desktop, and
every existing `options.lua` that predates this option -- to **HIGH**,
so the common case is unchanged. See `src/core/Performance.detect`.
- AUTO only chooses the *default*; all four tiers are selectable, so a
wrong guess is one row away from being overridden.
- The clamps are applied **live** against your stored options and never
rewrite them (`Game:applyOptions`), so a lower tier hides your TILT / GBC
FX / ZOOM without forgetting them -- raising the tier restores exactly
what you had. (This is why the TILT / GBC FX / ZOOM rows still show your
saved choice on a clamped tier: it's your preference, waiting for a tier
that can afford it.)
- Persisted as `save.options.performance` (`auto` | `high` | `balanced` |
`low`); unit-tested in `tests/engine/performance_tiers.lua`.
## Peer-to-peer link play (lua-enet)
Trades and link battles connect two copies of the game directly over
@@ -151,6 +192,13 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
Red, Blue, and Yellow copies link with each other, as the real cable
does. The compatibility fingerprint hashes only data a link mode can
actually read, so Yellow's Dragonair/Dragonite catch-rate retunes (the
only R/B/Y link-surface difference) no longer read as different games
(issue #511). Moving the fingerprint is a link parity change: builds
from before this fix will refuse to pair with builds after it.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
@@ -161,10 +209,15 @@ duration:
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
clock faster than the opponent racing it.
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
mods enabled offers to switch them all off and relaunch (mods merge at
boot, so a restart is the only way). The restart is confirmed, not
silent. They stay listed as disabled, ready to switch back on.
- **Online play runs vanilla, except for your language.** Picking ONLINE
MATCH or TOURNAMENT with mods enabled offers to switch the gameplay ones
off and relaunch (mods merge at boot, so a restart is the only way). The
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
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
@@ -193,6 +246,11 @@ flushes the live options. Old saves that still embed an `options` table are
migrated once into `options.lua` on load.
- Music / SFX volume
- PIKACHU VOL (0-7, Yellow only): trims Pikachu's PCM voice clips under the
SFX level, so the follower's constant chatter, the title-screen cry and
every in-battle "Pika!" can be pulled down (or muted at 0) without
quieting the rest of the sound effects. The row is hidden on Red/Blue,
which have no voice clips.
- Music Filter
- OG GLITCHES on / off (Gen 1 quirks vs. modern-clean battle rules)
- BATTLE LAYOUT (OG / WIDE); see "Widescreen battle layout" below
@@ -260,6 +318,20 @@ the last controller restores it immediately. Layout re-derives from the
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 launcher's **Touch Controls** button opens a drag editor: move each
button freely, resize the whole pad with **-/+** (60% to 160%), **Disable**
to hide the overlay permanently (for controllers / emulation handhelds --
distinct from the temporary gamepad auto-hide), **Reset** for defaults,
**Done** to save into `options.lua` as normalized window fractions so a
different screen keeps the relative placement.
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
Every string the player can read is now reachable from a mod, so a
@@ -319,8 +391,11 @@ semantics - so the two windows read as one app. Six tabs:
party dock, so deposit and withdraw live in one place. Empty slots are
clickable and create a mon there.
- **Items**: money, a searchable item picker (replacing the arrows that
cycled one id at a time through ~250 items), the 20-slot bag, PC storage
with no slot cap, and the eight badges as toggle chips.
cycled one id at a time through ~250 items), the configurable bag (20 slots
by default), PC storage
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
toggles, with a real filter field and a two-column paged grid.
- **Map**: any map rendered with the game's own renderer, warps followable,
@@ -362,3 +437,74 @@ kind, number, height/weight, dex text) to a PNG at 4x scale under
`prints/` in the save directory, then reports the filename in a dialog.
No printer hardware or link cable emulation involved; the file is the
printout.
## Find Mods (community mod indexes)
A FIND MODS tab sits beside MODS in the launcher and browses a published
mod index: a metadata-only feed listing mods that live in their authors'
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
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.
## 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.
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Mike Freno
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
-- modules/Behavior.lua
--
-- Base module for the pluggable behavior system that drives the Behavior &
-- Mode Unification refactor.
--
-- A *behavior* is a small, stateless table produced by `Behavior.new(spec)`
-- that implements a fixed lifecycle hook set. Concrete behaviors (Clickable,
-- Scrollable, TextEditable, Selectable, ...) each live in their own module and
-- are attached to an Element. The Element's `update`/`draw`/save-restore paths
-- iterate `element.behaviors` and dispatch to the appropriate hooks, replacing
-- the swarm of `if self.scrollable` / immediate-mode-branch checks previously
-- hard-coded in Element.lua.
--
-- Element.new iterates a registry of behavior prototypes and auto-attaches
-- whichever return true from `shouldAttach(props)`. Element therefore never
-- needs to know what an individual behavior does — only that it conforms to
-- this interface.
--
-- Design constraints (locked — tasks 02-13 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Stays fully stub-testable standalone (see testing/__tests__/behavior_test.lua).
-- * Minimal interface — exactly 6 lifecycle hooks + a `shouldAttach` predicate.
-- Do NOT add hooks "just in case"; new capabilities become new behaviors,
-- not new hooks. Extending HOOK_NAMES is an architectural decision that must
-- be mirrored by every concrete behavior.
-- * Immutable instances — behavior tables are produced once and treated as
-- read-only. Per-element runtime state lives on the element (or a subsystem
-- the behavior attaches), NEVER on the behavior instance itself, so a single
-- behavior instance can be shared across many elements.
--
-- Lifecycle hook contract (each receives the owning element as first argument):
-- onAttach(element) — called once when the behavior is attached
-- (element fully constructed). Allocate
-- subsystems / register listeners here.
-- onDetach(element) — called once when the behavior is detached
-- (element destroyed / mode switch). Tear
-- down anything onAttach created.
-- onUpdate(element, dt) — called every frame from Element:update.
-- onDraw(element, ctx) — called every frame from Element:draw; `ctx`
-- is the draw context (viewport transform,
-- scissor state, theme renderer, ...).
-- saveState(element) -> state — called during Element save-state; returns
-- a serializable snapshot (or nil) so the
-- behavior's runtime state survives the
-- immediate-mode recreation cycle.
-- restoreState(element, state) — called after reconstruction with the
-- snapshot previously returned by saveState.
--
-- shouldAttach(props) -> boolean — class-level predicate (not a hook): given
-- an element's props table, return true if
-- this behavior should be auto-attached.
-- Defaults to false (opt-in).
--- A behavior instance: a frozen table of lifecycle hooks + a shouldAttach
--- predicate. All hooks are always present (custom override or no-op default).
---@class Behavior
---@field onAttach fun(element:table)
---@field onDetach fun(element:table)
---@field onUpdate fun(element:table, dt:number)
---@field onDraw fun(element:table, ctx:table)
---@field saveState fun(element:table):any
---@field restoreState fun(element:table, state:any)
---@field shouldAttach fun(props:table):boolean
local Behavior = {}
-- The fixed, ordered lifecycle hook set. Order is preserved so downstream tasks
-- (Element behavior iteration) can rely on a deterministic dispatch sequence.
-- HOOK_NAMES is intentionally NOT extended casually — see file header.
Behavior.HOOK_NAMES = {
"onAttach",
"onDetach",
"onUpdate",
"onDraw",
"saveState",
"restoreState",
}
-- Allowlist of spec keys accepted by Behavior.new. Anything else is rejected so
-- a typo (e.g. `onUpdat`) surfaces immediately instead of silently no-op'ing.
-- Hook keys (HOOK_NAMES + shouldAttach) MUST be functions; metadata keys
-- (drawLayer) may hold any value.
local ALLOWED_KEYS = {
onAttach = true,
onDetach = true,
onUpdate = true,
onDraw = true,
saveState = true,
restoreState = true,
shouldAttach = true,
drawLayer = true,
}
-- Spec keys whose values are NOT required to be functions (passive metadata
-- consumed by dispatch sites, e.g. Element:draw's pre/post-children split).
local NON_FUNCTION_KEYS = {
drawLayer = true,
}
-- Default no-op hook. Behaviors override only the hooks they need; every other
-- hook resolves to this so dispatch sites never have to nil-check.
local function noop() end
-- Default shouldAttach predicate: never auto-attach unless the behavior opts in
-- by providing its own predicate. This is the safe default — a behavior with no
-- opinion about which elements it applies to stays inert in the auto-attach
-- pass (it can still be attached explicitly by name in a later task).
local function defaultShouldAttach()
return false
end
-- Module-level default predicate exposed for callers/tests that want to
-- reference the base default directly without constructing an instance.
Behavior.shouldAttach = defaultShouldAttach
--- Factory: create a frozen behavior instance from a spec table.
---
--- `spec` is a table whose keys may be any subset of the 6 lifecycle hook names
--- plus `shouldAttach`; each value (when present) must be a function. The
--- returned table contains every lifecycle hook (custom override OR no-op) and
--- a `shouldAttach` predicate (custom OR always-false default), so dispatch
--- sites can call any hook unconditionally without nil-checking.
---
--- Unknown spec keys and non-function values raise an error immediately so
--- mistakes fail fast at construction rather than as silent no-ops later.
---
---@param spec table|nil spec table overriding select hooks / shouldAttach
---@return Behavior
function Behavior.new(spec)
spec = spec or {}
-- Validate spec keys up front so typos surface here, not as silent no-ops.
for key, value in pairs(spec) do
if not ALLOWED_KEYS[key] then
error(string.format("Behavior.new: unknown spec key '%s'", tostring(key)), 2)
end
if not NON_FUNCTION_KEYS[key] and type(value) ~= "function" then
error(string.format("Behavior.new: spec key '%s' must be a function, got %s", tostring(key), type(value)), 2)
end
end
local instance = {}
-- Populate every lifecycle hook: custom override when provided, no-op default
-- otherwise. Guarantees `instance.hook` is always callable.
for _, hook in ipairs(Behavior.HOOK_NAMES) do
instance[hook] = spec[hook] or noop
end
-- shouldAttach defaults to always-false; behaviors opt in by supplying one.
instance.shouldAttach = spec.shouldAttach or defaultShouldAttach
-- drawLayer: optional metadata field (default nil = "background"/pre-children).
-- Dispatch sites (Element:draw) use it to split rendering into pre-children
-- (background layers) and post-children (overlay layers, e.g. scrollbars).
instance.drawLayer = spec.drawLayer
-- Freeze: prevent adding new fields. Behavior instances are shared, stateless
-- objects; runtime state belongs on the element, never on the behavior.
-- (Reassigning an existing hook is still possible via direct index write —
-- Lua metatables cannot intercept that — but the freeze communicates intent
-- and catches accidental field additions.)
local mt = {
__newindex = function(_, key)
error(string.format("Behavior: behavior instances are immutable (cannot set '%s')", tostring(key)), 2)
end,
--- Mark the metatable so consumers can detect a Behavior instance.
---@return string
__tostring = function()
return "Behavior"
end,
__metatable = "Behavior",
}
setmetatable(instance, mt)
return instance
end
--- Type guard: returns true if `value` is a Behavior instance produced by
--- `Behavior.new`. Used by Element's attach path to validate registry entries
--- without depending on identity.
---@param value any
---@return boolean
function Behavior.isBehavior(value)
return type(value) == "table" and getmetatable(value) == "Behavior"
end
return Behavior
+686
View File
@@ -0,0 +1,686 @@
-- Lua 5.2+ compatibility for unpack
local unpack = table.unpack or unpack
-- Warning cache to prevent duplicate warnings for the same element
local warningCache = {}
local Cache = {
canvases = {},
quads = {},
blurInstances = {}, -- Cache blur instances by quality
blurredCanvases = {}, -- Cache pre-blurred canvases for immediate mode
MAX_CANVAS_SIZE = 20,
MAX_QUAD_SIZE = 20,
MAX_BLURRED_CANVAS_CACHE = 50, -- Maximum cached blurred canvases
RADIUS_THRESHOLD = 0.5, -- Skip blur below this radius
LARGE_BLUR_THRESHOLD = 250 * 250, -- Warn if blur area exceeds this (250x250px)
}
--- Round canvas size to nearest bucket for better reuse
---@param size number Size to bucket
---@return number bucketSize Bucketed size
local function bucketSize(size)
if size <= 128 then
return math.ceil(size / 32) * 32
elseif size <= 512 then
return math.ceil(size / 64) * 64
elseif size <= 1024 then
return math.ceil(size / 128) * 128
else
return math.ceil(size / 256) * 256
end
end
--- Get or create a canvas from cache
---@param width number Canvas width
---@param height number Canvas height
---@return love.Canvas canvas The cached or new canvas
function Cache.getCanvas(width, height)
-- Use bucketed sizes for better cache reuse
local bucketedWidth = bucketSize(width)
local bucketedHeight = bucketSize(height)
local key = string.format("%dx%d", bucketedWidth, bucketedHeight)
if not Cache.canvases[key] then
Cache.canvases[key] = {}
end
local cache = Cache.canvases[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.canvas
end
end
local canvas = love.graphics.newCanvas(bucketedWidth, bucketedHeight)
table.insert(cache, { canvas = canvas, inUse = true })
if #cache > Cache.MAX_CANVAS_SIZE then
local removed = table.remove(cache, 1)
if removed and removed.canvas then
removed.canvas:release()
end
end
return canvas
end
--- Release a canvas back to the cache
---@param canvas love.Canvas Canvas to release
function Cache.releaseCanvas(canvas)
for _, sizeCache in pairs(Cache.canvases) do
for _, entry in ipairs(sizeCache) do
if entry.canvas == canvas then
entry.inUse = false
return
end
end
end
end
--- Get or create a quad from cache
---@param x number X position
---@param y number Y position
---@param width number Quad width
---@param height number Quad height
---@param sw number Source width
---@param sh number Source height
---@return love.Quad quad The cached or new quad
function Cache.getQuad(x, y, width, height, sw, sh)
local key = string.format("%d,%d,%d,%d,%d,%d", x, y, width, height, sw, sh)
if not Cache.quads[key] then
Cache.quads[key] = {}
end
local cache = Cache.quads[key]
for i, entry in ipairs(cache) do
if not entry.inUse then
entry.inUse = true
return entry.quad
end
end
local quad = love.graphics.newQuad(x, y, width, height, sw, sh)
table.insert(cache, { quad = quad, inUse = true })
if #cache > Cache.MAX_QUAD_SIZE then
table.remove(cache, 1)
end
return quad
end
--- Release a quad back to the cache
---@param quad love.Quad Quad to release
function Cache.releaseQuad(quad)
for _, keyCache in pairs(Cache.quads) do
for _, entry in ipairs(keyCache) do
if entry.quad == quad then
entry.inUse = false
return
end
end
end
end
--- Generate cache key for blurred canvas
---@param elementId string Element ID
---@param x number X position
---@param y number Y position
---@param width number Width
---@param height number Height
---@param radius number Blur radius
---@param quality number Blur quality
---@param isBackdrop boolean Whether this is backdrop blur
---@return string key Cache key
function Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, quality, isBackdrop)
return string.format(
"%s:%d:%d:%d:%d:%.1f:%d:%s",
elementId,
x,
y,
width,
height,
radius,
quality,
tostring(isBackdrop)
)
end
--- Get cached blurred canvas
---@param key string Cache key
---@return love.Canvas|nil canvas Cached canvas or nil
function Cache.getBlurredCanvas(key)
local entry = Cache.blurredCanvases[key]
if entry then
entry.lastUsed = os.time()
return entry.canvas
end
return nil
end
--- Store blurred canvas in cache
---@param key string Cache key
---@param canvas love.Canvas Canvas to cache
function Cache.setBlurredCanvas(key, canvas)
-- Limit cache size
local count = 0
for _ in pairs(Cache.blurredCanvases) do
count = count + 1
end
if count >= Cache.MAX_BLURRED_CANVAS_CACHE then
-- Remove oldest entry
local oldestKey = nil
local oldestTime = math.huge
for k, v in pairs(Cache.blurredCanvases) do
if v.lastUsed < oldestTime then
oldestTime = v.lastUsed
oldestKey = k
end
end
if oldestKey then
if Cache.blurredCanvases[oldestKey].canvas then
Cache.blurredCanvases[oldestKey].canvas:release()
end
Cache.blurredCanvases[oldestKey] = nil
end
end
Cache.blurredCanvases[key] = {
canvas = canvas,
lastUsed = os.time(),
}
end
--- Clear blurred canvas cache for specific element
---@param elementId string Element ID to clear cache for
function Cache.clearBlurredCanvasesForElement(elementId)
for key, entry in pairs(Cache.blurredCanvases) do
if key:match("^" .. elementId .. ":") then
if entry.canvas then
entry.canvas:release()
end
Cache.blurredCanvases[key] = nil
end
end
end
--- Clear all caches
function Cache.clear()
-- Release all blurred canvases
for _, entry in pairs(Cache.blurredCanvases) do
if entry.canvas then
entry.canvas:release()
end
end
Cache.canvases = {}
Cache.quads = {}
Cache.blurInstances = {}
Cache.blurredCanvases = {}
warningCache = {} -- Clear warning cache on cache clear
end
-- ============================================================================
-- SHADER BUILDER
-- ============================================================================
local ShaderBuilder = {}
--- Build Gaussian blur shader with given parameters
---@param taps number Number of samples (must be odd, >= 3)
---@param offset number Offset value
---@param offsetType string "weighted" or "center"
---@param sigma number Sigma value for Gaussian distribution
---@return love.Shader shader The compiled blur shader
function ShaderBuilder.build(taps, offset, offsetType, sigma)
taps = math.floor(taps)
sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6
sigma = math.max(sigma, 1)
local steps = (taps + 1) / 2
local gOffsets = {}
local gWeights = {}
for i = 1, steps do
gOffsets[i] = offset * (i - 1)
gWeights[i] = math.exp(-0.5 * (gOffsets[i] - 0) ^ 2 * 1 / sigma ^ 2)
end
local offsets = {}
local weights = {}
for i = #gWeights, 2, -2 do
local oA, oB = gOffsets[i], gOffsets[i - 1]
local wA, wB = gWeights[i], gWeights[i - 1]
wB = oB == 0 and wB / 2 or wB
local weight = wA + wB
offsets[#offsets + 1] = offsetType == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight
weights[#weights + 1] = weight
end
local code = {
[[
extern vec2 direction;
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]],
}
local norm = 0
if #gWeights % 2 == 0 then
code[#code + 1] = "vec4 c = vec4( 0.0 );"
else
local weight = gWeights[1]
norm = norm + weight
code[#code + 1] = string.format("vec4 c = %f * texture2D(tex, tc);", weight)
end
local template = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n"
for i = 1, #offsets do
local offset = offsets[i]
local weight = weights[i]
norm = norm + weight * 2
code[#code + 1] = string.format(template, weight, offset, offset)
end
code[#code + 1] = string.format("return c * vec4(%f) * color; }", 1 / norm)
local shaderCode = table.concat(code)
return love.graphics.newShader(shaderCode)
end
--- Get or create a blur instance from cache
---@param quality number Quality level (1-10)
---@return table blurData Cached blur data {shader, taps}
function Cache.getBlurInstance(quality)
if not Cache.blurInstances[quality] then
local taps = 3 + (quality - 1) * 1.5
taps = math.floor(taps)
if taps % 2 == 0 then
taps = taps + 1
end
local shader = ShaderBuilder.build(taps, 1.0, "weighted", -1)
Cache.blurInstances[quality] = {
shader = shader,
taps = taps,
}
end
return Cache.blurInstances[quality]
end
---@class BlurProps
---@field quality number? Quality level (1-10, default: 5)
---@class Blur
---@field shader love.Shader The blur shader
---@field quality number Quality level (1-10)
---@field taps number Number of shader taps
---@field _ErrorHandler table? Reference to ErrorHandler module
local Blur = {}
Blur.__index = Blur
--- Check if we should warn about large blur area in immediate mode
---@param elementId string|nil Element ID for caching warnings
---@param width number Blur area width
---@param height number Blur area height
---@param blurType string "content" or "backdrop"
local function checkLargeBlurWarning(elementId, width, height, blurType)
-- Skip if no ErrorHandler available
if not Blur._ErrorHandler then
return
end
-- Skip if not in immediate mode
if not Blur._blurOptimizations then
return
end
-- Calculate blur area
local area = width * height
-- Skip if area is below threshold
if area <= Cache.LARGE_BLUR_THRESHOLD then
return
end
-- Generate warning key (use elementId if available, otherwise use dimensions)
local warningKey = elementId or string.format("%dx%d:%s", width, height, blurType)
-- Skip if already warned for this element/area
if warningCache[warningKey] then
return
end
-- Mark as warned
warningCache[warningKey] = true
-- Issue warning
local message =
string.format("Large %s blur area detected (%dx%d = %d pixels) in immediate mode", blurType, width, height, area)
local suggestion =
"Consider using retained mode for this component to avoid recreating blur effects every frame. Large blur operations are expensive and can cause performance issues in immediate mode."
Blur._ErrorHandler:warn("Blur", "PERF_003", {
area = string.format("%.0fx%.0f", width or 0, height or 0),
})
end
--- Create a new blur effect instance
---@param props BlurProps? Blur configuration
---@return Blur blur The new blur instance
function Blur.new(props)
props = props or {}
local quality = props.quality or 5
quality = math.max(1, math.min(10, quality))
-- Get cached blur instance for this quality level
local blurData = Cache.getBlurInstance(quality)
local self = setmetatable({}, Blur)
self.shader = blurData.shader
self.quality = quality
self.taps = blurData.taps
return self
end
--- Apply blur to a region of the screen
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param drawFunc function Function to draw content to be blurred
function Blur:applyToRegion(radius, x, y, width, height, drawFunc)
if type(drawFunc) ~= "function" then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_001")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
drawFunc()
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
drawFunc()
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(nil, width, height, "content")
-- Calculate offset multiplier based on radius and quality
-- Higher quality = more samples = smaller steps for same radius
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.push()
love.graphics.origin()
love.graphics.translate(-x, -y)
drawFunc()
love.graphics.pop()
love.graphics.setShader(self.shader)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
end
--- Apply backdrop blur effect (blur content behind a region)
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
function Blur:applyBackdrop(radius, x, y, width, height, backdropCanvas)
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Get the current quality level
---@return number quality Quality level (1-10)
function Blur:getQuality()
return self.quality
end
--- Get the number of shader taps
---@return number taps Number of shader taps
function Blur:getTaps()
return self.taps
end
--- Clear all caches (call on window resize or memory cleanup)
function Blur.clearCache()
Cache.clear()
end
--- Apply backdrop blur with caching support
---@param radius number Blur radius in pixels
---@param x number X position
---@param y number Y position
---@param width number Width of region
---@param height number Height of region
---@param backdropCanvas love.Canvas Canvas containing the backdrop content
---@param elementId string|nil Element ID for caching (nil disables caching)
function Blur:applyBackdropCached(radius, x, y, width, height, backdropCanvas, elementId)
-- If caching is disabled or no element ID, fall back to regular apply
if not Blur._blurOptimizations or not elementId then
return self:applyBackdrop(radius, x, y, width, height, backdropCanvas)
end
-- Generate cache key
local cacheKey = Cache.generateBlurCacheKey(elementId, x, y, width, height, radius, self.quality, true)
-- Check cache
local cachedCanvas = Cache.getBlurredCanvas(cacheKey)
if cachedCanvas then
-- Draw cached blur
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(cachedCanvas, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
return
end
-- Not cached, render and cache
if not backdropCanvas then
if Blur._ErrorHandler then
Blur._ErrorHandler:warn("Blur", "BLUR_002")
end
return
end
if radius <= 0 or width <= 0 or height <= 0 then
return
end
-- Early exit for very low radius (optimization)
if radius < Cache.RADIUS_THRESHOLD then
return
end
-- Check for large blur area in immediate mode
checkLargeBlurWarning(elementId, width, height, "backdrop")
-- Calculate offset multiplier based on radius and quality
local offsetMultiplier = radius / self.quality
local canvas1 = Cache.getCanvas(width, height)
local canvas2 = Cache.getCanvas(width, height)
local prevCanvas = love.graphics.getCanvas()
local prevShader = love.graphics.getShader()
local prevColor = { love.graphics.getColor() }
local prevBlendMode = love.graphics.getBlendMode()
love.graphics.setCanvas(canvas1)
love.graphics.clear()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.setBlendMode("alpha", "premultiplied")
local backdropWidth, backdropHeight = backdropCanvas:getDimensions()
local quad = Cache.getQuad(x, y, width, height, backdropWidth, backdropHeight)
love.graphics.draw(backdropCanvas, quad, 0, 0)
love.graphics.setShader(self.shader)
-- Single pass with radius-controlled offset
love.graphics.setCanvas(canvas2)
love.graphics.clear()
self.shader:send("direction", { offsetMultiplier / width, 0 })
love.graphics.draw(canvas1, 0, 0)
love.graphics.setCanvas(canvas1)
love.graphics.clear()
self.shader:send("direction", { 0, offsetMultiplier / height })
love.graphics.draw(canvas2, 0, 0)
-- Cache the result
local cachedResult = love.graphics.newCanvas(width, height)
love.graphics.setCanvas(cachedResult)
love.graphics.clear()
love.graphics.setShader()
love.graphics.setBlendMode("alpha", "premultiplied")
love.graphics.draw(canvas1, 0, 0)
Cache.setBlurredCanvas(cacheKey, cachedResult)
love.graphics.setCanvas(prevCanvas)
love.graphics.setShader()
love.graphics.setBlendMode(prevBlendMode)
love.graphics.draw(canvas1, x, y)
love.graphics.setShader(prevShader)
love.graphics.setColor(unpack(prevColor))
Cache.releaseCanvas(canvas1)
Cache.releaseCanvas(canvas2)
Cache.releaseQuad(quad)
end
--- Clear blur cache for specific element
---@param elementId string Element ID
function Blur.clearElementCache(elementId)
Cache.clearBlurredCanvasesForElement(elementId)
end
--- Initialize Blur module with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler?, immediateModeOptimizations = boolean? }
function Blur.init(deps)
if type(deps) == "table" then
Blur._ErrorHandler = deps.ErrorHandler
Blur._blurOptimizations = deps.immediateModeOptimizations or false
end
end
Blur.Cache = Cache
Blur.ShaderBuilder = ShaderBuilder
return Blur
+385
View File
@@ -0,0 +1,385 @@
--- Utility module for parsing and evaluating CSS-like calc() expressions
--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh)
---@class Calc
local Calc = {}
--- Initialize Calc module with dependencies
---@param deps CalcDependencies Dependencies: { ErrorHandler = ErrorHandler? }
function Calc.init(deps)
Calc._ErrorHandler = deps.ErrorHandler
end
--- Token types for lexical analysis
local TokenType = {
NUMBER = "NUMBER",
UNIT = "UNIT",
PLUS = "PLUS",
MINUS = "MINUS",
MULTIPLY = "MULTIPLY",
DIVIDE = "DIVIDE",
LPAREN = "LPAREN",
RPAREN = "RPAREN",
EOF = "EOF",
}
--- Tokenize a calc expression string into tokens
---@param expr string The expression to tokenize (e.g., "50% - 10vw")
---@return CalcToken[]? tokens Array of tokens with type, value, unit
---@return string? error Error message if tokenization fails
local function tokenize(expr)
local tokens = {}
local i = 1
local len = #expr
while i <= len do
local char = expr:sub(i, i)
-- Skip whitespace
if char:match("%s") then
i = i + 1
-- Number (including decimals, but NOT negative - handled separately below)
elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then
local numStr = ""
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
-- Operators
elseif char == "+" then
table.insert(tokens, { type = TokenType.PLUS })
i = i + 1
elseif char == "-" then
-- Check if this is a negative number or subtraction
-- It's a negative number if previous token is an operator or opening paren
local prevToken = tokens[#tokens]
if
not prevToken
or prevToken.type == TokenType.PLUS
or prevToken.type == TokenType.MINUS
or prevToken.type == TokenType.MULTIPLY
or prevToken.type == TokenType.DIVIDE
or prevToken.type == TokenType.LPAREN
then
-- This is a negative number, continue to number parsing
local numStr = "-"
i = i + 1
-- Parse integer and decimal parts
while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do
numStr = numStr .. expr:sub(i, i)
i = i + 1
end
local num = tonumber(numStr)
if not num then
return nil, "Invalid number: " .. numStr
end
-- Check for unit following the number
local unitStr = ""
while i <= len and expr:sub(i, i):match("[%a%%]") do
unitStr = unitStr .. expr:sub(i, i)
i = i + 1
end
-- Default to px if no unit
if unitStr == "" then
unitStr = "px"
end
-- Validate unit
local validUnits = { px = true, ["%"] = true, vw = true, vh = true }
if not validUnits[unitStr] then
return nil, "Invalid unit: " .. unitStr
end
table.insert(tokens, {
type = TokenType.NUMBER,
value = num,
unit = unitStr,
})
else
-- This is subtraction operator
table.insert(tokens, { type = TokenType.MINUS })
i = i + 1
end
elseif char == "*" then
table.insert(tokens, { type = TokenType.MULTIPLY })
i = i + 1
elseif char == "/" then
table.insert(tokens, { type = TokenType.DIVIDE })
i = i + 1
elseif char == "(" then
table.insert(tokens, { type = TokenType.LPAREN })
i = i + 1
elseif char == ")" then
table.insert(tokens, { type = TokenType.RPAREN })
i = i + 1
else
return nil, "Unexpected character: " .. char
end
end
table.insert(tokens, { type = TokenType.EOF })
return tokens
end
--- Parser for calc expressions using recursive descent
---@class Parser
---@field tokens CalcToken[] Array of tokens
---@field pos number Current token position
local Parser = {}
Parser.__index = Parser
--- Create a new parser
---@param tokens CalcToken[] Array of tokens
---@return Parser
function Parser.new(tokens)
local self = setmetatable({}, Parser)
self.tokens = tokens
self.pos = 1
return self
end
--- Get current token
---@return CalcToken token Current token
function Parser:current()
return self.tokens[self.pos]
end
--- Advance to next token
function Parser:advance()
self.pos = self.pos + 1
end
--- Parse expression (handles + and -)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseExpression()
local left = self:parseTerm()
while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do
local op = self:current().type
self:advance()
local right = self:parseTerm()
left = {
type = op == TokenType.PLUS and "add" or "subtract",
left = left,
right = right,
}
end
return left
end
--- Parse term (handles * and /)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseTerm()
local left = self:parseFactor()
while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do
local op = self:current().type
self:advance()
local right = self:parseFactor()
left = {
type = op == TokenType.MULTIPLY and "multiply" or "divide",
left = left,
right = right,
}
end
return left
end
--- Parse factor (handles numbers and parentheses)
---@return CalcASTNode ast Abstract syntax tree node
function Parser:parseFactor()
local token = self:current()
if token.type == TokenType.NUMBER then
self:advance()
return {
type = "number",
value = token.value,
unit = token.unit,
}
elseif token.type == TokenType.LPAREN then
self:advance()
local expr = self:parseExpression()
if self:current().type ~= TokenType.RPAREN then
error("Expected closing parenthesis")
end
self:advance()
return expr
else
error("Unexpected token: " .. token.type)
end
end
--- Parse the tokens into an AST
---@return CalcASTNode ast Abstract syntax tree
function Parser:parse()
local ast = self:parseExpression()
if self:current().type ~= TokenType.EOF then
error("Unexpected tokens after expression")
end
return ast
end
--- Create a calc expression object that can be resolved later
--- This is the main API function that users call
---@param expr string The calc expression (e.g., "50% - 10vw")
---@return CalcObject calcObject A calc expression object with AST
function Calc.new(expr)
-- Tokenize
local tokens, err = tokenize(expr)
if not tokens then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = err,
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = err,
}
end
-- Parse
local parser = Parser.new(tokens)
local success, ast = pcall(function()
return parser:parse()
end)
if not success then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = expr,
error = ast, -- ast contains error message on failure
})
end
-- Return a fallback calc object that resolves to 0
return {
_isCalc = true,
_expr = expr,
_ast = nil,
_error = ast,
}
end
return {
_isCalc = true,
_expr = expr,
_ast = ast,
}
end
--- Check if a value is a calc expression
---@param value any The value to check
---@return boolean isCalc True if value is a calc expression
function Calc.isCalc(value)
return type(value) == "table" and value._isCalc == true
end
--- Resolve a calc expression to pixel value
---@param calcObj CalcObject The calc expression object
---@param viewportWidth number Viewport width in pixels
---@param viewportHeight number Viewport height in pixels
---@param parentSize number? Parent dimension for percentage units
---@return number resolvedValue Resolved pixel value
function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize)
if not calcObj._ast then
-- Error during parsing, return 0
return 0
end
--- Evaluate AST node recursively
---@param node table AST node
---@return number value Evaluated value in pixels
local function evaluate(node)
if node.type == "number" then
-- Convert unit to pixels
local value = node.value
local unit = node.unit
if unit == "px" then
return value
elseif unit == "%" then
if not parentSize then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "LAY_003", {
unit = "%",
issue = "parent dimension not available",
})
end
return 0
end
return (value / 100) * parentSize
elseif unit == "vw" then
return (value / 100) * viewportWidth
elseif unit == "vh" then
return (value / 100) * viewportHeight
else
return 0
end
elseif node.type == "add" then
return evaluate(node.left) + evaluate(node.right)
elseif node.type == "subtract" then
return evaluate(node.left) - evaluate(node.right)
elseif node.type == "multiply" then
return evaluate(node.left) * evaluate(node.right)
elseif node.type == "divide" then
local divisor = evaluate(node.right)
if divisor == 0 then
if Calc._ErrorHandler then
Calc._ErrorHandler:warn("Calc", "VAL_006", {
expression = calcObj._expr,
error = "Division by zero",
})
end
return 0
end
return evaluate(node.left) / divisor
else
return 0
end
end
return evaluate(calcObj._ast)
end
return Calc
+346
View File
@@ -0,0 +1,346 @@
---@class Color
local Color = {}
Color.__index = Color
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler}
function Color.init(deps)
if type(deps) == "table" then
Color._ErrorHandler = deps.ErrorHandler
end
end
--- Build type-safe color objects with automatic validation and clamping
--- Use this to avoid invalid color values and ensure consistent LÖVE-compatible colors (0-1 range)
---@param r number? Red component (0-1), defaults to 0
---@param g number? Green component (0-1), defaults to 0
---@param b number? Blue component (0-1), defaults to 0
---@param a number? Alpha component (0-1), defaults to 1
---@return Color color The new color instance
function Color.new(r, g, b, a)
-- Sanitize and clamp color components
local _, sanitizedR = Color.validateColorChannel(r or 0, 1)
local _, sanitizedG = Color.validateColorChannel(g or 0, 1)
local _, sanitizedB = Color.validateColorChannel(b or 0, 1)
local _, sanitizedA = Color.validateColorChannel(a or 1, 1)
-- FFI structs don't support metatables/methods without wrapping
-- The wrapping overhead negates the FFI benefits
local self = setmetatable({}, Color)
self.r = sanitizedR or 0
self.g = sanitizedG or 0
self.b = sanitizedB or 0
self.a = sanitizedA or 1
return self
end
--- Extract individual color channels for use with love.graphics.setColor()
--- Use this to pass colors to LÖVE's rendering functions
---@return number r Red component (0-1)
---@return number g Green component (0-1)
---@return number b Blue component (0-1)
---@return number a Alpha component (0-1)
function Color:toRGBA()
return self.r, self.g, self.b, self.a
end
--- Parse CSS-style hex colors into Color objects for designer-friendly workflows
--- Use this to work with colors from design tools that export hex values
---@param hexWithTag string Hex color string (e.g. "#RRGGBB" or "#RRGGBBAA")
---@return Color color The parsed color (returns white on error with warning)
function Color.fromHex(hexWithTag)
-- Validate input type
if type(hexWithTag) ~= "string" then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = tostring(hexWithTag),
issue = "not a string",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1)
end
local hex = hexWithTag:gsub("#", "")
if #hex == 6 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
if not r or not g or not b then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, 1)
elseif #hex == 8 then
local r = tonumber("0x" .. hex:sub(1, 2))
local g = tonumber("0x" .. hex:sub(3, 4))
local b = tonumber("0x" .. hex:sub(5, 6))
local a = tonumber("0x" .. hex:sub(7, 8))
if not r or not g or not b or not a then
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
issue = "invalid hex digits",
fallback = "white (#FFFFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
return Color.new(r / 255, g / 255, b / 255, a / 255)
else
Color._ErrorHandler:warn("Color", "VAL_004", {
input = hexWithTag,
expected = "#RRGGBB or #RRGGBBAA",
hexLength = #hex,
fallback = "white (#FFFFFF)",
})
return Color.new(1, 1, 1, 1) -- Return white as fallback
end
end
--- Verify and sanitize individual color components to prevent rendering errors
--- Use this to safely process user input or external color data
---@param value any Value to validate
---@param max number? Maximum value (255 for 0-255 range, 1 for 0-1 range), defaults to 1
---@return boolean valid True if valid
---@return number? clamped Clamped value in 0-1 range, nil if invalid
function Color.validateColorChannel(value, max)
max = max or 1
if type(value) ~= "number" then
return false, nil
end
-- Check for NaN
if value ~= value then
return false, nil
end
-- Check for Infinity
if value == math.huge or value == -math.huge then
return false, nil
end
-- Normalize to 0-1 range
local normalized = value
if max == 255 then
normalized = value / 255
end
-- Clamp to valid range
normalized = math.max(0, math.min(1, normalized))
return true, normalized
end
--- Validate hex color format
---@param hex string Hex color string (with or without #)
---@return boolean valid True if valid format
---@return string? error Error message if invalid, nil if valid
function Color.validateHexColor(hex)
if type(hex) ~= "string" then
return false, "Hex color must be a string"
end
-- Remove # prefix
local cleanHex = hex:gsub("^#", "")
-- Check length (3, 6, or 8 characters)
if #cleanHex ~= 3 and #cleanHex ~= 6 and #cleanHex ~= 8 then
return false, string.format("Invalid hex length: %d. Expected 3, 6, or 8 characters", #cleanHex)
end
-- Check for valid hex characters
if not cleanHex:match("^[0-9A-Fa-f]+$") then
return false, "Invalid hex characters. Use only 0-9, A-F"
end
return true, nil
end
--- Validate RGB/RGBA color values
---@param r number Red component
---@param g number Green component
---@param b number Blue component
---@param a number? Alpha component (optional, defaults to max)
---@param max number? Maximum value (255 or 1), defaults to 1
---@return boolean valid True if valid
---@return string? error Error message if invalid, nil if valid
function Color.validateRGBColor(r, g, b, a, max)
max = max or 1
a = a or max
local rValid = Color.validateColorChannel(r, max)
local gValid = Color.validateColorChannel(g, max)
local bValid = Color.validateColorChannel(b, max)
local aValid = Color.validateColorChannel(a, max)
if not rValid then
return false, string.format("Invalid red channel: %s", tostring(r))
end
if not gValid then
return false, string.format("Invalid green channel: %s", tostring(g))
end
if not bValid then
return false, string.format("Invalid blue channel: %s", tostring(b))
end
if not aValid then
return false, string.format("Invalid alpha channel: %s", tostring(a))
end
return true, nil
end
--- Check if a value is a valid color format
---@param value any Value to check
---@return string? format Format type ("hex", "named", "table"), nil if invalid
function Color.isValidColorFormat(value)
local valueType = type(value)
-- Check for hex string
if valueType == "string" then
if value:match("^#?[0-9A-Fa-f]+$") then
local valid = Color.validateHexColor(value)
if valid then
return "hex"
end
end
return nil
end
-- Check for table format
if valueType == "table" then
-- Check for Color instance
if getmetatable(value) == Color then
return "table"
end
-- Check for array format {r, g, b, a}
if value[1] and value[2] and value[3] then
local valid = Color.validateRGBColor(value[1], value[2], value[3], value[4])
if valid then
return "table"
end
end
-- Check for named format {r=, g=, b=, a=}
if value.r and value.g and value.b then
local valid = Color.validateRGBColor(value.r, value.g, value.b, value.a)
if valid then
return "table"
end
end
return nil
end
return nil
end
--- Convert any color format to a valid Color object with graceful fallbacks
--- Use this to robustly handle colors from any source without crashes
---@param value any Color value to sanitize (hex, named, table, or Color instance)
---@param default Color? Default color if invalid (defaults to black)
---@return Color color Sanitized color instance (guaranteed non-nil)
function Color.sanitizeColor(value, default)
default = default or Color.new(0, 0, 0, 1)
local format = Color.isValidColorFormat(value)
if not format then
return default
end
-- Handle hex format
if format == "hex" then
local cleanHex = value:gsub("^#", "")
-- Expand 3-digit hex to 6-digit
if #cleanHex == 3 then
cleanHex = cleanHex:gsub("(.)", "%1%1")
end
-- Try to parse
local success, result = pcall(Color.fromHex, "#" .. cleanHex)
if success then
return result
else
return default
end
end
if format == "table" then
-- Color instance
if getmetatable(value) == Color then
return value
end
-- Array format
if value[1] then
local _, r = Color.validateColorChannel(value[1], 1)
local _, g = Color.validateColorChannel(value[2], 1)
local _, b = Color.validateColorChannel(value[3], 1)
local _, a = Color.validateColorChannel(value[4] or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
-- Named format
if value.r then
local _, r = Color.validateColorChannel(value.r, 1)
local _, g = Color.validateColorChannel(value.g, 1)
local _, b = Color.validateColorChannel(value.b, 1)
local _, a = Color.validateColorChannel(value.a or 1, 1)
if r and g and b and a then
return Color.new(r, g, b, a)
end
end
end
return default
end
--- Universally convert any color format (hex, named, table) into a Color object
--- Use this as your main color input handler to accept flexible color specifications
---@param value any Color value (hex string, named color, table, or Color instance)
---@return Color color Parsed color instance (defaults to black on error)
function Color.parse(value)
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
end
--- Smoothly transition between two colors for animations and gradients
--- Use this to create color-based animations without manual channel calculations
---@param colorA Color Starting color
---@param colorB Color Ending color
---@param t number Interpolation factor (0-1)
---@return Color color Interpolated color
function Color.lerp(colorA, colorB, t)
-- Sanitize inputs
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
colorA = Color.new(0, 0, 0, 1)
end
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
colorB = Color.new(0, 0, 0, 1)
end
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
t = 0
end
-- Clamp t to 0-1 range
t = math.max(0, math.min(1, t))
-- Linear interpolation for each channel
local oneMinusT = 1 - t
local r = colorA.r * oneMinusT + colorB.r * t
local g = colorA.g * oneMinusT + colorB.g * t
local b = colorA.b * oneMinusT + colorB.b * t
local a = colorA.a * oneMinusT + colorB.a * t
return Color.new(r, g, b, a)
end
return Color
+596
View File
@@ -0,0 +1,596 @@
---@class Context
local modulePath = (...):match("(.-)[^%.]+$")
local ZIndex = require(modulePath .. "ZIndex")
local Element = require(modulePath .. "Element")
local Context = {
topElements = {},
-- Base scale configuration
baseScale = nil, -- {width: number, height: number}
-- Current scale factors
scaleFactors = { x = 1.0, y = 1.0 },
defaultTheme = nil,
_focusedElement = nil,
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
_activeEventElement = nil,
_cachedViewport = { width = 0, height = 0 },
-- Immediate mode state
_immediateMode = false,
_frameNumber = 0,
_currentFrameElements = {},
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
_frameStarted = false,
_autoBeganFrame = false,
-- Z-index ordered element tracking for immediate mode
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
-- Focus management guard
_settingFocus = false,
-- Hook called whenever focus changes: function(element) or nil
_onFocusChanged = nil,
-- Navigation state
_navigationContext = {
lastFocusedElement = nil, -- For returning from modals
navigationMode = "sequential", -- "sequential" or "directional"
containerElement = nil, -- Current navigation container
},
initialized = false,
-- Expose internal hit-testing helpers for unit testing only.
-- These are populated below after their local definitions. They are NOT part
-- of the public API and must not be relied on by callers; they exist so the
-- shared hit-test core (the single place display:none guarding lives) can be
-- exercised directly by the test suite. Subsequent unified-event-routing
-- tasks consume these locals through the mode-agnostic query functions.
_test = {
pointHitsElement = nil,
elementHasScrollableOverflow = nil,
},
-- Debug draw overlay
_debugDraw = false,
_debugDrawKey = nil,
-- Initialization state tracking
---@type "uninitialized"|"initializing"|"ready"
_initState = "uninitialized",
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
_initQueue = {},
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
-- + realloc + sort for every interactive element sharing the same cursor.
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
-- each flexlove.update (both modes) and in clearFrameElements (immediate
-- mid-frame rebuild). It also self-invalidates when the topElements table
-- reference changes (tests replace it per-case; immediate-mode beginFrame
-- reassigns it each frame), so direct callers that never go through
-- flexlove.update still see fresh results across tree swaps.
_interactiveLookupCache = {
valid = false,
x = nil,
y = nil,
result = nil,
topElementsRef = nil,
frameNumber = -1,
},
}
--- Check if a point hits an element, accounting for scroll offsets and display:none.
--- All mode-agnostic query functions use this as their single hit-test entry point,
--- ensuring fixes like display:none guarding apply everywhere.
---
--- This is the single canonical place where `element.display == false` short-
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
--- the caller's responsibility: callers walk the parent chain (using
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
--- accumulated scroll offset in here. Keeping the parent walk outside this core
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
--- z-index list) callers share the exact same primitive bounds/display logic.
---@param element Element
---@param mx number Screen X coordinate
---@param my number Screen Y coordinate
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
---@return boolean hits
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
-- Skip display:none elements entirely
if element.display == false then
return false
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
local adjustedX = mx + scrollOffsetX
local adjustedY = my + scrollOffsetY
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
end
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
--- overflow values that clip/translate descendant content and therefore require
--- scroll-offset compensation when hit testing descendants.
---@param element Element
---@return boolean
local function elementHasScrollableOverflow(element)
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
return overflowX == "scroll"
or overflowX == "auto"
or overflowY == "scroll"
or overflowY == "auto"
or overflowX == "hidden"
or overflowY == "hidden"
end
-- Expose the two core helpers for unit testing only (see Context._test above).
Context._test.pointHitsElement = pointHitsElement
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
-- Public exposure of the canonical hit-test primitive so other modules
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
-- tree walks) can share the single implementation of bounds + display:none
-- guarding instead of duplicating the `display == false` check inline.
-- This keeps "display == false" in exactly one place for hit-testing.
Context.pointHitsElement = pointHitsElement
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
--- Find the first scrollable element at a screen position, regardless of mode.
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
--- that previously lived inline in `flexlove.wheelmoved`:
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
--- * retained mode — recursed through `Context.topElements` with a private
--- `findScrollableAtPosition(elements, x, y)` helper.
--- Both paths now collapse into this single function, which routes every
--- hit test through `pointHitsElement` (the single place `display == false`
--- is guarded) and every scroll-offset decision through
--- `elementHasScrollableOverflow`. As a result display:none elements are never
--- returned in either mode, fixing the latent bug where the immediate-mode
--- path's `isPointInElement` did not skip display:none elements.
---
--- The retained-mode branch intentionally mirrors the original
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
--- children checked before self) but is upgraded to thread accumulated scroll
--- offsets through `pointHitsElement` so nested scrolled containers are tested
--- against their visible position. The original helper is removed once
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The scrollable element, or nil
function Context.findScrollableAtPosition(x, y)
if Context.isImmediateMode() then
-- Immediate mode: iterate the z-index ordered list (reverse order =
-- topmost first). pointHitsElement supplies the bounds + display guard.
for i = #Context._zIndexOrderedElements, 1, -1 do
local element = Context._zIndexOrderedElements[i]
if pointHitsElement(element, x, y) then
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
else
-- Retained mode: recursive tree walk from topElements. Children are
-- checked before self (deepest scrollable wins); accumulated scroll
-- offsets are threaded through pointHitsElement so descendants of
-- scrolled containers are hit-tested against their translated position.
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
for i = #elements, 1, -1 do
local element = elements[i]
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
if #element.children > 0 then
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
if childResult then
return childResult
end
end
-- No descendant was scrollable — check self.
local overflowX = element.overflowX or element.overflow
local overflowY = element.overflowY or element.overflow
if
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
and (element._overflowX or element._overflowY)
then
return element
end
end
end
return nil
end
return findInTree(Context.topElements)
end
end
--- Check whether immediate mode is active.
--- This is the single canonical accessor for the mode flag consumed throughout
--- the framework. Mode-aware branches elsewhere call this instead of reading
--- `Context._immediateMode` directly, so the literal mode flag only appears
--- here (its definition) and in StateManager (its mirrored storage) — never
--- scattered across Element / behaviors / managers (behavior-mode-unification
--- task 11).
---@return boolean
function Context.isImmediateMode()
return Context._immediateMode
end
---@return number, number -- scaleX, scaleY
function Context.getScaleFactors()
return Context.scaleFactors.x, Context.scaleFactors.y
end
--- Register an element in the z-index ordered tree (for immediate mode)
---@param element Element The element to register
function Context.registerElement(element)
if not Context.isImmediateMode() then
return
end
table.insert(Context._zIndexOrderedElements, element)
end
function Context.clearFrameElements()
Context._zIndexOrderedElements = {}
Context.clearInteractiveCache()
end
--- Compute the composite z-index key for an element.
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
---
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
--- remaining 3 digits without interfering with the depth component.
---
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
---
--- This is the SINGLE canonical z-index ordering function, used by both
--- sortElementsByZIndex (the immediate-mode flat list sort) and
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
--- on the same key ensures the interactive topmost element matches the visual
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
--- z=0 BottomBar even when both buttons default to own z=0.
local function getEffectiveZIndex(elem)
local ownZ = elem.z or 0
local rootZ = ownZ
local depth = 0
local current = elem.parent
while current do
rootZ = current.z or 0
depth = depth + 1
current = current.parent
end
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
end
-- Public exposure so FlexLove.getElementAtPosition shares the single
-- implementation instead of duplicating the parent-chain walk as a closure.
Context.getEffectiveZIndex = getEffectiveZIndex
--- Sort elements by z-index (called after all elements are registered)
function Context.sortElementsByZIndex()
-- Precompute the composite key ONCE per element so the sort comparator is a
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
-- O(N log N) comparison. This function runs every frame in immediate mode.
local elements = Context._zIndexOrderedElements
local zIndices = {}
for i = 1, #elements do
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
end
table.sort(elements, function(a, b)
return zIndices[a] < zIndices[b]
end)
end
--- Find the topmost interactive element at a screen position, regardless of mode.
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
--- mechanism — both are now funneled through this single entry point.
---
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
--- in immediate mode). In retained mode this provides the same role as the
--- _activeEventElement set by flexlove.getElementAtPosition().
---
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
---@param x number Screen X coordinate
---@param y number Screen Y coordinate
---@return Element|nil The topmost interactive element, or nil
function Context.findInteractiveAtPosition(x, y)
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
-- element under the same cursor, but the result for a given (x,y) is
-- identical across all of them within a single update pass. Returning a
-- cached element restores the old 1x/frame cost of the _activeEventElement
-- mechanism that task 05 replaced. Cache auto-invalidates when the
-- topElements table reference changes (so tests and mid-frame rebuilds get
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
local cache = Context._interactiveLookupCache
if
cache.valid
and cache.x == x
and cache.y == y
and cache.topElementsRef == Context.topElements
and cache.frameNumber == Context._frameNumber
then
return cache.result
end
local interactiveCandidates = {}
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
scrollOffsetX = scrollOffsetX or 0
scrollOffsetY = scrollOffsetY or 0
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
return
end
-- Check if this element is interactive
if element.onEvent or element.themeComponent or element.editable then
table.insert(interactiveCandidates, element)
end
-- Recurse into children with accumulated scroll offset
local childScrollOffsetX = scrollOffsetX
local childScrollOffsetY = scrollOffsetY
if elementHasScrollableOverflow(element) then
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
end
for _, child in ipairs(element.children) do
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
end
end
-- Always traverse the tree (works in both modes — topElements exists always)
for _, element in ipairs(Context.topElements) do
collectInteractive(element)
end
-- Sort by composite z-index descending — topmost wins. The composite key
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
-- topmost element matches the visual draw order. This is critical for the
-- game's multi-window layout: a button inside a z=50 MainMenu window must
-- occlude a button inside a z=0 BottomBar even when both buttons default to
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
-- distinguish them, so the wrong window's button could win, leaving the
-- visible button's isActiveElement=false and clicks/hover dead.
local zIndices = {}
for _, el in ipairs(interactiveCandidates) do
zIndices[el] = getEffectiveZIndex(el)
end
table.sort(interactiveCandidates, function(a, b)
return zIndices[a] > zIndices[b]
end)
local result = interactiveCandidates[1]
cache.x = x
cache.y = y
cache.result = result
cache.topElementsRef = Context.topElements
cache.frameNumber = Context._frameNumber
cache.valid = true
return result
end
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
--- rebuild). After invalidation the next lookup recomputes fresh.
function Context.clearInteractiveCache()
local cache = Context._interactiveLookupCache
cache.valid = false
cache.x = nil
cache.y = nil
cache.result = nil
cache.topElementsRef = nil
cache.frameNumber = -1
end
--- Set the focused element (centralizes focus management)
--- Automatically blurs the previously focused element if different
---@param element Element|nil The element to focus (nil to clear focus)
function Context.setFocused(element)
if Context._focusedElement == element then
return -- Already focused
end
-- Prevent re-entry during focus change
if Context._settingFocus then
return
end
Context._settingFocus = true
-- Save reference to previously focused element before updating
local oldFocusedElement = Context._focusedElement
-- Blur previously focused element
if oldFocusedElement and oldFocusedElement ~= element then
if oldFocusedElement._textEditor then
oldFocusedElement._textEditor:blur(oldFocusedElement)
end
end
-- Set new focused element and persist its id for immediate-mode rehydration
Context._focusedElement = element
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
-- Notify any registered focus change hook (e.g. FocusIndicator)
if Context._onFocusChanged then
Context._onFocusChanged(element)
end
-- Focus the new element's text editor if it has one
if element and element._textEditor then
element._textEditor._focused = true
end
Context._settingFocus = false
end
--- Recursively search for an element by id in an element tree
---@param root Element The root element to start searching from
---@param targetId string The id to search for
---@return Element|nil The element with the matching id, or nil if not found
local function findElementById(root, targetId)
if root.id == targetId then
return root
end
for _, child in ipairs(root.children or {}) do
local found = findElementById(child, targetId)
if found then
return found
end
end
return nil
end
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
--- Called at the start of getFocused() in immediate mode so stale references
--- are always replaced with the current-frame object before use.
function Context._rehydrateFocus()
if not Context._focusedElementId then
Context._focusedElement = nil
return
end
-- First, try a fast linear search through all registered elements
for _, elem in ipairs(Context._zIndexOrderedElements) do
if elem.id == Context._focusedElementId then
Context._focusedElement = elem
return
end
end
-- If not found, recursively search from top-level elements
-- This handles cases where elements may not be in _zIndexOrderedElements
for _, topLevel in ipairs(Context.topElements or {}) do
local found = findElementById(topLevel, Context._focusedElementId)
if found then
Context._focusedElement = found
return
end
end
-- Element with that id is not present this frame (e.g. screen changed)
Context._focusedElement = nil
end
--- Get the currently focused element
---@return Element|nil The focused element, or nil if none
function Context.getFocused()
if Context.isImmediateMode() then
Context._rehydrateFocus()
end
return Context._focusedElement
end
--- Clear focus from any element
function Context.clearFocus()
Context._focusedElementId = nil
Context.setFocused(nil)
end
--- Get all focusable elements in tab order, regardless of mode.
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
--- In retained mode it walks the element tree (DOM order).
--- In both modes, display:none elements are excluded.
---@return table<Element> List of focusable elements in tab order
function Context.getFocusableElements()
local focusable = {}
local function isFocusable(elem)
if elem.display == false then
return false
end
-- Use Element:isFocusable() for consistent behavior
return Element.isFocusable(elem)
end
local function collectFromTree(elements)
for _, elem in ipairs(elements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
if #elem.children > 0 then
collectFromTree(elem.children)
end
end
end
if Context._immediateMode then
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
-- which approximates tab order for most UIs.
for _, elem in ipairs(Context._zIndexOrderedElements) do
if isFocusable(elem) then
table.insert(focusable, elem)
end
end
else
-- Retained mode: walk the top element trees in DOM order
collectFromTree(Context.topElements)
end
return focusable
end
-- ====================
-- Navigation Context
-- ====================
--- Push current focus onto stack (for modals/dialogs)
---@param element Element?
function Context.pushFocusStack(element)
Context._navigationContext.lastFocusedElement = Context._focusedElement
if element then
Context.setFocused(element)
end
end
--- Pop focus from stack (return from modal)
---@return Element?
function Context.popFocusStack()
local previous = Context._navigationContext.lastFocusedElement
Context._navigationContext.lastFocusedElement = nil
Context.setFocused(previous)
return previous
end
--- Set navigation container (scope for tab navigation)
---@param element Element?
function Context.setNavigationContainer(element)
Context._navigationContext.containerElement = element
end
--- Get navigation container
---@return Element?
function Context.getNavigationContainer()
return Context._navigationContext.containerElement
end
return Context
File diff suppressed because it is too large Load Diff
+171
View File
@@ -0,0 +1,171 @@
-- Layout, flex, text, image, and ARIA enums used across FlexLove.
-- Extracted from utils so utils stays under its LOC budget; re-exported as
-- `utils.enums` for backward compatibility.
local enums = {
---@enum TextAlign
TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" },
---@enum TextAlignVertical
TextAlignVertical = { START = "start", CENTER = "center", END = "end" },
---@enum Positioning
Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" },
---@enum FlexDirection
FlexDirection = {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
ROW = "row",
COLUMN = "column",
HORIZONTAL_REVERSE = "horizontal-reverse",
VERTICAL_REVERSE = "vertical-reverse",
ROW_REVERSE = "row-reverse",
COLUMN_REVERSE = "column-reverse",
},
---@enum JustifyContent
JustifyContent = {
FLEX_START = "flex-start",
CENTER = "center",
SPACE_AROUND = "space-around",
FLEX_END = "flex-end",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum JustifySelf
JustifySelf = {
AUTO = "auto",
FLEX_START = "flex-start",
CENTER = "center",
FLEX_END = "flex-end",
SPACE_AROUND = "space-around",
SPACE_EVENLY = "space-evenly",
SPACE_BETWEEN = "space-between",
},
---@enum AlignItems
AlignItems = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignSelf
AlignSelf = {
AUTO = "auto",
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
BASELINE = "baseline",
},
---@enum AlignContent
AlignContent = {
STRETCH = "stretch",
FLEX_START = "flex-start",
FLEX_END = "flex-end",
CENTER = "center",
SPACE_BETWEEN = "space-between",
SPACE_AROUND = "space-around",
},
---@enum FlexWrap
FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" },
---@enum TextSize
TextSize = {
XXS = "xxs",
XS = "xs",
SM = "sm",
MD = "md",
LG = "lg",
XL = "xl",
XXL = "xxl",
XL3 = "3xl",
XL4 = "4xl",
},
---@enum ImageRepeat
ImageRepeat = {
NO_REPEAT = "no-repeat",
REPEAT = "repeat",
REPEAT_X = "repeat-x",
REPEAT_Y = "repeat-y",
SPACE = "space",
ROUND = "round",
},
---@enum ARIA Role (accessibility roles for screen readers)
ARIA = {
-- Widget roles
BUTTON = "button",
CHECKBOX = "checkbox",
LINK = "link",
MENUITEM = "menuitem",
MENUITEMCHECKBOX = "menuitemcheckbox",
MENUITEMRADIO = "menuitemradio",
PROGRESSBAR = "progressbar",
RADIO = "radio",
SCROLLBAR = "scrollbar",
SLIDER = "slider",
SPINBUTTON = "spinbutton",
SWITCH = "switch",
TAB = "tab",
TABLIST = "tablist",
TABPANEL = "tabpanel",
TEXTBOX = "textbox",
TOOLTIP = "tooltip",
TREEITEM = "treeitem",
COMBOBOX = "combobox",
GRID = "grid",
GRIDCELL = "gridcell",
LISTBOX = "listbox",
LISTITEM = "listitem",
MENU = "menu",
MENUBAR = "menubar",
TREE = "tree",
TREEGRID = "treegrid",
WINDOW = "window",
DIALOG = "dialog",
ALERTDIALOG = "alertdialog",
-- Landmark roles
BANNER = "banner",
COMPLEMENTARY = "complementary",
CONTENTINFO = "contentinfo",
FORM = "form",
MAIN = "main",
NAVIGATION = "navigation",
REGION = "region",
SEARCH = "search",
-- Live region roles
ALERT = "alert",
LOG = "log",
MARQUEE = "marquee",
STATUS = "status",
TIMERTIME = "timer",
-- Document structure roles
ARTICLE = "article",
BLOCKQUOTEBLOCKQUOTE = "blockquote",
CAPTION = "caption",
CODE = "code",
DEFINITION = "definition",
DELETED = "deletion",
DIRECTORY = "directory",
DIVISION = "division",
EMphasis = "emphasis",
HEADING = "heading",
INSERTED = "insertion",
LIST = "list",
MARK = "mark",
MATH = "math",
NONE = "none",
PARAGRAPH = "paragraph",
PRESENTATION = "presentation",
SEPARATOR = "separator",
STRONG = "strong",
SUBSCRIPT = "subscript",
SUPERSCRIPT = "superscript",
TERM = "term",
TIME = "time",
VARIABLE = "variable",
},
}
return { enums = enums }
File diff suppressed because it is too large Load Diff
+843
View File
@@ -0,0 +1,843 @@
---@class EventHandler
---@field onEvent fun(element:Element, event:InputEvent)?
---@field onEventDeferred boolean?
---@field onTouchEvent fun(element:Element, touchEvent:InputEvent)? -- Touch-specific callback
---@field onTouchEventDeferred boolean? -- Whether onTouchEvent is deferred
---@field onGesture fun(element:Element, gesture:table)? -- Gesture callback
---@field onGestureDeferred boolean? -- Whether onGesture is deferred
---@field touchEnabled boolean -- Whether touch events are processed (default: true)
---@field multiTouchEnabled boolean -- Whether multi-touch is supported (default: false)
---@field _pressed table<number, boolean>
---@field _lastClickTime number?
---@field _lastClickButton number?
---@field _clickCount number
---@field _dragStartX table<number, number>
---@field _dragStartY table<number, number>
---@field _lastMouseX table<number, number>
---@field _lastMouseY table<number, number>
---@field _touches table<string, table> -- Multi-touch state per touch ID
---@field _touchStartPositions table<string, table> -- Touch start positions
---@field _lastTouchPositions table<string, table> -- Last touch positions for delta
---@field _touchHistory table<string, table> -- Touch position history for gestures (last 5)
---@field _hovered boolean
---@field _scrollbarPressHandled boolean
---@field _InputEvent table
---@field _utils table
---@field _Performance Performance? Performance module dependency
---@field _ErrorHandler ErrorHandler
local EventHandler = {}
EventHandler.__index = EventHandler
--- Initialize module with shared dependencies
---@param deps table Dependencies {Performance, ErrorHandler, InputEvent, Context, utils}
function EventHandler.init(deps)
EventHandler._Performance = deps.Performance
EventHandler._ErrorHandler = deps.ErrorHandler
EventHandler._InputEvent = deps.InputEvent
EventHandler._utils = deps.utils
EventHandler._Context = deps.Context
end
---@param config table Configuration options
---@return EventHandler
function EventHandler.new(config)
config = config or {}
local self = setmetatable({}, EventHandler)
self.onEvent = config.onEvent
self.onEventDeferred = config.onEventDeferred
self.onTouchEvent = config.onTouchEvent
self.onTouchEventDeferred = config.onTouchEventDeferred or false
self.onGesture = config.onGesture
self.onGestureDeferred = config.onGestureDeferred or false
self.touchEnabled = config.touchEnabled ~= false -- Default true
self.multiTouchEnabled = config.multiTouchEnabled or false -- Default false
self._pressed = config._pressed or {}
self._lastClickTime = config._lastClickTime
self._lastClickButton = config._lastClickButton
self._clickCount = config._clickCount or 0
-- FocusIndicator reference (set after initialization)
self._FocusIndicator = nil
self._dragStartX = config._dragStartX or {}
self._dragStartY = config._dragStartY or {}
self._lastMouseX = config._lastMouseX or {}
self._lastMouseY = config._lastMouseY or {}
-- Multi-touch tracking
self._touches = config._touches or {}
self._touchStartPositions = config._touchStartPositions or {}
self._lastTouchPositions = config._lastTouchPositions or {}
self._touchHistory = config._touchHistory or {}
self._hovered = config._hovered or false
self._scrollbarPressHandled = false
return self
end
--- Get state for persistence (for immediate mode)
---@return table State data
function EventHandler:getState()
return {
_pressed = self._pressed,
_lastClickTime = self._lastClickTime,
_lastClickButton = self._lastClickButton,
_clickCount = self._clickCount,
_dragStartX = self._dragStartX,
_dragStartY = self._dragStartY,
_lastMouseX = self._lastMouseX,
_lastMouseY = self._lastMouseY,
_touches = self._touches,
_touchStartPositions = self._touchStartPositions,
_lastTouchPositions = self._lastTouchPositions,
_touchHistory = self._touchHistory,
_hovered = self._hovered,
}
end
--- Restore state from persistence (for immediate mode)
---@param state table State data
function EventHandler:setState(state)
if not state then
return
end
self._pressed = state._pressed or {}
self._lastClickTime = state._lastClickTime
self._lastClickButton = state._lastClickButton
self._clickCount = state._clickCount or 0
self._dragStartX = state._dragStartX or {}
self._dragStartY = state._dragStartY or {}
self._lastMouseX = state._lastMouseX or {}
self._lastMouseY = state._lastMouseY or {}
self._touches = state._touches or {}
self._touchStartPositions = state._touchStartPositions or {}
self._lastTouchPositions = state._lastTouchPositions or {}
self._touchHistory = state._touchHistory or {}
self._hovered = state._hovered or false
end
--- Process mouse button events in the update cycle
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param isHovering boolean Whether mouse is over element
---@param isActiveElement boolean Whether this is the top element at mouse position
function EventHandler:processMouseEvents(element, mx, my, isHovering, isActiveElement)
-- Start performance timing
-- Performance accessed via EventHandler._Performance
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_mouse")
end
-- Check if currently dragging (allows drag continuation even if occluded)
local isDragging = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and love.mouse.isDown(button) then
isDragging = true
break
end
end
-- Check if any button is currently pressed (tracked state)
local hasTrackedPress = false
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] then
hasTrackedPress = true
break
end
end
-- Can only process events if we have handler, element is enabled, and is active or dragging or has tracked press
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.editable
or element._selectState
or element.selectOption
)
and element.visibility ~= "hidden"
and not element.disabled
and (isActiveElement or isDragging or hasTrackedPress)
if not canProcessEvents then
-- If not hovering and no buttons are physically pressed, reset all pressed states
-- This ensures the pressed state is cleared when mouse leaves without button held
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Track hover state changes even when events can't be processed
-- Fire synthetic unhover when element becomes disabled while hovered
if element.disabled and self._hovered then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
elseif self._hovered and not isHovering then
self._hovered = false
if element.onEvent or self.onEvent then
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
end
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
return
end
-- Track hover state changes and fire hover/unhover events BEFORE button processing
-- This ensures hover fires before press when mouse first enters element
local wasHovered = self._hovered
local isHoveringAndActive = isHovering and isActiveElement
if isHoveringAndActive and not wasHovered then
-- Just started hovering - fire hover event
self._hovered = true
local modifiers = EventHandler._utils.getModifiers()
local hoverEvent = EventHandler._InputEvent.new({
type = "hover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, hoverEvent)
elseif not isHoveringAndActive and wasHovered then
-- Just stopped hovering - fire unhover event
self._hovered = false
local modifiers = EventHandler._utils.getModifiers()
local unhoverEvent = EventHandler._InputEvent.new({
type = "unhover",
button = 0,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 0,
})
self:_invokeCallback(element, unhoverEvent)
end
-- Process all three mouse buttons
local buttons = { 1, 2, 3 } -- left, right, middle
for _, button in ipairs(buttons) do
-- Check if this button was tracked as pressed
local wasPressed = self._pressed[button]
local isPhysicallyPressed = love.mouse.isDown(button)
if isHovering or isDragging or wasPressed then
if isPhysicallyPressed then
-- Button is pressed down
if not wasPressed then
-- Just pressed - fire press event (only if hovering)
if isHovering then
self:_handleMousePress(element, mx, my, button)
end
else
-- Button is still pressed - check for drag
self:_handleMouseDrag(element, mx, my, button, isHovering)
end
elseif wasPressed then
-- Button was just released
-- Only fire click and release events if mouse is still hovering AND element is active
-- (not occluded by another element)
if isHovering and isActiveElement then
self:_handleMouseRelease(element, mx, my, button)
else
-- Mouse left before release OR element is occluded - just clear the pressed state without firing events
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
end
-- After processing events, reset pressed states for buttons that are no longer held
-- This handles the case where mouse leaves while button is held, then released
if not isHovering and not isDragging then
for _, button in ipairs({ 1, 2, 3 }) do
if self._pressed[button] and not love.mouse.isDown(button) then
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_mouse")
end
end
--- Handle mouse button press
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button (1=left, 2=right, 3=middle)
function EventHandler:_handleMousePress(element, mx, my, button)
-- Check if press is on scrollbar first (skip if already handled)
if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress then
if element:_handleScrollbarPress(mx, my, button) then
-- Scrollbar consumed the event, mark as pressed to prevent onEvent
self._pressed[button] = true
self._scrollbarPressHandled = true
return
end
end
-- Fire press event
local modifiers = EventHandler._utils.getModifiers()
local pressEvent = EventHandler._InputEvent.new({
type = "press",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, pressEvent)
self._pressed[button] = true
-- On left click, set keyboard focus to any focusable element (not just editable).
-- Clear the focus indicator since mouse navigation doesn't use it.
local isFocusable
if type(element.isFocusable) == "function" then
isFocusable = element:isFocusable()
else
isFocusable = (element.editable == true)
or (type(element.onEvent) == "function")
or element._selectState ~= nil
or element.selectOption ~= nil
end
if button == 1 and EventHandler._Context and isFocusable then
EventHandler._Context.setFocused(element)
-- Hide focus indicator - it's only for keyboard navigation
if EventHandler._FocusIndicator then
EventHandler._FocusIndicator.setFocused(nil)
end
end
-- Set mouse down position for text selection on left click
if button == 1 and element._textEditor then
element._mouseDownPosition = element._textEditor:mouseToTextPosition(element, mx, my)
element._textDragOccurred = false -- Reset drag flag on press
end
-- Record drag start position per button
self._dragStartX[button] = mx
self._dragStartY[button] = my
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
--- Handle mouse drag (while button is pressed and mouse moves)
---@param element Element The parent element
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
---@param isHovering boolean Whether mouse is over element
function EventHandler:_handleMouseDrag(element, mx, my, button, isHovering)
local lastX = self._lastMouseX[button] or mx
local lastY = self._lastMouseY[button] or my
if lastX ~= mx or lastY ~= my then
-- Handle scrollbar drag if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarDrag then
element:_handleScrollbarDrag(mx, my)
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
return -- Don't process other drag events while dragging scrollbar
end
-- Mouse has moved - fire drag event only if still hovering
if isHovering then
local modifiers = EventHandler._utils.getModifiers()
local dx = mx - self._dragStartX[button]
local dy = my - self._dragStartY[button]
local dragEvent = EventHandler._InputEvent.new({
type = "drag",
button = button,
x = mx,
y = my,
dx = dx,
dy = dy,
modifiers = modifiers,
clickCount = 1,
})
self:_invokeCallback(element, dragEvent)
end
-- Handle text selection drag for editable elements
if button == 1 and element.editable and element._focused and element._handleTextDrag then
element:_handleTextDrag(mx, my)
end
-- Update last known position for this button
self._lastMouseX[button] = mx
self._lastMouseY[button] = my
end
end
--- Handle mouse button release
---@param mx number Mouse X position
---@param my number Mouse Y position
---@param button number Mouse button
function EventHandler:_handleMouseRelease(element, mx, my, button)
local currentTime = love.timer.getTime()
local modifiers = EventHandler._utils.getModifiers()
-- Handle scrollbar release if scrollbar was pressed
if button == 1 and self._scrollbarPressHandled and element._handleScrollbarRelease then
element:_handleScrollbarRelease(button)
self._scrollbarPressHandled = false -- Reset flag
self._pressed[button] = false
self._dragStartX[button] = nil
self._dragStartY[button] = nil
return -- Don't process click events for scrollbar release
end
-- Determine click count (double-click detection)
local clickCount
local doubleClickThreshold = 0.3 -- 300ms for double-click
if
self._lastClickTime
and self._lastClickButton == button
and (currentTime - self._lastClickTime) < doubleClickThreshold
then
clickCount = self._clickCount + 1
else
clickCount = 1
end
self._clickCount = clickCount
self._lastClickTime = currentTime
self._lastClickButton = button
-- Determine event type based on button
local eventType = "click"
if button == 2 then
eventType = "rightclick"
elseif button == 3 then
eventType = "middleclick"
end
-- Fire click event
local clickEvent = EventHandler._InputEvent.new({
type = eventType,
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, clickEvent)
self._pressed[button] = false
-- Clean up drag tracking
self._dragStartX[button] = nil
self._dragStartY[button] = nil
-- Clean up text selection drag tracking
if button == 1 then
element._mouseDownPosition = nil
end
-- Focus editable elements on left click
if button == 1 and element.editable then
-- Only focus if not already focused (to avoid moving cursor to end)
local wasFocused = element:isFocused()
if not wasFocused then
element:focus()
end
-- Handle text click for cursor positioning and word selection
-- Only process click if no text drag occurred (to preserve drag selection)
if element._handleTextClick and not element._textDragOccurred then
element:_handleTextClick(mx, my, clickCount)
end
-- Reset drag flag after release
element._textDragOccurred = false
end
-- Fire release event
local releaseEvent = EventHandler._InputEvent.new({
type = "release",
button = button,
x = mx,
y = my,
modifiers = modifiers,
clickCount = clickCount,
})
self:_invokeCallback(element, releaseEvent)
if button == 1 and element._handleSelectRelease then
element:_handleSelectRelease()
end
end
--- Process touch events in the update cycle
---@param element Element The parent element
function EventHandler:processTouchEvents(element)
-- Start performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:startTimer("event_touch")
end
-- Check if element can process events
local canProcessEvents = (
element.onEvent
or self.onEvent
or element.onTouchEvent
or self.onTouchEvent
or element.editable
)
and not element.disabled
and self.touchEnabled
if not canProcessEvents then
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
return
end
local bx = element.x
local by = element.y
local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Get current active touches from LÖVE
local activeTouches = {}
local touches = love.touch.getTouches()
for _, id in ipairs(touches) do
activeTouches[tostring(id)] = true
end
-- Count active tracked touches for multi-touch filtering
local trackedTouchCount = 0
for _ in pairs(self._touches) do
trackedTouchCount = trackedTouchCount + 1
end
-- Process active touches
for _, id in ipairs(touches) do
local touchId = tostring(id)
local tx, ty = love.touch.getPosition(id)
local pressure = 1.0 -- LÖVE doesn't provide pressure by default
-- Check if touch is within element bounds
local isInside = tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh
if isInside then
if not self._touches[touchId] then
-- Multi-touch filtering: reject new touches when multiTouchEnabled=false
-- and we already have an active touch
if self.multiTouchEnabled or trackedTouchCount == 0 then
-- New touch began
self:_handleTouchBegan(element, touchId, tx, ty, pressure)
trackedTouchCount = trackedTouchCount + 1
end
else
-- Touch moved
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
end
elseif self._touches[touchId] then
-- Touch moved outside or ended
if activeTouches[touchId] then
-- Still active but outside - fire moved event
self:_handleTouchMoved(element, touchId, tx, ty, pressure)
else
-- Touch ended
self:_handleTouchEnded(element, touchId, tx, ty, pressure)
end
end
end
-- Check for ended touches (touches that were tracked but are no longer active)
for touchId, _ in pairs(self._touches) do
if not activeTouches[touchId] then
-- Touch ended or cancelled
local lastPos = self._lastTouchPositions[touchId]
if lastPos then
self:_handleTouchEnded(element, touchId, lastPos.x, lastPos.y, 1.0)
else
-- Cleanup orphaned touch
self:_cleanupTouch(touchId)
end
end
end
-- Stop performance timing
if EventHandler._Performance and EventHandler._Performance.enabled then
EventHandler._Performance:stopTimer("event_touch")
end
end
--- Handle touch began event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchBegan(element, touchId, x, y, pressure)
-- Create touch state
self._touches[touchId] = {
x = x,
y = y,
pressure = pressure,
timestamp = love.timer.getTime(),
phase = "began",
}
-- Record start position
self._touchStartPositions[touchId] = { x = x, y = y }
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Initialize touch history
self._touchHistory[touchId] = { { x = x, y = y, timestamp = love.timer.getTime() } }
-- Create and fire touch press event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "began", pressure)
touchEvent.type = "touchpress"
touchEvent.dx = 0
touchEvent.dy = 0
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
--- Handle touch moved event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchMoved(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local lastPos = self._lastTouchPositions[touchId]
if not lastPos or lastPos.x ~= x or lastPos.y ~= y then
-- Touch position changed
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Update touch state
touchState.x = x
touchState.y = y
touchState.pressure = pressure
touchState.phase = "moved"
-- Update last position
self._lastTouchPositions[touchId] = { x = x, y = y }
-- Add to touch history (keep last 5 positions)
local history = self._touchHistory[touchId] or {}
table.insert(history, { x = x, y = y, timestamp = love.timer.getTime() })
if #history > 5 then
table.remove(history, 1)
end
self._touchHistory[touchId] = history
-- Create and fire touch move event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "moved", pressure)
touchEvent.type = "touchmove"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
end
end
--- Handle touch ended event
---@param element Element The parent element
---@param touchId string Touch identifier
---@param x number Touch X position
---@param y number Touch Y position
---@param pressure number Touch pressure (0-1)
function EventHandler:_handleTouchEnded(element, touchId, x, y, pressure)
local touchState = self._touches[touchId]
if not touchState then
-- Touch not tracked, ignore
return
end
local startPos = self._touchStartPositions[touchId]
local dx = x - startPos.x
local dy = y - startPos.y
-- Create and fire touch release event
local touchEvent = EventHandler._InputEvent.fromTouch(touchId, x, y, "ended", pressure)
touchEvent.type = "touchrelease"
touchEvent.dx = dx
touchEvent.dy = dy
self:_invokeCallback(element, touchEvent)
self:_invokeTouchCallback(element, touchEvent)
-- Cleanup touch state
self:_cleanupTouch(touchId)
end
--- Cleanup touch state
---@param touchId string Touch ID
function EventHandler:_cleanupTouch(touchId)
self._touches[touchId] = nil
self._touchStartPositions[touchId] = nil
self._lastTouchPositions[touchId] = nil
self._touchHistory[touchId] = nil
end
--- Get active touches on this element
---@return table<string, table> Active touches
function EventHandler:getActiveTouches()
return self._touches
end
--- Reset scrollbar press flag (called each frame)
function EventHandler:resetScrollbarPressFlag()
self._scrollbarPressHandled = false
end
--- Check if any mouse button is pressed
---@return boolean True if any button is pressed
function EventHandler:isAnyButtonPressed()
for _, pressed in pairs(self._pressed) do
if pressed then
return true
end
end
return false
end
--- Check if a specific button is pressed
---@param button number Mouse button (1=left, 2=right, 3=middle)
---@return boolean True if button is pressed
function EventHandler:isButtonPressed(button)
return self._pressed[button] == true
end
--- Invoke the onEvent callback, optionally deferring it if onEventDeferred is true
---@param element Element The element that triggered the event
---@param event InputEvent The event data
function EventHandler:_invokeCallback(element, event)
-- Read onEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onEvent or self.onEvent
if not callback then
return
end
if self.onEventDeferred then
-- Get FlexLove module to defer the callback
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onTouchEvent callback, optionally deferring it
---@param element Element The element that triggered the event
---@param event InputEvent The touch event data
function EventHandler:_invokeTouchCallback(element, event)
-- Read onTouchEvent from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onTouchEvent or self.onTouchEvent
if not callback then
return
end
if self.onTouchEventDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, event)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
eventType = event.type,
})
end
else
callback(element, event)
end
end
--- Invoke the onGesture callback, optionally deferring it
---@param element Element The element that triggered the event
---@param gesture table The gesture data from GestureRecognizer
function EventHandler:_invokeGestureCallback(element, gesture)
-- Read onGesture from element (source of truth), fallback to handler cache for backwards compat
local callback = element.onGesture or self.onGesture
if not callback then
return
end
if self.onGestureDeferred then
local FlexLove = package.loaded["FlexLove"] or package.loaded["libs.FlexLove"]
if FlexLove and FlexLove.deferCallback then
FlexLove.deferCallback(function()
callback(element, gesture)
end)
else
EventHandler._ErrorHandler:error("EventHandler", "SYS_003", {
gestureType = gesture.type,
})
end
else
callback(element, gesture)
end
end
return EventHandler
+232
View File
@@ -0,0 +1,232 @@
local packageName = ... or "FocusIndicator"
local modulePath = packageName:match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local FocusIndicator = {}
--- Configuration
---@type KeyboardNavigationFocusIndicatorConfig
FocusIndicator.config = {
enabled = true,
--- Custom draw function to override default rendering
---@type function|nil
--- Called with: element, bounds, style - return true to skip default drawing
draw = nil,
-- Appearance
color = { 0.2, 0.6, 1.0, 0.8 }, -- Blue with 80% opacity
lineWidth = 2,
inset = -3, -- Negative value extends beyond element
borderRadius = 4,
-- Animation
animationDuration = 0.15, -- Seconds for focus animation
pulseEnabled = false, -- Enable pulsing animation
pulseDuration = 1.0, -- Seconds per pulse cycle
pulseScaleMin = 0.95, -- Minimum scale during pulse
pulseScaleMax = 1.05, -- Maximum scale during pulse
}
--- State
FocusIndicator._focusedElement = nil
FocusIndicator._animationProgress = 0
FocusIndicator._pulsePhase = 0
FocusIndicator._hidden = true
FocusIndicator._deps = nil
--- Initialize FocusIndicator module
---@param deps table Dependencies table containing Context and Color modules
---@field deps.Context table Context module for getting focused element
---@field deps.Color table Color module for color manipulation
function FocusIndicator.init(deps)
FocusIndicator._deps = deps
FocusIndicator._Context = deps.Context
FocusIndicator._Color = deps.Color
end
--- Update animation state for entrance and pulse effects
---@param dt number Delta time in seconds since last frame
function FocusIndicator:update(dt)
if not FocusIndicator.config.enabled then
return
end
-- Update focus entrance animation
if FocusIndicator._animationProgress < 1 then
FocusIndicator._animationProgress =
math.min(1, FocusIndicator._animationProgress + (dt / FocusIndicator.config.animationDuration))
end
-- Update pulse animation
if FocusIndicator.config.pulseEnabled then
FocusIndicator._pulsePhase = (FocusIndicator._pulsePhase + dt) % FocusIndicator.config.pulseDuration
end
end
--- Set the focused element to render indicator around
---@param element Element? The element to show focus indicator around, or nil to hide
function FocusIndicator.setFocused(element)
FocusIndicator._focusedElement = element
FocusIndicator._hidden = element == nil
-- Reset animation when focus changes
if element then
FocusIndicator._animationProgress = 0
end
end
--- Get the current scale factor for animations
--- Combines entrance scale (0.8 to 1.0) with optional pulse scale
---@return number Scale factor (typically 0.8-1.05 range)
function FocusIndicator:getScale()
local scale = 1
-- Apply entrance animation (scale up from 0.8)
local entranceScale = 0.8 + (0.2 * FocusIndicator._animationProgress)
scale = scale * entranceScale
-- Apply pulse animation
if FocusIndicator.config.pulseEnabled then
local pulseProgress = FocusIndicator._pulsePhase / FocusIndicator.config.pulseDuration
-- Smooth sine wave pulse
local pulseScale = FocusIndicator.config.pulseScaleMin
+ (FocusIndicator.config.pulseScaleMax - FocusIndicator.config.pulseScaleMin)
* (0.5 + 0.5 * math.sin(2 * math.pi * pulseProgress))
scale = scale * pulseScale
end
return scale
end
--- Get the current opacity for the indicator
--- Applies entrance animation fade-in to the configured alpha
---@return number Alpha value (0-1 range)
function FocusIndicator:getOpacity()
-- Fade in on focus
return FocusIndicator.config.color[4] * FocusIndicator._animationProgress
end
--- Draw the focus indicator around the focused element
--- Renders a rounded rectangle border, or calls custom draw function if configured
--- Should be called from within love.draw() after all elements are drawn
function FocusIndicator:draw()
if not FocusIndicator.config.enabled then
return
end
if FocusIndicator._hidden then
return
end
-- In immediate mode the stored element reference is stale (recreated every frame).
-- Always resolve through Context so we get the live object with up-to-date positions.
local element
if FocusIndicator._Context then
element = FocusIndicator._Context.getFocused()
else
element = FocusIndicator._focusedElement
end
if not element then
return
end
-- Get element dimensions (use border-box size which includes padding)
local x = element.x or 0
local y = element.y or 0
local w = element._borderBoxWidth
or (element.width + (element.padding and (element.padding.left + element.padding.right) or 0))
local h = element._borderBoxHeight
or (element.height + (element.padding and (element.padding.top + element.padding.bottom) or 0))
if w == 0 or h == 0 then
return
end
-- Calculate indicator dimensions with inset and scale
local inset = FocusIndicator.config.inset
local scale = self:getScale()
local indicatorX = x + inset
local indicatorY = y + inset
local indicatorW = w - 2 * inset
local indicatorH = h - 2 * inset
-- Center the scale around the element
local offsetX = (indicatorW * (1 - scale)) / 2
local offsetY = (indicatorH * (1 - scale)) / 2
indicatorX = indicatorX + offsetX
indicatorY = indicatorY + offsetY
indicatorW = indicatorW * scale
indicatorH = indicatorH * scale
-- Get color with animated opacity
local r, g, b = FocusIndicator.config.color[1], FocusIndicator.config.color[2], FocusIndicator.config.color[3]
local a = self:getOpacity()
-- Build style table for custom draw callback
local bounds = {
x = indicatorX,
y = indicatorY,
width = indicatorW,
height = indicatorH,
}
local style = {
color = { r = r, g = g, b = b, a = a },
lineWidth = FocusIndicator.config.lineWidth,
borderRadius = FocusIndicator.config.borderRadius,
scale = scale,
opacity = a,
}
-- Check for custom draw callback
if FocusIndicator.config.draw then
local skipDefault = FocusIndicator.config.draw(element, bounds, style)
if skipDefault then
return
end
end
-- Save current love.graphics state
local prevBlend, prevAlphaMode = love.graphics.getBlendMode()
local prevR, prevG, prevB, prevA = love.graphics.getColor()
local prevLineWidth = love.graphics.getLineWidth()
-- Set blend mode for transparency
love.graphics.setBlendMode("alpha")
-- Draw rounded rectangle border
love.graphics.setColor(r, g, b, a)
love.graphics.setLineWidth(FocusIndicator.config.lineWidth)
-- Draw the rounded rectangle border
local borderRadius = FocusIndicator.config.borderRadius
love.graphics.rectangle("line", indicatorX, indicatorY, indicatorW, indicatorH, borderRadius)
-- Restore love.graphics state
love.graphics.setBlendMode(prevBlend, prevAlphaMode)
love.graphics.setColor(prevR, prevG, prevB, prevA)
love.graphics.setLineWidth(prevLineWidth)
end
--- Set the indicator color
---@param r number Red component (0-1 range)
---@param g number Green component (0-1 range)
---@param b number Blue component (0-1 range)
---@param a number|nil Alpha component (0-1 range), defaults to current alpha if omitted
function FocusIndicator.setColor(r, g, b, a)
FocusIndicator.config.color = { r, g, b, a or FocusIndicator.config.color[4] }
end
--- Set the stroke width for the indicator border
---@param width number Line width in pixels
function FocusIndicator.setLineWidth(width)
FocusIndicator.config.lineWidth = width
end
return FocusIndicator
+269
View File
@@ -0,0 +1,269 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Font cache with LRU eviction, font resolution, and cache management.
-- `ErrorHandler` and `resolveImagePath` are injected via init() to avoid
-- a cross-import into utils (utils re-exports the cache via aliases).
-- Font cache with LRU eviction
local FONT_CACHE = {}
local FONT_CACHE_MAX_SIZE = 50
local FONT_CACHE_STATS = {
hits = 0,
misses = 0,
evictions = 0,
size = 0,
}
local ErrorHandler = nil
local resolveImagePath = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler, resolveImagePath = function }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
resolveImagePath = deps.resolveImagePath
end
end
-- LRU tracking: each entry has {font, lastUsed, accessCount}
local function updateCacheAccess(cacheKey)
local entry = FONT_CACHE[cacheKey]
if entry then
entry.lastUsed = love.timer.getTime()
entry.accessCount = entry.accessCount + 1
end
end
local function evictLRU()
local oldestKey = nil
local oldestTime = math.huge
for key, entry in pairs(FONT_CACHE) do
-- Skip methods (get, getFont) - only evict cache entries (tables with lastUsed)
if type(entry) == "table" and entry.lastUsed then
if entry.lastUsed < oldestTime then
oldestTime = entry.lastUsed
oldestKey = key
end
end
end
if oldestKey then
FONT_CACHE[oldestKey] = nil
FONT_CACHE_STATS.evictions = FONT_CACHE_STATS.evictions + 1
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size - 1
end
end
--- Create or get a font from cache
---@param size number
---@param fontPath string?
---@return love.Font
function FONT_CACHE.get(size, fontPath)
-- Bucket font sizes for better cache reuse (reduces unique cache entries)
-- Small sizes (< 20): round to nearest 2
-- Medium sizes (20-40): round to nearest 4
-- Large sizes (> 40): round to nearest 8
if size < 20 then
size = math.floor((size + 1) / 2) * 2
elseif size < 40 then
size = math.floor((size + 2) / 4) * 4
else
size = math.floor((size + 4) / 8) * 8
end
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if FONT_CACHE[cacheKey] then
-- Cache hit
FONT_CACHE_STATS.hits = FONT_CACHE_STATS.hits + 1
updateCacheAccess(cacheKey)
return FONT_CACHE[cacheKey].font
end
-- Cache miss
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
if ErrorHandler then
ErrorHandler:warn("utils", "RES_004", {
resourceType = "font",
path = fontPath,
})
end
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
-- Add to cache with LRU metadata
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
return font
end
--- Get font for text size (cached)
---@param textSize number?
---@param fontPath string?
---@return love.Font
function FONT_CACHE.getFont(textSize, fontPath)
if textSize then
return FONT_CACHE.get(textSize, fontPath)
else
return love.graphics.getFont()
end
end
-- Font resolution utilities
--- Resolve font path from fontFamily and theme
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return string? Resolved font path or nil
local function resolveFontPath(fontFamily, themeComponent, themeManager)
if fontFamily then
-- Check if fontFamily is a theme font name
local themeToUse = themeManager and themeManager:getTheme()
if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then
return themeToUse.fonts[fontFamily]
else
-- Treat as direct path to font file
return fontFamily
end
elseif themeComponent and themeManager then
-- If using themeComponent but no fontFamily specified, check for default font in theme
return themeManager:getDefaultFontFamily()
end
return nil
end
--- Get font for element (resolves from theme or fontFamily)
---@param textSize number? Text size in pixels
---@param fontFamily string? Font family name or direct path
---@param themeComponent string? Theme component name
---@param themeManager table? ThemeManager instance
---@return love.Font
local function getFont(textSize, fontFamily, themeComponent, themeManager)
local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager)
return FONT_CACHE.getFont(textSize, fontPath)
end
-- Font cache management
--- Get font cache statistics
---@return table stats {hits, misses, evictions, size, hitRate}
local function getFontCacheStats()
local total = FONT_CACHE_STATS.hits + FONT_CACHE_STATS.misses
local hitRate = total > 0 and (FONT_CACHE_STATS.hits / total) or 0
return {
hits = FONT_CACHE_STATS.hits,
misses = FONT_CACHE_STATS.misses,
evictions = FONT_CACHE_STATS.evictions,
size = FONT_CACHE_STATS.size,
hitRate = hitRate,
}
end
--- Set maximum font cache size
---@param maxSize number Maximum number of fonts to cache
local function setFontCacheSize(maxSize)
FONT_CACHE_MAX_SIZE = math.max(1, maxSize)
-- Evict entries if cache is now over limit
while FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE do
evictLRU()
end
end
--- Clear font cache
local function clearFontCache()
-- Clear cache entries but preserve methods (get, getFont)
for key, entry in pairs(FONT_CACHE) do
if type(entry) == "table" and entry.lastUsed then
FONT_CACHE[key] = nil
end
end
FONT_CACHE_STATS.size = 0
FONT_CACHE_STATS.evictions = 0
end
--- Preload font at multiple sizes
---@param fontPath string? Path to font file (nil for default font)
---@param sizes table Array of font sizes to preload
local function preloadFont(fontPath, sizes)
for _, size in ipairs(sizes) do
-- Round size to reduce cache entries
size = math.floor(size + 0.5)
local cacheKey = fontPath and (fontPath .. ":" .. tostring(size)) or ("default:" .. tostring(size))
if not FONT_CACHE[cacheKey] then
local font
if fontPath then
local resolvedPath = resolveImagePath(fontPath)
local success, result = pcall(love.graphics.newFont, resolvedPath, size)
if success then
font = result
else
font = love.graphics.newFont(size)
end
else
font = love.graphics.newFont(size)
end
FONT_CACHE[cacheKey] = {
font = font,
lastUsed = love.timer.getTime(),
accessCount = 1,
}
FONT_CACHE_STATS.size = FONT_CACHE_STATS.size + 1
FONT_CACHE_STATS.misses = FONT_CACHE_STATS.misses + 1
-- Evict if cache is full
if FONT_CACHE_STATS.size > FONT_CACHE_MAX_SIZE then
evictLRU()
end
end
end
end
--- Reset font cache statistics
local function resetFontCacheStats()
FONT_CACHE_STATS.hits = 0
FONT_CACHE_STATS.misses = 0
FONT_CACHE_STATS.evictions = 0
end
return {
FONT_CACHE = FONT_CACHE,
init = init,
resolveFontPath = resolveFontPath,
getFont = getFont,
getFontCacheStats = getFontCacheStats,
setFontCacheSize = setFontCacheSize,
clearFontCache = clearFontCache,
preloadFont = preloadFont,
resetFontCacheStats = resetFontCacheStats,
}
+583
View File
@@ -0,0 +1,583 @@
---@class GestureRecognizer
---@field _touches table<string, table> -- Current touch states
---@field _gestureStates table -- Active gesture states
---@field _config table -- Gesture configuration (thresholds, etc.)
---@field _InputEvent table
---@field _utils table
local GestureRecognizer = {}
GestureRecognizer.__index = GestureRecognizer
-- Gesture types enum
local GestureType = {
TAP = "tap",
DOUBLE_TAP = "double_tap",
LONG_PRESS = "long_press",
SWIPE = "swipe",
PAN = "pan",
PINCH = "pinch",
ROTATE = "rotate",
}
-- Gesture states
local GestureState = {
POSSIBLE = "possible",
BEGAN = "began",
CHANGED = "changed",
ENDED = "ended",
CANCELLED = "cancelled",
FAILED = "failed",
}
-- Default configuration
local defaultConfig = {
-- Tap gesture
tapMaxDuration = 0.3, -- seconds
tapMaxMovement = 10, -- pixels
-- Double-tap gesture
doubleTapInterval = 0.3, -- seconds between taps
-- Long-press gesture
longPressMinDuration = 0.5, -- seconds
longPressMaxMovement = 10, -- pixels
-- Swipe gesture
swipeMinDistance = 50, -- pixels
swipeMaxDuration = 0.2, -- seconds
swipeMinVelocity = 200, -- pixels per second
-- Pan gesture
panMinMovement = 5, -- pixels to start pan
-- Pinch gesture
pinchMinScaleChange = 0.1, -- 10% scale change
-- Rotate gesture
rotateMinAngleChange = 5, -- degrees
}
--- Create a new GestureRecognizer instance
---@param config table? Optional configuration options
---@param deps table Dependencies {InputEvent, utils}
---@return GestureRecognizer
function GestureRecognizer.new(config, deps)
config = config or {}
local self = setmetatable({}, GestureRecognizer)
self._InputEvent = deps.InputEvent
self._utils = deps.utils
-- Merge configuration with defaults
self._config = {}
for key, value in pairs(defaultConfig) do
self._config[key] = config[key] or value
end
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = {},
rotate = {},
}
return self
end
--- Update gesture recognizer with touch event
---@param event InputEvent Touch event
function GestureRecognizer:processTouchEvent(event)
if not event.touchId then
return nil
end
local touchId = event.touchId
local gestures = {}
-- Update touch state
if event.type == "touchpress" then
self._touches[touchId] = {
startX = event.x,
startY = event.y,
x = event.x,
y = event.y,
startTime = event.timestamp,
lastTime = event.timestamp,
phase = "began",
}
-- Initialize gesture detection
self:_detectTapBegan(touchId, event)
self:_detectLongPressBegan(touchId, event)
elseif event.type == "touchmove" then
local touch = self._touches[touchId]
if touch then
touch.x = event.x
touch.y = event.y
touch.lastTime = event.timestamp
touch.phase = "moved"
-- Update gesture detection
local panGesture = self:_detectPan(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
local swipeGesture = self:_detectSwipe(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
-- Multi-touch gestures
if self:_getTouchCount() >= 2 then
local pinchGesture = self:_detectPinch(event)
if pinchGesture then
table.insert(gestures, pinchGesture)
end
local rotateGesture = self:_detectRotate(event)
if rotateGesture then
table.insert(gestures, rotateGesture)
end
end
end
elseif event.type == "touchrelease" then
local touch = self._touches[touchId]
if touch then
touch.phase = "ended"
-- Finalize gesture detection
local tapGesture = self:_detectTapEnded(touchId, event)
if tapGesture then
table.insert(gestures, tapGesture)
end
local swipeGesture = self:_detectSwipeEnded(touchId, event)
if swipeGesture then
table.insert(gestures, swipeGesture)
end
local panGesture = self:_detectPanEnded(touchId, event)
if panGesture then
table.insert(gestures, panGesture)
end
-- Cleanup touch
self._touches[touchId] = nil
end
elseif event.type == "touchcancel" then
-- Cancel all active gestures for this touch
self._touches[touchId] = nil
self:_cancelAllGestures()
end
return #gestures > 0 and gestures or nil
end
--- Get number of active touches
---@return number
function GestureRecognizer:_getTouchCount()
local count = 0
for _ in pairs(self._touches) do
count = count + 1
end
return count
end
--- Detect tap gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapBegan(touchId, event)
-- Tap detection happens on touch end
-- Just record the touch for now
end
--- Detect tap gesture ended
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectTapEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid tap
if duration < self._config.tapMaxDuration and distance < self._config.tapMaxMovement then
local currentTime = event.timestamp
local doubleTapState = self._gestureStates.doubleTap
-- Check for double-tap
if currentTime - doubleTapState.lastTapTime < self._config.doubleTapInterval then
doubleTapState.tapCount = doubleTapState.tapCount + 1
if doubleTapState.tapCount >= 2 then
-- Fire double-tap gesture
return {
type = GestureType.DOUBLE_TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
else
doubleTapState.tapCount = 1
end
doubleTapState.lastTapTime = currentTime
-- Fire tap gesture
return {
type = GestureType.TAP,
state = GestureState.ENDED,
x = event.x,
y = event.y,
timestamp = event.timestamp,
}
end
end
--- Detect long-press gesture began
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectLongPressBegan(touchId, event)
-- Long-press detection happens continuously during touch
self._gestureStates.longPress[touchId] = {
startX = event.x,
startY = event.y,
startTime = event.timestamp,
triggered = false,
}
end
--- Detect pan gesture
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPan(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
local panState = self._gestureStates.pan[touchId]
if not panState then
-- Check if pan should begin
if distance >= self._config.panMinMovement then
self._gestureStates.pan[touchId] = {
active = true,
lastX = touch.startX,
lastY = touch.startY,
}
panState = self._gestureStates.pan[touchId]
return {
type = GestureType.PAN,
state = GestureState.BEGAN,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
else
-- Pan is active, fire changed event
local panDx = event.x - panState.lastX
local panDy = event.y - panState.lastY
panState.lastX = event.x
panState.lastY = event.y
return {
type = GestureType.PAN,
state = GestureState.CHANGED,
x = event.x,
y = event.y,
dx = panDx,
dy = panDy,
totalDx = dx,
totalDy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect pan ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPanEnded(touchId, event)
local panState = self._gestureStates.pan[touchId]
if panState and panState.active then
self._gestureStates.pan[touchId] = nil
local touch = self._touches[touchId]
local dx = event.x - touch.startX
local dy = event.y - touch.startY
return {
type = GestureType.PAN,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect swipe gesture
---@param touchId string
---@param event InputEvent
function GestureRecognizer:_detectSwipe(touchId, event)
-- Swipe detection happens on touch end
end
--- Detect swipe ended
---@param touchId string
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectSwipeEnded(touchId, event)
local touch = self._touches[touchId]
if not touch then
return nil
end
local duration = event.timestamp - touch.startTime
local dx = event.x - touch.startX
local dy = event.y - touch.startY
local distance = math.sqrt(dx * dx + dy * dy)
-- Check if it's a valid swipe
if distance >= self._config.swipeMinDistance and duration <= self._config.swipeMaxDuration then
local velocity = distance / duration
if velocity >= self._config.swipeMinVelocity then
-- Determine swipe direction
local angle = math.atan2(dy, dx)
local direction = "right"
if angle >= -math.pi / 4 and angle < math.pi / 4 then
direction = "right"
elseif angle >= math.pi / 4 and angle < 3 * math.pi / 4 then
direction = "down"
elseif angle >= -3 * math.pi / 4 and angle < -math.pi / 4 then
direction = "up"
else
direction = "left"
end
return {
type = GestureType.SWIPE,
state = GestureState.ENDED,
x = event.x,
y = event.y,
dx = dx,
dy = dy,
direction = direction,
velocity = velocity,
timestamp = event.timestamp,
}
end
end
return nil
end
--- Detect pinch gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectPinch(event)
-- Get two touches for pinch
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current distance
local currentDx = t2.x - t1.x
local currentDy = t2.y - t1.y
local currentDistance = math.sqrt(currentDx * currentDx + currentDy * currentDy)
-- Calculate initial distance
local initialDx = t2.startX - t1.startX
local initialDy = t2.startY - t1.startY
local initialDistance = math.sqrt(initialDx * initialDx + initialDy * initialDy)
if initialDistance == 0 then
return nil
end
-- Calculate scale
local scale = currentDistance / initialDistance
local pinchState = self._gestureStates.pinch
if not pinchState.active then
-- Check if pinch should begin
if math.abs(scale - 1.0) >= self._config.pinchMinScaleChange then
pinchState.active = true
pinchState.initialScale = scale
pinchState.lastScale = scale
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.PINCH,
state = GestureState.BEGAN,
scale = scale,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Pinch is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local scaleChange = scale - pinchState.lastScale
pinchState.lastScale = scale
return {
type = GestureType.PINCH,
state = GestureState.CHANGED,
scale = scale,
scaleChange = scaleChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Detect rotate gesture
---@param event InputEvent
---@return table? Gesture event
function GestureRecognizer:_detectRotate(event)
-- Get two touches for rotation
local touches = {}
for touchId, touch in pairs(self._touches) do
table.insert(touches, { id = touchId, touch = touch })
if #touches >= 2 then
break
end
end
if #touches < 2 then
return nil
end
local t1 = touches[1].touch
local t2 = touches[2].touch
-- Calculate current angle
local currentAngle = math.atan2(t2.y - t1.y, t2.x - t1.x)
-- Calculate initial angle
local initialAngle = math.atan2(t2.startY - t1.startY, t2.startX - t1.startX)
-- Calculate rotation (in degrees)
local rotation = (currentAngle - initialAngle) * 180 / math.pi
local rotateState = self._gestureStates.rotate
if not rotateState.active then
-- Check if rotation should begin
if math.abs(rotation) >= self._config.rotateMinAngleChange then
rotateState.active = true
rotateState.initialRotation = rotation
rotateState.lastRotation = rotation
-- Calculate center point
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
return {
type = GestureType.ROTATE,
state = GestureState.BEGAN,
rotation = rotation,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
else
-- Rotation is active, fire changed event
local centerX = (t1.x + t2.x) / 2
local centerY = (t1.y + t2.y) / 2
local rotationChange = rotation - rotateState.lastRotation
rotateState.lastRotation = rotation
return {
type = GestureType.ROTATE,
state = GestureState.CHANGED,
rotation = rotation,
rotationChange = rotationChange,
centerX = centerX,
centerY = centerY,
timestamp = event.timestamp,
}
end
return nil
end
--- Cancel all active gestures
function GestureRecognizer:_cancelAllGestures()
for gestureType, state in pairs(self._gestureStates) do
if type(state) == "table" and state.active then
state.active = false
end
end
end
--- Reset gesture recognizer state
function GestureRecognizer:reset()
self._touches = {}
self._gestureStates = {
tap = nil,
doubleTap = { lastTapTime = 0, tapCount = 0 },
longPress = {},
swipe = {},
pan = {},
pinch = { active = false },
rotate = { active = false },
}
end
-- Export gesture types and states
GestureRecognizer.GestureType = GestureType
GestureRecognizer.GestureState = GestureState
return GestureRecognizer
+336
View File
@@ -0,0 +1,336 @@
local modulePath = (...):match("(.-)[^%.]+$")
local utils = require(modulePath .. "utils")
local enums = utils.enums
local Units = require(modulePath .. "Units")
local Positioning = enums.Positioning
local AlignItems = enums.AlignItems
--- Grid layout with variable column widths / row heights
--- Supports px, %, fr, auto, vw, vh, and calc track sizes
local Grid = {}
--- Parse a single track spec into {type, value}
--- Uses the Units pipeline for standard CSS units (px, %, vw, vh, calc).
--- Grid-specific types (fr, auto) are handled directly.
---@param spec number|string Track specification: number (px), string ("100px", "50%", "10vw", "1fr", "auto")
---@param availableSize number Container size for % resolution
---@param viewportWidth number Viewport width for vw resolution
---@param viewportHeight number Viewport height for vh resolution
---@return table {type: "px"|"fr"|"auto", value: number}
function Grid._parseTrack(spec, availableSize, viewportWidth, viewportHeight)
-- Handle calc objects (tables with _isCalc flag from FlexLove.calc())
if type(spec) == "table" then
local resolved = Units.resolve(spec, "calc", viewportWidth, viewportHeight, availableSize)
return { type = "px", value = resolved }
end
if type(spec) == "number" then
return { type = "px", value = spec }
end
if type(spec) == "string" then
if spec == "auto" then
return { type = "auto", value = 0 }
end
-- Check for fr unit (grid-specific, not in Units pipeline)
local numStr, unit = spec:match("^([%-]?[%d%.]+)(.*)$")
if numStr and unit == "fr" then
local num = tonumber(numStr)
if num then
return { type = "fr", value = num }
end
end
-- Delegate all other units to the Units pipeline (px, %, vw, vh, calc)
local parsedVal, parsedUnit = Units.parse(spec)
local resolved = Units.resolve(parsedVal, parsedUnit, viewportWidth, viewportHeight, availableSize)
return { type = "px", value = resolved }
end
-- Default: 1fr
return { type = "fr", value = 1 }
end
--- Build track list from gridColumns/gridRows or fall back to equal 1fr tracks
---@param spec number|table? Track count (number = equal 1fr tracks) or array of track specs (e.g., {"1fr", "2fr", "100px"})
---@param availableSize number Container size for % resolution
---@param viewportWidth number Viewport width for vw resolution
---@param viewportHeight number Viewport height for vh resolution
---@return table Array of {type, value} track descriptors
function Grid._buildTracks(spec, availableSize, viewportWidth, viewportHeight)
if type(spec) == "table" and #spec > 0 then
local tracks = {}
for i, s in ipairs(spec) do
tracks[i] = Grid._parseTrack(s, availableSize, viewportWidth, viewportHeight)
end
return tracks
end
-- Fallback: equal 1fr tracks
local count = (type(spec) == "number" and spec > 0) and spec or 1
local tracks = {}
for i = 1, count do
tracks[i] = { type = "fr", value = 1 }
end
return tracks
end
--- Measure intrinsic content sizes for auto tracks
--- Maps children to their tracks and computes each child's max-content contribution.
--- For children with explicit dimensions (units unit ~= "auto"), uses the original
--- explicit size. For auto-sized children, uses calculated content size.
--- Stores the max per auto track. Matches CSS Grid auto sizing where tracks size
--- to the max-content contribution of their grid items.
---@param tracks table Array of {type, value} track descriptors
---@param children table Array of grid child elements
---@param axis "width"|"height" Dimension axis to measure
function Grid._measureAutoTracks(tracks, children, axis)
local trackSizes = {}
local numTracks = #tracks
for i, child in ipairs(children) do
local index = i - 1
local trackIdx = (index % numTracks) + 1
local intrinsicSize
if axis == "width" then
local unit = child.units and child.units.width and child.units.width.unit
if unit and unit ~= "auto" then
-- Explicit width: use original value + padding (not stretched border-box)
intrinsicSize = (child.units.width.value or 0) + child.padding.left + child.padding.right
else
-- Auto-sized: use calculated content size
intrinsicSize = child:calculateAutoWidth()
end
else
local unit = child.units and child.units.height and child.units.height.unit
if unit and unit ~= "auto" then
intrinsicSize = (child.units.height.value or 0) + child.padding.top + child.padding.bottom
else
intrinsicSize = child:calculateAutoHeight()
end
end
if intrinsicSize > 0 then
trackSizes[trackIdx] = math.max(trackSizes[trackIdx] or 0, intrinsicSize)
end
end
-- Apply measured sizes to auto tracks
for i, track in ipairs(tracks) do
if track.type == "auto" and trackSizes[i] then
track.value = trackSizes[i]
end
end
end
--- Resolve track sizes: auto (content) first, then px (fixed), then fr (remaining)
--- CSS Grid algorithm:
--- 1. auto tracks size to their content (max-content) — measured by _measureAutoTracks
--- 2. px tracks consume their fixed size
--- 3. fr tracks consume remaining free space proportionally
--- 4. If no fr tracks exist, auto tracks share remaining space equally
--- Mutates tracks in-place, converting all to {type="px", value=number}
---@param tracks table Array of {type, value} track descriptors
---@param availableSize number Total space available for tracks
---@param gap number Gap between tracks
function Grid._resolveTracks(tracks, availableSize, gap)
local count = #tracks
local totalGaps = (count > 1 and (count - 1) * gap) or 0
local remaining = math.max(0, availableSize - totalGaps)
-- Pass 1: Treat auto tracks as fixed (content-measured) and subtract
for _, track in ipairs(tracks) do
if track.type == "px" then
remaining = remaining - track.value
elseif track.type == "auto" then
remaining = remaining - math.max(0, track.value)
end
end
remaining = math.max(0, remaining)
-- Pass 2: Count fr shares
local totalFr = 0
local autoCount = 0
for _, track in ipairs(tracks) do
if track.type == "fr" then
totalFr = totalFr + track.value
elseif track.type == "auto" then
autoCount = autoCount + 1
end
end
-- Pass 3: Distribute remaining space
if totalFr > 0 then
-- fr tracks consume all remaining free space
local frUnit = remaining / totalFr
for _, track in ipairs(tracks) do
if track.type == "fr" then
track.value = frUnit * track.value
track.type = "px"
end
end
elseif autoCount > 0 then
-- No fr tracks: auto tracks share remaining space equally (grow beyond content)
local extraPerAuto = math.max(0, remaining) / autoCount
for _, track in ipairs(tracks) do
if track.type == "auto" then
track.value = track.value + extraPerAuto
track.type = "px"
end
end
end
end
--- Layout grid items within a grid container
--- Supports variable column widths and row heights via gridColumns/gridRows (number or track specs)
--- Falls back to equal-sized 1fr tracks when nil
---@param element Element -- Grid container element
function Grid.layoutGridItems(element)
-- Calculate space reserved by absolutely positioned siblings
local reservedLeft = 0
local reservedRight = 0
local reservedTop = 0
local reservedBottom = 0
for _, child in ipairs(element.children) do
-- Only consider absolutely positioned children with explicit positioning and display != false
if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute and child.display ~= false then
-- BORDER-BOX MODEL: Use border-box dimensions for space calculations
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
if child.left then
reservedLeft = math.max(reservedLeft, child.left + childBorderBoxWidth)
end
if child.right then
reservedRight = math.max(reservedRight, child.right + childBorderBoxWidth)
end
if child.top then
reservedTop = math.max(reservedTop, child.top + childBorderBoxHeight)
end
if child.bottom then
reservedBottom = math.max(reservedBottom, child.bottom + childBorderBoxHeight)
end
end
end
-- Calculate available space (accounting for padding and reserved space)
-- BORDER-BOX MODEL: element.width and element.height are already content dimensions
local availableWidth = math.max(0, element.width - reservedLeft - reservedRight)
local availableHeight = math.max(0, element.height - reservedTop - reservedBottom)
-- Get gaps
local columnGap = element.columnGap or 0
local rowGap = element.rowGap or 0
-- Collect grid children (exclude explicitly absolute and display=false)
local gridChildren = {}
for _, child in ipairs(element.children) do
if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) and child.display ~= false then
table.insert(gridChildren, child)
end
end
-- Get viewport dimensions for unit resolution (vw, vh, %)
local vpw, vph = Units.getViewport()
-- Build tracks, measure auto tracks by content, then resolve sizes
local colTracks = Grid._buildTracks(element.gridColumns, availableWidth, vpw, vph)
local rowTracks = Grid._buildTracks(element.gridRows, availableHeight, vpw, vph)
Grid._measureAutoTracks(colTracks, gridChildren, "width")
Grid._measureAutoTracks(rowTracks, gridChildren, "height")
Grid._resolveTracks(colTracks, availableWidth, columnGap)
Grid._resolveTracks(rowTracks, availableHeight, rowGap)
-- Compute column start positions (for positioning)
local colStarts = {}
local currentX = element.x + element.padding.left + reservedLeft
for col = 1, #colTracks do
colStarts[col] = currentX
currentX = currentX + colTracks[col].value + columnGap
end
local rowStarts = {}
local currentY = element.y + element.padding.top + reservedTop
for row = 1, #rowTracks do
rowStarts[row] = currentY
currentY = currentY + rowTracks[row].value + rowGap
end
local effectiveAlignItems = element.alignItems or AlignItems.STRETCH
for i, child in ipairs(gridChildren) do
-- Calculate row and column (0-indexed for calculation)
local index = i - 1
local col = index % #colTracks
local row = math.floor(index / #colTracks)
if row >= #rowTracks then
break
end
-- Get resolved cell position and size
local colIdx = col + 1
local rowIdx = row + 1
local cellX = colStarts[colIdx]
local cellY = rowStarts[rowIdx]
local cellWidth = colTracks[colIdx].value
local cellHeight = rowTracks[rowIdx].value
-- Apply alignment within grid cell (default to stretch)
-- BORDER-BOX MODEL: Set border-box dimensions, content area adjusts automatically
if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then
child.x = cellX
child.y = cellY
child._borderBoxWidth = cellWidth
child._borderBoxHeight = cellHeight
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
-- Disable auto-sizing when stretched by grid
child.autosizing.width = false
child.autosizing.height = false
elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
child.x = cellX + (cellWidth - childBorderBoxWidth) / 2
child.y = cellY + (cellHeight - childBorderBoxHeight) / 2
elseif
effectiveAlignItems == AlignItems.FLEX_START
or effectiveAlignItems == "flex-start"
or effectiveAlignItems == "start"
then
child.x = cellX
child.y = cellY
elseif
effectiveAlignItems == AlignItems.FLEX_END
or effectiveAlignItems == "flex-end"
or effectiveAlignItems == "end"
then
local childBorderBoxWidth = child:getBorderBoxWidth()
local childBorderBoxHeight = child:getBorderBoxHeight()
child.x = cellX + cellWidth - childBorderBoxWidth
child.y = cellY + cellHeight - childBorderBoxHeight
else
child.x = cellX
child.y = cellY
child._borderBoxWidth = cellWidth
child._borderBoxHeight = cellHeight
child.width = math.max(0, cellWidth - child.padding.left - child.padding.right)
child.height = math.max(0, cellHeight - child.padding.top - child.padding.bottom)
-- Disable auto-sizing when stretched by grid
child.autosizing.width = false
child.autosizing.height = false
end
if #child.children > 0 then
child:layoutChildren()
end
end
end
return Grid
+160
View File
@@ -0,0 +1,160 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
local utils = req("utils")
-- ErrorHandler will be injected via init
local ErrorHandler = nil
---@class ImageCache
---@field _cache table<string, {image: love.Image, imageData: love.ImageData?}>
local ImageCache = {}
ImageCache._cache = {}
--- Initialize ImageCache with dependencies
---@param deps table Dependencies table with ErrorHandler
function ImageCache.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
--- Load an image from file path with caching
--- Returns cached image if already loaded, otherwise loads and caches it
---@param imagePath string -- Path to image file
---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false)
---@return love.Image|nil -- Image object or nil on error
---@return string|nil -- Error message if loading failed
function ImageCache.load(imagePath, loadImageData)
if not imagePath or type(imagePath) ~= "string" or imagePath == "" then
return nil, "Invalid image path: path must be a non-empty string"
end
local normalizedPath = utils.normalizePath(imagePath)
if ImageCache._cache[normalizedPath] then
return ImageCache._cache[normalizedPath].image, nil
end
local success, imageOrError = pcall(love.graphics.newImage, normalizedPath)
if not success then
if ErrorHandler then
ErrorHandler:warn("ImageCache", "RES_004", {
resourceType = "image",
path = imagePath,
error = tostring(imageOrError),
})
end
return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError))
end
local image = imageOrError
local imgData = nil
if loadImageData then
local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath)
if dataSuccess then
imgData = dataOrError
elseif ErrorHandler then
ErrorHandler:warn("ImageCache", "RES_004", {
resourceType = "image data",
path = imagePath,
error = tostring(dataOrError),
})
end
end
ImageCache._cache[normalizedPath] = {
image = image,
imageData = imgData,
}
return image, nil
end
--- Get a cached image without loading
---@param imagePath string -- Path to image file
---@return love.Image|nil -- Cached image or nil if not found
function ImageCache.get(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return nil
end
local normalizedPath = utils.normalizePath(imagePath)
local cached = ImageCache._cache[normalizedPath]
return cached and cached.image or nil
end
--- Get cached ImageData for an image
---@param imagePath string -- Path to image file
---@return love.ImageData|nil -- Cached ImageData or nil if not found
function ImageCache.getImageData(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return nil
end
local normalizedPath = utils.normalizePath(imagePath)
local cached = ImageCache._cache[normalizedPath]
return cached and cached.imageData or nil
end
--- Remove a specific image from cache
---@param imagePath string -- Path to image file to remove
---@return boolean -- True if image was removed, false if not found
function ImageCache.remove(imagePath)
if not imagePath or type(imagePath) ~= "string" then
return false
end
local normalizedPath = utils.normalizePath(imagePath)
if ImageCache._cache[normalizedPath] then
local cached = ImageCache._cache[normalizedPath]
if cached.image then
cached.image:release()
end
if cached.imageData then
cached.imageData:release()
end
ImageCache._cache[normalizedPath] = nil
return true
end
return false
end
--- Clear all cached images
function ImageCache.clear()
for path, cached in pairs(ImageCache._cache) do
if cached.image then
cached.image:release()
end
if cached.imageData then
cached.imageData:release()
end
end
ImageCache._cache = {}
end
--- Get cache statistics
---@return {count: number, memoryEstimate: number} -- Cache stats
function ImageCache.getStats()
local count = 0
local memoryEstimate = 0
for path, cached in pairs(ImageCache._cache) do
count = count + 1
if cached.image then
local w, h = cached.image:getDimensions()
-- Estimate: 4 bytes per pixel (RGBA)
memoryEstimate = memoryEstimate + (w * h * 4)
end
end
return {
count = count,
memoryEstimate = memoryEstimate,
}
end
return ImageCache
+380
View File
@@ -0,0 +1,380 @@
---@class ImageRenderer
local ImageRenderer = {}
-- ErrorHandler and utils will be injected via init
local ErrorHandler = nil
local utils = nil
--- Initialize ImageRenderer with dependencies
---@param deps table Dependencies table with ErrorHandler and utils
function ImageRenderer.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
if deps and deps.utils then
utils = deps.utils
end
end
--- Calculate rendering parameters for object-fit modes
--- Returns source and destination rectangles for rendering
---@param imageWidth number -- Natural width of the image
---@param imageHeight number -- Natural height of the image
---@param boundsWidth number -- Width of the bounds to fit within
---@param boundsHeight number -- Height of the bounds to fit within
---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill")
---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center")
---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number}
function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition)
fitMode = fitMode or "fill"
objectPosition = objectPosition or "center center"
if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then
ErrorHandler:error("ImageRenderer", "VAL_002", {
imageWidth = imageWidth,
imageHeight = imageHeight,
boundsWidth = boundsWidth,
boundsHeight = boundsHeight,
})
end
local result = {
sx = 0, -- Source X
sy = 0, -- Source Y
sw = imageWidth, -- Source width
sh = imageHeight, -- Source height
dx = 0, -- Destination X
dy = 0, -- Destination Y
dw = boundsWidth, -- Destination width
dh = boundsHeight, -- Destination height
scaleX = 1, -- Scale factor X
scaleY = 1, -- Scale factor Y
}
if fitMode == "fill" then
-- Stretch to fill bounds (may distort)
result.scaleX = boundsWidth / imageWidth
result.scaleY = boundsHeight / imageHeight
result.dw = boundsWidth
result.dh = boundsHeight
elseif fitMode == "contain" then
-- Scale to fit within bounds (preserves aspect ratio)
local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight)
result.scaleX = scale
result.scaleY = scale
result.dw = imageWidth * scale
result.dh = imageHeight * scale
-- Apply object-position for letterbox alignment
local posX, posY = ImageRenderer._parsePosition(objectPosition)
result.dx = (boundsWidth - result.dw) * posX
result.dy = (boundsHeight - result.dh) * posY
elseif fitMode == "cover" then
-- Scale to cover bounds (preserves aspect ratio, may crop)
local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight)
result.scaleX = scale
result.scaleY = scale
local scaledWidth = imageWidth * scale
local scaledHeight = imageHeight * scale
-- Apply object-position for crop alignment
local posX, posY = ImageRenderer._parsePosition(objectPosition)
-- Calculate which part of the scaled image to show
local cropX = (scaledWidth - boundsWidth) * posX
local cropY = (scaledHeight - boundsHeight) * posY
-- Convert back to source coordinates
result.sx = cropX / scale
result.sy = cropY / scale
result.sw = boundsWidth / scale
result.sh = boundsHeight / scale
result.dx = 0
result.dy = 0
result.dw = boundsWidth
result.dh = boundsHeight
elseif fitMode == "none" then
-- Use natural size (no scaling)
result.scaleX = 1
result.scaleY = 1
result.dw = imageWidth
result.dh = imageHeight
-- Apply object-position
local posX, posY = ImageRenderer._parsePosition(objectPosition)
result.dx = (boundsWidth - imageWidth) * posX
result.dy = (boundsHeight - imageHeight) * posY
elseif fitMode == "scale-down" then
-- Use none or contain, whichever is smaller
if imageWidth <= boundsWidth and imageHeight <= boundsHeight then
-- Image fits naturally, use "none"
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition)
else
-- Image too large, use "contain"
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition)
end
else
ErrorHandler:warn("ImageRenderer", "VAL_007", {
fitMode = fitMode,
fallback = "fill",
})
-- Use 'fill' as fallback
return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "fill", objectPosition)
end
return result
end
--- Parse object-position string into normalized coordinates (0-1)
--- Supports keywords (center, top, bottom, left, right) and percentages
---@param position string -- Position string like "center center", "top left", "50% 50%"
---@return number, number -- Normalized X and Y positions (0-1)
function ImageRenderer._parsePosition(position)
if not position or type(position) ~= "string" then
return 0.5, 0.5 -- Default to center
end
-- Split into X and Y components
local parts = {}
for part in position:gmatch("%S+") do
table.insert(parts, part:lower())
end
-- If only one value, use it for both axes (with special handling)
if #parts == 1 then
local val = parts[1]
if val == "left" or val == "right" then
parts = { val, "center" }
elseif val == "top" or val == "bottom" then
parts = { "center", val }
else
parts = { val, val }
end
elseif #parts == 0 then
return 0.5, 0.5 -- Default to center
end
local function parseValue(val)
-- Handle keywords
if val == "center" then
return 0.5
elseif val == "left" or val == "top" then
return 0
elseif val == "right" or val == "bottom" then
return 1
end
-- Handle percentages
local percent = val:match("^([%d%.]+)%%$")
if percent then
return tonumber(percent) / 100
end
-- Handle plain numbers (treat as percentage)
local num = tonumber(val)
if num then
return num / 100
end
-- Invalid value, default to center
return 0.5
end
local x = parseValue(parts[1])
local y = parseValue(parts[2] or parts[1])
-- Clamp to 0-1 range
x = math.max(0, math.min(1, x))
y = math.max(0, math.min(1, y))
return x, y
end
--- Draw an image with specified object-fit mode
---@param image love.Image -- Image to draw
---@param x number -- X position of bounds
---@param y number -- Y position of bounds
---@param width number -- Width of bounds
---@param height number -- Height of bounds
---@param fitMode string? -- Object-fit mode (default: "fill")
---@param objectPosition string? -- Object-position (default: "center center")
---@param opacity number? -- Opacity 0-1 (default: 1)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
if not image then
return -- Nothing to draw
end
opacity = opacity or 1
fitMode = fitMode or "fill"
objectPosition = objectPosition or "center center"
local imgWidth, imgHeight = image:getDimensions()
local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition)
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
-- Draw image
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
-- Need to use a quad for cropping
local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight)
love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh)
else
-- Simple draw with scaling
love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY)
end
-- Restore color
love.graphics.setColor(r, g, b, a)
end
--- Draw an image with tiling/repeat mode
---@param image love.Image -- Image to draw
---@param x number -- X position of bounds
---@param y number -- Y position of bounds
---@param width number -- Width of bounds
---@param height number -- Height of bounds
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
---@param opacity number? -- Opacity 0-1 (default: 1)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
if not image then
return -- Nothing to draw
end
opacity = opacity or 1
repeatMode = repeatMode or "no-repeat"
local imgWidth, imgHeight = image:getDimensions()
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
if repeatMode == "no-repeat" then
-- Just draw once, no tiling
love.graphics.draw(image, x, y)
elseif repeatMode == "repeat" then
-- Tile in both directions
local tilesX = math.ceil(width / imgWidth)
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawY = y + (tileY * imgHeight)
-- Calculate how much of the tile to draw (for partial tiles at edges)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawWidth < imgWidth or drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, drawY)
else
-- Draw full tile
love.graphics.draw(image, drawX, drawY)
end
end
end
elseif repeatMode == "repeat-x" then
-- Tile horizontally only
local tilesX = math.ceil(width / imgWidth)
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
if drawWidth < imgWidth then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, y)
else
-- Draw full tile
love.graphics.draw(image, drawX, y)
end
end
elseif repeatMode == "repeat-y" then
-- Tile vertically only
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
local drawY = y + (tileY * imgHeight)
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, x, drawY)
else
-- Draw full tile
love.graphics.draw(image, x, drawY)
end
end
elseif repeatMode == "space" then
-- Distribute tiles with even spacing
local tilesX = math.floor(width / imgWidth)
local tilesY = math.floor(height / imgHeight)
if tilesX < 1 then
tilesX = 1
end
if tilesY < 1 then
tilesY = 1
end
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * (imgWidth + spaceX))
local drawY = y + (tileY * (imgHeight + spaceY))
love.graphics.draw(image, drawX, drawY)
end
end
elseif repeatMode == "round" then
-- Scale tiles to fit bounds exactly
local tilesX = math.max(1, utils.round(width / imgWidth))
local tilesY = math.max(1, utils.round(height / imgHeight))
local scaleX = width / (tilesX * imgWidth)
local scaleY = height / (tilesY * imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth * scaleX)
local drawY = y + (tileY * imgHeight * scaleY)
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
end
end
else
ErrorHandler:warn("ImageRenderer", "VAL_007", {
repeatMode = repeatMode,
fallback = "no-repeat",
})
love.graphics.draw(image, x, y)
end
-- Restore color
love.graphics.setColor(r, g, b, a)
end
return ImageRenderer
+174
View File
@@ -0,0 +1,174 @@
-- ====================
-- ImageScaler
-- ====================
local ImageScaler = {}
-- ErrorHandler will be injected via init
local ErrorHandler = nil
--- Initialize ImageScaler with dependencies
---@param deps table Dependencies table with ErrorHandler
function ImageScaler.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
end
--- Scale an ImageData region using nearest-neighbor sampling
--- Produces sharp, pixelated scaling - ideal for pixel art
---@param sourceImageData love.ImageData -- Source image data
---@param srcX number -- Source region X (0-based)
---@param srcY number -- Source region Y (0-based)
---@param srcW number -- Source region width
---@param srcH number -- Source region height
---@param destW number -- Destination width
---@param destH number -- Destination height
---@return love.ImageData -- Scaled image data
function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
if not sourceImageData then
ErrorHandler:error("ImageScaler", "VAL_001", {
parameter = "sourceImageData",
})
end
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
ErrorHandler:warn("ImageScaler", "VAL_002", {
srcW = srcW,
srcH = srcH,
destW = destW,
destH = destH,
fallback = "1x1 transparent image",
})
-- Return a minimal 1x1 transparent image as fallback
local fallbackImageData = love.image.newImageData(1, 1)
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
return fallbackImageData
end
-- Create destination ImageData
local destImageData = love.image.newImageData(destW, destH)
-- Calculate scale ratios (cached outside loops for performance)
local scaleX = srcW / destW
local scaleY = srcH / destH
-- Nearest-neighbor sampling
for destY = 0, destH - 1 do
for destX = 0, destW - 1 do
-- Calculate source pixel coordinates using floor (nearest-neighbor)
local srcPixelX = math.floor(destX * scaleX) + srcX
local srcPixelY = math.floor(destY * scaleY) + srcY
-- Clamp to source bounds (safety check)
srcPixelX = math.min(srcPixelX, srcX + srcW - 1)
srcPixelY = math.min(srcPixelY, srcY + srcH - 1)
-- Sample source pixel
local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY)
-- Write to destination
destImageData:setPixel(destX, destY, r, g, b, a)
end
end
return destImageData
end
--- Linear interpolation helper
--- Blends between two values based on interpolation factor
---@param a number -- Start value
---@param b number -- End value
---@param t number -- Interpolation factor [0, 1]
---@return number -- Interpolated value
local function lerp(a, b, t)
return a + (b - a) * t
end
--- Scale an ImageData region using bilinear interpolation
--- Produces smooth, filtered scaling - ideal for high-quality upscaling
---@param sourceImageData love.ImageData -- Source image data
---@param srcX number -- Source region X (0-based)
---@param srcY number -- Source region Y (0-based)
---@param srcW number -- Source region width
---@param srcH number -- Source region height
---@param destW number -- Destination width
---@param destH number -- Destination height
---@return love.ImageData -- Scaled image data
function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH)
if not sourceImageData then
ErrorHandler:error("ImageScaler", "VAL_001", {
parameter = "sourceImageData",
})
end
if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then
ErrorHandler:warn("ImageScaler", "VAL_002", {
srcW = srcW,
srcH = srcH,
destW = destW,
destH = destH,
fallback = "1x1 transparent image",
})
-- Return a minimal 1x1 transparent image as fallback
local fallbackImageData = love.image.newImageData(1, 1)
fallbackImageData:setPixel(0, 0, 0, 0, 0, 0)
return fallbackImageData
end
-- Create destination ImageData
local destImageData = love.image.newImageData(destW, destH)
-- Calculate scale ratios
local scaleX = srcW / destW
local scaleY = srcH / destH
-- Bilinear interpolation
for destY = 0, destH - 1 do
for destX = 0, destW - 1 do
-- Calculate fractional source position
local srcXf = destX * scaleX
local srcYf = destY * scaleY
-- Get integer coordinates for 2x2 sampling grid
local x0 = math.floor(srcXf)
local y0 = math.floor(srcYf)
local x1 = math.min(x0 + 1, srcW - 1)
local y1 = math.min(y0 + 1, srcH - 1)
-- Get fractional parts for interpolation
local fx = srcXf - x0
local fy = srcYf - y0
-- Sample 4 neighboring pixels (with source offset)
local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0)
local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0)
local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1)
local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1)
-- Interpolate horizontally (top and bottom rows)
local rTop = lerp(r00, r10, fx)
local gTop = lerp(g00, g10, fx)
local bTop = lerp(b00, b10, fx)
local aTop = lerp(a00, a10, fx)
local rBottom = lerp(r01, r11, fx)
local gBottom = lerp(g01, g11, fx)
local bBottom = lerp(b01, b11, fx)
local aBottom = lerp(a01, a11, fx)
-- Interpolate vertically (final result)
local r = lerp(rTop, rBottom, fy)
local g = lerp(gTop, gBottom, fy)
local b = lerp(bTop, bBottom, fy)
local a = lerp(aTop, aBottom, fy)
-- Write to destination
destImageData:setPixel(destX, destY, r, g, b, a)
end
end
return destImageData
end
return ImageScaler
+88
View File
@@ -0,0 +1,88 @@
---@class InputEvent
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle)
---@field x number -- Mouse/Touch X position
---@field y number -- Mouse/Touch Y position
---@field dx number? -- Delta X from drag/touch start (only for drag/touch events)
---@field dy number? -- Delta Y from drag/touch start (only for drag/touch events)
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number -- Number of clicks (for double/triple click detection)
---@field timestamp number -- Time when event occurred
---@field touchId string? -- Touch identifier (for multi-touch)
---@field pressure number? -- Touch pressure (0-1, defaults to 1.0)
---@field phase string? -- Touch phase: "began", "moved", "ended", "cancelled"
local InputEvent = {}
InputEvent.__index = InputEvent
---@class InputEventProps
---@field type "click"|"press"|"release"|"rightclick"|"middleclick"|"drag"|"hover"|"unhover"|"touchpress"|"touchmove"|"touchrelease"|"touchcancel"
---@field button number
---@field x number
---@field y number
---@field dx number?
---@field dy number?
---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, super:boolean}
---@field clickCount number?
---@field timestamp number?
---@field touchId string?
---@field pressure number?
---@field phase string?
--- Create a new input event
---@param props InputEventProps
---@return InputEvent
function InputEvent.new(props)
local self = setmetatable({}, InputEvent)
self.type = props.type
self.button = props.button
self.x = props.x
self.y = props.y
self.dx = props.dx
self.dy = props.dy
self.modifiers = props.modifiers
self.clickCount = props.clickCount or 1
self.timestamp = props.timestamp or love.timer.getTime()
-- Touch-specific properties
self.touchId = props.touchId
self.pressure = props.pressure or 1.0
self.phase = props.phase
return self
end
--- Create an InputEvent from LÖVE touch data
---@param id userdata Touch ID from LÖVE
---@param x number Touch X position
---@param y number Touch Y position
---@param phase string Touch phase: "began", "moved", "ended", "cancelled"
---@param pressure number? Touch pressure (0-1, defaults to 1.0)
---@return InputEvent
function InputEvent.fromTouch(id, x, y, phase, pressure)
local touchIdStr = tostring(id)
local eventType = "touchpress"
if phase == "moved" then
eventType = "touchmove"
elseif phase == "ended" then
eventType = "touchrelease"
elseif phase == "cancelled" then
eventType = "touchcancel"
end
return InputEvent.new({
type = eventType,
button = 1, -- Treat touch as left button
x = x,
y = y,
dx = 0,
dy = 0,
modifiers = { shift = false, ctrl = false, alt = false, super = false },
clickCount = 1,
timestamp = love.timer.getTime(),
touchId = touchIdStr,
pressure = pressure or 1.0,
phase = phase,
})
end
return InputEvent
@@ -0,0 +1,748 @@
local packageName = ... or "KeyboardNavigation"
local modulePath = packageName:match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
---@class KeyboardNavigation
---@field config KeyboardNavigationConfig
local KeyboardNavigation = {
config = {
-- Global settings
enabled = true,
debugMode = false,
-- Key bindings
keys = {
next = "tab",
previous = "shifttab",
up = "up",
down = "down",
left = "left",
right = "right",
activate = { "return", "space" },
dismiss = "escape",
toggleDebug = "f12",
inspect = "i",
},
-- Navigation behavior
wrapAround = true,
directionalNavigation = true,
focusVisible = true,
autofocusOnCreate = false,
--- Drop focus after pressing Enter/Space to activate an element
--- When false, focus remains on the element after activation
dropFocusOnSelection = true,
-- Developer tools
developerTools = {
enabled = true,
showProperties = true,
highlightColor = { 1, 0.8, 0, 0.5 },
},
-- Focus indicator style
focusIndicator = {
color = { 0.2, 0.6, 1.0, 0.8 },
lineWidth = 2,
inset = -3,
borderRadius = 4,
animationDuration = 0.15,
},
},
-- State
_navigationStack = {},
_lastNavigationTime = 0,
_inspectMode = false,
_deps = nil,
-- Spatial index for directional navigation (performance optimization)
_spatialIndex = {
enabled = false,
cellSize = 100, -- Grid cell size in pixels
grid = {}, -- Grid storing element references
elementPositions = {}, -- Cache of element positions {element = {x, y, w, h}}
lastUpdateFrame = 0,
},
}
--- Initialize KeyboardNavigation module
---@param deps table {Context, Element, ErrorHandler, utils, InputEvent}
function KeyboardNavigation.init(deps)
-- Validate required dependencies
local required = { Context = true, Element = true, ErrorHandler = true, utils = true, InputEvent = true }
for depName, _ in pairs(required) do
if not deps[depName] then
error(string.format("KeyboardNavigation.init: Missing required dependency: %s", depName))
end
end
KeyboardNavigation._deps = deps
KeyboardNavigation._ErrorHandler = deps.ErrorHandler
KeyboardNavigation._InputEvent = deps.InputEvent
KeyboardNavigation._Context = deps.Context
KeyboardNavigation._Element = deps.Element
KeyboardNavigation._utils = deps.utils
end
--- Handle keyboard press for navigation
---@param key string
---@param scancode string
---@param isrepeat boolean
---@return boolean handled
function KeyboardNavigation:handleKeyPress(key, scancode, isrepeat)
if not KeyboardNavigation._Context then
return false
end
-- Debug logging
if KeyboardNavigation.config.debugMode then
print(
string.format(
"[KeyboardNavigation] Key pressed: %s (scancode: %s, repeat: %s)",
key,
scancode,
tostring(isrepeat)
)
)
print(string.format("[KeyboardNavigation] Enabled: %s", tostring(KeyboardNavigation.config.enabled)))
end
local config = KeyboardNavigation.config
local keys = config.keys
-- Check for activation keys
for _, activateKey in ipairs(keys.activate) do
if key == activateKey then
return self:activateElement()
end
end
-- Check for dismiss key
if key == keys.dismiss then
return self:dismissElement()
end
-- Check for next/previous navigation
-- Tab with shift held = previous; Tab without shift = next
if key == keys.next then
if love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") then
return self:previousFocusable()
end
return self:nextFocusable()
end
if key == keys.previous then
return self:previousFocusable()
end
-- Check for directional navigation
if config.directionalNavigation then
if key == keys.up then
return self:navigateDirectional("up")
elseif key == keys.down then
return self:navigateDirectional("down")
elseif key == keys.left then
return self:navigateDirectional("left")
elseif key == keys.right then
return self:navigateDirectional("right")
end
end
return false
end
--- Find next focusable element in the focusable list
---@param focusableList table<Element> List of focusable elements in tab order
---@param current Element? Currently focused element
---@return Element?
function KeyboardNavigation:_findNextInList(focusableList, current)
local currentIndex = 0
if current then
for i, elem in ipairs(focusableList) do
if elem.id == current.id then
currentIndex = i
break
end
end
end
-- Search forward
if currentIndex < #focusableList then
return focusableList[currentIndex + 1]
end
-- Wrap around if enabled
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
return focusableList[1]
end
return nil
end
--- Get the focusable element list scoped to the navigation container
---@return Element[]
function KeyboardNavigation:_getScopedFocusableList()
local Context = KeyboardNavigation._Context
local container = Context.getNavigationContainer()
if container then
return container:getFocusableChildren()
end
return Context.getFocusableElements()
end
--- Navigate to next focusable element (Tab)
---@return boolean success
function KeyboardNavigation:nextFocusable()
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
if KeyboardNavigation.config.debugMode then
print(
string.format("[KeyboardNavigation] Tab pressed - Current focus: %s", tostring(current and current.id or "nil"))
)
end
local focusableList = self:_getScopedFocusableList()
local nextElem = self:_findNextInList(focusableList, current)
if nextElem then
self:_focusElement(nextElem)
return true
end
return false
end
--- Find previous focusable element in the focusable list
---@param focusableList table<Element> List of focusable elements in tab order
---@param current Element? Currently focused element
---@return Element?
function KeyboardNavigation:_findPreviousInList(focusableList, current)
local currentIndex = #focusableList + 1
if current then
for i, elem in ipairs(focusableList) do
if elem.id == current.id then
currentIndex = i
break
end
end
end
-- Search backward
if currentIndex - 1 >= 1 then
return focusableList[currentIndex - 1]
end
-- Wrap around if enabled
if KeyboardNavigation.config.wrapAround and #focusableList > 0 then
return focusableList[#focusableList]
end
return nil
end
--- Navigate to previous focusable element (Shift+Tab)
---@return boolean success
function KeyboardNavigation:previousFocusable()
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
local focusableList = self:_getScopedFocusableList()
local prevElem = self:_findPreviousInList(focusableList, current)
if prevElem then
self:_focusElement(prevElem)
return true
end
return false
end
--- Navigate using arrow keys
---@param direction "up"|"down"|"left"|"right"
---@return boolean success
function KeyboardNavigation:navigateDirectional(direction)
local Context = KeyboardNavigation._Context
local current = Context.getFocused()
if not current then
return false
end
local nextElem = KeyboardNavigation:_findDirectionalNeighbor(current, direction)
if nextElem then
self:_focusElement(nextElem)
return true
end
return false
end
--- Find closest focusable element in the given direction
---@param current Element
---@param direction "up"|"down"|"left"|"right"
---@return Element?
function KeyboardNavigation:_findDirectionalNeighbor(current, direction)
-- Try spatial index first if enabled
if KeyboardNavigation._spatialIndex.enabled then
local spatialResult = self:_findDirectionalNeighborSpatial(current, direction)
if spatialResult then
return spatialResult
end
end
-- Collect all focusable elements visible this frame
local Context = KeyboardNavigation._Context
local focusable = {}
local function collectFocusable(elem)
if elem:isFocusable() and elem ~= current then
table.insert(focusable, elem)
end
for _, child in ipairs(elem.children) do
collectFocusable(child)
end
end
-- Mode-agnostic: collect from Context's focusable list
local allFocusable = Context.getFocusableElements()
for _, elem in ipairs(allFocusable) do
if elem ~= current then
table.insert(focusable, elem)
end
end
if #focusable == 0 then
return nil
end
local currentRect = {
x = current.x,
y = current.y,
width = current.width or 0,
height = current.height or 0,
}
local closest = nil
local closestDistance = math.huge
for _, elem in ipairs(focusable) do
local elemRect = {
x = elem.x,
y = elem.y,
width = elem.width or 0,
height = elem.height or 0,
}
local distance, isInDirection = self:_calculateDirectionalDistance(currentRect, elemRect, direction)
if isInDirection and distance < closestDistance then
closest = elem
closestDistance = distance
end
end
-- If no element found in exact direction, try with looser criteria
if not closest then
closest = self:_findClosestInDirection(current, focusable, direction)
end
return closest
end
--- Calculate distance and direction between elements
---@param from table {x, y, width, height}
---@param to table {x, y, width, height}
---@param direction string
---@return number distance, boolean isInDirection
function KeyboardNavigation:_calculateDirectionalDistance(from, to, direction)
-- Calculate bounding box edges
local fromLeft = from.x
local fromRight = from.x + from.width
local fromTop = from.y
local fromBottom = from.y + from.height
local toLeft = to.x
local toRight = to.x + to.width
local toTop = to.y
local toBottom = to.y + to.height
local distance = math.huge
local isInDirection = false
if direction == "up" then
if toBottom < fromTop then
isInDirection = true
distance = fromTop - toBottom
end
elseif direction == "down" then
if toTop > fromBottom then
isInDirection = true
distance = toTop - fromBottom
end
elseif direction == "left" then
if toRight < fromLeft then
isInDirection = true
distance = fromLeft - toRight
end
elseif direction == "right" then
if toLeft > fromRight then
isInDirection = true
distance = toLeft - fromRight
end
end
return distance, isInDirection
end
--- Find closest element in direction using center-to-center distance
---@param current Element
---@param focusable Element[]
---@param direction string
---@return Element?
function KeyboardNavigation:_findClosestInDirection(current, focusable, direction)
local currentCenterX = current.x + (current.width or 0) / 2
local currentCenterY = current.y + (current.height or 0) / 2
local closest = nil
local closestDistance = math.huge
for _, elem in ipairs(focusable) do
if elem ~= current then
local elemCenterX = elem.x + (elem.width or 0) / 2
local elemCenterY = elem.y + (elem.height or 0) / 2
local dx = elemCenterX - currentCenterX
local dy = elemCenterY - currentCenterY
-- Check if element is generally in the right direction
local isInDirection = false
if direction == "up" and dy < 0 then
isInDirection = true
elseif direction == "down" and dy > 0 then
isInDirection = true
elseif direction == "left" and dx < 0 then
isInDirection = true
elseif direction == "right" and dx > 0 then
isInDirection = true
end
if isInDirection then
local distance = math.sqrt(dx * dx + dy * dy)
if distance < closestDistance then
closest = elem
closestDistance = distance
end
end
end
end
return closest
end
--- Focus an element
---@param element Element
function KeyboardNavigation:_focusElement(element)
local Context = KeyboardNavigation._Context
if element and element:isFocusable() then
if KeyboardNavigation.config.debugMode then
print(
string.format(
"[KeyboardNavigation] Focusing element: %s (id: %s)",
element.themeComponent or "unknown",
tostring(element.id)
)
)
end
Context.setFocused(element)
-- Update focus indicator
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator.setFocused(element)
end
-- Call onFocus callback if it exists
if element.onFocus then
local success, err = pcall(function()
if element.onFocusDeferred then
table.insert(Context._deferredCallbacks or {}, function()
element:onFocus(element)
end)
else
element:onFocus(element)
end
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_001", {
elementId = element.id or "unknown",
error = tostring(err),
})
end
end
end
end
---@param element Element
---@return boolean
function KeyboardNavigation:_shouldDropFocusOnSelection(element)
if element and element.dropFocusOnSelection ~= nil then
return element.dropFocusOnSelection == true
end
return KeyboardNavigation.config.dropFocusOnSelection == true
end
--- Activate currently focused element
---@return boolean success
function KeyboardNavigation:activateElement()
local Context = KeyboardNavigation._Context
local focused = Context.getFocused()
if not focused then
return false
end
if focused.disabled then
return false
end
-- Fire press and release events
if focused.onEvent then
local modifiers = KeyboardNavigation._utils.getModifiers()
local pressEvent = KeyboardNavigation._InputEvent.new({
type = "press",
button = 1,
x = focused.x,
y = focused.y,
modifiers = modifiers,
clickCount = 1,
})
local releaseEvent = KeyboardNavigation._InputEvent.new({
type = "release",
button = 1,
x = focused.x,
y = focused.y,
modifiers = modifiers,
clickCount = 1,
})
local success, err = pcall(function()
focused.onEvent(focused, pressEvent)
focused.onEvent(focused, releaseEvent)
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_002", {
elementId = focused.id or "unknown",
error = tostring(err),
})
end
-- Drop focus after selection based on per-element override or global config.
if KeyboardNavigation:_shouldDropFocusOnSelection(focused) then
Context.clearFocus()
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator.setFocused(nil)
end
end
return true
end
return false
end
--- Dismiss currently focused element
---@return boolean success
function KeyboardNavigation:dismissElement()
local Context = KeyboardNavigation._Context
local focused = Context.getFocused()
if not focused then
return false
end
-- Check if element has a dismiss handler
if focused.onDismiss then
local success, err = pcall(function()
if focused.onDismissDeferred then
table.insert(Context._deferredCallbacks or {}, function()
focused:onDismiss(focused)
end)
else
focused:onDismiss(focused)
end
end)
if not success then
KeyboardNavigation._ErrorHandler:warn("KeyboardNavigation", "NAV_003", {
elementId = focused.id or "unknown",
error = tostring(err),
})
end
return true -- Handler took care of dismissal
end
-- Default behavior: blur the element (only if no onDismiss handler)
Context.clearFocus()
return true
end
--- Update keyboard navigation (for animations, etc.)
---@param dt number
function KeyboardNavigation:update(dt)
-- Update focus indicator if it exists
if KeyboardNavigation.FocusIndicator then
KeyboardNavigation.FocusIndicator:update(dt)
end
end
--- Push current focus onto stack (for modals/dialogs)
--- Saves current focus and sets new focus to the given element
---@param element Element? The element to focus (e.g., modal dialog)
function KeyboardNavigation:pushFocus(element)
local Context = KeyboardNavigation._Context
table.insert(KeyboardNavigation._navigationStack, Context.getFocused())
Context.pushFocusStack(element)
end
--- Pop focus from stack (return from modal)
--- Restores previously focused element from the stack
---@return Element? The previously focused element, or nil if stack was empty
function KeyboardNavigation:popFocus()
local Context = KeyboardNavigation._Context
local previous = Context.popFocusStack()
if #KeyboardNavigation._navigationStack > 0 then
previous = table.remove(KeyboardNavigation._navigationStack)
end
return previous
end
-- ====================
-- Spatial Index (Performance Optimization)
-- ====================
--- Enable spatial index for faster directional navigation
---@param enabled boolean
function KeyboardNavigation.enableSpatialIndex(enabled)
KeyboardNavigation._spatialIndex.enabled = enabled
if not enabled then
KeyboardNavigation:_clearSpatialIndex()
end
end
--- Clear spatial index
function KeyboardNavigation:_clearSpatialIndex()
KeyboardNavigation._spatialIndex.grid = {}
KeyboardNavigation._spatialIndex.elementPositions = {}
end
--- Find directional neighbor using spatial index
---@param current Element
---@param direction "up"|"down"|"left"|"right"
---@return Element?
function KeyboardNavigation:_findDirectionalNeighborSpatial(current, direction)
local index = KeyboardNavigation._spatialIndex
local cellSize = index.cellSize
-- Get current element's grid position
local currentPos = index.elementPositions[current]
if not currentPos then
return nil
end
local centerX = currentPos.x + currentPos.w / 2
local centerY = currentPos.y + currentPos.h / 2
local currentCellX = math.floor(centerX / cellSize)
local currentCellY = math.floor(centerY / cellSize)
-- Search in direction, expanding outward
local maxSearchRadius = 20 -- Maximum cells to search
local visited = {}
for radius = 1, maxSearchRadius do
local candidates = {}
-- Get cells in the search ring
if direction == "up" then
table.insert(candidates, { currentCellX, currentCellY - radius })
if radius > 1 then
table.insert(candidates, { currentCellX - 1, currentCellY - radius })
table.insert(candidates, { currentCellX + 1, currentCellY - radius })
end
elseif direction == "down" then
table.insert(candidates, { currentCellX, currentCellY + radius })
if radius > 1 then
table.insert(candidates, { currentCellX - 1, currentCellY + radius })
table.insert(candidates, { currentCellX + 1, currentCellY + radius })
end
elseif direction == "left" then
table.insert(candidates, { currentCellX - radius, currentCellY })
if radius > 1 then
table.insert(candidates, { currentCellX - radius, currentCellY - 1 })
table.insert(candidates, { currentCellX - radius, currentCellY + 1 })
end
elseif direction == "right" then
table.insert(candidates, { currentCellX + radius, currentCellY })
if radius > 1 then
table.insert(candidates, { currentCellX + radius, currentCellY - 1 })
table.insert(candidates, { currentCellX + radius, currentCellY + 1 })
end
end
-- Check each candidate cell
for _, cell in ipairs(candidates) do
local cellKey = string.format("%d,%d", cell[1], cell[2])
local cellElements = index.grid[cellKey]
if cellElements then
for _, elem in ipairs(cellElements) do
if elem ~= current and not visited[elem] then
visited[elem] = true
local elemPos = index.elementPositions[elem]
if elemPos then
local elemCenterX = elemPos.x + elemPos.w / 2
local elemCenterY = elemPos.y + elemPos.h / 2
-- Check if element is in the correct direction
local isInDirection = false
if direction == "up" and elemCenterY < centerY then
isInDirection = true
elseif direction == "down" and elemCenterY > centerY then
isInDirection = true
elseif direction == "left" and elemCenterX < centerX then
isInDirection = true
elseif direction == "right" and elemCenterX > centerX then
isInDirection = true
end
if isInDirection then
return elem
end
end
end
end
end
end
end
return nil
end
return KeyboardNavigation
File diff suppressed because it is too large Load Diff
+697
View File
@@ -0,0 +1,697 @@
---@class MemoryScanner
---@field _StateManager table
---@field _Context table
---@field _ImageCache table
---@field _ErrorHandler table
local MemoryScanner = {}
---Initialize MemoryScanner with dependencies
---@param deps {StateManager: table, Context: table, ImageCache: table, ErrorHandler: table}
function MemoryScanner.init(deps)
MemoryScanner._StateManager = deps.StateManager
MemoryScanner._Context = deps.Context
MemoryScanner._ImageCache = deps.ImageCache
MemoryScanner._ErrorHandler = deps.ErrorHandler
end
---Count items in a table
---@param tbl table
---@return number
local function countTable(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
---Calculate memory size estimate for a table (recursive)
---@param tbl table
---@param visited table? Tracking table to prevent circular references
---@param depth number? Current recursion depth
---@return number bytes Estimated memory usage in bytes
local function estimateTableSize(tbl, visited, depth)
if type(tbl) ~= "table" then
return 0
end
visited = visited or {}
depth = depth or 0
-- Limit recursion depth to prevent stack overflow
if depth > 10 then
return 0
end
-- Check for circular references
if visited[tbl] then
return 0
end
visited[tbl] = true
local size = 40 -- Base table overhead (approximate)
for k, v in pairs(tbl) do
-- Key size
if type(k) == "string" then
size = size + #k + 24 -- String overhead
elseif type(k) == "number" then
size = size + 8
else
size = size + 8 -- Reference
end
-- Value size
if type(v) == "string" then
size = size + #v + 24
elseif type(v) == "number" then
size = size + 8
elseif type(v) == "boolean" then
size = size + 4
elseif type(v) == "table" then
size = size + estimateTableSize(v, visited, depth + 1)
elseif type(v) == "function" then
size = size + 16 -- Function reference
else
size = size + 8 -- Other references
end
end
return size
end
---Scan StateManager for memory issues
---@return table report Detailed report of StateManager memory usage
function MemoryScanner.scanStateManager()
local report = {
stateCount = 0,
stateStoreSize = 0,
metadataSize = 0,
callSiteCounterSize = 0,
orphanedStates = {},
staleStates = {},
largeStates = {},
issues = {},
}
if not MemoryScanner._StateManager then
table.insert(report.issues, {
severity = "error",
message = "StateManager not initialized",
})
return report
end
local internal = MemoryScanner._StateManager._getInternalState()
local stateStore = internal.stateStore
local stateMetadata = internal.stateMetadata
local callSiteCounters = internal.callSiteCounters
local currentFrame = MemoryScanner._StateManager.getFrameNumber()
-- Count states
report.stateCount = countTable(stateStore)
-- Estimate sizes
report.stateStoreSize = estimateTableSize(stateStore)
report.metadataSize = estimateTableSize(stateMetadata)
report.callSiteCounterSize = estimateTableSize(callSiteCounters)
-- Check for orphaned states (metadata without state)
for id, _ in pairs(stateMetadata) do
if not stateStore[id] then
table.insert(report.orphanedStates, id)
end
end
-- Check for stale states (not accessed in many frames)
local staleThreshold = 120 -- 2 seconds at 60fps
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = currentFrame - meta.lastFrame
if framesSinceAccess > staleThreshold then
table.insert(report.staleStates, {
id = id,
framesSinceAccess = framesSinceAccess,
createdFrame = meta.createdFrame,
accessCount = meta.accessCount,
})
end
end
-- Check for large states (may indicate memory bloat)
for id, state in pairs(stateStore) do
local stateSize = estimateTableSize(state)
if stateSize > 1024 then -- More than 1KB
table.insert(report.largeStates, {
id = id,
size = stateSize,
keyCount = countTable(state),
})
end
end
-- Check callSiteCounters (should be near 0 after frame cleanup)
local callSiteCount = countTable(callSiteCounters)
if callSiteCount > 100 then
table.insert(report.issues, {
severity = "warning",
message = string.format("callSiteCounters has %d entries (expected near 0)", callSiteCount),
suggestion = "incrementFrame() may not be called properly, or counters aren't being reset",
})
end
-- Check for excessive state count
if report.stateCount > 500 then
table.insert(report.issues, {
severity = "warning",
message = string.format("High state count: %d states", report.stateCount),
suggestion = "Consider reducing element count or implementing more aggressive cleanup",
})
end
-- Check for orphaned states
if #report.orphanedStates > 0 then
table.insert(report.issues, {
severity = "error",
message = string.format("Found %d orphaned states (metadata without state)", #report.orphanedStates),
suggestion = "This indicates a bug in state management - metadata should be cleaned up with state",
})
end
-- Check for stale states
if #report.staleStates > 10 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Found %d stale states (not accessed in 2+ seconds)", #report.staleStates),
suggestion = "Cleanup may not be aggressive enough - consider reducing stateRetentionFrames",
})
end
return report
end
---Scan Context for memory issues
---@return table report Detailed report of Context memory usage
function MemoryScanner.scanContext()
local report = {
topElementCount = 0,
zIndexElementCount = 0,
frameElementCount = 0,
issues = {},
}
if not MemoryScanner._Context then
table.insert(report.issues, {
severity = "error",
message = "Context not initialized",
})
return report
end
-- Count elements
report.topElementCount = #MemoryScanner._Context.topElements
report.zIndexElementCount = #MemoryScanner._Context._zIndexOrderedElements
report.frameElementCount = #MemoryScanner._Context._currentFrameElements
-- Check for stale z-index elements (should be cleared each frame)
if MemoryScanner._Context.isImmediateMode() then
-- In immediate mode, _zIndexOrderedElements should be cleared at frame start
-- If it has elements outside of frame rendering, that's a leak
if not MemoryScanner._Context._frameStarted and report.zIndexElementCount > 0 then
table.insert(report.issues, {
severity = "warning",
message = string.format("Z-index array has %d elements outside of frame", report.zIndexElementCount),
suggestion = "clearFrameElements() may not be called properly in beginFrame()",
})
end
end
-- Check for excessive element count
if report.topElementCount > 100 then
table.insert(report.issues, {
severity = "info",
message = string.format("High top-level element count: %d", report.topElementCount),
suggestion = "Consider consolidating elements or using fewer top-level containers",
})
end
return report
end
---Scan ImageCache for memory issues
---@return table report Detailed report of ImageCache memory usage
function MemoryScanner.scanImageCache()
local report = {
imageCount = 0,
estimatedMemory = 0,
issues = {},
}
if not MemoryScanner._ImageCache then
table.insert(report.issues, {
severity = "error",
message = "ImageCache not initialized",
})
return report
end
local stats = MemoryScanner._ImageCache.getStats()
report.imageCount = stats.count
report.estimatedMemory = stats.memoryEstimate
-- Check for excessive memory usage (>100MB)
if report.estimatedMemory > 100 * 1024 * 1024 then
table.insert(report.issues, {
severity = "warning",
message = string.format("ImageCache using ~%.2f MB", report.estimatedMemory / 1024 / 1024),
suggestion = "Consider implementing cache eviction or clearing unused images",
})
end
-- Check for excessive image count
if report.imageCount > 50 then
table.insert(report.issues, {
severity = "info",
message = string.format("ImageCache has %d images", report.imageCount),
suggestion = "Review if all cached images are necessary",
})
end
return report
end
---Check if a circular reference is intentional (parent-child, module, or metatable)
---@param path string The current path where circular ref was detected
---@param originalPath string The original path where the table was first seen
---@return boolean True if this is an intentional circular reference
local function isIntentionalCircularReference(path, originalPath)
-- Pattern 1: child.parent points back to parent
-- Example: "topElements.1.children.1.parent" -> "topElements.1"
if path:match("%.parent$") then
local parentPath = path:match("^(.+)%.children%.[^.]+%.parent$")
if parentPath == originalPath then
return true
end
end
-- Pattern 2: parent.children[n] points to child, child points back somewhere in parent tree
-- Example: "topElements.1" -> "topElements.1.children.1.parent"
if originalPath:match("%.parent$") then
local childParentPath = originalPath:match("^(.+)%.children%.[^.]+%.parent$")
if childParentPath == path then
return true
end
end
-- Pattern 3: Check for nested parent-child cycles
-- child.children[n].parent -> child
local segments = {}
for segment in path:gmatch("[^.]+") do
table.insert(segments, segment)
end
-- Look for .children.N.parent pattern
for i = 1, #segments - 2 do
if segments[i] == "children" and segments[i + 2] == "parent" then
-- Reconstruct path without the .children.N.parent suffix
local reconstructedPath = table.concat(segments, ".", 1, i - 1)
if reconstructedPath == originalPath then
return true
end
end
end
-- Pattern 4: Metatable __index self-references (modules)
-- Example: "element._renderer._Theme.__index" -> "element._renderer._Theme"
if path:match("%.__index$") then
local basePath = path:match("^(.+)%.__index$")
if basePath == originalPath then
return true
end
end
-- Pattern 5: Shared module references (elements sharing same module instances)
-- Example: Multiple elements referencing _utils, _Theme, _Blur, etc.
-- These start with _ and are typically modules
local pathModuleName = path:match("%.(_[%w]+)%.")
local originalModuleName = originalPath:match("%.(_[%w]+)%.")
if pathModuleName and originalModuleName then
-- If both paths reference the same internal module (starting with _), it's intentional
if pathModuleName == originalModuleName then
return true
end
end
-- Pattern 6: Shared Color/Transform objects between elements
-- These are value objects that can be safely shared
if path:match("Color") and originalPath:match("Color") then
return true
end
if path:match("Transform") and originalPath:match("Transform") then
return true
end
-- Pattern 7: LayoutEngine holding reference to its element
-- Example: "element._layoutEngine.element" -> "element"
if path:match("%._layoutEngine%.element$") then
local elementPath = path:match("^(.+)%._layoutEngine%.element$")
if elementPath == originalPath then
return true
end
end
-- Pattern 8: Renderer holding references to element properties
-- Example: "element._renderer.cornerRadius" -> "element.cornerRadius"
if path:match("%._renderer%.") then
local rendererBasePath = path:match("^(.+)%._renderer%.")
local originalBasePath = originalPath:match("^(.+)%.")
if rendererBasePath == originalBasePath then
return true
end
end
-- Pattern 9: Context reference from layout engine (shared singleton)
-- Example: "element._layoutEngine._Context.topElements" -> "topElements"
if path:match("%._layoutEngine%._Context%.") and originalPath == "topElements" then
return true
end
return false
end
---Detect circular references in a table
---@param tbl table Table to check
---@param path string? Current path (for reporting)
---@param visited table? Tracking table
---@return table[] circularRefs Array of circular reference paths
---@return table[] intentionalRefs Array of intentional parent-child refs
local function detectCircularReferences(tbl, path, visited)
if type(tbl) ~= "table" then
return {}, {}
end
path = path or "root"
visited = visited or {}
local circularRefs = {}
local intentionalRefs = {}
-- Check if we've seen this table before
if visited[tbl] then
local ref = {
path = path,
originalPath = visited[tbl],
}
-- Determine if this is an intentional circular reference
if isIntentionalCircularReference(path, visited[tbl]) then
table.insert(intentionalRefs, ref)
else
table.insert(circularRefs, ref)
end
return circularRefs, intentionalRefs
end
-- Mark as visited
visited[tbl] = path
-- Recursively check children
for k, v in pairs(tbl) do
if type(v) == "table" then
local childPath = path .. "." .. tostring(k)
local childRefs, childIntentionalRefs = detectCircularReferences(v, childPath, visited)
for _, ref in ipairs(childRefs) do
table.insert(circularRefs, ref)
end
for _, ref in ipairs(childIntentionalRefs) do
table.insert(intentionalRefs, ref)
end
end
end
return circularRefs, intentionalRefs
end
---Scan for circular references in immediate mode
---@return table report Detailed report of circular references
function MemoryScanner.scanCircularReferences()
local report = {
stateStoreCircularRefs = {},
stateStoreIntentionalRefs = {},
contextCircularRefs = {},
contextIntentionalRefs = {},
issues = {},
}
if MemoryScanner._StateManager then
local internal = MemoryScanner._StateManager._getInternalState()
report.stateStoreCircularRefs, report.stateStoreIntentionalRefs =
detectCircularReferences(internal.stateStore, "stateStore")
end
if MemoryScanner._Context then
report.contextCircularRefs, report.contextIntentionalRefs =
detectCircularReferences(MemoryScanner._Context.topElements, "topElements")
end
-- Report issues only for cross-module circular references
if #report.stateStoreCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format(
"Found %d cross-module circular references in StateManager",
#report.stateStoreCircularRefs
),
suggestion = "These are typically architectural dependencies between modules, not memory leaks",
})
end
if #report.contextCircularRefs > 0 then
table.insert(report.issues, {
severity = "info",
message = string.format("Found %d cross-module circular references in Context", #report.contextCircularRefs),
suggestion = "These are typically architectural dependencies (e.g., layout engine ↔ renderer), not memory leaks",
})
end
return report
end
---Run comprehensive memory scan
---@return table report Complete memory analysis report
function MemoryScanner.scan()
local startMemory = collectgarbage("count")
local report = {
timestamp = os.time(),
startMemory = startMemory / 1024, -- MB
stateManager = MemoryScanner.scanStateManager(),
context = MemoryScanner.scanContext(),
imageCache = MemoryScanner.scanImageCache(),
circularRefs = MemoryScanner.scanCircularReferences(),
summary = {
totalIssues = 0,
criticalIssues = 0,
warnings = 0,
info = 0,
},
}
-- Count issues by severity
local function countIssues(subReport)
for _, issue in ipairs(subReport.issues or {}) do
report.summary.totalIssues = report.summary.totalIssues + 1
if issue.severity == "error" then
report.summary.criticalIssues = report.summary.criticalIssues + 1
elseif issue.severity == "warning" then
report.summary.warnings = report.summary.warnings + 1
elseif issue.severity == "info" then
report.summary.info = report.summary.info + 1
end
end
end
countIssues(report.stateManager)
countIssues(report.context)
countIssues(report.imageCache)
countIssues(report.circularRefs)
-- Force GC and measure freed memory
local beforeGC = collectgarbage("count")
collectgarbage("collect")
collectgarbage("collect")
local afterGC = collectgarbage("count")
report.gcAnalysis = {
beforeGC = beforeGC / 1024, -- MB
afterGC = afterGC / 1024, -- MB
freed = (beforeGC - afterGC) / 1024, -- MB
freedPercent = ((beforeGC - afterGC) / beforeGC) * 100,
}
-- Analyze GC effectiveness
if report.gcAnalysis.freedPercent < 5 then
table.insert(report.stateManager.issues, {
severity = "info",
message = string.format("GC freed only %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Most memory is still referenced - this is normal if UI is active",
})
elseif report.gcAnalysis.freedPercent > 30 then
table.insert(report.stateManager.issues, {
severity = "warning",
message = string.format("GC freed %.1f%% of memory", report.gcAnalysis.freedPercent),
suggestion = "Significant memory was unreferenced - may indicate cleanup issues",
})
end
return report
end
---Format report as human-readable string
---@param report table Memory scan report
---@return string formatted Formatted report
function MemoryScanner.formatReport(report)
local lines = {}
table.insert(lines, "=== FlexLöve Memory Scanner Report ===")
table.insert(lines, string.format("Timestamp: %s", os.date("%Y-%m-%d %H:%M:%S", report.timestamp)))
table.insert(lines, string.format("Memory: %.2f MB", report.startMemory))
table.insert(lines, "")
-- Summary
table.insert(lines, "--- Summary ---")
table.insert(lines, string.format("Total Issues: %d", report.summary.totalIssues))
table.insert(lines, string.format(" Critical: %d", report.summary.criticalIssues))
table.insert(lines, string.format(" Warnings: %d", report.summary.warnings))
table.insert(lines, string.format(" Info: %d", report.summary.info))
table.insert(lines, "")
-- StateManager
table.insert(lines, "--- StateManager ---")
table.insert(lines, string.format("State Count: %d", report.stateManager.stateCount))
table.insert(lines, string.format("State Store Size: %.2f KB", report.stateManager.stateStoreSize / 1024))
table.insert(lines, string.format("Metadata Size: %.2f KB", report.stateManager.metadataSize / 1024))
table.insert(lines, string.format("CallSite Counters: %.2f KB", report.stateManager.callSiteCounterSize / 1024))
table.insert(lines, string.format("Orphaned States: %d", #report.stateManager.orphanedStates))
table.insert(lines, string.format("Stale States: %d", #report.stateManager.staleStates))
table.insert(lines, string.format("Large States: %d", #report.stateManager.largeStates))
if #report.stateManager.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.stateManager.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Context
table.insert(lines, "--- Context ---")
table.insert(lines, string.format("Top Elements: %d", report.context.topElementCount))
table.insert(lines, string.format("Z-Index Elements: %d", report.context.zIndexElementCount))
table.insert(lines, string.format("Frame Elements: %d", report.context.frameElementCount))
if #report.context.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.context.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- ImageCache
table.insert(lines, "--- ImageCache ---")
table.insert(lines, string.format("Image Count: %d", report.imageCache.imageCount))
table.insert(lines, string.format("Estimated Memory: %.2f MB", report.imageCache.estimatedMemory / 1024 / 1024))
if #report.imageCache.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.imageCache.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
end
table.insert(lines, "")
-- Circular References
table.insert(lines, "--- Circular References ---")
table.insert(lines, string.format("StateStore (Cross-module refs): %d", #report.circularRefs.stateStoreCircularRefs))
table.insert(
lines,
string.format(
"StateStore (Intentional - parent-child, modules, metatables): %d",
#report.circularRefs.stateStoreIntentionalRefs
)
)
table.insert(lines, string.format("Context (Cross-module refs): %d", #report.circularRefs.contextCircularRefs))
table.insert(
lines,
string.format(
"Context (Intentional - parent-child, modules, metatables): %d",
#report.circularRefs.contextIntentionalRefs
)
)
if #report.circularRefs.issues > 0 then
table.insert(lines, "Issues:")
for _, issue in ipairs(report.circularRefs.issues) do
table.insert(lines, string.format(" [%s] %s", string.upper(issue.severity), issue.message))
if issue.suggestion then
table.insert(lines, string.format(" → %s", issue.suggestion))
end
end
else
table.insert(lines, " ✓ No unexpected circular references detected")
end
table.insert(lines, " Note: Cross-module refs are typically architectural dependencies, not memory leaks")
table.insert(lines, "")
-- GC Analysis
table.insert(lines, "--- Garbage Collection Analysis ---")
table.insert(lines, string.format("Before GC: %.2f MB", report.gcAnalysis.beforeGC))
table.insert(lines, string.format("After GC: %.2f MB", report.gcAnalysis.afterGC))
table.insert(lines, string.format("Freed: %.2f MB (%.1f%%)", report.gcAnalysis.freed, report.gcAnalysis.freedPercent))
table.insert(lines, "")
table.insert(lines, "=== End Report ===")
return table.concat(lines, "\n")
end
---Save report to file
---@param report table Memory scan report
---@param filename string? Output filename (default: memory_report.txt)
function MemoryScanner.saveReport(report, filename)
filename = filename or "memory_report.txt"
local formatted = MemoryScanner.formatReport(report)
local file = io.open(filename, "w")
if file then
file:write(formatted)
file:close()
if MemoryScanner._ErrorHandler then
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
resourceType = "report",
path = filename,
status = "saved",
})
end
else
if MemoryScanner._ErrorHandler then
MemoryScanner._ErrorHandler:warn("MemoryScanner", "RES_004", {
resourceType = "report",
path = filename,
status = "failed to save",
})
end
end
end
return MemoryScanner
+202
View File
@@ -0,0 +1,202 @@
---@class ModuleLoader
local ModuleLoader = {}
-- Module registry to track loaded vs. stub modules
ModuleLoader._registry = {}
ModuleLoader._ErrorHandler = nil
--- Initialize ModuleLoader with dependencies
---@param deps table
function ModuleLoader.init(deps)
ModuleLoader._ErrorHandler = deps.ErrorHandler
end
--- Create a null-object stub for a missing optional module
--- Provides safe defaults that won't cause runtime errors
---@param moduleName string
---@return table
local function createNullObject(moduleName)
local stub = {
_isStub = true,
_moduleName = moduleName,
}
-- Common method stubs that return safe defaults
local metatable = {
__index = function(_, key)
-- Common initialization method
if key == "init" then
return function()
return stub
end
end
-- Common constructor method
if key == "new" then
return function()
return stub
end
end
-- Common draw method
if key == "draw" then
return function() end
end
-- Common update method
if key == "update" then
return function() end
end
-- Common render method
if key == "render" then
return function() end
end
-- Common cleanup method
if key == "destroy" then
return function() end
end
-- Common cleanup method
if key == "cleanup" then
return function() end
end
-- Common clear method
if key == "clear" then
return function() end
end
-- Common reset method
if key == "reset" then
return function() end
end
-- Common get method
if key == "get" then
return function()
return nil
end
end
-- Common set method
if key == "set" then
return function() end
end
-- Common load method
if key == "load" then
return function()
return stub
end
end
-- Common cache-related methods
if key == "cache" or key == "getCache" or key == "clearCache" then
return function()
return {}
end
end
-- For any unknown method, return a no-op function that accepts any arguments
-- This allows safe method calls on stub objects (e.g., Performance:startFrame())
return function()
return stub
end
end,
-- Make function calls safe (in case the stub itself is called)
__call = function()
return stub
end,
}
setmetatable(stub, metatable)
return stub
end
--- Safely require a module with graceful fallback for optional modules
--- Returns the module if it exists, or a null-object stub if it's optional and missing
--- Throws an error if a required module is missing
---@param modulePath string Full path to the module (e.g., "modules.Performance")
---@param isOptional boolean If true, returns null-object on failure; if false, throws error
---@return table module The loaded module or a null-object stub
function ModuleLoader.safeRequire(modulePath, isOptional)
-- Check if already loaded
if ModuleLoader._registry[modulePath] then
return ModuleLoader._registry[modulePath]
end
-- Attempt to load the module
local success, result = pcall(require, modulePath)
if success then
-- Module loaded successfully
ModuleLoader._registry[modulePath] = result
return result
else
-- Module failed to load
if isOptional then
-- Create null-object stub for optional module
local stub = createNullObject(modulePath)
ModuleLoader._registry[modulePath] = stub
-- Log warning about missing optional module
if ModuleLoader._ErrorHandler then
ModuleLoader._ErrorHandler:warn("ModuleLoader", "MOD_001", {
modulePath = modulePath,
})
end
return stub
else
-- Required module is missing - throw error
error(string.format("Required module '%s' not found: %s", modulePath, tostring(result)))
end
end
end
--- Check if a module is actually loaded (not a stub)
---@param modulePath string Full path to the module
---@return boolean isLoaded True if module is loaded, false if it's a stub or not loaded
function ModuleLoader.isModuleLoaded(modulePath)
local module = ModuleLoader._registry[modulePath]
if not module then
return false
end
-- Check if it's a stub
return not module._isStub
end
--- Get list of all loaded modules
---@return table modules List of module paths that are actually loaded (not stubs)
function ModuleLoader.getLoadedModules()
local loaded = {}
for path, module in pairs(ModuleLoader._registry) do
if not module._isStub then
table.insert(loaded, path)
end
end
return loaded
end
--- Get list of all stub modules
---@return table stubs List of module paths that are stubs
function ModuleLoader.getStubModules()
local stubs = {}
for path, module in pairs(ModuleLoader._registry) do
if module._isStub then
table.insert(stubs, path)
end
end
return stubs
end
--- Clear the module registry (useful for testing)
function ModuleLoader._clearRegistry()
ModuleLoader._registry = {}
end
return ModuleLoader
+217
View File
@@ -0,0 +1,217 @@
local modulePath = (...):match("(.-)[^%.]+$")
local ImageScaler = require(modulePath .. "ImageScaler")
local NinePatch = {}
-- ErrorHandler will be injected via init
local ErrorHandler = nil
--- Initialize NinePatch with dependencies
---@param deps table Dependencies table with ErrorHandler
function NinePatch.init(deps)
if deps and deps.ErrorHandler then
ErrorHandler = deps.ErrorHandler
end
-- Also initialize ImageScaler since it's a dependency
if ImageScaler.init then
ImageScaler.init(deps)
end
end
--- Draw a 9-patch component using Android-style rendering
--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only
---@param component ThemeComponent
---@param atlas love.Image
---@param x number -- X position (top-left corner)
---@param y number -- Y position (top-left corner)
---@param width number -- Total width (border-box)
---@param height number -- Total height (border-box)
---@param opacity number?
---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier)
---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm
function NinePatch.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm)
if not component or not atlas then
return
end
opacity = opacity or 1
love.graphics.setColor(1, 1, 1, opacity)
local regions = component.regions
-- Extract border dimensions from regions (in pixels)
local left = regions.topLeft.w
local right = regions.topRight.w
local top = regions.topLeft.h
local bottom = regions.bottomLeft.h
local centerW = regions.middleCenter.w
local centerH = regions.middleCenter.h
-- Calculate content area (space remaining after borders)
local contentWidth = width - left - right
local contentHeight = height - top - bottom
-- Clamp to prevent negative dimensions
contentWidth = math.max(0, contentWidth)
contentHeight = math.max(0, contentHeight)
-- Calculate stretch scales for edges and center
local scaleX = contentWidth / centerW
local scaleY = contentHeight / centerH
-- Create quads for each region
local atlasWidth, atlasHeight = atlas:getDimensions()
local function makeQuad(region)
return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight)
end
-- Get corner scale multiplier
-- Priority: element-level override > component setting > default (nil = no scaling)
local scaleCorners = elementScaleCorners
if scaleCorners == nil then
scaleCorners = component.scaleCorners
end
-- Priority: element-level override > component setting > default ("bilinear")
local scalingAlgorithm = elementScalingAlgorithm
if scalingAlgorithm == nil then
scalingAlgorithm = component.scalingAlgorithm or "bilinear"
end
if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then
-- Initialize cache if needed
if not component._scaledRegionCache then
component._scaledRegionCache = {}
end
-- Use the numeric scale multiplier directly
local scaleFactor = scaleCorners
-- Helper to get or create scaled region
local function getScaledRegion(regionName, region, targetWidth, targetHeight)
local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm)
if component._scaledRegionCache[cacheKey] then
return component._scaledRegionCache[cacheKey]
end
-- Get ImageData from component (stored during theme loading)
local atlasData = component._loadedAtlasData
if not atlasData then
ErrorHandler.error(
"NinePatch",
"REN_007",
"No ImageData available for atlas. Image must be loaded with safeLoadImage.",
{
componentType = component.type,
}
)
end
local scaledData
if scalingAlgorithm == "nearest" then
scaledData =
ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
else
scaledData =
ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight)
end
-- Convert to image and cache
local scaledImage = love.graphics.newImage(scaledData)
component._scaledRegionCache[cacheKey] = scaledImage
return scaledImage
end
-- Calculate scaled dimensions for corners
local scaledLeft = math.floor(left * scaleFactor + 0.5)
local scaledRight = math.floor(right * scaleFactor + 0.5)
local scaledTop = math.floor(top * scaleFactor + 0.5)
local scaledBottom = math.floor(bottom * scaleFactor + 0.5)
-- CORNERS (scaled using algorithm)
local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop)
local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop)
local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom)
local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom)
love.graphics.draw(topLeftScaled, x, y)
love.graphics.draw(topRightScaled, x + width - scaledRight, y)
love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom)
love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom)
-- Update content dimensions to account for scaled borders
local adjustedContentWidth = width - scaledLeft - scaledRight
local adjustedContentHeight = height - scaledTop - scaledBottom
adjustedContentWidth = math.max(0, adjustedContentWidth)
adjustedContentHeight = math.max(0, adjustedContentHeight)
-- Recalculate stretch scales
local adjustedScaleX = adjustedContentWidth / centerW
local adjustedScaleY = adjustedContentHeight / centerH
-- TOP/BOTTOM EDGES (stretch horizontally, scale vertically)
if adjustedContentWidth > 0 then
local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop)
local bottomCenterScaled =
getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom)
love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1)
love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1)
end
-- LEFT/RIGHT EDGES (stretch vertically, scale horizontally)
if adjustedContentHeight > 0 then
local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h)
local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h)
love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY)
love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY)
end
-- CENTER (stretch both dimensions, no scaling)
if adjustedContentWidth > 0 and adjustedContentHeight > 0 then
love.graphics.draw(
atlas,
makeQuad(regions.middleCenter),
x + scaledLeft,
y + scaledTop,
0,
adjustedScaleX,
adjustedScaleY
)
end
else
-- Original rendering logic (no scaling)
-- CORNERS (no scaling - 1:1 pixel perfect)
love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y)
love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y)
love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight)
love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight)
-- TOP/BOTTOM EDGES (stretch horizontally only)
if contentWidth > 0 then
love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1)
love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1)
end
-- LEFT/RIGHT EDGES (stretch vertically only)
if contentHeight > 0 then
love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY)
love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY)
end
-- CENTER (stretch both dimensions)
if contentWidth > 0 and contentHeight > 0 then
love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY)
end
end
-- Reset color
love.graphics.setColor(1, 1, 1, 1)
end
return NinePatch
+351
View File
@@ -0,0 +1,351 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- All numeric, range, type, and enum validation lives here.
-- `clamp` is injected via init() to avoid a cross-import into utils.
-- `ErrorHandler` is injected via init() so error reporting routes through
-- the shared handler (matching the pre-split behavior of utils.validate*).
local ErrorHandler = nil
local clamp = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = table, clamp = function }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler or ErrorHandler
clamp = deps.clamp or clamp
end
end
-- Numeric validation utilities
--- Check if a value is NaN (not-a-number)
--- @param value any Value to check
--- @return boolean
local function isNaN(value)
return type(value) == "number" and value ~= value
end
--- Check if a value is Infinity
--- @param value any Value to check
--- @return boolean
local function isInfinity(value)
return type(value) == "number" and (value == math.huge or value == -math.huge)
end
--- Validate a numeric value with comprehensive checks
--- @param value any Value to validate
--- @param options table? Validation options
--- @return boolean, string?, number? Returns valid, errorMessage, sanitizedValue
local function validateNumber(value, options)
options = options or {}
-- Check if value is a number type
if type(value) ~= "number" then
if options.default ~= nil then
return true, nil, options.default
end
return false, string.format("Value must be a number, got %s", type(value)), nil
end
-- Check for NaN
if isNaN(value) then
if not options.allowNaN then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is NaN (not-a-number)", nil
end
end
-- Check for Infinity
if isInfinity(value) then
if not options.allowInfinity then
if options.default ~= nil then
return true, nil, options.default
end
return false, "Value is Infinity", nil
end
end
-- Check for integer requirement
if options.integer and math.floor(value) ~= value then
return false, string.format("Value must be an integer, got %s", value), nil
end
-- Check for positive requirement
if options.positive and value <= 0 then
return false, string.format("Value must be positive, got %s", value), nil
end
-- Check bounds
if options.min and value < options.min then
return false, string.format("Value %s is below minimum %s", value, options.min), nil
end
if options.max and value > options.max then
return false, string.format("Value %s is above maximum %s", value, options.max), nil
end
return true, nil, value
end
--- Sanitize a numeric value (never errors, always returns valid number)
--- @param value any Value to sanitize
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @param default number? Default value for invalid inputs
--- @return number Sanitized value
local function sanitizeNumber(value, min, max, default)
default = default or 0
min = min or -math.huge
max = max or math.huge
-- Convert to number if possible
if type(value) == "string" then
value = tonumber(value)
end
-- Handle non-numeric
if type(value) ~= "number" then
return default
end
-- Handle NaN
if isNaN(value) then
return default
end
-- Handle Infinity
if value == math.huge then
return max
end
if value == -math.huge then
return min
end
-- Clamp to range
return clamp(value, min, max)
end
--- Validate and convert to integer
--- @param value any Value to validate
--- @param min number? Minimum value
--- @param max number? Maximum value
--- @return boolean, string?, number? Returns valid, errorMessage, integerValue
local function validateInteger(value, min, max)
local valid, err, sanitized = validateNumber(value, {
min = min,
max = max,
integer = true,
})
if not valid then
return false, err, nil
end
return true, nil, math.floor(sanitized or value)
end
--- Validate and normalize percentage value
--- @param value any Value to validate (can be "50%", 0.5, or 50)
--- @return boolean, string?, number? Returns valid, errorMessage, normalizedValue (0-1)
local function validatePercentage(value)
-- Handle string percentage
if type(value) == "string" then
local num = value:match("^(%d+%.?%d*)%%$")
if num then
value = tonumber(num)
if value then
value = value / 100
end
else
value = tonumber(value)
end
end
if type(value) ~= "number" then
return false, "Percentage must be a number", nil
end
if isNaN(value) or isInfinity(value) then
return false, "Percentage cannot be NaN or Infinity", nil
end
-- If value is > 1, assume it's 0-100 range
if value > 1 then
value = value / 100
end
-- Clamp to 0-1
value = clamp(value, 0, 1)
return true, nil, value
end
--- Validate opacity value (0-1)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, opacityValue
local function validateOpacity(value)
return validateNumber(value, { min = 0, max = 1, default = 1 })
end
--- Validate degree value (0-360)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, degreeValue
local function validateDegrees(value)
local valid, err, sanitized = validateNumber(value)
if not valid then
return false, err, nil
end
-- Normalize to 0-360 range
local degrees = sanitized or value
degrees = degrees % 360
if degrees < 0 then
degrees = degrees + 360
end
return true, nil, degrees
end
--- Validate coordinate value (pixel position)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, coordinateValue
local function validateCoordinate(value)
return validateNumber(value, {
allowNaN = false,
allowInfinity = false,
})
end
--- Validate dimension value (width/height, must be non-negative)
--- @param value any Value to validate
--- @return boolean, string?, number? Returns valid, errorMessage, dimensionValue
local function validateDimension(value)
return validateNumber(value, {
min = 0,
allowNaN = false,
allowInfinity = false,
})
end
--- Validate that a value is in an enum table
---@param value any Value to validate
---@param enumTable table Enum table with valid values
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateEnum(value, enumTable, propName, moduleName)
if value == nil then
return true
end
for _, validValue in pairs(enumTable) do
if value == validValue then
return true
end
end
-- Build list of valid options
local validOptions = {}
for _, v in pairs(enumTable) do
table.insert(validOptions, "'" .. v .. "'")
end
table.sort(validOptions)
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_007", {
property = propName,
expected = table.concat(validOptions, ", "),
got = tostring(value),
})
else
error(
string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))
)
end
end
--- Validate that a numeric value is within a range
---@param value any Value to validate
---@param min number Minimum allowed value
---@param max number Maximum allowed value
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateRange(value, min, max, propName, moduleName)
if value == nil then
return true
end
if type(value) ~= "number" then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_001", {
property = propName,
expected = "number",
got = type(value),
})
else
error(string.format("%s must be a number, got %s", propName, type(value)))
end
elseif value < min or value > max then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_002", {
property = propName,
min = tostring(min),
max = tostring(max),
value = tostring(value),
})
else
error(
string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))
)
end
end
return true
end
--- Validate that a value is of the expected type
---@param value any Value to validate
---@param expectedType string Expected type name
---@param propName string Property name for error messages
---@param moduleName string? Module name for error messages (default: "Element")
---@return boolean True if valid
local function validateType(value, expectedType, propName, moduleName)
if value == nil then
return true
end
local actualType = type(value)
if actualType ~= expectedType then
if ErrorHandler then
ErrorHandler:error(moduleName or "Element", "VAL_001", {
property = propName,
expected = expectedType,
got = actualType,
})
else
error(string.format("%s must be %s, got %s", propName, expectedType, actualType))
end
end
return true
end
return {
init = init,
isNaN = isNaN,
isInfinity = isInfinity,
validateNumber = validateNumber,
sanitizeNumber = sanitizeNumber,
validateInteger = validateInteger,
validatePercentage = validatePercentage,
validateOpacity = validateOpacity,
validateDegrees = validateDegrees,
validateCoordinate = validateCoordinate,
validateDimension = validateDimension,
validateEnum = validateEnum,
validateRange = validateRange,
validateType = validateType,
}
+198
View File
@@ -0,0 +1,198 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Path sanitization, validation, and file-extension helpers.
-- Uses love.filesystem when available (optional) for existence checks.
--- Normalize a file path for consistent cache keys
---@param path string File path to normalize
---@return string Normalized path
local function normalizePath(path)
path = path:match("^%s*(.-)%s*$")
path = path:gsub("\\", "/")
path = path:gsub("/+", "/")
return path
end
--- Sanitize a file path
--- @param path string Path to sanitize
--- @return string Sanitized path
local function sanitizePath(path)
if path == nil then
return ""
end
path = tostring(path)
-- Trim whitespace
path = path:match("^%s*(.-)%s*$") or ""
-- Normalize separators to forward slash
path = path:gsub("\\", "/")
-- Remove duplicate slashes
path = path:gsub("/+", "/")
-- Remove trailing slash (except for root)
if #path > 1 and path:sub(-1) == "/" then
path = path:sub(1, -2)
end
return path
end
--- Check if a path is safe (no traversal attacks)
--- @param path string Path to check
--- @param baseDir string? Base directory to check against (optional)
--- @return boolean, string? Returns true if safe, or false with reason
local function isPathSafe(path, baseDir)
if path == nil or path == "" then
return false, "Path is empty"
end
-- Sanitize the path
path = sanitizePath(path)
-- Check for suspicious patterns
if path:match("%.%.") then
return false, "Path contains '..' (parent directory reference)"
end
-- Check for null bytes
if path:match("%z") then
return false, "Path contains null bytes"
end
-- Check for encoded traversal attempts (including double-encoding)
local lowerPath = path:lower()
if
lowerPath:match("%%2e")
or lowerPath:match("%%2f")
or lowerPath:match("%%5c")
or lowerPath:match("%%252e")
or lowerPath:match("%%252f")
or lowerPath:match("%%255c")
then
return false, "Path contains URL-encoded directory separators"
end
-- If baseDir is provided, ensure path is within it
if baseDir then
baseDir = sanitizePath(baseDir)
-- For relative paths, prepend baseDir
local fullPath = path
if not path:match("^/") and not path:match("^%a:") then
fullPath = baseDir .. "/" .. path
end
fullPath = sanitizePath(fullPath)
-- Check if fullPath starts with baseDir
if not fullPath:match("^" .. baseDir:gsub("[%(%)%.%%%+%-%*%?%[%]%^%$]", "%%%1")) then
return false, "Path is outside allowed directory"
end
end
return true, nil
end
--- Validate a file path with comprehensive checks
--- @param path string Path to validate
--- @param options table? Validation options
--- @return boolean, string? Returns true if valid, or false with error message
local function validatePath(path, options)
options = options or {}
-- Check path is not nil/empty
if path == nil or path == "" then
return false, "Path is empty"
end
path = tostring(path)
-- Check maximum length
local maxLength = options.maxLength or 4096
if #path > maxLength then
return false, string.format("Path exceeds maximum length of %d characters", maxLength)
end
-- Sanitize path
path = sanitizePath(path)
-- Check for safety (traversal attacks)
local safe, reason = isPathSafe(path, options.baseDir)
if not safe then
return false, reason
end
-- Check allowed extensions
if options.allowedExtensions then
local ext = path:match("%.([^%.]+)$")
if not ext then
return false, "Path has no file extension"
end
ext = ext:lower()
local allowed = false
for _, allowedExt in ipairs(options.allowedExtensions) do
if ext == allowedExt:lower() then
allowed = true
break
end
end
if not allowed then
return false, string.format("File extension '%s' is not allowed", ext)
end
end
-- Check if file must exist
if options.mustExist and love and love.filesystem then
local info = love.filesystem.getInfo(path)
if not info then
return false, "File does not exist"
end
end
return true, nil
end
--- Get file extension from path
--- @param path string File path
--- @return string? extension File extension (lowercase) or nil
local function getFileExtension(path)
if not path then
return nil
end
local ext = path:match("%.([^%.]+)$")
return ext and ext:lower() or nil
end
--- Check if path has allowed extension
--- @param path string File path
--- @param allowedExtensions table Array of allowed extensions
--- @return boolean
local function hasAllowedExtension(path, allowedExtensions)
local ext = getFileExtension(path)
if not ext then
return false
end
for _, allowedExt in ipairs(allowedExtensions) do
if ext == allowedExt:lower() then
return true
end
end
return false
end
return {
normalizePath = normalizePath,
sanitizePath = sanitizePath,
isPathSafe = isPathSafe,
validatePath = validatePath,
getFileExtension = getFileExtension,
hasAllowedExtension = hasAllowedExtension,
}
+560
View File
@@ -0,0 +1,560 @@
---@class Performance
---@field enabled boolean
---@field hudEnabled boolean
---@field hudToggleKey string
---@field hudPosition {x: number, y: number}
---@field warningThresholdMs number
---@field criticalThresholdMs number
---@field logToConsole boolean
---@field logWarnings boolean
---@field warningsEnabled boolean
---@field _ErrorHandler table?
---@field _timers table
---@field _metrics table
---@field _lastMetricsCleanup number
---@field _frameMetrics table
---@field _memoryMetrics table
---@field _warnings table
---@field _lastFrameStart number?
---@field _shownWarnings table
---@field _memoryProfiler table
local Performance = {}
Performance.__index = Performance
---@type Performance|nil
local instance = nil
local METRICS_CLEANUP_INTERVAL = 30
local METRICS_RETENTION_TIME = 10
local MAX_METRICS_COUNT = 500
local CORE_METRICS = { frame = true, layout = true, render = true }
---@param config {enabled?: boolean, hudEnabled?: boolean, hudToggleKey?: string, hudPosition?: {x: number, y: number}, warningThresholdMs?: number, criticalThresholdMs?: number, logToConsole?: boolean, logWarnings?: boolean, warningsEnabled?: boolean, memoryProfiling?: boolean}?
---@param deps {ErrorHandler: ErrorHandler}
---@return Performance
function Performance.init(config, deps)
if instance == nil then
local self = setmetatable({}, Performance)
-- Configuration
self.enabled = config and config.enabled or false
self.hudEnabled = config and config.hudEnabled or false
self.hudToggleKey = config and config.hudToggleKey or "f3"
self.hudPosition = config and config.hudPosition or { x = 10, y = 10 }
self.warningThresholdMs = config and config.warningThresholdMs or 13.0
self.criticalThresholdMs = config and config.criticalThresholdMs or 16.67
self.logToConsole = config and config.logToConsole or false
self.logWarnings = config and config.logWarnings or true
self.warningsEnabled = config and config.warningsEnabled or true
self._timers = {}
self._metrics = {}
self._lastMetricsCleanup = 0
self._frameMetrics = {
frameCount = 0,
totalTime = 0,
lastFrameTime = 0,
minFrameTime = math.huge,
maxFrameTime = 0,
fps = 0,
lastFpsUpdate = 0,
fpsUpdateInterval = 0.5,
}
self._memoryMetrics = {
current = 0,
peak = 0,
gcCount = 0,
lastGcCheck = 0,
}
self._warnings = {}
self._lastFrameStart = nil
self._shownWarnings = {}
self._memoryProfiler = {
enabled = config and config.memoryProfiling or false,
sampleInterval = 60,
framesSinceLastSample = 0,
samples = {},
maxSamples = 20,
monitoredTables = {},
}
self._ErrorHandler = deps and deps.ErrorHandler
instance = self
end
return instance
end
--- Toggle HUD visibility
function Performance:toggleHUD()
self.hudEnabled = not self.hudEnabled
end
function Performance:startTimer(name)
if not self.enabled then
return
end
self._timers[name] = love.timer.getTime()
end
function Performance:stopTimer(name)
if not self.enabled then
return nil
end
local startTime = self._timers[name]
if not startTime then
-- Silently return nil if timer wasn't started
-- This can happen legitimately when Performance is toggled mid-frame
-- or when layout functions have early returns
return nil
end
local elapsed = (love.timer.getTime() - startTime) * 1000
self._timers[name] = nil
-- Update metrics
if not self._metrics[name] then
self._metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
lastUsed = love.timer.getTime(),
}
end
local m = self._metrics[name]
m.total = m.total + elapsed
m.count = m.count + 1
m.min = math.min(m.min, elapsed)
m.max = math.max(m.max, elapsed)
m.average = m.total / m.count
m.lastUsed = love.timer.getTime()
-- Check for warnings
if elapsed > self.criticalThresholdMs then
self:_addWarning(name, elapsed, "critical")
elseif elapsed > self.warningThresholdMs then
self:_addWarning(name, elapsed, "warning")
end
if self.logToConsole then
-- Use ErrorHandler if available, otherwise fall back to print
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn("Performance", "PERF_001", {
metric = name,
elapsed = string.format("%.3fms", elapsed),
})
else
print(string.format("[Performance] %s: %.3fms", name, elapsed))
end
end
return elapsed
end
--- Update with actual delta time from LÖVE (call from love.update)
---@param dt number Delta time in seconds
function Performance:updateDeltaTime(dt)
if not self.enabled then
return
end
local now = love.timer.getTime()
if now - self._frameMetrics.lastFpsUpdate >= self._frameMetrics.fpsUpdateInterval then
if dt > 0 then
self._frameMetrics.fps = math.floor(1 / dt + 0.5)
end
self._frameMetrics.lastFpsUpdate = now
end
end
--- Start frame timing (call at beginning of frame)
function Performance:startFrame()
if not self.enabled then
return
end
self._lastFrameStart = love.timer.getTime()
self:_updateMemory()
end
function Performance:endFrame()
if not self.enabled or not self._lastFrameStart then
return
end
local now = love.timer.getTime()
local frameTime = (now - self._lastFrameStart) * 1000
self._frameMetrics.lastFrameTime = frameTime
self._frameMetrics.totalTime = self._frameMetrics.totalTime + frameTime
self._frameMetrics.frameCount = self._frameMetrics.frameCount + 1
self._frameMetrics.minFrameTime = math.min(self._frameMetrics.minFrameTime, frameTime)
self._frameMetrics.maxFrameTime = math.max(self._frameMetrics.maxFrameTime, frameTime)
if frameTime > self.criticalThresholdMs then
self:_addWarning("frame", frameTime, "critical")
end
self:updateMemoryProfiling()
-- Periodic metrics cleanup
if now - self._lastMetricsCleanup >= METRICS_CLEANUP_INTERVAL then
local cleanupTime = now - METRICS_RETENTION_TIME
for name, data in pairs(self._metrics) do
if not CORE_METRICS[name] and data.lastUsed and data.lastUsed < cleanupTime then
self._metrics[name] = nil
end
end
self._lastMetricsCleanup = now
end
-- Enforce max metrics limit
local metricsCount = 0
for _ in pairs(self._metrics) do
metricsCount = metricsCount + 1
end
if metricsCount > MAX_METRICS_COUNT then
local sortedMetrics = {}
for name, data in pairs(self._metrics) do
if not CORE_METRICS[name] then
table.insert(sortedMetrics, { name = name, lastUsed = data.lastUsed or 0 })
end
end
table.sort(sortedMetrics, function(a, b)
return a.lastUsed < b.lastUsed
end)
local toRemove = metricsCount - MAX_METRICS_COUNT
for i = 1, math.min(toRemove, #sortedMetrics) do
self._metrics[sortedMetrics[i].name] = nil
end
end
end
--- Update memory metrics
function Performance:_updateMemory()
if not self.enabled then
return
end
local memKb = collectgarbage("count")
self._memoryMetrics.current = memKb
self._memoryMetrics.peak = math.max(self._memoryMetrics.peak, memKb)
local now = love.timer.getTime()
if now - self._memoryMetrics.lastGcCheck >= 1.0 then
self._memoryMetrics.gcCount = self._memoryMetrics.gcCount + 1
self._memoryMetrics.lastGcCheck = now
end
end
--- Add a performance warning (private)
--- @param name string Metric name
--- @param value number Metric value
--- @param level "warning"|"critical" Warning level
function Performance:_addWarning(name, value, level)
if not self.logWarnings then
return
end
local warning = {
name = name,
value = value,
level = level,
time = love.timer.getTime(),
}
table.insert(self._warnings, warning)
if #self._warnings > 100 then
table.remove(self._warnings, 1)
end
if self.logToConsole or self.warningsEnabled then
local warningKey = name .. "_" .. level
local lastWarningTime = self._shownWarnings[warningKey] or 0
local now = love.timer.getTime()
if now - lastWarningTime >= 60 then
if self._ErrorHandler and self._ErrorHandler.warn then
local code = level == "critical" and "PERF_002" or "PERF_001"
self._ErrorHandler:warn("Performance", code, {
metric = name,
value = string.format("%.2fms", value),
threshold = level == "critical" and self.criticalThresholdMs or self.warningThresholdMs,
})
end
self._shownWarnings[warningKey] = now
end
end
end
--- Render performance HUD
--- @param x number? X position (default: 10)
--- @param y number? Y position (default: 10)
function Performance:renderHUD(x, y)
if not self.hudEnabled then
return
end
x = x or self.hudPosition.x
y = y or self.hudPosition.y
self:_updateMemory()
local fm = self._frameMetrics
local mm = self._memoryMetrics
love.graphics.setColor(0, 0, 0, 0.8)
love.graphics.rectangle("fill", x, y, 300, 220)
love.graphics.setColor(1, 1, 1, 1)
local lineHeight = 18
local currentY = y + 10
-- FPS
local fpsColor = { 1, 1, 1 }
if fm.lastFrameTime > self.criticalThresholdMs then
fpsColor = { 1, 0, 0 }
elseif fm.lastFrameTime > self.warningThresholdMs then
fpsColor = { 1, 1, 0 }
end
love.graphics.setColor(fpsColor)
love.graphics.print(string.format("FPS: %d (%.2fms)", fm.fps, fm.lastFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.setColor(1, 1, 1, 1)
local avgFrame = fm.frameCount > 0 and fm.totalTime / fm.frameCount or 0
love.graphics.print(string.format("Avg Frame: %.2fms", avgFrame), x + 10, currentY)
currentY = currentY + lineHeight
love.graphics.print(string.format("Min/Max: %.2f/%.2fms", fm.minFrameTime, fm.maxFrameTime), x + 10, currentY)
currentY = currentY + lineHeight
local currentMb = mm.current / 1024
local peakMb = mm.peak / 1024
love.graphics.print(string.format("Memory: %.2f MB (peak: %.2f MB)", currentMb, peakMb), x + 10, currentY)
currentY = currentY + lineHeight
local metricsCount = 0
for _ in pairs(self._metrics) do
metricsCount = metricsCount + 1
end
local metricsColor = metricsCount > MAX_METRICS_COUNT * 0.8 and { 1, 0.5, 0 } or { 1, 1, 1 }
love.graphics.setColor(metricsColor)
love.graphics.print(string.format("Metrics: %d/%d", metricsCount, MAX_METRICS_COUNT), x + 10, currentY)
currentY = currentY + lineHeight + 5
-- Top timings
love.graphics.setColor(1, 1, 1, 1)
local sortedMetrics = {}
for name, data in pairs(self._metrics) do
table.insert(sortedMetrics, { name = name, average = data.average })
end
table.sort(sortedMetrics, function(a, b)
return a.average > b.average
end)
love.graphics.print("Top Timings:", x + 10, currentY)
currentY = currentY + lineHeight
for i = 1, math.min(5, #sortedMetrics) do
local m = sortedMetrics[i]
love.graphics.print(string.format(" %s: %.3fms", m.name, m.average), x + 10, currentY)
currentY = currentY + lineHeight
end
if #self._warnings > 0 then
love.graphics.setColor(1, 0.5, 0, 1)
love.graphics.print(string.format("Warnings: %d", #self._warnings), x + 10, currentY)
end
end
--- Handle keyboard input for HUD toggle
--- @param key string Key pressed
function Performance:keypressed(key)
if key == self.hudToggleKey then
self:toggleHUD()
end
end
--- Log a performance warning (only once per warning key)
--- @param warningKey string Unique key for this warning type
--- @param module string Module name (e.g., "LayoutEngine", "Element")
--- @param message string Warning message
--- @param details table? Additional details
--- @param suggestion string? Optimization suggestion
function Performance:logWarning(warningKey, module, message, details, suggestion)
if not self.warningsEnabled then
return
end
if self._shownWarnings[warningKey] then
return
end
self._shownWarnings[warningKey] = true
local count = 0
for _ in pairs(self._shownWarnings) do
count = count + 1
end
if count > 1000 then
self._shownWarnings = { [warningKey] = true }
end
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn(module, "PERF_001", details or {})
end
end
--- Track a counter metric (increments per frame)
--- @param name string Counter name
--- @param value number? Value to add (default: 1)
function Performance:incrementCounter(name, value)
if not self.enabled then
return
end
value = value or 1
if not self._metrics[name] then
self._metrics[name] = {
total = 0,
count = 0,
min = math.huge,
max = 0,
average = 0,
frameValue = 0,
lastUsed = love.timer.getTime(),
}
end
local m = self._metrics[name]
m.frameValue = (m.frameValue or 0) + value
m.lastUsed = love.timer.getTime()
end
--- Reset frame counters (call at end of frame)
function Performance:resetFrameCounters()
if not self.enabled then
return
end
local now = love.timer.getTime()
local toRemove = {}
for name, data in pairs(self._metrics) do
if data.frameValue then
if data.frameValue > 0 then
data.total = data.total + data.frameValue
data.count = data.count + 1
data.min = math.min(data.min, data.frameValue)
data.max = math.max(data.max, data.frameValue)
data.average = data.total / data.count
data.lastUsed = now
end
data.frameValue = 0
if data.count == 0 and not CORE_METRICS[name] then
table.insert(toRemove, name)
end
end
end
for _, name in ipairs(toRemove) do
self._metrics[name] = nil
end
end
--- Register a table for memory leak monitoring
--- @param name string Friendly name for the table
--- @param tableRef table Reference to the table to monitor
function Performance:registerTableForMonitoring(name, tableRef)
self._memoryProfiler.monitoredTables[name] = tableRef
end
function Performance:_sampleMemory()
local sample = {
time = love.timer.getTime(),
memory = collectgarbage("count") / 1024, -- MB
tableSizes = {},
}
local function getTableSize(tbl)
local count = 0
for _ in pairs(tbl) do
count = count + 1
end
return count
end
for name, tableRef in pairs(self._memoryProfiler.monitoredTables) do
sample.tableSizes[name] = getTableSize(tableRef)
end
table.insert(self._memoryProfiler.samples, sample)
-- Keep only maxSamples
if #self._memoryProfiler.samples > self._memoryProfiler.maxSamples then
table.remove(self._memoryProfiler.samples, 1)
end
-- Check for memory leaks (consistent growth)
if #self._memoryProfiler.samples >= 5 then
for name, _ in pairs(self._memoryProfiler.monitoredTables) do
local sizes = {}
for i = math.max(1, #self._memoryProfiler.samples - 4), #self._memoryProfiler.samples do
table.insert(sizes, self._memoryProfiler.samples[i].tableSizes[name])
end
-- Check if table is consistently growing
local growing = true
for i = 2, #sizes do
if sizes[i] <= sizes[i - 1] then
growing = false
break
end
end
if growing and sizes[#sizes] > sizes[1] * 1.5 then
self:_addWarning("memory_leak", sizes[#sizes], "warning")
if not self._shownWarnings[name] then
local message = string.format("Table '%s' growing consistently", name)
if self._ErrorHandler and self._ErrorHandler.warn then
self._ErrorHandler:warn("Performance", "MEM_001", {
table = name,
initialSize = sizes[1],
currentSize = sizes[#sizes],
growthPercent = math.floor(((sizes[#sizes] / sizes[1]) - 1) * 100),
})
end
self._shownWarnings[name] = true
end
elseif not growing then
self._shownWarnings[name] = nil
end
end
end
end
--- Update memory profiling (call from endFrame)
function Performance:updateMemoryProfiling()
if not self._memoryProfiler.enabled then
return
end
self._memoryProfiler.framesSinceLastSample = self._memoryProfiler.framesSinceLastSample + 1
if self._memoryProfiler.framesSinceLastSample >= self._memoryProfiler.sampleInterval then
self:_sampleMemory()
self._memoryProfiler.framesSinceLastSample = 0
end
end
return Performance
+505
View File
@@ -0,0 +1,505 @@
-- modules/PropertySchema.lua
--
-- Declarative source of truth for every Element prop.
--
-- Each entry describes one prop that Element.new / Element:setProperty currently
-- handles inline. Downstream tasks (03 data-driven prop binding, 05 registry-driven
-- setProperty dispatch) read this metadata instead of hardcoding property names.
--
-- Design constraints (locked — tasks 03/05 depend on this API):
-- * Pure Lua — NO `love` import, NO dependency on utils/Color/Units/ErrorHandler.
-- Normalizers/validators are small, dependency-free closures so the module is
-- unit-testable standalone. Color/^/unit/enum *defaults* that require those
-- modules are left as `nil` here and applied by construction-time special
-- handlers in Task 03; only defaults expressible as literals are stored.
-- * O(1) lookup — `get(name)` is a single table index into a pre-built registry;
-- no per-call construction.
-- * Additive — `define(specs)` merges entries by name so build profiles can
-- extend/override without rebuilding the whole table.
--
-- Metadata shape per prop (all fields present, false/nil when not applicable):
-- type string — type tag for tooling ("number"|"string"|"boolean"|
-- "table"|"function"|"color"|"any")
-- default any|nil — literal default value applied when prop is absent
-- normalizer fn|nil — pure fn(value) -> value; transforms input before
-- storage (e.g. single-value padding -> 4-side table)
-- validator fn|nil — pure fn(value) -> bool; returns false for invalid
-- input (Task 03 warns + falls back on false)
-- isDimension boolean — true for width/height: setProperty routes these
-- through _resolveDimensionProperty (unit-string
-- resolution + border-box sync). Other unit-accepting
-- props (x/y/gap/padding/etc.) are resolved at
-- construction via special handlers, NOT via this flag.
-- affectsLayout boolean — true for props in the legacy setProperty
-- `layoutProperties` table; setting one invalidates
-- layout (matches baseline behavior exactly).
-- syncsTheme boolean — true for props whose setProperty path must reach
-- ThemeManager/Renderer (disabled/active/themeComponent)
-- hasDeferred boolean — true for callbacks that have an `on<Name>Deferred`
-- boolean companion prop (auto-wired by Task 03)
-- storageKey string|nil— when set, the prop is stored on the element under
-- this key instead of its own name (prop aliases, e.g.
-- isDisabled -> stored as `disabled`)
local PropertySchema = {}
---@type table<string, table>
local registry = {}
-- ---------------------------------------------------------------------------
-- Pure normalizers (small + dependency-free; hot-pathed during construction)
-- ---------------------------------------------------------------------------
--- Expand a single value to a 4-side table. Leaves tables unchanged. nil passthrough.
--- Used by padding/margin: `padding = 5` -> `{top=5,right=5,bottom=5,left=5}`.
local function expandSides(value)
if value == nil then
return nil
end
if type(value) == "table" then
return value
end
return { top = value, right = value, bottom = value, left = value }
end
--- Normalize flex direction aliases to internal enum names.
--- "row" -> "horizontal", "column" -> "vertical",
--- "row-reverse" -> "horizontal-reverse", "column-reverse" -> "vertical-reverse";
--- everything else passes through.
local function normalizeFlexDirection(value)
if value == "row" then
return "horizontal"
elseif value == "column" then
return "vertical"
elseif value == "row-reverse" then
return "horizontal-reverse"
elseif value == "column-reverse" then
return "vertical-reverse"
end
return value
end
--- Replicate Element.new's border-shape normalization (pure).
--- * table with sides: true -> 1, number -> value, false/nil -> false; nil if no
--- truthy side remains.
--- * number / other truthy scalar: kept as-is.
--- * nil / false: nil.
local function normalizeBorder(value)
if value == nil or value == false then
return nil
end
if type(value) == "table" then
local function side(v)
if v == true then
return 1
elseif type(v) == "number" then
return v
else
return false
end
end
local t = side(value.top)
local r = side(value.right)
local b = side(value.bottom)
local l = side(value.left)
if not (t or r or b or l) then
return nil
end
return { top = t, right = r, bottom = b, left = l }
end
return value
end
--- Replicate Element.new's cornerRadius-shape normalization (pure).
--- * number: 0 -> nil, else the number.
--- * table: nil if all four sides are zero/absent, else fill zeros for absent sides.
--- * nil -> nil.
local function normalizeCornerRadius(value)
if value == nil then
return nil
end
if type(value) == "number" then
if value == 0 then
return nil
end
return value
end
if type(value) == "table" then
-- Mirrors Element.new: `or` truthiness (0 is truthy in Lua). Only an all-
-- nil/false table collapses to nil; any present side — including 0 — yields
-- the 4-side table with zero-filled absent sides.
local hasAny = value.topLeft or value.topRight or value.bottomLeft or value.bottomRight
if not hasAny then
return nil
end
return {
topLeft = value.topLeft or 0,
topRight = value.topRight or 0,
bottomLeft = value.bottomLeft or 0,
bottomRight = value.bottomRight or 0,
}
end
return value
end
-- ---------------------------------------------------------------------------
-- Pure validators (dependency-free; return boolean)
-- ---------------------------------------------------------------------------
--- Range validator factory: returns fn(v) -> bool. nil is treated as valid
--- (absence handling is the default mechanism's job).
local function rangeValidator(min, max)
return function(v)
if v == nil then
return true
end
return type(v) == "number" and v >= min and v <= max
end
end
--- Enum validator factory: returns fn(v) -> bool for membership in `set` (set may
--- be an array or a map of value->truthy).
local function enumValidator(set)
local lookup = {}
if type(set) == "table" then
for k, v in pairs(set) do
if type(k) == "number" then
lookup[v] = true
else
lookup[k] = true
end
end
end
return function(v)
if v == nil then
return true
end
return lookup[v] == true
end
end
--- Boolean validator: nil is valid (absence); otherwise must be a boolean.
local function booleanValidator(v)
return v == nil or type(v) == "boolean"
end
-- ---------------------------------------------------------------------------
-- Registry construction
-- ---------------------------------------------------------------------------
--- Build a fully-populated metadata entry, filling omitted fields with defaults.
local function entry(spec)
return {
type = spec.type or "any",
default = spec.default,
normalizer = spec.normalizer,
validator = spec.validator,
isDimension = spec.isDimension == true,
affectsLayout = spec.affectsLayout == true,
syncsTheme = spec.syncsTheme == true,
hasDeferred = spec.hasDeferred == true,
storageKey = spec.storageKey,
}
end
--- Merge prop specs into the registry (additive; later entries override earlier).
---@param specs table<string, table> map of prop-name -> spec
---@return table registry the live registry table (for chaining/inspection)
function PropertySchema.define(specs)
for name, spec in pairs(specs) do
registry[name] = entry(spec)
end
return registry
end
--- O(1) metadata lookup.
---@param name string prop name
---@return table|nil metadata nil for unknown props (no error)
function PropertySchema.get(name)
return registry[name]
end
--- Return the live registry (for inspection / coverage assertions only — not for
--- per-call construction).
---@return table
function PropertySchema.all()
return registry
end
--- True if a prop is registered.
---@param name string
---@return boolean
function PropertySchema.has(name)
return registry[name] ~= nil
end
--- True if setting this prop invalidates layout (legacy `layoutProperties` set).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `layoutProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.affectsLayout(name)
local meta = registry[name]
return meta ~= nil and meta.affectsLayout == true
end
--- True for dimension props (width/height) that `setProperty` routes through
--- `_resolveDimensionProperty` (unit-string resolution + border-box sync).
--- O(1) registry lookup — no per-call table construction. Unknown props return false,
--- matching the legacy `dimensionProperties[name]` nil-lookup behavior exactly.
---@param name string prop name
---@return boolean
function PropertySchema.isDimension(name)
local meta = registry[name]
return meta ~= nil and meta.isDimension == true
end
--- True for props whose setProperty path must reach ThemeManager/Renderer
--- (disabled/active/themeComponent). O(1) registry lookup — no per-call table
--- construction. Unknown props return false, matching a legacy nil-lookup exactly.
---@param name string prop name
---@return boolean
function PropertySchema.syncsTheme(name)
local meta = registry[name]
return meta ~= nil and meta.syncsTheme == true
end
-- ---------------------------------------------------------------------------
-- Default schema (covers every prop handled in Element.new lines 259-1909 and
-- Element:setProperty lines 4291-4417 of the Task-01 baseline).
-- ---------------------------------------------------------------------------
local function defineDefaults()
PropertySchema.define({
-- ------------------------------------------------------------------ identity
id = { type = "string" },
userdata = { type = "any" },
parent = { type = "table", affectsLayout = true },
children = { type = "table" },
-- ------------------------------------------------------------------ callbacks
onEvent = { type = "function", hasDeferred = true },
onFocus = { type = "function", hasDeferred = true },
onBlur = { type = "function", hasDeferred = true },
onTextInput = { type = "function", hasDeferred = true },
onTextChange = { type = "function", hasDeferred = true },
onEnter = { type = "function", hasDeferred = true },
onCreate = { type = "function", hasDeferred = true },
onTouchEvent = { type = "function", hasDeferred = true },
onGesture = { type = "function", hasDeferred = true },
onImageLoad = { type = "function", hasDeferred = true },
onImageError = { type = "function", hasDeferred = true },
-- Deferred companion flags (stored directly; no further Deferred companion)
onEventDeferred = { type = "boolean", default = false },
onFocusDeferred = { type = "boolean", default = false },
onBlurDeferred = { type = "boolean", default = false },
onTextInputDeferred = { type = "boolean", default = false },
onTextChangeDeferred = { type = "boolean", default = false },
onEnterDeferred = { type = "boolean", default = false },
onCreateDeferred = { type = "boolean", default = false },
onTouchEventDeferred = { type = "boolean", default = false },
onGestureDeferred = { type = "boolean", default = false },
onImageLoadDeferred = { type = "boolean", default = false },
onImageErrorDeferred = { type = "boolean", default = false },
-- focus / touch behavior
dropFocusOnSelection = { type = "boolean" },
customDraw = { type = "function" },
touchEnabled = { type = "boolean", default = true },
multiTouchEnabled = { type = "boolean", default = false },
-- ------------------------------------------------------------------ theme
theme = { type = "table" },
themeComponent = { type = "string", syncsTheme = true },
disabled = { type = "boolean", default = false, syncsTheme = true },
isDisabled = {
type = "boolean",
default = false,
syncsTheme = true,
storageKey = "disabled",
},
active = { type = "boolean", default = false, syncsTheme = true },
disableHighlight = { type = "boolean" },
themeStateLock = { type = "boolean" },
themeComponentDisabledStates = { type = "table" },
scaleCorners = { type = "boolean" },
scalingAlgorithm = { type = "string" },
contentAutoSizingMultiplier = { type = "table" },
contentBlur = { type = "table" },
backdropBlur = { type = "table" },
-- ------------------------------------------------------------------ text editing
editable = { type = "boolean", default = false },
multiline = { type = "boolean", default = false },
passwordMode = { type = "boolean", default = false },
textWrap = { type = "string" }, -- default computed from multiline
maxLines = { type = "number" },
maxLength = { type = "number" },
placeholder = { type = "string" },
inputType = { type = "string", default = "text" },
textOverflow = { type = "string", default = "clip" },
scrollable = { type = "boolean" }, -- default = multiline
autoGrow = { type = "boolean" }, -- default = multiline
selectOnFocus = { type = "boolean", default = false },
cursorColor = { type = "color" },
selectionColor = { type = "color" },
cursorBlinkRate = { type = "number", default = 0.5 },
text = { type = "string" },
textAlign = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end", "justify" }),
},
-- textAlignVertical is a derived storage field split out from textAlign
-- (bindVisualState resolves table/compound-string input into H + V). Its
-- validator is exposed for bindVisualState to validate the V component; the
-- prop itself stays in SPECIAL_PROPS because compound parsing needs
-- ErrorHandler warnings (schema is pure-Lua, cannot warn).
textAlignVertical = {
type = "string",
default = "start",
validator = enumValidator({ "start", "center", "end" }),
},
textColor = { type = "color" },
fontFamily = { type = "string" },
textSize = { type = "any" }, -- number | preset string; resolved by special handler
minTextSize = { type = "number" },
maxTextSize = { type = "number" },
autoScaleText = { type = "boolean", default = true },
-- ------------------------------------------------------------------ dimensions / box model
width = { type = "any", isDimension = true, affectsLayout = true },
height = { type = "any", isDimension = true, affectsLayout = true },
x = { type = "any", affectsLayout = false },
y = { type = "any", affectsLayout = false },
minWidth = { type = "any" },
maxWidth = { type = "any" },
minHeight = { type = "any" },
maxHeight = { type = "any" },
gap = { type = "any", affectsLayout = true },
padding = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
margin = {
type = "any",
affectsLayout = true,
normalizer = expandSides,
},
flexDirection = {
type = "string",
default = "horizontal",
affectsLayout = true,
normalizer = normalizeFlexDirection,
},
flexWrap = { type = "string", default = "nowrap", affectsLayout = true },
justifyContent = { type = "string", default = "flex-start", affectsLayout = true },
alignItems = { type = "string", default = "stretch", affectsLayout = true },
alignContent = { type = "string", default = "stretch", affectsLayout = true },
positioning = { type = "string", default = "relative", affectsLayout = true },
gridRows = { type = "number", affectsLayout = true },
gridColumns = { type = "number", affectsLayout = true },
top = { type = "any", affectsLayout = true },
right = { type = "any", affectsLayout = true },
bottom = { type = "any", affectsLayout = true },
left = { type = "any", affectsLayout = true },
columnGap = { type = "any" },
rowGap = { type = "any" },
flex = { type = "any" }, -- shorthand: expands to flexGrow/flexShrink/flexBasis
flexGrow = { type = "number", default = 0, validator = rangeValidator(0, math.huge) },
flexShrink = { type = "number", default = 1, validator = rangeValidator(0, math.huge) },
flexBasis = { type = "any", default = "auto" },
alignSelf = { type = "string", default = "auto" },
justifySelf = { type = "string" },
z = { type = "number", default = 0 },
tabIndex = { type = "number" },
-- ------------------------------------------------------------------ border / background / visual
border = { type = "any", normalizer = normalizeBorder },
borderColor = { type = "color" }, -- default Color.new(0,0,0,1) via special handler
backgroundColor = { type = "color" }, -- default transparent via special handler
opacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
visibility = { type = "string", default = "visible" },
display = {
type = "boolean",
default = true,
validator = booleanValidator,
},
transform = { type = "table" },
cornerRadius = { type = "any", normalizer = normalizeCornerRadius },
-- ------------------------------------------------------------------ image
imagePath = { type = "string" },
image = { type = "table" },
objectFit = {
type = "string",
default = "fill",
validator = enumValidator({ "fill", "contain", "cover", "scale-down", "none" }),
},
objectPosition = { type = "string", default = "center center" },
imageOpacity = {
type = "number",
default = 1,
validator = rangeValidator(0, 1),
},
imageRepeat = {
type = "string",
default = "no-repeat",
validator = enumValidator({
"no-repeat",
"repeat",
"repeat-x",
"repeat-y",
"space",
"round",
}),
},
imageTint = { type = "color" },
-- ------------------------------------------------------------------ scroll / scrollbar
overflow = { type = "string" },
overflowX = { type = "string" },
overflowY = { type = "string" },
scrollbarWidth = { type = "number" },
scrollbarColor = { type = "color" },
scrollbarTrackColor = { type = "color" },
scrollbarRadius = { type = "number" },
scrollbarPadding = { type = "number" },
scrollSpeed = { type = "number" },
invertScroll = { type = "boolean" },
smoothScrollEnabled = { type = "boolean" },
scrollBarStyle = { type = "string" },
scrollbarKnobOffset = { type = "number" },
hideScrollbars = { type = "boolean" },
scrollbarPlacement = { type = "string" },
scrollbarBalance = { type = "number" },
_scrollX = { type = "number", storageKey = "_scrollX" },
_scrollY = { type = "number", storageKey = "_scrollY" },
-- ------------------------------------------------------------------ select
selectParent = { type = "table" },
selectOption = { type = "table" },
-- ------------------------------------------------------------------ transition
transition = { type = "table", default = {} },
})
end
--- (Re)populate the default schema. Idempotent: safe to call from Element.init
--- for build profiles that re-require the module. Returns the live registry.
---@return table registry
function PropertySchema.populate()
defineDefaults()
return registry
end
-- Auto-populate on require so the registry is ready without an explicit init call
-- (pure module, no external deps — safe at load time).
PropertySchema.populate()
return PropertySchema
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
local RoundedRect = {}
--- Generate points for a rounded rectangle
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number
---@param segments number? -- Number of segments per corner arc (default: 10)
---@return table -- Array of vertices for love.graphics.polygon
function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments)
segments = segments or 10
local points = {}
-- Helper to add arc points
local function addArc(cx, cy, radius, startAngle, endAngle)
if radius <= 0 then
table.insert(points, cx)
table.insert(points, cy)
return
end
for i = 0, segments do
local angle = startAngle + (endAngle - startAngle) * (i / segments)
table.insert(points, cx + math.cos(angle) * radius)
table.insert(points, cy + math.sin(angle) * radius)
end
end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius,
}
end
local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2)
local r2 = math.min(cornerRadius.topRight, width / 2, height / 2)
local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2)
local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2)
-- Top-right corner
addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0)
-- Bottom-right corner
addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2)
-- Bottom-left corner
addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi)
-- Top-left corner
addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5)
return points
end
--- Draw a filled rounded rectangle
---@param mode string -- "fill" or "line"
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
function RoundedRect.draw(mode, x, y, width, height, cornerRadius)
-- OPTIMIZATION: Handle nil cornerRadius (no rounding)
if not cornerRadius then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Handle uniform corner radius (number)
if type(cornerRadius) == "number" then
if cornerRadius <= 0 then
love.graphics.rectangle(mode, x, y, width, height)
return
end
-- Convert to table format for processing
cornerRadius = {
topLeft = cornerRadius,
topRight = cornerRadius,
bottomLeft = cornerRadius,
bottomRight = cornerRadius,
}
end
-- Check if any corners are rounded
local hasRoundedCorners = cornerRadius.topLeft > 0
or cornerRadius.topRight > 0
or cornerRadius.bottomLeft > 0
or cornerRadius.bottomRight > 0
if not hasRoundedCorners then
-- No rounded corners, use regular rectangle
love.graphics.rectangle(mode, x, y, width, height)
return
end
local points = RoundedRect.getPoints(x, y, width, height, cornerRadius)
if mode == "fill" then
love.graphics.polygon("fill", points)
else
-- For line mode, draw the outline
love.graphics.polygon("line", points)
end
end
--- Create a stencil function for rounded rectangle clipping
---@param x number
---@param y number
---@param width number
---@param height number
---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}|number|nil
---@return function
function RoundedRect.stencilFunction(x, y, width, height, cornerRadius)
return function()
RoundedRect.draw("fill", x, y, width, height, cornerRadius)
end
end
return RoundedRect
File diff suppressed because it is too large Load Diff
+719
View File
@@ -0,0 +1,719 @@
---@class Select
local Select = {}
---Initialize Select module with required dependencies
---@param deps table
function Select.init(deps)
Select._ErrorHandler = deps.ErrorHandler
Select._Context = deps.Context
Select._StateManager = deps.StateManager
Select._utils = deps.utils
Select._Element = deps.Element
end
---Initialize selectParent state on an element
---@param element Element
---@param selectParentConfig table
function Select.initSelectParent(element, selectParentConfig)
element._selectState = {
value = selectParentConfig.value,
open = selectParentConfig.open or false,
placeholder = selectParentConfig.placeholder,
selectFrame = nil,
selectAnchor = nil,
onChange = selectParentConfig.onChange,
options = {},
optionLookup = {},
expectedFrameParent = nil,
frameAdopted = false,
}
-- Restore select state from StateManager. Mode-aware via
-- Context.isImmediateMode (behavior-mode-unification task 11).
if Select._Context.isImmediateMode() and element._stateId and element._stateId ~= "" then
local state = Select._StateManager.getState(element._stateId)
if state and state._selectOpen ~= nil then
element._selectState.open = state._selectOpen
end
if state and state._selectValue ~= nil then
element._selectState.value = state._selectValue
if element.selectParent then
element.selectParent.value = state._selectValue
end
end
if state and state._selectSelectedLabel ~= nil then
element._selectState.selectedLabel = state._selectSelectedLabel
end
end
end
---Initialize selectOption on an element
---@param element Element
---@param selectOptionConfig table
function Select.initSelectOption(element, selectOptionConfig)
element.selectOption = {
value = selectOptionConfig.value,
label = selectOptionConfig.label or element.text,
disabled = selectOptionConfig.disabled or false,
}
end
---@param selectParent Element
function Select.rebuildOptionLookup(selectParent)
if not selectParent or not selectParent._selectState then
return
end
selectParent._selectState.optionLookup = {}
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement and optionElement.selectOption then
selectParent._selectState.optionLookup[optionElement.selectOption.value] = optionElement
end
end
end
---@param selectParent Element
function Select.syncOptionStates(selectParent)
if not selectParent or not selectParent._selectState then
return
end
local selectedOption = nil
local selectedLabel = selectParent._selectState.selectedLabel
for _, optionElement in ipairs(selectParent._selectState.options) do
local isSelected = optionElement.selectOption
and optionElement.selectOption.value == selectParent._selectState.value
optionElement._selectSelected = isSelected
optionElement.ariaChecked = isSelected
if isSelected then
selectedOption = optionElement
selectedLabel = optionElement.selectOption.label or optionElement.text
end
end
selectParent._selectState.selectedOption = selectedOption
selectParent._selectState.selectedLabel = selectedLabel
end
---@param element Element
function Select.resetOptions(element)
if not element._selectState then
return
end
element._selectState.options = {}
element._selectState.optionLookup = {}
element._selectState.selectedOption = nil
end
---@param frame any
---@return boolean
function Select.isValidSelectFrame(frame)
local Element = Select._Element
return type(frame) == "table" and getmetatable(frame) == Element
end
---@param element Element
---@param code string
---@param details table?
function Select.warnSelectFrame(element, code, details)
Select._ErrorHandler:warn("Element", code, details or { element = element.id })
end
---@param element Element
---@param frame Element
function Select.trackManagedFrame(element, frame)
element._selectState.selectFrame = frame
local expectedParent = element._selectState.selectAnchor or element
element._selectState.expectedFrameParent = expectedParent
element._selectState.frameAdopted = frame.parent == expectedParent
if frame._managedSelectBaseOpacity == nil then
frame._managedSelectBaseOpacity = frame.opacity
end
if frame._managedSelectBaseVisibility == nil then
frame._managedSelectBaseVisibility = frame.visibility or "visible"
end
if frame._managedSelectBaseDisabled == nil then
frame._managedSelectBaseDisabled = frame.disabled or false
end
frame._managedSelectOwner = element
frame._managedSelectFrame = true
end
---@param element Element
---@return Element
function Select.getOrCreateManagedAnchor(element)
if element._selectState.selectAnchor then
return element._selectState.selectAnchor
end
local Element = Select._Element
local anchor = Element.new({
id = string.format("%s__select_anchor", element.id or "select"),
parent = element,
positioning = Select._utils.enums.Positioning.ABSOLUTE,
left = 0,
top = element:getBorderBoxHeight(),
width = element:getBorderBoxWidth(),
opacity = 1,
visibility = "hidden",
disabled = true,
})
anchor._managedSelectAnchor = true
anchor._managedSelectOwner = element
element._selectState.selectAnchor = anchor
return anchor
end
---@param element Element
---@param frame Element
function Select.applyManagedFrameLayout(element, frame)
local anchor = Select.getOrCreateManagedAnchor(element)
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
frame.positioning = frame.positioning or Select._utils.enums.Positioning.RELATIVE
frame._explicitlyAbsolute = false
frame.left = nil
frame.top = nil
frame.right = nil
frame.bottom = nil
if frame.parent ~= anchor then
frame:setParent(anchor)
end
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
element._selectState.expectedFrameParent = anchor
element._selectState.frameAdopted = frame.parent == anchor
end
---@param element Element
---@param frame Element
function Select.adoptSelectFrame(element, frame)
if not element._selectState then
return
end
if not Select.isValidSelectFrame(frame) then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
got = type(frame),
})
return
end
if frame == element then
Select.warnSelectFrame(element, "ELEM_007", {
element = element.id,
property = "selectParent.selectFrame",
reason = "select cannot use itself as its managed frame",
})
return
end
local anchor = Select.getOrCreateManagedAnchor(element)
if frame.parent and frame.parent ~= element and frame.parent ~= anchor then
Select.warnSelectFrame(element, "ELEM_008", {
element = element.id,
frame = frame.id,
parent = frame.parent.id,
})
end
Select.trackManagedFrame(element, frame)
Select.applyManagedFrameLayout(element, frame)
Select.syncManagedFrameVisibility(element)
-- Layout is deferred to endFrame in immediate mode. shouldLayout()
-- encapsulates the mode check (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
anchor:layoutChildren()
element:layoutChildren()
end
local pendingOptions = {}
for _, child in ipairs(element.children) do
if child ~= frame and child.selectOption then
table.insert(pendingOptions, child)
end
end
for _, option in ipairs(pendingOptions) do
Select.attachOptionToManagedFrame(option)
end
end
---@param element Element
function Select.ensureFrameState(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if anchor then
local triggerBorderBoxWidth = element:getBorderBoxWidth()
anchor.left = 0
anchor.top = element:getBorderBoxHeight()
anchor.width = triggerBorderBoxWidth
anchor.units.left = { value = 0, unit = "px" }
anchor.units.top = { value = element:getBorderBoxHeight(), unit = "px" }
anchor.units.width = { value = triggerBorderBoxWidth, unit = "px" }
frame._managedSelectMinimumBorderBoxWidth = triggerBorderBoxWidth
if frame.autosizing and frame.autosizing.width then
local contentWidth = frame:calculateAutoWidth()
frame._borderBoxWidth = contentWidth + frame.padding.left + frame.padding.right
frame.width = contentWidth
end
if frame.parent == anchor then
anchor.width = math.max(triggerBorderBoxWidth, frame:getBorderBoxWidth())
anchor.units.width = { value = anchor.width, unit = "px" }
end
if frame.parent == anchor then
anchor:layoutChildren()
end
elseif frame.parent == element then
Select.applyManagedFrameLayout(element, frame)
end
local expectedParent = anchor or element._selectState.expectedFrameParent
if frame.parent ~= expectedParent then
Select.warnSelectFrame(element, "ELEM_009", {
element = element.id,
frame = frame.id,
expectedParent = expectedParent and expectedParent.id or nil,
actualParent = frame.parent and frame.parent.id or nil,
})
element._selectState.expectedFrameParent = frame.parent
element._selectState.frameAdopted = frame.parent == expectedParent
end
end
---@param element Element
function Select.syncManagedFrameVisibility(element)
if not element._selectState or not element._selectState.selectFrame then
return
end
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
local isOpen = element._selectState.open == true
frame.visibility = isOpen and (frame._managedSelectBaseVisibility or "visible") or "hidden"
frame.opacity = frame._managedSelectBaseOpacity or 1
if isOpen then
frame.disabled = frame._managedSelectBaseDisabled == true
else
frame.disabled = true
end
if anchor then
anchor.visibility = isOpen and "visible" or "hidden"
anchor.opacity = 1
anchor.disabled = not isOpen
end
end
---@param element Element
---@return Element?
function Select.findOwningSelectParent(element)
if element._selectParentHint and element._selectParentHint._selectState then
return element._selectParentHint
end
local current = element.parent
while current do
if current._selectState then
return current
end
current = current.parent
end
return nil
end
---@param element Element
function Select.registerWithSelectParent(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent then
return
end
element._selectParentElement = selectParent
for _, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
return
end
end
table.insert(selectParent._selectState.options, element)
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
end
---@param element Element
function Select.attachOptionToManagedFrame(element)
if not element.selectOption then
return
end
local selectParent = Select.findOwningSelectParent(element)
if not selectParent or not selectParent._selectState or not selectParent._selectState.selectFrame then
return
end
local selectFrame = selectParent._selectState.selectFrame
if element.parent ~= selectFrame then
element._selectParentHint = selectParent
if
element._originalPositioning == Select._utils.enums.Positioning.ABSOLUTE
and element._managedSelectOptionUsesFrameLayout == nil
then
element._managedSelectOptionUsesFrameLayout = true
element.positioning = Select._utils.enums.Positioning.RELATIVE
element._originalPositioning = nil
element._explicitlyAbsolute = false
element.left = nil
element.top = nil
element.right = nil
element.bottom = nil
end
element:setParent(selectFrame)
-- Ensure frame geometry eagerly only in retained mode; deferred to the
-- per-frame update in immediate mode (behavior-mode-unification task 11).
if Select._StateManager.shouldLayout() then
Select.ensureFrameState(selectParent)
end
end
end
---@param element Element
function Select.unregisterFromSelectParent(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
element._selectParentElement = nil
return
end
local selectParent = element._selectParentElement
for index, optionElement in ipairs(selectParent._selectState.options) do
if optionElement == element then
table.remove(selectParent._selectState.options, index)
break
end
end
Select.rebuildOptionLookup(selectParent)
Select.syncOptionStates(selectParent)
element._selectParentElement = nil
end
---@param element Element
function Select.saveStateToStateManager(element)
if not element._selectState then
return
end
if element._stateId and Select._Context.isImmediateMode() and element._stateId ~= "" then
Select._StateManager.updateState(element._stateId, {
_selectOpen = element._selectState.open,
_selectValue = element._selectState.value,
_selectSelectedLabel = element._selectState.selectedLabel,
})
end
end
---@param element Element
function Select.openSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = true
element.ariaExpanded = true
if element.selectParent then
element.selectParent.open = true
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.closeSelect(element)
if not element._selectState then
return
end
Select.ensureFrameState(element)
element._selectState.open = false
element.ariaExpanded = false
if element.selectParent then
element.selectParent.open = false
end
Select.syncManagedFrameVisibility(element)
Select.saveStateToStateManager(element)
end
---@param element Element
function Select.toggleSelect(element)
if not element._selectState then
return
end
if element.disabled then
return
end
if element._selectState.open then
Select.closeSelect(element)
else
Select.openSelect(element)
end
if element.onEvent then
element.onEvent(element, { type = "selecttoggle", open = element._selectState.open })
end
end
---@param element Element
---@return boolean
function Select.isSelectOpen(element)
return element._selectState ~= nil and element._selectState.open == true
end
---@param element Element
---@return any
function Select.getSelectValue(element)
if not element._selectState then
return nil
end
return element._selectState.value
end
---@param element Element
---@return string?
function Select.getSelectLabel(element)
if not element._selectState then
return nil
end
local selectedOption = element._selectState.selectedOption
or element._selectState.optionLookup[element._selectState.value]
if selectedOption and selectedOption.selectOption then
return selectedOption.selectOption.label or selectedOption.text
end
return element._selectState.selectedLabel or element._selectState.placeholder
end
---@param element Element
---@return boolean
function Select.isSelectedOption(element)
if not element.selectOption or not element._selectParentElement or not element._selectParentElement._selectState then
return false
end
return element._selectParentElement._selectState.value == element.selectOption.value
end
---@param element Element
---@param value any
---@param optionElement Element?
function Select.setSelectValue(element, value, optionElement)
if not element._selectState then
return
end
if element.disabled then
return
end
local didChange = element._selectState.value ~= value
element._selectState.value = value
if element.selectParent then
element.selectParent.value = value
end
if optionElement and optionElement.selectOption then
element._selectState.selectedLabel = optionElement.selectOption.label or optionElement.text
end
Select.syncOptionStates(element)
Select.closeSelect(element)
Select.saveStateToStateManager(element)
if element.onEvent then
element.onEvent(element, { type = "selectchange", value = value, option = optionElement })
end
if didChange and element._selectState.onChange then
element._selectState.onChange(element, value, optionElement and optionElement.selectOption or nil)
end
end
---@param element Element
function Select.handleRelease(element)
if element.disabled then
return
end
if element.selectOption then
local selectParent = element._selectParentElement or Select.findOwningSelectParent(element)
if not selectParent then
return
end
if element.selectOption.disabled then
Select.closeSelect(selectParent)
return
end
Select.setSelectValue(selectParent, element.selectOption.value, element)
return
end
if element._selectState then
Select.toggleSelect(element)
end
end
---Save select state for state persistence (called from Element:saveState)
---@param element Element
---@return table?
function Select.saveState(element)
if not element._selectState then
return nil
end
return {
value = element._selectState.value,
open = element._selectState.open,
selectedLabel = element._selectState.selectedLabel,
}
end
---Restore select state (called from Element:restoreState)
---@param element Element
---@param state table
function Select.restoreState(element, state)
if not element._selectState or not state then
return
end
element._selectState.value = state.value
element._selectState.open = state.open or false
element._selectState.selectedLabel = state.selectedLabel
if element.selectParent then
element.selectParent.value = state.value
element.selectParent.open = state.open or false
end
element.ariaExpanded = element._selectState.open
Select.syncOptionStates(element)
end
---Clean up select-related resources (called from Element:destroy)
---@param element Element
function Select.cleanupDestroy(element)
if element._selectState then
local frame = element._selectState.selectFrame
local anchor = element._selectState.selectAnchor
if frame then
frame._managedSelectOwner = nil
frame._managedSelectFrame = nil
frame._managedSelectBaseOpacity = nil
frame._managedSelectBaseVisibility = nil
frame._managedSelectBaseDisabled = nil
end
if anchor then
anchor._managedSelectOwner = nil
anchor._managedSelectAnchor = nil
end
element._selectState = nil
end
if element._managedSelectFrame and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectFrame = nil
element._managedSelectOwner._selectState.expectedFrameParent = nil
element._managedSelectOwner._selectState.frameAdopted = false
end
element._managedSelectOwner = nil
element._managedSelectFrame = nil
element._managedSelectBaseOpacity = nil
element._managedSelectBaseVisibility = nil
element._managedSelectBaseDisabled = nil
end
if element._managedSelectAnchor and element._managedSelectOwner then
if element._managedSelectOwner._selectState then
element._managedSelectOwner._selectState.selectAnchor = nil
end
element._managedSelectOwner = nil
element._managedSelectAnchor = nil
end
if element.selectParent then
element.selectParent.onChange = nil
end
end
--- Called when a select parent removes a child: clears frame/anchor refs if the removed child was the
--- select-managed frame or anchor. Keeps select state-mutation logic owned by the Select module.
---@param element Element The select parent whose child was removed.
---@param child Element The removed child.
function Select.handleChildRemoved(element, child)
if not element._selectState then
return
end
if element._selectState.selectFrame == child then
element._selectState.selectFrame = nil
element._selectState.expectedFrameParent = nil
element._selectState.frameAdopted = false
end
if element._selectState.selectAnchor == child then
element._selectState.selectAnchor = nil
end
end
--- Layout-path hook: adjust an auto-width child's border-box width for a managed-select frame.
--- Invoked from LayoutEngine (via the Element delegate) during vertical-flex auto-width calculation.
---@param element Element The managed-select frame (the dropdown container).
---@param child Element The flex child being measured.
---@param childBorderBoxWidth number Current computed border-box width of `child`.
---@return number Possibly-adjusted border-box width.
function Select.adjustAutoWidthChild(element, child, childBorderBoxWidth)
if
element._managedSelectFrame
and element.autosizing
and element.autosizing.width
and child.units
and child.units.width
and child.units.width.unit == "%"
then
local intrinsicBorderBoxWidth = child:calculateAutoWidth() + child.padding.left + child.padding.right
return math.max(childBorderBoxWidth, intrinsicBorderBoxWidth)
end
return childBorderBoxWidth
end
return Select
+790
View File
@@ -0,0 +1,790 @@
---@class StateManager
local StateManager = {}
-- ErrorHandler will be injected via init
local ErrorHandler
-- State storage: ID -> state table
local stateStore = {}
-- Frame tracking metadata: ID -> {lastFrame, createdFrame, accessCount}
local stateMetadata = {}
-- Frame counter
local frameNumber = 0
-- Counter to track multiple elements created at the same source location (e.g., in loops)
local callSiteCounters = {}
-- Stateful element mapping: stateId -> element instance
-- Used in retained mode for cache-through: StateManager resolves id -> element -> field
local statefulElements = {}
-- Dirty state tracking for flushFrame: set of {id, key} pairs modified this frame
local dirtyState = {}
-- Immediate mode flag
local _immediateMode = false
-- Configuration
local config = {
stateRetentionFrames = 2, -- Keep unused state for 2 frames
maxStateEntries = 1000, -- Maximum state entries before forced GC
}
-- Default state values (sparse storage - don't store these)
local stateDefaults = {
-- Interaction states
hover = false,
pressed = false,
focused = false,
disabled = false,
active = false,
-- Scrollbar states
scrollbarHoveredVertical = false,
scrollbarHoveredHorizontal = false,
scrollbarDragging = false,
hoveredScrollbar = nil,
scrollbarDragOffset = 0,
dragStartMouseX = 0,
dragStartMouseY = 0,
dragStartScrollX = 0,
dragStartScrollY = 0,
-- Scroll position
scrollX = 0,
scrollY = 0,
_scrollX = 0,
_scrollY = 0,
-- Click tracking
_clickCount = 0,
_lastClickTime = nil,
_lastClickButton = nil,
-- Internal states
_hovered = nil,
_focused = nil,
_cursorPosition = nil,
_selectionStart = nil,
_selectionEnd = nil,
_textBuffer = "",
_cursorBlinkTimer = 0,
_cursorVisible = true,
_cursorBlinkPaused = false,
_cursorBlinkPauseTimer = 0,
}
--- Check if a value equals the default for a key
---@param key string State key
---@param value any Value to check
---@return boolean isDefault True if value equals default
local function isDefaultValue(key, value)
local defaultVal = stateDefaults[key]
-- If no default defined, check for common defaults
if defaultVal == nil then
-- Empty tables are default
if type(value) == "table" and next(value) == nil then
return true
end
-- nil values are default
if value == nil then
return true
end
-- Otherwise, not a default value
return false
end
-- Compare values
if type(value) == "table" then
-- Empty tables are considered default
if next(value) == nil then
return true
end
-- For other tables, compare contents (shallow)
if type(defaultVal) ~= "table" then
return false
end
for k, v in pairs(value) do
if defaultVal[k] ~= v then
return false
end
end
return true
else
return value == defaultVal
end
end
-- ====================
-- ID Generation
-- ====================
--- Generate a hash from a table of properties
---@param props table
---@param visited table|nil Tracking table to prevent circular references
---@param depth number|nil Current recursion depth
---@return string
local function hashProps(props, visited, depth)
if not props then
return ""
end
-- Initialize visited table on first call
visited = visited or {}
depth = depth or 0
-- Limit recursion depth to prevent deep nesting issues
if depth > 3 then
return "[deep]"
end
-- Check if we've already visited this table (circular reference)
if visited[props] then
return "[circular]"
end
-- Mark this table as visited
visited[props] = true
local parts = {}
local keys = {}
-- Properties to skip (they cause issues or aren't relevant for ID generation)
local skipKeys = {
onEvent = true,
parent = true,
children = true,
onFocus = true,
onBlur = true,
onTextInput = true,
onTextChange = true,
onEnter = true,
userdata = true,
-- Dynamic input/state properties that should not affect ID stability
text = true, -- Text content changes as user types
placeholder = true, -- Placeholder text is presentational
editable = true, -- Editable state can be toggled dynamically
selectOnFocus = true, -- Input behavior flag
autoGrow = true, -- Auto-grow behavior flag
passwordMode = true, -- Password mode can be toggled
}
-- Collect and sort keys for consistent ordering
for k in pairs(props) do
if not skipKeys[k] then
table.insert(keys, k)
end
end
table.sort(keys)
-- Build hash string from sorted key-value pairs
for _, k in ipairs(keys) do
local v = props[k]
local vtype = type(v)
if vtype == "string" or vtype == "number" or vtype == "boolean" then
table.insert(parts, k .. "=" .. tostring(v))
elseif vtype == "table" then
table.insert(parts, k .. "={" .. hashProps(v, visited, depth + 1) .. "}")
end
end
return table.concat(parts, ";")
end
--- Generate a unique ID from call site and properties
---@param props table|nil Optional properties to include in ID generation
---@param parent table|nil Optional parent element for tree-based ID generation
---@return string
function StateManager.generateID(props, parent)
-- Get call stack information
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
if not info then
-- Fallback to random ID if debug info unavailable
return "auto_" .. tostring(math.random(1000000, 9999999))
end
local source = info.source or "unknown"
local line = info.currentline or 0
-- Create base location key from source file and line number
local filename = source:match("([^/\\]+)$") or source -- Get filename
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
local locationKey = filename .. "_L" .. line
-- If we have a parent, use tree-based ID generation for stability
if parent and parent.id and parent.id ~= "" then
-- For child elements, use call-site (file + line) like top-level elements
-- This ensures the same call site always generates the same ID, even when
-- retained children persist in parent.children array
local baseID = parent.id .. "_" .. locationKey
-- Count how many children have been created at THIS call site
local callSiteKey = parent.id .. "_" .. locationKey
callSiteCounters[callSiteKey] = (callSiteCounters[callSiteKey] or 0) + 1
local instanceNum = callSiteCounters[callSiteKey]
if instanceNum > 1 then
baseID = baseID .. "_" .. instanceNum
end
-- Add property hash if provided (for additional differentiation)
if props then
local propHash = hashProps(props)
if propHash ~= "" then
-- Use first 8 chars of a simple hash
local hash = 0
for i = 1, #propHash do
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
end
baseID = baseID .. "_" .. hash
end
end
return baseID
end
-- No parent (top-level element): use call-site counter approach
-- Track how many elements have been created at this location
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
local instanceNum = callSiteCounters[locationKey]
local baseID = locationKey
-- Add instance number if multiple elements created at same location (e.g., in loops)
if instanceNum > 1 then
baseID = baseID .. "_" .. instanceNum
end
-- Add property hash if provided (for additional differentiation)
if props then
local propHash = hashProps(props)
if propHash ~= "" then
-- Use first 8 chars of a simple hash
local hash = 0
for i = 1, #propHash do
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
end
baseID = baseID .. "_" .. hash
end
end
return baseID
end
-- ====================
-- State Management
-- ====================
--- Initialize StateManager with dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
function StateManager.init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
end
end
--- Get state for an element ID, creating if it doesn't exist
---@param id string Element ID
---@param defaultState table|nil Default state if creating new
---@return table state State table for the element
function StateManager.getState(id, defaultState)
if not id then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id",
value = "nil",
})
end
-- Create state if it doesn't exist
if not stateStore[id] then
-- Start with empty state (sparse storage)
stateStore[id] = defaultState or {}
-- Create metadata
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 0,
}
else
-- Update metadata
local meta = stateMetadata[id]
meta.lastFrame = frameNumber
meta.accessCount = meta.accessCount + 1
end
return stateStore[id]
end
--- Set state for an element ID (replaces entire state)
---@param id string Element ID
---@param state table State to store
function StateManager.setState(id, state)
if not id then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id",
value = "nil",
})
end
-- Create sparse state (remove default values)
local sparseState = {}
for key, value in pairs(state) do
if not isDefaultValue(key, value) then
sparseState[key] = value
end
end
stateStore[id] = sparseState
-- Update or create metadata
if not stateMetadata[id] then
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 1,
}
else
stateMetadata[id].lastFrame = frameNumber
end
end
--- Update state for an element ID (merges with existing state)
---@param id string Element ID
---@param newState table New state values to merge
function StateManager.updateState(id, newState)
local state = StateManager.getState(id)
-- Merge new state into existing state (with diffing optimization)
local changed = false
for key, value in pairs(newState) do
if state[key] ~= value then
state[key] = value
changed = true
end
end
-- Only update metadata if something actually changed
if changed then
stateMetadata[id].lastFrame = frameNumber
end
end
--- Update state only if values have changed (optimized for immediate mode)
---@param id string Element ID
---@param newState table New state values to merge
---@return boolean changed True if any values changed
function StateManager.updateStateIfChanged(id, newState)
local state = StateManager.getState(id)
local changed = false
for key, value in pairs(newState) do
-- Skip if value hasn't changed (optimization)
if state[key] ~= value then
state[key] = value
changed = true
end
end
if changed then
stateMetadata[id].lastFrame = frameNumber
end
return changed
end
--- Clear state for a specific element ID
---@param id string Element ID
function StateManager.clearState(id)
stateStore[id] = nil
stateMetadata[id] = nil
end
--- Mark state as used this frame (updates last accessed frame)
---@param id string Element ID
function StateManager.markStateUsed(id)
if stateMetadata[id] then
stateMetadata[id].lastFrame = frameNumber
end
end
-- ====================
-- Frame Management
-- ====================
--- Increment frame counter (called at frame start)
function StateManager.incrementFrame()
frameNumber = frameNumber + 1
-- Reset call site counters for new frame
callSiteCounters = {}
end
--- Get current frame number
---@return number
function StateManager.getFrameNumber()
return frameNumber
end
-- ====================
-- Granular State Access (Unified API for both modes)
-- ====================
--- Get a single state value by key for a given element ID.
--- Works identically in both modes — the caller does not need to know the mode.
---
--- Immediate mode: reads from persistent state store.
--- Retained mode: resolves through registered element field (cache-through).
---
---@param id string Element state ID
---@param key string State key
---@return any value The stored value, or nil if not found
function StateManager.getStateValue(id, key)
if not id or not key then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id and key",
value = "missing",
})
end
-- Update metadata for access tracking
if stateMetadata[id] then
stateMetadata[id].lastFrame = frameNumber
stateMetadata[id].accessCount = stateMetadata[id].accessCount + 1
end
if _immediateMode then
-- Immediate mode: read from persistent state store
local state = stateStore[id]
if state then
return state[key]
end
return nil
else
-- Retained mode: resolve through element field
local element = statefulElements[id]
if element then
return element[key]
end
return nil
end
end
--- Set a single state value by key for a given element ID.
--- Works identically in both modes — the caller does not need to know the mode.
---
--- Immediate mode: marks dirty for flushFrame() persistence.
--- Retained mode: writes directly to element field (cache-through).
---
---@param id string Element state ID
---@param key string State key
---@param value any Value to store
function StateManager.setStateValue(id, key, value)
if not id or not key then
ErrorHandler:error("StateManager", "SYS_001", {
parameter = "id and key",
value = "missing",
})
end
-- Update metadata
if not stateMetadata[id] then
stateMetadata[id] = {
lastFrame = frameNumber,
createdFrame = frameNumber,
accessCount = 1,
}
else
stateMetadata[id].lastFrame = frameNumber
end
if _immediateMode then
-- Immediate mode: mark dirty for flushFrame persistence
local state = StateManager.getState(id)
state[key] = value
dirtyState[id] = dirtyState[id] or {}
dirtyState[id][key] = true
else
-- Retained mode: write directly to element field
local element = statefulElements[id]
if element then
element[key] = value
end
end
end
-- ====================
-- Stateful Element Registration (Retained Mode Cache-Through)
-- ====================
--- Register an element instance for retained-mode cache-through.
--- After registration, getStateValue/setStateValue will resolve through the element's fields.
---
--- Called by Element in _construct phase.
---
---@param id string State ID (typically element.id)
---@param element table Element instance to link
function StateManager.registerStateful(id, element)
if not id or not element then
return
end
statefulElements[id] = element
end
--- Unregister an element instance.
--- After unregistration, retained-mode access will fall back to nil.
---
--- Called by Element in _cleanup phase.
---
---@param id string State ID to unregister
function StateManager.unregisterStateful(id)
if id then
statefulElements[id] = nil
end
end
-- ====================
-- Frame Flush (Immediate Mode Dirty State Persistence)
-- ====================
--- Flush dirty state to persistent store at end of frame.
--- Called automatically at frame end in immediate mode.
--- Behaviors call setStateValue during update without knowing the mode.
---
--- In retained mode, this is a no-op (state is written directly to elements).
function StateManager.flushFrame()
if not _immediateMode then
return
end
-- All dirty writes were already applied to stateStore during setStateValue
-- This method exists for future extensions (e.g., batching, analytics)
-- Reset dirty tracking for next frame
dirtyState = {}
end
-- ====================
-- Mode Configuration
-- ====================
--- Configure immediate mode state.
--- Called by Context when immediate mode is enabled/disabled.
---
---@param enabled boolean Whether immediate mode is active
function StateManager.setImmediateMode(enabled)
_immediateMode = enabled
end
--- Check if immediate mode is active.
---@return boolean
function StateManager.isImmediateMode()
return _immediateMode
end
--- Whether at-construction layout / eager initialization should run now.
--- Returns true in retained mode (layout eagerly), false in immediate mode
--- (layout is deferred to `FlexLove.endFrame` / FlexLove so it runs once all
--- elements for the frame have been created). This replaces the scattered
--- `if not _immediateMode then layoutChildren()` mode checks with a single
--- mode-aware query (behavior-mode-unification task 11).
---@return boolean
function StateManager.shouldLayout()
return not _immediateMode
end
-- ====================
-- Cleanup & Maintenance
-- ====================
--- Clean up stale states (not accessed recently)
---@return number count Number of states cleaned up
function StateManager.cleanup()
local cleanedCount = 0
local retentionFrames = config.stateRetentionFrames
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = frameNumber - meta.lastFrame
if framesSinceAccess > retentionFrames then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
-- Clean up empty states (sparse storage optimization)
for id, state in pairs(stateStore) do
if next(state) == nil then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
return cleanedCount
end
--- Force cleanup if state count exceeds maximum
---@return number count Number of states cleaned up
function StateManager.forceCleanupIfNeeded()
local stateCount = StateManager.getStateCount()
if stateCount > config.maxStateEntries then
-- Clean up states not accessed in last 10 frames (aggressive)
local cleanedCount = 0
for id, meta in pairs(stateMetadata) do
local framesSinceAccess = frameNumber - meta.lastFrame
if framesSinceAccess > 10 then
stateStore[id] = nil
stateMetadata[id] = nil
cleanedCount = cleanedCount + 1
end
end
return cleanedCount
end
return 0
end
--- Get total number of stored states
---@return number
function StateManager.getStateCount()
local count = 0
for _ in pairs(stateStore) do
count = count + 1
end
return count
end
--- Clear all states
function StateManager.clearAllStates()
stateStore = {}
stateMetadata = {}
end
--- Configure state management
---@param newConfig {stateRetentionFrames?: number, maxStateEntries?: number}
function StateManager.configure(newConfig)
if newConfig.stateRetentionFrames then
config.stateRetentionFrames = newConfig.stateRetentionFrames
end
if newConfig.maxStateEntries then
config.maxStateEntries = newConfig.maxStateEntries
end
end
--- Get state statistics for debugging
---@return table stats State usage statistics
function StateManager.getStats()
local stateCount = StateManager.getStateCount()
local oldest = nil
local newest = nil
for _, meta in pairs(stateMetadata) do
if not oldest or meta.createdFrame < oldest then
oldest = meta.createdFrame
end
if not newest or meta.createdFrame > newest then
newest = meta.createdFrame
end
end
-- Count callSiteCounters
local callSiteCount = 0
for _ in pairs(callSiteCounters) do
callSiteCount = callSiteCount + 1
end
-- Warn if callSiteCounters is unexpectedly large
if callSiteCount > 1000 then
if ErrorHandler then
ErrorHandler.warn("StateManager", "STATE_001", {
count = callSiteCount,
expected = "near 0",
frameNumber = frameNumber,
})
end
end
return {
stateCount = stateCount,
frameNumber = frameNumber,
oldestState = oldest,
newestState = newest,
callSiteCounterCount = callSiteCount,
}
end
--- Get internal state (for debugging/profiling only)
---@return table internal {stateStore, stateMetadata, callSiteCounters}
function StateManager._getInternalState()
return {
stateStore = stateStore,
stateMetadata = stateMetadata,
callSiteCounters = callSiteCounters,
}
end
--- Reset the entire state system (for testing)
function StateManager.reset()
stateStore = {}
stateMetadata = {}
frameNumber = 0
callSiteCounters = {}
statefulElements = {}
dirtyState = {}
_immediateMode = false
end
-- ====================
-- Convenience Functions (for backward compatibility)
-- ====================
--- Check if an element is currently hovered
---@param id string Element ID
---@return boolean
function StateManager.isHovered(id)
local state = StateManager.getState(id)
return state.hover or false
end
--- Check if an element is currently pressed
---@param id string Element ID
---@return boolean
function StateManager.isPressed(id)
local state = StateManager.getState(id)
return state.pressed or false
end
--- Check if an element is currently focused
---@param id string Element ID
---@return boolean
function StateManager.isFocused(id)
local state = StateManager.getState(id)
return state.focused or false
end
--- Check if an element is disabled
---@param id string Element ID
---@return boolean
function StateManager.isDisabled(id)
local state = StateManager.getState(id)
return state.disabled or false
end
--- Check if an element is active (e.g., input focused)
---@param id string Element ID
---@return boolean
function StateManager.isActive(id)
local state = StateManager.getState(id)
return state.active or false
end
return StateManager
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
local modulePath = (...):match("(.-)[^%.]+$")
local function req(name)
return require(modulePath .. name)
end
-- Text sanitization, escaping, and input validation utilities.
-- ErrorHandler is injected via init() for truncation warnings.
local ErrorHandler = nil
--- Initialize dependencies
---@param deps table Dependencies: { ErrorHandler = ErrorHandler }
local function init(deps)
if type(deps) == "table" then
ErrorHandler = deps.ErrorHandler
end
end
--- Sanitize text to prevent security vulnerabilities
--- @param text string? Text to sanitize
--- @param options table? Sanitization options
--- @return string Sanitized text
local function sanitizeText(text, options)
local utf8 = require("utf8")
-- Handle nil or non-string inputs
if text == nil then
return ""
end
if type(text) ~= "string" then
text = tostring(text)
end
-- Default options
options = options or {}
local maxLength = options.maxLength or 10000
local allowNewlines = options.allowNewlines ~= false -- default true
local allowTabs = options.allowTabs ~= false -- default true
local stripControls = options.stripControls ~= false -- default true
local trimWhitespace = options.trimWhitespace ~= false -- default true
-- Remove null bytes (critical security risk)
text = text:gsub("%z", "")
-- Strip control characters except allowed ones
if stripControls then
local pattern = "[\1-\31\127]" -- All control characters
if allowNewlines and allowTabs then
pattern = "[\1-\8\11\12\14-\31\127]" -- Exclude \t (9), \n (10), \r (13)
elseif allowNewlines then
pattern = "[\1-\9\11\12\14-\31\127]" -- Exclude \n (10), \r (13)
elseif allowTabs then
pattern = "[\1-\8\10\12-\31\127]" -- Exclude \t (9)
end
text = text:gsub(pattern, "")
end
-- Trim leading/trailing whitespace
if trimWhitespace then
text = text:match("^%s*(.-)%s*$") or ""
end
-- Limit string length (use UTF-8 character count, not byte count)
local charCount = utf8.len(text)
if charCount and charCount > maxLength then
if ErrorHandler then
ErrorHandler:warn("utils", "UTIL_001", {
original = charCount,
truncated = maxLength,
})
end
-- Truncate to maxLength UTF-8 characters
local bytePos = utf8.offset(text, maxLength + 1)
if bytePos then
text = text:sub(1, bytePos - 1)
end
if ErrorHandler then
ErrorHandler:warn("utils", string.format("Text truncated from %d to %d characters", charCount, maxLength))
end
end
return text
end
--- Validate text input against rules
--- @param text string Text to validate
--- @param rules table Validation rules
--- @return boolean, string? Returns true if valid, or false with error message
local function validateTextInput(text, rules)
rules = rules or {}
-- Check minimum length
if rules.minLength and #text < rules.minLength then
return false, string.format("Text must be at least %d characters", rules.minLength)
end
-- Check maximum length
if rules.maxLength and #text > rules.maxLength then
return false, string.format("Text must be at most %d characters", rules.maxLength)
end
-- Check pattern match
if rules.pattern and not text:match(rules.pattern) then
return false, rules.patternError or "Text does not match required pattern"
end
-- Check character whitelist
if rules.allowedChars then
local pattern = "[^" .. rules.allowedChars .. "]"
if text:match(pattern) then
return false, "Text contains invalid characters"
end
end
-- Check character blacklist
if rules.forbiddenChars then
local pattern = "[" .. rules.forbiddenChars .. "]"
if text:match(pattern) then
return false, "Text contains forbidden characters"
end
end
return true, nil
end
--- Validate text against range/length rules (alias of validateTextInput)
--- @param text string Text to validate
--- @param rules table Validation rules (minLength, maxLength, pattern, etc.)
--- @return boolean, string? Returns true if valid, or false with error message
local function validateTextRange(text, rules)
return validateTextInput(text, rules)
end
--- Escape HTML special characters
--- @param text string Text to escape
--- @return string Escaped text
local function escapeHtml(text)
if text == nil then
return ""
end
text = tostring(text)
text = text:gsub("&", "&amp;")
text = text:gsub("<", "&lt;")
text = text:gsub(">", "&gt;")
text = text:gsub('"', "&quot;")
text = text:gsub("'", "&#39;")
return text
end
--- Escape Lua pattern special characters
--- @param text string Text to escape
--- @return string Escaped text
local function escapeLuaPattern(text)
if text == nil then
return ""
end
text = tostring(text)
-- Escape all Lua pattern special characters
text = text:gsub("([%^%$%(%)%%%.%[%]%*%+%-%?])", "%%%1")
return text
end
--- Strip all non-printable characters from text
--- @param text string Text to clean
--- @return string Cleaned text
local function stripNonPrintable(text)
if text == nil then
return ""
end
text = tostring(text)
-- Keep printable ASCII (32-126), newline (10), tab (9), and carriage return (13)
text = text:gsub("[^\9\10\13\32-\126]", "")
return text
end
return {
init = init,
sanitizeText = sanitizeText,
validateTextInput = validateTextInput,
validateTextRange = validateTextRange,
escapeHtml = escapeHtml,
escapeLuaPattern = escapeLuaPattern,
stripNonPrintable = stripNonPrintable,
}

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