Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ba49bdf3f | |||
| abf95ce1c4 | |||
| bd1046f398 | |||
| d5ad830fb8 | |||
| c2c7fdafcf | |||
| c01bda3570 | |||
| 74e04cb086 | |||
| 25ec896545 | |||
| 8240205254 | |||
| 55616fc03d | |||
| 675971068e | |||
| cfa8406306 | |||
| 0ea224d5db | |||
| c2e1db0f89 | |||
| 7b1e796c48 | |||
| 4c13770e70 | |||
| cf45cbbf92 | |||
| 4e1ab1879b | |||
| 917735a41c | |||
| c877ea80a7 | |||
| 7d1ddf9b7c | |||
| faf82c2cec | |||
| 5bc6036735 | |||
| 809628f0fa | |||
| 4817ff8bf9 | |||
| 4f8739c029 | |||
| c655217120 | |||
| fbdfc1c053 | |||
| 72c244b433 | |||
| e0e030003b | |||
| ce2afb83f1 | |||
| 28f741f72f | |||
| 22bcd95da1 | |||
| ea28f886f3 | |||
| 6cd8f0ddea | |||
| 7e25da70f0 | |||
| df3d3e7600 | |||
| 8c9af95598 | |||
| 45519ad550 | |||
| 3cca70608f | |||
| fb4eaeda10 |
@@ -120,7 +120,7 @@ jobs:
|
|||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics)_test\.lua$|tests/engine/platform_nx)'; then
|
if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_switch\.sh$|scripts/switch/|docs/switch-.*\.md$|tests/switch_ci_workflows_test\.lua$|tests/switch_transfer_docs_test\.lua$|\.github/workflows/(ci|release|switch-artifact-comment)\.yml$|src/core/(NxAssetOverlay|Platform|GameVersion)\.lua$|src/import/CacheFs\.lua$|tests/engine/(assets_version_fallback|nx_generated_guard|nx_yellow_boot|switch_diagnostics|cache_fs_gold_nx_load)_test\.lua$|tests/engine/platform_nx)'; then
|
||||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -151,6 +151,9 @@ jobs:
|
|||||||
luajit tests/engine/assets_version_fallback_test.lua
|
luajit tests/engine/assets_version_fallback_test.lua
|
||||||
luajit tests/engine/nx_generated_guard_test.lua
|
luajit tests/engine/nx_generated_guard_test.lua
|
||||||
luajit tests/engine/nx_yellow_boot_test.lua
|
luajit tests/engine/nx_yellow_boot_test.lua
|
||||||
|
luajit tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
|
luajit tests/engine/cache_fs_blue_mount_test.lua
|
||||||
|
luajit tests/engine/switch_diagnostics_test.lua
|
||||||
|
|
||||||
switch-build:
|
switch-build:
|
||||||
name: Switch fused build
|
name: Switch fused build
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# AI Disclosure
|
||||||
|
|
||||||
|
This is a disclosure of the use of AI in this project.
|
||||||
|
|
||||||
|
## AI Use
|
||||||
|
|
||||||
|
Anyone who demands the dislosure of how AI was used in an engineering project,
|
||||||
|
has no idea what AI is, or how it works.
|
||||||
|
|
||||||
|
AI was used in this project as a tool. Several contributors used AI in their
|
||||||
|
commits, and so you will see like 7 commits by Claude or Codex or Cursor.
|
||||||
|
|
||||||
|
However those commits were reviewed by human beings, and it was declared that
|
||||||
|
the exact same fix would have been done by a human, so they were accepted.
|
||||||
|
|
||||||
|
AI was not used to make decisions, or to create the project.
|
||||||
|
|
||||||
|
If you would like to read more, well then continue reading:
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque ante leo, luctus in semper a, maximus ut est. Vivamus nec magna vitae quam luctus suscipit nec eu orci. Vestibulum ut felis a dolor cursus vulputate. Phasellus pharetra elementum sollicitudin. Aenean elementum imperdiet ultrices. In risus mauris, scelerisque sed viverra in, iaculis non eros. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin quam justo, maximus sit amet fringilla in, tristique eget sapien.
|
||||||
|
|
||||||
|
Pellentesque rhoncus, erat nec elementum ultricies, augue purus suscipit augue, in condimentum nisl enim vel velit. Etiam at semper turpis. Vestibulum ipsum magna, ultrices non sem ut, hendrerit bibendum mi. Curabitur in varius sapien. Morbi posuere bibendum ex, at ultrices orci. Fusce feugiat ultrices varius. Suspendisse sed ante ligula. Sed dignissim lorem est, nec tristique arcu commodo sed. Cras consectetur sapien dolor, vitae finibus enim lacinia id.
|
||||||
|
|
||||||
|
Donec quis magna est. Maecenas dui arcu, venenatis sit amet libero nec, lacinia eleifend leo. Quisque lobortis vulputate lacus a elementum. Proin nec metus lectus. Donec eu auctor sem, at finibus ipsum. Curabitur eget dignissim justo. Donec lobortis leo eu arcu tristique, in volutpat augue eleifend. Morbi lacinia a risus in suscipit. Maecenas suscipit est eu interdum dictum. Cras in nulla imperdiet, dapibus mauris posuere, facilisis velit. Nunc dapibus, leo quis interdum tempor, elit mi mattis dolor, sagittis dictum mi urna sed lectus. Maecenas elementum, mauris id molestie dapibus, diam arcu egestas erat, at tempor justo orci vitae nibh. Etiam sagittis facilisis erat a vulputate. Praesent condimentum ac odio quis sollicitudin.
|
||||||
|
|
||||||
|
Fusce vitae orci vestibulum, sagittis dolor non, cursus urna. Morbi eleifend pretium pellentesque. Pellentesque ornare elementum sem in imperdiet. Maecenas dapibus, erat et lobortis porttitor, velit magna auctor odio, quis interdum elit est eu justo. In posuere euismod odio, in porttitor magna iaculis eget. In id quam pulvinar, ultrices dolor in, pellentesque dolor. Nunc varius ante at felis dictum, id porttitor sem efficitur. Integer pretium dignissim commodo. Suspendisse in est a arcu blandit faucibus. Donec quis lacus mollis, tincidunt nunc quis, suscipit nunc. Nunc non arcu dignissim, dignissim sem in, finibus neque. Aliquam non porta eros. Donec et pretium augue, non cursus eros.
|
||||||
|
|
||||||
|
Nunc at dignissim nisi. Nam nec metus augue. Proin nulla sapien, tristique a purus vel, vulputate commodo mi. Sed id erat leo. Quisque ullamcorper a nisl id molestie. Aliquam erat volutpat. Donec eget hendrerit mauris. Fusce tincidunt nisl a lorem tincidunt dapibus. Nam volutpat rhoncus tortor.
|
||||||
|
|
||||||
|
Nunc et sapien enim. Proin at nunc a nulla maximus consectetur nec eget tortor. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Mauris orci odio, sodales et elementum laoreet, porta at lacus. Maecenas vestibulum lectus risus, pulvinar scelerisque dolor posuere viverra. Proin gravida tellus vitae accumsan dignissim. Nunc non sapien aliquet ex cursus ultricies ac quis diam. Sed luctus feugiat risus eu tincidunt. Duis auctor lacinia fringilla. Donec pretium cursus magna a feugiat. Duis tristique, leo vulputate semper iaculis, est ipsum dapibus lacus, et molestie nulla enim nec ex. Nunc non feugiat neque.
|
||||||
|
|
||||||
|
Fusce euismod egestas elit ut pretium. Nulla eros quam, auctor sit amet faucibus eu, scelerisque eu neque. Sed nisi felis, lobortis in sapien a, tempor efficitur nunc. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Pellentesque ut justo est. Sed maximus, tellus in bibendum posuere, quam augue finibus eros, sed gravida arcu diam quis lectus. Donec eu placerat ligula. Ut quis imperdiet lorem. Maecenas a mi ac augue semper sodales. Curabitur in justo velit. Praesent et felis quis enim porttitor sagittis.
|
||||||
|
|
||||||
|
Nulla sed sagittis felis, sit amet placerat tortor. Ut metus est, sollicitudin ac turpis quis, aliquet congue lorem. Fusce auctor erat non convallis aliquet. Nullam sodales rutrum tellus ac malesuada. Quisque sem diam, iaculis in ultricies sit amet, fermentum quis sem. Integer condimentum placerat purus non lacinia. Integer hendrerit ultricies tellus, at dignissim nibh. Suspendisse accumsan eget tortor nec cursus. Proin accumsan rhoncus leo, eget pretium est tristique ac.
|
||||||
|
|
||||||
|
Sed feugiat sed diam a porta. Nullam varius lacus at fermentum fringilla. Morbi pharetra scelerisque pharetra. Nulla placerat vitae ligula non efficitur. Suspendisse quam dui, rutrum eget nulla eu, semper eleifend ante. Aenean ut condimentum arcu. Suspendisse auctor metus non sem ornare, vel tincidunt odio vehicula. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||||
|
|
||||||
|
Donec vel metus ut libero sollicitudin posuere a nec nulla. Vivamus a scelerisque nisi. Aliquam eu sollicitudin tortor. Aliquam purus eros, blandit et quam et, pretium porttitor sem. Nunc iaculis arcu enim, et maximus sem malesuada in. Morbi nec nunc volutpat, semper diam sit amet, gravida elit. Vestibulum eu turpis vel lacus imperdiet congue. Donec rhoncus auctor sem.
|
||||||
|
|
||||||
|
Suspendisse eu lorem non dolor pretium finibus euismod quis dolor. Cras finibus egestas velit, commodo rutrum est placerat sit amet. Pellentesque vitae semper diam, sit amet auctor metus. Sed porttitor porttitor nunc, vel imperdiet neque volutpat quis. Sed hendrerit sapien et lacus imperdiet, nec hendrerit lorem sodales. Integer lobortis rutrum odio at ullamcorper. Aliquam tincidunt magna a tellus suscipit, at porta turpis dapibus. Ut ultricies auctor felis eu feugiat. Sed tempus sem et dictum fringilla. Nunc non pellentesque tortor. Suspendisse pulvinar, arcu ut imperdiet gravida, eros ex mattis mauris, vel ultricies est erat et dui. Praesent porttitor tortor et erat interdum efficitur. Phasellus et luctus lectus, et egestas ante. Praesent ex ipsum, rutrum id efficitur et, vulputate non tortor. Aenean maximus nunc ac purus sodales, et venenatis lacus laoreet.
|
||||||
|
|
||||||
|
Cras egestas ultrices dui, at tempor leo varius vitae. Donec porta, nisl nec ornare maximus, est arcu auctor mauris, varius elementum nisl arcu venenatis neque. Aenean metus quam, vestibulum eget justo non, hendrerit dapibus nunc. Vivamus diam ante, mattis sed nulla at, iaculis elementum magna. Sed massa diam, efficitur vel nunc sed, malesuada interdum tortor. Aliquam non neque aliquet ex imperdiet finibus eget ac neque. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae;
|
||||||
|
|
||||||
|
Pellentesque fringilla tortor metus, luctus commodo leo gravida et. Fusce nec turpis at lorem rutrum porta vel in justo. Curabitur mattis suscipit felis, id dapibus arcu ornare ut. Proin sapien felis, pulvinar ut tristique vitae, aliquet ac dui. Vestibulum a erat tellus. Vestibulum sagittis dolor eget augue egestas fringilla. Fusce et purus a nunc auctor dapibus vel sed dui. Fusce interdum, libero vel pellentesque rutrum, urna massa iaculis tellus, et aliquam lectus diam vel sem. Donec a nunc et dui semper gravida. Donec posuere, eros eu consequat efficitur, justo metus ullamcorper lectus, et molestie massa ipsum eu sapien. Mauris eu suscipit neque. Morbi convallis sit amet leo a scelerisque. Cras ultrices libero ac mattis accumsan. Nam gravida ligula id erat semper ornare. Duis consequat ut ipsum eu volutpat. Quisque egestas sollicitudin ullamcorper.
|
||||||
|
|
||||||
|
Cras pellentesque quam non neque porta fringilla. Integer elementum, augue mattis blandit consequat, enim ipsum finibus ex, quis finibus neque eros eu ex. Fusce at urna justo. Donec erat eros, maximus id mauris vel, rutrum rutrum sapien. Morbi sed rutrum ex. Suspendisse lacus velit, varius ut elementum vitae, finibus non enim. Suspendisse vehicula euismod ipsum, id consequat nulla sodales vel. Morbi eu sem id leo congue dapibus a nec velit. Sed nec neque quam. Etiam rhoncus id nulla id volutpat. Nulla facilisi. Donec non maximus enim. Aenean consequat, sapien sit amet malesuada rutrum, erat sem euismod sapien, et feugiat lectus mauris id velit.
|
||||||
|
|
||||||
|
Fusce sodales porttitor gravida. Proin placerat ante nec nibh tempor aliquam. Sed ut diam eu sem fringilla malesuada. Maecenas aliquam risus vel quam dictum, at iaculis nibh pretium. Mauris convallis quam vitae dolor varius suscipit. Etiam nec fermentum dui. Aliquam in magna tincidunt, consectetur quam eget, aliquam purus. Ut dictum aliquet finibus. Pellentesque vel lacinia felis. Nulla malesuada vestibulum varius. Sed quam diam, efficitur id felis in, volutpat bibendum erat. Praesent luctus vulputate urna at interdum. Aenean ac aliquam eros. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Morbi mollis, nisl vel consequat vehicula, massa tellus porta arcu, vel dictum sapien mauris sit amet nunc. Integer sed maximus neque, ac iaculis urna.
|
||||||
|
|
||||||
|
Integer non erat a leo euismod convallis quis eget magna. Morbi gravida ac urna sed ornare. Nunc vehicula mauris accumsan, ornare sem in, egestas mauris. Vestibulum vel vulputate felis. Nulla eu scelerisque diam. Suspendisse ac odio tempor nunc pellentesque hendrerit at a magna. Vivamus ultrices nunc ut orci fermentum pharetra. Nullam laoreet hendrerit ligula ut gravida. Proin scelerisque magna sit amet arcu malesuada, pharetra ultrices est molestie. Nullam pulvinar placerat dui, vitae hendrerit tortor luctus sed. Pellentesque elementum tellus eget arcu pulvinar varius.
|
||||||
|
|
||||||
|
Ut placerat, magna vitae tincidunt ultricies, est orci aliquet urna, at luctus augue erat vel ipsum. Fusce odio sem, venenatis vel consequat nec, bibendum sed dolor. Cras a sodales eros. Nullam eget dui congue, vehicula purus ut, condimentum dui. Maecenas libero ipsum, condimentum tincidunt nisi in, sodales lacinia ex. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus.
|
||||||
|
|
||||||
|
Duis gravida velit ac euismod suscipit. Proin sed ligula erat. Nullam eu ornare massa, non fringilla felis. Curabitur eu erat ex. Quisque sit amet dolor id arcu mattis scelerisque at at eros. In arcu nulla, fermentum non maximus eget, rhoncus in lacus. Nulla sit amet augue eu tortor vulputate congue. Vivamus laoreet condimentum tempus. Maecenas tempor, diam sed laoreet venenatis, mauris arcu lacinia enim, quis facilisis nunc turpis a ligula. Pellentesque quis placerat nisi, sit amet ullamcorper diam. Suspendisse a elementum elit, vel tristique dolor. Nam pretium ante tortor, vel tristique ipsum ultricies ut. Quisque non lectus imperdiet, placerat erat ac, pharetra tellus. In condimentum at magna a posuere. Aliquam et fringilla ipsum. Sed facilisis, nulla a finibus gravida, elit elit vulputate velit, dictum ornare est sapien a nisl.
|
||||||
|
|
||||||
|
Mauris eleifend vulputate felis sed mattis. Praesent id velit vitae ex porta pretium. Cras mollis malesuada justo, ut ornare quam placerat ac. Donec lobortis arcu tellus, luctus tempor mi malesuada quis. Maecenas condimentum libero vitae finibus malesuada. Vestibulum sollicitudin fringilla diam eget egestas. Sed vulputate urna nec ipsum maximus hendrerit. Maecenas blandit ex ut massa sodales, vitae tincidunt lorem ullamcorper. Phasellus vitae nisl ornare, cursus sem a, pulvinar arcu. Vestibulum faucibus risus nec tincidunt pellentesque. Pellentesque vel porttitor ex. Vivamus sollicitudin gravida lacus in suscipit. Aliquam urna neque, sodales quis quam ac, suscipit condimentum ante.
|
||||||
|
|
||||||
|
Morbi id arcu sit amet sapien ornare gravida eget quis sapien. In hac habitasse platea dictumst. In quis interdum ligula. Donec sed mi vulputate, scelerisque turpis vitae, interdum odio. Proin tristique condimentum arcu, et malesuada tellus convallis in. Fusce egestas maximus magna, sit amet convallis velit porttitor ut. Curabitur venenatis lacus ut blandit convallis. Phasellus scelerisque congue turpis eget vehicula. Nam venenatis mi sit amet rhoncus pretium. Nulla sed odio purus. Phasellus cursus id sapien ut feugiat. Duis et ipsum vel dui tempus porta. Curabitur non tortor consectetur, sollicitudin tellus mollis, sollicitudin lorem.
|
||||||
|
|
||||||
|
Ut quis ornare justo. Nunc aliquam, leo sit amet placerat placerat, dui nulla luctus dui, ac iaculis nisl orci id metus. Vestibulum nunc nunc, porta nec dictum id, feugiat et ante. Quisque lobortis, lacus tristique vestibulum rhoncus, massa nulla dignissim massa, at scelerisque massa nunc at velit. Donec eu ipsum nec dui luctus pulvinar et et turpis. Quisque eu neque erat. Donec varius egestas nunc, ut pretium libero tempor ac. Vestibulum pellentesque mi erat, et semper dolor semper vitae. Morbi enim dui, laoreet non venenatis sit amet, dignissim a orci. Sed id odio turpis. Phasellus non rutrum magna. Maecenas placerat arcu ultricies ultrices congue. Nulla quis neque ligula. Etiam in diam commodo, pharetra mauris ac, pretium nisi. Praesent sed nibh nec odio condimentum commodo vel vel lacus.
|
||||||
|
|
||||||
|
Pellentesque id libero vitae ex egestas pharetra placerat nec augue. Ut eget lobortis lorem, at vehicula sapien. Aliquam eu tincidunt ligula. Aenean et vestibulum dui, quis porttitor dui. Nullam quis dolor libero. Sed accumsan eros vitae nisi ornare congue. Aliquam nisi sapien, sollicitudin quis odio vel, pharetra maximus urna.
|
||||||
|
|
||||||
|
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi accumsan felis id urna malesuada, non vulputate velit maximus. Proin urna velit, viverra a metus vel, sollicitudin faucibus nulla. Vivamus ipsum lectus, pharetra sit amet varius vel, ullamcorper nec neque. Suspendisse potenti. Vivamus sit amet justo ac augue placerat hendrerit in eu felis. Integer luctus ex quam, in pellentesque eros interdum sed. Fusce finibus quis neque vitae efficitur. Pellentesque vulputate consectetur egestas. Integer a neque fermentum, tincidunt ligula id, gravida urna. Pellentesque ultrices, leo et suscipit accumsan, lorem nunc porta dui, non congue ligula leo ut urna. Duis vehicula risus in mi eleifend luctus. Duis convallis, mi faucibus pellentesque cursus, libero mauris varius sem, sit amet fermentum massa metus nec tellus. In tortor ligula, faucibus eu nibh id, lobortis viverra erat. Morbi non nisi suscipit, mollis enim at, convallis velit.
|
||||||
|
|
||||||
|
Pellentesque consequat imperdiet felis quis scelerisque. Duis aliquam mollis nibh quis tincidunt. Vivamus elit odio, blandit quis volutpat at, blandit nec tortor. Cras maximus ex at odio maximus, dapibus condimentum risus malesuada. Pellentesque viverra orci at ante commodo, quis posuere sapien efficitur. Nunc tristique imperdiet diam elementum lobortis. Fusce velit dui, ultrices id ante pharetra, fermentum egestas augue. Curabitur ante augue, vestibulum non magna quis, feugiat pulvinar diam. Suspendisse sagittis dui a tellus scelerisque, ut tincidunt neque accumsan. Cras pharetra metus vel eros tincidunt, vel tincidunt lacus egestas. Donec eget pellentesque sapien. Cras condimentum in justo pulvinar feugiat. Quisque malesuada ac odio eget rhoncus. Fusce posuere justo sed finibus ornare.
|
||||||
|
|
||||||
|
Duis a auctor tortor. Pellentesque lobortis auctor risus, ultrices varius mi cursus quis. Sed dui nulla, mattis sit amet justo mattis, condimentum commodo justo. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Pellentesque hendrerit diam vel lacinia volutpat. Sed et luctus nunc, vitae congue arcu. Aenean placerat tincidunt ipsum. Ut molestie orci eu dapibus viverra. Cras sodales ullamcorper augue, at aliquam ex. Sed et justo augue.
|
||||||
|
|
||||||
|
Nullam feugiat risus et turpis faucibus, vel tincidunt nulla consequat. Aliquam libero erat, pellentesque sit amet tellus in, tempor ornare nisl. Donec viverra eget magna non pharetra. Cras sollicitudin, justo ut porttitor venenatis, risus nulla auctor est, at commodo sem urna in mi. Vestibulum mattis sapien vel nibh pulvinar, vel dignissim lacus cursus. Maecenas vel dictum tortor, ac ultricies justo. Praesent quis venenatis nisl. Morbi a diam fringilla, auctor lectus sed, varius est. Praesent faucibus auctor dolor, a commodo nisi mattis id. Fusce porta molestie ultrices. Sed pulvinar, leo ac consectetur hendrerit, erat velit gravida enim, eu blandit ligula justo sit amet neque.
|
||||||
|
|
||||||
|
Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean libero risus, porttitor in congue vel, consequat sed felis. Vivamus eget blandit ante. Vivamus vitae mattis massa. In ut dolor sit amet tellus sollicitudin mattis. Mauris iaculis nisl neque, in gravida lectus ultricies nec. Fusce vehicula vehicula lacinia. Fusce viverra sed nisl id rhoncus. Donec sed porta mi. Nam tempus purus non massa tincidunt iaculis. Morbi viverra massa ut gravida vestibulum.
|
||||||
|
|
||||||
|
Proin dapibus mi a libero sagittis, id vehicula nulla iaculis. Proin a enim in tortor tincidunt egestas. Integer finibus neque eu nibh pretium, et pellentesque urna finibus. Integer a sollicitudin mauris, at convallis erat. Nullam sit amet lectus sed turpis commodo efficitur. Nulla nec turpis dapibus, suscipit diam quis, vulputate urna. Aenean sed posuere justo.
|
||||||
|
|
||||||
|
Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Sed luctus purus nibh, id imperdiet risus pulvinar in. Quisque auctor sem lacinia turpis mollis, nec pretium ipsum suscipit. Aliquam sed metus sagittis, mollis nisi eget, hendrerit libero. Nulla sodales erat semper nisl condimentum, ultricies rhoncus lacus commodo. Ut suscipit libero augue, non vulputate dui tristique vel. Praesent convallis efficitur est, sed tincidunt mauris aliquet in. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||||
|
|
||||||
|
Etiam a vehicula purus. Curabitur lacus erat, ultrices et dictum id, posuere ut risus. Etiam at auctor leo. Pellentesque eleifend est a metus lacinia rutrum. Nullam justo ligula, tempor non facilisis vel, volutpat sed nulla. Integer non dolor consequat, dapibus lectus eu, luctus turpis. Aenean enim erat, sagittis vitae ornare bibendum, faucibus sit amet magna. Phasellus suscipit ultrices faucibus. Sed at risus molestie, viverra ipsum vel, bibendum lectus. Pellentesque molestie vitae risus non viverra. Phasellus eleifend massa id odio sagittis mattis. Nullam velit mauris, viverra quis fermentum sit amet, vestibulum ut ex. Suspendisse imperdiet, sapien sed sagittis pharetra, nisi nibh vulputate metus, quis mattis dolor nisi ut lorem. Praesent vestibulum nibh vulputate lacus pulvinar tempor. Vestibulum vulputate diam ligula, vitae efficitur enim dapibus non. Etiam at ornare enim.
|
||||||
|
|
||||||
|
Curabitur aliquet velit enim, euismod faucibus urna euismod sit amet. Vivamus viverra vulputate nulla, ut gravida neque rutrum in. Suspendisse potenti. Nulla vitae neque felis. Etiam eu erat ac nulla ornare volutpat. Quisque ut diam dui. Sed ut massa quis dolor volutpat eleifend. Duis posuere dolor sit amet varius auctor. Donec mollis malesuada erat, eu luctus libero viverra feugiat. Curabitur fermentum velit eu purus fringilla, consequat tincidunt diam rutrum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc volutpat vel est in vehicula. Morbi euismod, tortor ut posuere ullamcorper, velit justo ultricies lorem, vitae tincidunt erat ante vitae sem. Praesent semper, quam eget condimentum finibus, metus leo imperdiet augue, nec fringilla sem nunc id sapien.
|
||||||
|
|
||||||
|
Aliquam vestibulum ante porta sem finibus, ut rhoncus elit sodales. Phasellus a lacus congue, sagittis nulla eget, cursus libero. Duis laoreet fringilla faucibus. Aenean gravida lorem sed fringilla facilisis. Pellentesque sodales urna lorem, non rutrum tortor vulputate eget. Duis a enim semper, iaculis sem ac, facilisis urna. Donec iaculis nulla sit amet dignissim volutpat. Mauris cursus dui id feugiat suscipit. Fusce tempor placerat nulla vitae vestibulum. Vivamus imperdiet blandit nulla, in aliquet justo viverra in. Cras malesuada molestie ligula sit amet volutpat. Praesent ornare orci sit amet rutrum eleifend. Ut placerat metus felis, id malesuada justo mollis eget. Etiam mi turpis, pulvinar in pulvinar in, tincidunt in neque.
|
||||||
|
|
||||||
|
In venenatis euismod neque, eu convallis diam semper ac. Mauris auctor mi non massa vestibulum viverra. Aenean non turpis sapien. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Praesent gravida feugiat interdum. Vivamus sit amet consequat ligula. Praesent dictum nunc ac sapien ullamcorper consectetur. Ut malesuada blandit neque.
|
||||||
|
|
||||||
|
Duis tincidunt mauris sit amet odio dictum accumsan. Suspendisse efficitur nibh magna, ac dapibus augue vulputate nec. Etiam lectus neque, sollicitudin quis sapien vitae, aliquet fermentum mi. Aenean interdum interdum rhoncus. Donec eu libero urna. Donec semper lacus eu nunc scelerisque, vitae viverra quam consectetur. Integer sagittis, nulla congue venenatis auctor, purus justo mollis metus, at rutrum magna arcu nec est. Maecenas felis lorem, consequat non cursus vitae, lobortis vitae nisi. Cras eget magna justo. Nullam sagittis tellus id luctus ultricies. Etiam a arcu efficitur, consectetur libero non, imperdiet turpis. Donec ac velit et nisl semper semper. Duis iaculis interdum nunc sed tempor.
|
||||||
|
|
||||||
|
Sed diam odio, sagittis non dignissim nec, accumsan ac diam. Fusce sit amet dui sit amet justo ultrices viverra. Sed vel massa suscipit nibh porttitor laoreet in id nunc. Nam quis libero vitae nunc blandit sollicitudin et a lorem. Duis urna arcu, accumsan sed dignissim sit amet, vulputate at ex. Mauris porttitor libero mauris, vel fringilla diam euismod quis. Sed varius placerat tellus vel efficitur. Phasellus pulvinar gravida magna. Nulla dignissim consectetur finibus.
|
||||||
|
|
||||||
|
Nunc quis aliquet nisi. Cras luctus bibendum eros ac dignissim. Aenean suscipit felis vitae elementum eleifend. Proin commodo nunc non diam dignissim, in tincidunt nulla ultrices. Vivamus faucibus quam scelerisque interdum finibus. Sed porttitor vehicula urna, in laoreet arcu condimentum non. Praesent ac lacus diam. Vivamus aliquam euismod risus, luctus dictum lectus sodales et. Proin quis velit ac massa tristique scelerisque.
|
||||||
|
|
||||||
|
Sed non dolor efficitur, tincidunt mi eget, sagittis tortor. Quisque at varius felis, at finibus sem. Vestibulum vel lectus tincidunt, pharetra diam sit amet, interdum nulla. Sed ut elit tortor. Nam tincidunt tempus aliquam. Vivamus rhoncus faucibus sapien eget facilisis. Aliquam erat volutpat. Phasellus placerat aliquam lacus, eget ultrices orci pretium hendrerit. Fusce vitae dolor sit amet ante condimentum placerat. Nunc varius risus id tellus mollis, euismod luctus sapien viverra. Donec sodales est vel massa suscipit, eu sollicitudin ante convallis. Curabitur eu condimentum velit. Sed pharetra euismod tincidunt.
|
||||||
|
|
||||||
|
Nam at libero eros. Quisque bibendum, ligula quis sagittis ullamcorper, eros leo consectetur ex, quis elementum dolor justo vitae mi. Maecenas et elementum erat, et auctor enim. Nunc at nibh fermentum, ullamcorper mi elementum, facilisis erat. Vestibulum vestibulum leo ut pellentesque placerat. Suspendisse imperdiet nisl vitae justo sodales pellentesque. Interdum et malesuada fames ac ante ipsum primis in faucibus. In faucibus pretium nunc, sed interdum lectus vestibulum quis. Vestibulum luctus viverra ex at efficitur. Etiam ac est lorem. Maecenas mollis, orci at rhoncus congue, nulla leo rutrum dui, et pellentesque orci ligula eget ipsum. Suspendisse fermentum nisi turpis, ut sollicitudin purus imperdiet non. Maecenas vitae quam ornare, porta sem quis, rhoncus neque. Donec mattis purus a erat tristique, ac mollis est convallis. Duis vitae ipsum viverra, condimentum ante vel, sagittis ex. Maecenas placerat odio libero, id interdum turpis fermentum at.
|
||||||
|
|
||||||
|
Praesent faucibus nulla eget vehicula accumsan. Nulla elementum ante a nibh venenatis hendrerit. Proin nec nunc mattis, imperdiet nisl rutrum, sollicitudin libero. Nullam bibendum dignissim faucibus. Sed eget tortor vitae sapien cursus faucibus nec et lectus. Duis pharetra non odio id consectetur. Suspendisse at est sem. Nunc mauris ligula, ultrices id ante non, venenatis mattis erat. Vivamus sit amet viverra nisl.
|
||||||
|
|
||||||
|
Sed cursus vel nisi in mattis. Nunc porttitor dictum leo ac euismod. Sed blandit ornare nunc id lobortis. Aenean convallis ligula at volutpat commodo. Vestibulum sit amet laoreet urna. Donec et pellentesque orci, ac egestas nulla. In accumsan venenatis porta. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Donec scelerisque, metus nec viverra pharetra, dolor libero dictum velit, id ullamcorper enim nisi eu nibh. Aliquam nec dapibus quam. Curabitur vulputate, libero sit amet tempor ullamcorper, libero purus congue quam, nec sollicitudin orci erat non massa. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.
|
||||||
|
|
||||||
|
Aenean faucibus mollis placerat. Praesent lacinia venenatis turpis eu scelerisque. Nam tempus tortor a varius posuere. Cras ut viverra tortor. Cras facilisis mauris ut ante imperdiet, a malesuada justo luctus. Ut eu enim sit amet arcu porttitor pulvinar ac ac odio. Ut odio neque, molestie vitae ligula quis, dignissim viverra erat. In ullamcorper erat sed elementum varius. Sed in lacus maximus, euismod nisi vitae, tempus mi. Nunc tellus justo, auctor at luctus ac, feugiat sit amet dui.
|
||||||
|
|
||||||
|
Donec imperdiet purus lorem, sed venenatis dolor finibus non. Aenean lacus nunc, elementum nec arcu eget, faucibus elementum turpis. Aliquam lacinia massa ac quam efficitur, et tincidunt eros pretium. Fusce condimentum mi vel pharetra egestas. Quisque consectetur nibh vel leo dignissim sollicitudin. Duis ultrices felis ipsum, sed maximus arcu ornare vitae. Curabitur porttitor ligula in turpis facilisis, id venenatis augue ultricies. Phasellus vel dolor id tellus finibus sodales ut quis nisi. Integer id orci cursus erat tincidunt sagittis non in nunc. Pellentesque ligula lacus, vestibulum eu ante vel, facilisis viverra massa. Sed ut tincidunt metus, vel tristique est. Ut et cursus justo. Ut ac porttitor eros, at dictum felis. Phasellus ornare nisi sit amet risus varius, sed sollicitudin nulla ornare. Donec aliquam ipsum urna. Aliquam id bibendum magna, quis venenatis diam.
|
||||||
|
|
||||||
|
Duis tempor odio id iaculis egestas. Cras consequat neque ac posuere iaculis. Nulla tempus et nisi eu auctor. Vestibulum metus massa, dignissim ut metus eget, ullamcorper consectetur turpis. Integer vel est tellus. Ut ac vestibulum massa. Pellentesque nec venenatis erat. Nam vel pellentesque lectus.
|
||||||
|
|
||||||
|
Morbi a placerat est. Ut eleifend ante ut placerat porta. Donec sagittis semper leo, ut scelerisque nisi imperdiet feugiat. Mauris purus turpis, consequat ut fringilla ac, cursus eget augue. Fusce arcu dolor, sagittis et facilisis ut, scelerisque non lacus. Aliquam sit amet eleifend tellus. Mauris id est luctus, iaculis tortor eget, gravida justo. Suspendisse at tellus nisl. Nullam felis erat, vehicula eu porttitor bibendum, pulvinar et dui. Sed molestie lacus nec sagittis rutrum. Aliquam erat volutpat. Nullam ut aliquet eros. Sed feugiat, massa id pharetra auctor, leo turpis condimentum purus, sit amet volutpat sem nunc sed nisi.
|
||||||
|
|
||||||
|
Pellentesque feugiat ipsum at accumsan iaculis. Morbi et dui in lorem commodo hendrerit. Mauris tempor ex mollis mollis blandit. Cras eu turpis feugiat, suscipit velit quis, volutpat magna. Vestibulum varius ligula ut quam mollis, a volutpat nibh lobortis. Sed sodales euismod leo non suscipit. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nullam ultricies sagittis justo, sit amet lacinia eros tincidunt tristique. Etiam faucibus turpis in lacus efficitur, vitae rutrum magna porttitor. Cras finibus eros vel ante semper, facilisis vestibulum erat accumsan. Ut mollis dui ut commodo varius. In et sem malesuada erat egestas pellentesque. Maecenas pulvinar sodales risus, at euismod ligula aliquet in. Quisque fringilla malesuada dui vel cursus. Curabitur eu ex vulputate, pretium elit non, sodales sapien. Integer egestas facilisis odio et pretium.
|
||||||
|
|
||||||
|
Integer eleifend, felis vitae faucibus tempus, tellus lectus placerat nunc, eu efficitur lacus mi vel nisl. Mauris commodo pretium feugiat. In aliquet nibh diam, ac egestas mauris consequat ut. Integer cursus, tortor pharetra pellentesque pulvinar, neque risus ultricies felis, et consequat felis eros at eros. Pellentesque fermentum velit ac sodales facilisis. Suspendisse vestibulum metus quis convallis lacinia. Donec in pharetra magna. Proin gravida dolor eget ligula lobortis sagittis.
|
||||||
|
|
||||||
|
Nullam consectetur ut massa id ultrices. Fusce consectetur at eros at mollis. Donec nec nibh fringilla, porttitor ipsum eget, aliquam neque. Quisque suscipit tortor in dui commodo, sed venenatis augue cursus. Etiam feugiat purus id justo elementum placerat. Sed interdum dictum nibh at sodales. Maecenas lobortis, metus ac sagittis lacinia, elit arcu varius felis, quis facilisis magna elit in leo. Proin condimentum orci sit amet dignissim imperdiet. Fusce sed iaculis felis. Maecenas sodales non magna vitae rutrum.
|
||||||
|
|
||||||
|
Nulla id ex massa. Sed vehicula sed quam non elementum. Aliquam luctus, enim vel molestie posuere, sem arcu laoreet justo, quis finibus nisl justo et magna. Vivamus malesuada elit in aliquam dignissim. Sed at tellus in orci vulputate ullamcorper. Integer magna sem, mattis id hendrerit non, tincidunt in est. Praesent posuere aliquet lobortis. Quisque euismod leo ut nisl pellentesque, et imperdiet dui dapibus.
|
||||||
|
|
||||||
|
Sed a erat nec risus pulvinar venenatis. Integer ultrices eros at aliquet efficitur. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Quisque ac maximus ante. Sed sodales, nisi et sagittis accumsan, diam odio consequat elit, et mollis dolor turpis tincidunt metus. Proin justo quam, tincidunt id convallis a, molestie hendrerit massa. Etiam pharetra turpis eu ultrices mollis.
|
||||||
|
|
||||||
|
Proin fermentum libero in purus cursus molestie. In varius magna eu ante maximus, eget rutrum felis iaculis. Maecenas hendrerit, diam eget vestibulum vehicula, est quam porta magna, a dictum urna mi ut libero. Vestibulum dictum quis lacus vitae eleifend. Integer sapien libero, pretium vitae euismod eget, semper eget ante. Vivamus mollis elementum odio vel hendrerit. Phasellus tristique, metus eget luctus tincidunt, ex enim faucibus ipsum, at dictum eros urna ac mi. Aenean imperdiet felis eu ultricies egestas. Vivamus fermentum convallis nisi, non sollicitudin felis posuere nec. Ut commodo sit amet felis semper dictum. Aenean accumsan, tellus id blandit aliquet, lorem nibh pellentesque mi, sit amet volutpat erat ligula ut est. Vivamus non posuere velit. Sed vel rutrum diam, non pulvinar nulla. Suspendisse quis gravida lectus, ac accumsan justo. Sed lobortis neque ante, a imperdiet nisl iaculis nec.
|
||||||
@@ -4,8 +4,12 @@ A native LÖVE2D recreation of Poke Red, Blue and Yellow. The engine and map
|
|||||||
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
behavior are hand-written Lua; game data and graphics are decoded from a ROM
|
||||||
supplied by the player.
|
supplied by the player.
|
||||||
|
|
||||||
|
And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. ***Reverse Engineering Causes Obsessive Mental Problems***
|
||||||
|
|
||||||
|
[Click Here for the AI Use Disclosure!](AIDisclosure.md)
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!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.
|
> **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. Also, as I assumed would eventually happen, the idiot that made that website now pumped it full of adware. Please stay away from that website.
|
||||||
|
|
||||||
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
<p align="center"><img src="https://raw.githubusercontent.com/bryanthaboi/gen1recomp/refs/heads/dev/assets/logo/logo.png"></p>
|
||||||
|
|
||||||
@@ -350,7 +354,8 @@ request with real detail is one that can actually get built.
|
|||||||
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
- [Save editor](https://github.com/bryanthaboi/gen1recomp/wiki/Guide-Save-Editor)
|
||||||
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
— edit party, boxes, items, events, and Pokédex flags outside the game.
|
||||||
- `docs/architecture.md` — runtime details;
|
- `docs/architecture.md` — runtime details;
|
||||||
`docs/behavior-porting-notes.md` — formula provenance.
|
`docs/behavior-porting-notes.md` — formula provenance;
|
||||||
|
`docs/link-security.md` — what link play defends against, and what it doesn't.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# gb_anim -- bundled touch skin
|
||||||
|
|
||||||
|
Bezel art and overlay layout from libretro's `common-overlays`
|
||||||
|
(`gamepads/gb_anim_portrait`), licensed CC-BY-4.0:
|
||||||
|
https://github.com/libretro/common-overlays
|
||||||
|
|
||||||
|
`overlay.cfg` is the upstream `gb_big.cfg`, unmodified. It ships as the
|
||||||
|
reference skin for the RetroArch-overlay loader in
|
||||||
|
`src/core/TouchSkin.lua`: a full-device bezel, per-button press art, a
|
||||||
|
screen viewport, and page switching between the DMG and Color shells.
|
||||||
|
After Width: | Height: | Size: 973 B |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 649 B |
|
After Width: | Height: | Size: 645 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,89 @@
|
|||||||
|
overlays = 2
|
||||||
|
|
||||||
|
overlay0_name = "GameBoy"
|
||||||
|
overlay0_overlay = img/gb_back.png
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_normalized = true
|
||||||
|
overlay0_range_mod = 1.0
|
||||||
|
overlay0_alpha_mod = 0.001
|
||||||
|
overlay0_viewport = "0.0,0.0,1.0,0.505"
|
||||||
|
overlay0_viewport_fill = true
|
||||||
|
|
||||||
|
overlay1_name = "GameBoyColor"
|
||||||
|
overlay1_overlay = img/gbc_back.png
|
||||||
|
overlay1_full_screen = true
|
||||||
|
overlay1_normalized = true
|
||||||
|
overlay1_range_mod = 1.0
|
||||||
|
overlay1_alpha_mod = 0.001
|
||||||
|
overlay1_viewport = "0.0,0.0,1.0,0.505"
|
||||||
|
overlay1_viewport_fill = true
|
||||||
|
|
||||||
|
# GameBoy
|
||||||
|
overlay0_descs = 18
|
||||||
|
|
||||||
|
overlay0_desc0 = "left,0.12778,0.73417,radial,0.09630,0.04635"
|
||||||
|
overlay0_desc0_overlay = img/gb_left.png
|
||||||
|
overlay0_desc1 = "right,0.35370,0.73417,radial,0.09630,0.04635"
|
||||||
|
overlay0_desc1_overlay = img/gb_right.png
|
||||||
|
overlay0_desc2 = "up,0.24074,0.67063,radial,0.08241,0.05417"
|
||||||
|
overlay0_desc2_overlay = img/gb_up.png
|
||||||
|
overlay0_desc3 = "down,0.24074,0.79771,radial,0.08241,0.05417"
|
||||||
|
overlay0_desc3_overlay = img/gb_down.png
|
||||||
|
overlay0_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
|
||||||
|
overlay0_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
|
||||||
|
overlay0_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
|
||||||
|
overlay0_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
|
||||||
|
overlay0_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
|
||||||
|
overlay0_desc8_overlay = img/gb_a_b.png
|
||||||
|
overlay0_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
|
||||||
|
overlay0_desc9_overlay = img/gb_a_b.png
|
||||||
|
overlay0_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
|
||||||
|
overlay0_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
|
||||||
|
overlay0_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
|
||||||
|
overlay0_desc12_overlay = img/gb_start_select.png
|
||||||
|
overlay0_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
|
||||||
|
overlay0_desc13_overlay = img/gb_start_select.png
|
||||||
|
overlay0_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
|
||||||
|
overlay0_desc14_overlay = img/menu.png
|
||||||
|
overlay0_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
|
||||||
|
overlay0_desc15_overlay = img/rotate.png
|
||||||
|
overlay0_desc15_next_target = "GameBoyColor"
|
||||||
|
overlay0_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
|
||||||
|
overlay0_desc16_overlay =
|
||||||
|
overlay0_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
|
||||||
|
overlay0_desc17_overlay =
|
||||||
|
|
||||||
|
# GameBoyColor
|
||||||
|
overlay1_descs = 18
|
||||||
|
|
||||||
|
overlay1_desc0 = "left,0.14078,0.73417,radial,0.08530,0.04635"
|
||||||
|
overlay1_desc0_overlay = img/gbc_left.png
|
||||||
|
overlay1_desc1 = "right,0.34270,0.73417,radial,0.08530,0.04635"
|
||||||
|
overlay1_desc1_overlay = img/gbc_right.png
|
||||||
|
overlay1_desc2 = "up,0.24074,0.67863,radial,0.08241,0.04617"
|
||||||
|
overlay1_desc2_overlay = img/gbc_up.png
|
||||||
|
overlay1_desc3 = "down,0.24074,0.78971,radial,0.08241,0.04617"
|
||||||
|
overlay1_desc3_overlay = img/gbc_down.png
|
||||||
|
overlay1_desc4 = "left|up,0.09259,0.65188,rect,0.06481,0.03646"
|
||||||
|
overlay1_desc5 = "right|up,0.38704,0.65188,rect,0.06481,0.03646"
|
||||||
|
overlay1_desc6 = "left|down,0.09259,0.81750,rect,0.06481,0.03646"
|
||||||
|
overlay1_desc7 = "right|down,0.38704,0.81750,rect,0.06481,0.03646"
|
||||||
|
overlay1_desc8 = "a,0.87407,0.72417,radial,0.08889,0.05000"
|
||||||
|
overlay1_desc8_overlay = img/gbc_a.png
|
||||||
|
overlay1_desc9 = "b,0.68148,0.76584,radial,0.08889,0.05000"
|
||||||
|
overlay1_desc9_overlay = img/gbc_b.png
|
||||||
|
overlay1_desc10 = "a|b,0.77037,0.73417,radial,0.02963,0.01667"
|
||||||
|
overlay1_desc11 = "a|b,0.78518,0.75584,radial,0.02963,0.01667"
|
||||||
|
overlay1_desc12 = "start,0.66666,0.93000,radial,0.07037,0.03958"
|
||||||
|
overlay1_desc12_overlay = img/gbc_start_select.png
|
||||||
|
overlay1_desc13 = "select,0.33333,0.93000,radial,0.07037,0.03958"
|
||||||
|
overlay1_desc13_overlay = img/gbc_start_select.png
|
||||||
|
overlay1_desc14 = "menu_toggle,0.05000,0.52800,radial,0.041296,0.02323"
|
||||||
|
overlay1_desc14_overlay = img/menu.png
|
||||||
|
overlay1_desc15 = "overlay_next,0.95000,0.52800,radial,0.041296,0.02323"
|
||||||
|
overlay1_desc15_overlay = img/rotate.png
|
||||||
|
overlay1_desc15_next_target = "GameBoy"
|
||||||
|
overlay1_desc16 = "rewind,0.05000,0.97500,radial,0.041296,0.02323"
|
||||||
|
overlay1_desc16_overlay =
|
||||||
|
overlay1_desc17 = "hold_fast_forward,0.95000,0.97500,radial,0.041296,0.02323"
|
||||||
|
overlay1_desc17_overlay =
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# tv_crt -- bundled desktop bezel
|
||||||
|
|
||||||
|
CRT television border from libretro's `common-overlays`
|
||||||
|
(`borders/tv-integer.cfg` + `borders/img/tv-integer.png`), licensed
|
||||||
|
CC-BY-4.0: https://github.com/libretro/common-overlays
|
||||||
|
|
||||||
|
`overlay.cfg` is the upstream file, unmodified. It is the reference
|
||||||
|
DESKTOP skin: 1920x1080, `descs = 0` (pure decoration, no touch buttons),
|
||||||
|
and a `viewport` naming the transparent screen hole, so the Game Boy
|
||||||
|
picture is fitted into the TV's tube instead of the whole window.
|
||||||
|
After Width: | Height: | Size: 1.9 MiB |
@@ -0,0 +1,6 @@
|
|||||||
|
overlays = 1
|
||||||
|
overlay0_overlay = img/tv-integer.png
|
||||||
|
overlay0_full_screen = true
|
||||||
|
overlay0_descs = 0
|
||||||
|
overlay0_viewport = "0.2335,0.0855,0.5335,0.830"
|
||||||
|
overlay0_viewport_fill = true
|
||||||
@@ -702,6 +702,23 @@ M.MT_MOON_B2F = {
|
|||||||
return false
|
return false
|
||||||
end,
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
|
-- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils
|
||||||
|
-- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold
|
||||||
|
TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done)
|
||||||
|
if not superNerdBeaten(ow) then
|
||||||
|
engageSuperNerd(game, ow, done)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
local t = game.data.text
|
||||||
|
local flags = game.save.flags
|
||||||
|
local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL)
|
||||||
|
and (t._MtMoonB2FSuperNerdTheresAPokemonLabText
|
||||||
|
or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.")
|
||||||
|
or (t._MtMoonB2fSuperNerdEachTakeOneText
|
||||||
|
or "We'll each take\none!\nNo being greedy!")
|
||||||
|
game.stack:push(TextBox.new(game, line, done))
|
||||||
|
end,
|
||||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
|
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
|
||||||
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
|
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
|
||||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
|
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
|
||||||
@@ -716,7 +733,8 @@ local function museumClerk(game, ow, done, onDecline)
|
|||||||
local t = game.data.text or {}
|
local t = game.data.text or {}
|
||||||
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
if game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"Take your time,\nand enjoy it all!", done))
|
t._Museum1FScientist1TakePlentyOfTimeText
|
||||||
|
or "Take your time,\nand enjoy it all!", done))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- scripts/Museum1F.asm:72
|
-- scripts/Museum1F.asm:72
|
||||||
@@ -734,10 +752,12 @@ local function museumClerk(game, ow, done, onDecline)
|
|||||||
{ money = money }))
|
{ money = money }))
|
||||||
elseif yes then
|
elseif yes then
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"You don't have\nenough money.", onDecline or done, { money = money }))
|
t._Museum1FScientist1DontHaveEnoughMoneyText
|
||||||
|
or "You don't have\nenough money.", onDecline or done, { money = money }))
|
||||||
else
|
else
|
||||||
game.stack:push(TextBox.new(game,
|
game.stack:push(TextBox.new(game,
|
||||||
"Come again!", onDecline or done, { money = money }))
|
t._Museum1FScientist1ComeAgainText
|
||||||
|
or "Come again!", onDecline or done, { money = money }))
|
||||||
end
|
end
|
||||||
end }))
|
end }))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -118,33 +118,36 @@ M.ROUTE_15_GATE_2F = {
|
|||||||
|
|
||||||
M.MT_MOON_POKECENTER = {
|
M.MT_MOON_POKECENTER = {
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done)
|
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407)
|
||||||
local t = text(game)
|
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = {
|
||||||
if game.save.flags.EVENT_BOUGHT_MAGIKARP then
|
{ "check_flag", "EVENT_BOUGHT_MAGIKARP" },
|
||||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText
|
{ "jump_if_true", "no_refunds" },
|
||||||
or "Well, I don't\ngive refunds!", done)
|
-- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31
|
||||||
return
|
{ "text_opts", { money = true } },
|
||||||
end
|
{ "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" },
|
||||||
ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText
|
{ "jump_if_false", "declined" },
|
||||||
or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes)
|
{ "check_money", 500 },
|
||||||
if not yes then
|
{ "jump_if_false", "no_money" },
|
||||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoText
|
{ "give_pokemon", "MAGIKARP", 5 },
|
||||||
or "No? I'm only\nselling today!", done)
|
-- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged
|
||||||
return
|
{ "jump_if_false", "box_full" },
|
||||||
end
|
{ "take_money", 500 },
|
||||||
if game.save.money < 500 then
|
{ "set_flag", "EVENT_BOUGHT_MAGIKARP" },
|
||||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText
|
{ "text_sound", "Get_Item1" },
|
||||||
or "You'll need more\nmoney than that!", done)
|
{ "show_text", "_GotMonText", { RAM = "MAGIKARP" } },
|
||||||
return
|
{ "jump", "end" },
|
||||||
end
|
{ "label", "box_full" },
|
||||||
game.save.money = game.save.money - 500
|
{ "show_text", "_BoxIsFullText" },
|
||||||
game.save.flags.EVENT_BOUGHT_MAGIKARP = true
|
{ "jump", "end" },
|
||||||
local Commands = require("src.script.Commands")
|
{ "label", "declined" },
|
||||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" },
|
||||||
"MAGIKARP", 5)
|
{ "jump", "end" },
|
||||||
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
|
{ "label", "no_money" },
|
||||||
end)
|
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" },
|
||||||
end,
|
{ "jump", "end" },
|
||||||
|
{ "label", "no_refunds" },
|
||||||
|
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Link play: threat model and what the code actually guarantees
|
||||||
|
|
||||||
|
Link play is the only part of this game that reads bytes written by
|
||||||
|
somebody else. This is what it defends against, what it does not, and
|
||||||
|
where each guarantee lives.
|
||||||
|
|
||||||
|
## The boundary
|
||||||
|
|
||||||
|
Everything a peer or the relay sends arrives as one JSON object per line.
|
||||||
|
There is exactly one place it becomes a message:
|
||||||
|
|
||||||
|
src/link/Net.lua reads bytes, frames lines, decodes JSON
|
||||||
|
src/link/Wire.lua rebuilds each line as a typed message
|
||||||
|
src/link/Session.lua the only path from a transport into a mode
|
||||||
|
|
||||||
|
`Session:update` runs `Wire.sanitize` on every message before anything
|
||||||
|
else sees it. A schema returns a **new** table holding only the fields it
|
||||||
|
names, at the Lua types it names, so the rest of `src/link/` can read
|
||||||
|
`msg.slot`, `msg.parts.actives` or `msg.mons[i].dvs.hp` directly and be
|
||||||
|
right by construction. A message with no schema (a mod's, or a future
|
||||||
|
build's) keeps a bounded, scalar-only copy of its payload instead of
|
||||||
|
being dropped.
|
||||||
|
|
||||||
|
A message that fails its schema is **dropped and logged**, never fatal.
|
||||||
|
Latching a terminal failure would hand a hostile peer a cheaper
|
||||||
|
disconnect than sending nothing at all.
|
||||||
|
|
||||||
|
### Why the bounds are loose
|
||||||
|
|
||||||
|
Wire's numeric bounds are deliberately wider than the game's own clamps in
|
||||||
|
`Protocol.unpackMon`. Both peers run identical clamps over identical
|
||||||
|
packets; a bound that bit an honest value would change one side's copy of
|
||||||
|
a mon and desync the lockstep. Wire's job is types and sizes. Rules are
|
||||||
|
`Protocol`'s job, and it keeps its own clamps for the callers that reach
|
||||||
|
it without a Session (the mod API, `tests/`).
|
||||||
|
|
||||||
|
### Containment behind it
|
||||||
|
|
||||||
|
Assume something still gets through:
|
||||||
|
|
||||||
|
- `Game:step` pcalls the link pump, and pcalls `stack:update` **only
|
||||||
|
while a link session is active**. On a throw, `Game:breakLink` closes
|
||||||
|
the connection, unwinds to the overworld and says "The link was
|
||||||
|
broken." Outside link play the stack is unguarded on purpose: a blanket
|
||||||
|
pcall would swallow real engine bugs and leave the game silently wrong
|
||||||
|
instead of loudly broken.
|
||||||
|
- `Net` caps `rxBuf` at 256KB and its per-frame read at 512KB, so a peer
|
||||||
|
that never sends a newline ends as a clean disconnect.
|
||||||
|
- `Json.decode` refuses documents nested past 64 levels, and takes an
|
||||||
|
optional length cap that the link path passes and the mod-manifest path
|
||||||
|
does not.
|
||||||
|
|
||||||
|
## The relay (`../pokeserver`)
|
||||||
|
|
||||||
|
- A line that is not a JSON **object** with a string `type` is dropped
|
||||||
|
before any handler runs, and `onLine` is wrapped in try/catch.
|
||||||
|
`server.js` installs `uncaughtException`/`unhandledRejection` handlers:
|
||||||
|
one bad packet must never take every live match down with the process.
|
||||||
|
- Line buffers are capped, lines per second are capped, connections per
|
||||||
|
IP and in total are capped, and an unbound connection that never hosts
|
||||||
|
or joins is swept after 30s.
|
||||||
|
- `SERVER_ONLY` is the set of message types the server is the only
|
||||||
|
legitimate author of (`peer_gone`, `bracket_update`, `match_start`,
|
||||||
|
`tournament_over`, `spectate`, ...). A peer that sends one has them
|
||||||
|
dropped rather than forwarded, so a bracket opponent cannot forge a
|
||||||
|
tournament result or fake "your opponent left".
|
||||||
|
- Trainer names are reduced to a printable subset and capped at the same
|
||||||
|
10 characters the game enforces, on the way in, because they are
|
||||||
|
rendered by the dashboard and broadcast to every participant.
|
||||||
|
|
||||||
|
`pokeserver/test/hostile.js` is the regression net for all of that.
|
||||||
|
|
||||||
|
## What is NOT defended
|
||||||
|
|
||||||
|
**Party legality is trust-the-client.** Online play meets strangers, and
|
||||||
|
`Handshake.onlineAllowed` is a Lua function in the same VM the mods load
|
||||||
|
into. It cannot be made tamper-proof in-process, and pretending otherwise
|
||||||
|
would only cost honest mod authors. What lockstep and
|
||||||
|
`Protocol.unpackMon`'s recompute-from-species-data *do* guarantee is that
|
||||||
|
a cheater cannot invent stats, moves, or a shiny: every derived value is
|
||||||
|
rebuilt locally from real species data. They can send a legal party they
|
||||||
|
farmed or edited. That is the honest boundary.
|
||||||
|
|
||||||
|
What the relay does instead is **observe and record**. It already sees
|
||||||
|
every `hello`, so it keeps each connection's self-reported
|
||||||
|
`engineVersion`, `fingerprint` and `linkModified`, compares the two sides
|
||||||
|
of a room or a live tournament match, and logs and surfaces a
|
||||||
|
`modded` / `fingerprint_mismatch` / `version_skew` flag on the dashboard.
|
||||||
|
A patched client can still lie; what it cannot do is lie without the
|
||||||
|
tournament organizer having a record of it.
|
||||||
|
|
||||||
|
Client-side attestation is deliberately not built. This is an
|
||||||
|
open-source Lua game: it would be theater, and it would break honest
|
||||||
|
mods.
|
||||||
|
|
||||||
|
**The relay has no TLS.** Port 7778 is plaintext, so party contents,
|
||||||
|
trades and trainer names are visible to anyone on the network path. There
|
||||||
|
is nothing secret in a Pokemon party, but it is a real property of the
|
||||||
|
system and not an oversight. Fixing it means a TLS terminator in front of
|
||||||
|
the relay and a client that speaks it, which is a version break for every
|
||||||
|
shipped build.
|
||||||
|
|
||||||
|
**The dashboard has no default password.** `DASHBOARD_PASSWORD` is
|
||||||
|
required; with it unset the relay runs and the dashboard simply does not
|
||||||
|
start. It is still Basic Auth over plain HTTP, so it belongs behind an
|
||||||
|
IP restriction or an SSH tunnel (`pokeserver/DEPLOY.md`).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
luajit tests/link_hostile.lua every message type x every wrong type
|
||||||
|
luajit tests/link_desync_fuzz.lua lockstep fuzz, plus a mutation mode
|
||||||
|
luajit tests/run_link_tests.lua both of the above, plus the rest
|
||||||
|
cd ../pokeserver && npm test relay smoke, 16-player bracket, hostile
|
||||||
|
|
||||||
|
`tests/link_hostile.lua` builds its corpus from a template per message
|
||||||
|
type, replaces each field (and several nested ones) with every wrong Lua
|
||||||
|
type, and drives the survivors through the real trade session, a real
|
||||||
|
lockstep battle, a real spectator battle, and the tournament screen
|
||||||
|
**including its draw** -- because the two nastiest payloads are
|
||||||
|
delayed-fuse ones that crash on render rather than on receipt.
|
||||||
@@ -540,8 +540,9 @@ gains a field instead of the name gaining a prefix.
|
|||||||
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
`battle.crit`, `battle.accuracy`, `battle.turn_order`,
|
||||||
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
`battle.enemy_action`, `battle.run`, `battle.exp_award`, `exp.gain`,
|
||||||
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
`catch.rate`, `trainer.party`, `battle.overlay`, `battle.low_health_alarm`,
|
||||||
`battle.catch_exp`, `battle.bottom_ui_visible` and
|
`battle.catch_exp`, `battle.bottom_ui_visible`,
|
||||||
`battle.status_hud_visible`. One payload difference: Gen 1's vanilla
|
`battle.status_hud_visible` and `battle.move_grid_navigation`. One payload
|
||||||
|
difference: Gen 1's vanilla
|
||||||
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
`battle.low_health_alarm` link reads `ctx.battle.data`, and Gold's battle
|
||||||
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
screen has no `.data` field, so the Gen 2 site **adds** `ctx.data` beside the
|
||||||
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
Gen 1 keys. A mod that calls `nextFn` is unaffected; one that reaches through
|
||||||
|
|||||||
@@ -268,10 +268,10 @@ an optional stock `catchChance` percentage.
|
|||||||
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
`prompt` describes the currently visible choice (`menu`, `moves`, `party`,
|
||||||
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
`advance`, `safari`, or `mimic`) and is `locked` when another screen or battle
|
||||||
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
phase owns input. Generation-specific features remain optional: Gen 1 includes
|
||||||
battle medicine, balls, catch previews, Safari balls, and Mimic choices;
|
battle medicine, balls, catch previews, Safari balls, and Mimic choices. Gold
|
||||||
Gold currently returns an empty `items` list rather than guessing at its
|
exposes balls and their exact stock catch previews; targeted medicine remains
|
||||||
pocketed PACK flow. Callers should ignore unknown fields and tolerate absent
|
screen-owned and is omitted rather than guessing at its pocketed PACK flow.
|
||||||
optional ones.
|
Callers should ignore unknown fields and tolerate absent optional ones.
|
||||||
|
|
||||||
## Battle menu intents
|
## Battle menu intents
|
||||||
|
|
||||||
|
|||||||
@@ -11,28 +11,11 @@ Features intentionally added beyond the original Pokémon Red, Blue, and Yellow
|
|||||||
* **Persistent custom options** stored separately from game saves
|
* **Persistent custom options** stored separately from game saves
|
||||||
* **Optional widescreen battle layout**
|
* **Optional widescreen battle layout**
|
||||||
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
* **Mobile touch controls** with editable layouts, vibration, and orientation settings
|
||||||
* **Translation and custom font support**
|
* **Touch skins** in RetroArch overlay format, with bezel art, per-button press states, and Super Game Boy borders
|
||||||
* **Built-in save editor** for parties, boxes, items, events, maps, and Pokédex data
|
|
||||||
* **Tiled map editing tools** for mod authors
|
|
||||||
* **Pokédex diploma and printer image exports**
|
* **Pokédex diploma and printer image exports**
|
||||||
* **Community mod browser**
|
* **Mod download counts** from the index feed, with Most-downloaded and Trending sorts
|
||||||
* **Soft reset button combination**
|
|
||||||
* **Keyboard and controller rebinding**
|
|
||||||
* **Mod profiles** with separate mod settings and save slots
|
|
||||||
* **Sandboxed mods**: an installed mod can read only its own folder and write only its own storage
|
|
||||||
* **Improved launcher and save editor UI**, including background downloads and update checks
|
|
||||||
* **Direct-launch options** for shortcuts, Steam entries, and handheld frontends
|
|
||||||
|
|
||||||
## Pokémon Gold (Gen 2)
|
## Gen 2 Specifics
|
||||||
|
|
||||||
* **COLOR, zoom, tilt, GBC FX, and quick save/load**
|
|
||||||
* **UI that stays fixed while the overworld zooms**
|
|
||||||
* **Border-block surrounds** for maps smaller than the screen
|
|
||||||
* **Gold-specific launcher options**
|
|
||||||
* **Optional widescreen battle layout**
|
|
||||||
* **Skippable trade animation** with B or START
|
|
||||||
* **QUIT and EXIT GAME** from the menus
|
|
||||||
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
* **Mod manager** with Gen 1 mod adapters, per-game targeting, and `modkit gen2check`
|
||||||
* **Followers** for mods, plus Gen 2-only registries and hooks
|
* **Followers** for mods, plus Gen 2-only registries and hooks
|
||||||
* **On-screen touch pad** and controller SELECT for registered items
|
|
||||||
* **Older mods keep loading** after the sandbox change, through per-mod compat stand-ins for the pre-sandbox globals
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# Touch skins and the Skin Studio
|
||||||
|
|
||||||
|
A **skin** replaces the on-screen controls wholesale: a bezel image, a
|
||||||
|
control layout, and the rectangle the Game Boy screen is drawn into. Engine:
|
||||||
|
`src/core/TouchSkin.lua` (model, parsers, zip export), `src/core/TouchControls.lua`
|
||||||
|
(draw and input), `src/render/Renderer.lua` (the screen viewport),
|
||||||
|
`src/ui/SkinStudio.lua` (the desktop editor). Tests:
|
||||||
|
`tests/engine/touch_skin_test.lua`, `tests/engine/skin_studio_test.lua`,
|
||||||
|
`tests/engine/skin_studio_image_import.lua`,
|
||||||
|
`tests/engine/launcher_skins_tab.lua`.
|
||||||
|
|
||||||
|
Skins are picked in the launcher's **Skins** tab, which also imports them and
|
||||||
|
opens the studio. `options.touchControls.skin` holds the folder name.
|
||||||
|
|
||||||
|
## Formats
|
||||||
|
|
||||||
|
Two load. `skin.lua` wins when a folder has both.
|
||||||
|
|
||||||
|
**RetroArch overlay `.cfg`.** The libretro `common-overlays` collection loads
|
||||||
|
as-is. Supported keys:
|
||||||
|
|
||||||
|
| Key | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `overlays` | page count |
|
||||||
|
| `overlayN_name` | page name, the target of `next_target` |
|
||||||
|
| `overlayN_overlay` | bezel image |
|
||||||
|
| `overlayN_full_screen` | stretch the page to the window |
|
||||||
|
| `overlayN_rect` | page placement, default `0,0,1,1` |
|
||||||
|
| `overlayN_aspect_ratio` | design aspect; the overlay letterboxes to it even when full screen |
|
||||||
|
| `overlayN_range_mod`, `overlayN_alpha_mod` | desc defaults |
|
||||||
|
| `overlayN_viewport` | `x,y,w,h`, the screen cutout |
|
||||||
|
| `overlayN_viewport_fill` | parsed; the engine always fits, see below |
|
||||||
|
| `overlayN_descM` | `binds,x,y,shape,range_x,range_y` |
|
||||||
|
| `overlayN_descM_overlay` | control art |
|
||||||
|
| `overlayN_descM_next_target` | page to switch to |
|
||||||
|
| `overlayN_descM_range_mod`, `_alpha_mod` | per-control overrides |
|
||||||
|
| `overlayN_descM_reach_x/_y/_up/_down/_left/_right` | hitbox reach |
|
||||||
|
|
||||||
|
`x,y` is the centre and `range_x,range_y` are half extents, both normalized.
|
||||||
|
Hitboxes are `radial` or `rect`. Pipe-separated binds (`left|down`) are one
|
||||||
|
control that holds both. A `nul` desc is decoration: it draws and never
|
||||||
|
captures a touch.
|
||||||
|
|
||||||
|
Alpha follows RetroArch (`input_driver.c`, `input_overlay_post_poll`): every
|
||||||
|
image sits at the overlay opacity, and a pressed control's image swaps to
|
||||||
|
`opacity * alpha_mod`. So `alpha_mod` above 1 lights a control up and below 1
|
||||||
|
fades it out, and both directions read as a press animation.
|
||||||
|
|
||||||
|
**Native `skin.lua`.** This module's own model written back out: one Lua
|
||||||
|
table, no flat key space, and a separate `imagePressed` per control that a
|
||||||
|
`.cfg` cannot express. Loaded with an empty environment, so a skin authored by
|
||||||
|
a stranger cannot reach `love` or `io`. Sizes here are full width and height
|
||||||
|
rather than RetroArch's half extents, because that is what an editor's numeric
|
||||||
|
fields mean.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
return {
|
||||||
|
name = "my_skin",
|
||||||
|
pages = {
|
||||||
|
{
|
||||||
|
name = "main",
|
||||||
|
image = "img/bezel.png",
|
||||||
|
fullScreen = true,
|
||||||
|
viewport = { x = 0.0, y = 0.0, w = 1.0, h = 0.5, fill = false },
|
||||||
|
controls = {
|
||||||
|
{ bind = "a", x = 0.87, y = 0.72, w = 0.18, h = 0.10,
|
||||||
|
shape = "radial", image = "img/a.png", imagePressed = "img/a_down.png" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bindable actions
|
||||||
|
|
||||||
|
The eight Game Boy buttons: `a`, `b`, `start`, `select`, `up`, `down`,
|
||||||
|
`left`, `right`.
|
||||||
|
|
||||||
|
Engine hotkeys, handled in `Game:touchSkinHotkey`:
|
||||||
|
|
||||||
|
| Bind | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `overlay_next`, `overlay_previous` | switch page, honouring `next_target` |
|
||||||
|
| `hold_fast_forward`, `fast_forward` | fast forward while held |
|
||||||
|
| `toggle_fast_forward` | step the speed option |
|
||||||
|
| `reset` | soft reset to the title |
|
||||||
|
| `menu_toggle` | open OPTIONS |
|
||||||
|
|
||||||
|
`screenshot`, `pause_toggle` and `exit_emulator` are recognised but have no
|
||||||
|
handler yet: a control bound to them draws and does nothing. Anything else,
|
||||||
|
`rewind` included, is not in the bind table at all, so the control falls back
|
||||||
|
to decoration and never captures a touch.
|
||||||
|
|
||||||
|
As an extension to the format, `key:<name>` presses any keyboard key, which is
|
||||||
|
how a skin button reaches a mod hotkey.
|
||||||
|
|
||||||
|
## The screen viewport
|
||||||
|
|
||||||
|
`overlayN_viewport` is the cutout the picture is fitted into. The Game Boy
|
||||||
|
screen keeps its whole-pixel scale and letterboxes inside that rect rather than
|
||||||
|
stretching to it, so a bezel gets an exact 160x144 picture; `viewport_fill` is
|
||||||
|
parsed but does not stretch. `overlayN_viewport_expand = true` is an extension
|
||||||
|
that lets a widescreen bezel take the filling survey-zoom world view instead.
|
||||||
|
|
||||||
|
A viewport also implies the faithful-ratio lock. Without it the world pass
|
||||||
|
expands to fill the cutout and you get more map instead of a Game Boy screen.
|
||||||
|
|
||||||
|
Border art often ships with a transparent hole and no `viewport` key. **Detect
|
||||||
|
screen from bezel** in the studio measures the hole out of the art's alpha
|
||||||
|
channel and writes the rect.
|
||||||
|
|
||||||
|
## Bezels versus pads
|
||||||
|
|
||||||
|
A skin whose active page binds nothing is a frame rather than a pad: a TV
|
||||||
|
surround, a handheld shell, a Super Game Boy border. Those draw on **desktop**
|
||||||
|
as well, where the touch overlay itself does not, and a gamepad does not hide
|
||||||
|
them. Anything that binds a button still follows the usual mobile /
|
||||||
|
`POKEPORT_TOUCH` rule.
|
||||||
|
|
||||||
|
## Installing
|
||||||
|
|
||||||
|
Drop a folder or a `.zip` into `skins/` in the save directory, or drop a zip on
|
||||||
|
the launcher window while the Skins tab is open. A zip is mounted in place, so
|
||||||
|
there is nothing to unpack. The folder needs one `skin.lua` or `.cfg`
|
||||||
|
(`overlay.cfg` is preferred when there are several) and the images it names.
|
||||||
|
|
||||||
|
Two ship bundled, both from libretro's `common-overlays` under CC-BY-4.0:
|
||||||
|
|
||||||
|
| Skin | Source | Shape |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `gb_anim` | `gamepads/gb_anim_portrait` | handheld shell, working buttons, two pages |
|
||||||
|
| `tv_crt` | `borders/tv-integer` | CRT television frame, no buttons |
|
||||||
|
|
||||||
|
Attribution lives in each folder's `README.md`. `tv_crt` is a photograph of a
|
||||||
|
real television: CC-BY-4.0 upstream, but treat it as a test asset rather than
|
||||||
|
shipping branding.
|
||||||
|
|
||||||
|
## The studio
|
||||||
|
|
||||||
|
Launcher, Skins tab, **Open Skin Studio**, or the gear on any skin row to open
|
||||||
|
that skin. Desktop only: the launcher does not offer it on Android or iOS,
|
||||||
|
because it wants a mouse, typed coordinates and room for an inspector.
|
||||||
|
|
||||||
|
**Canvas.** A mock device at a chosen preset, so a phone skin is authored at
|
||||||
|
phone proportions on a desktop monitor.
|
||||||
|
|
||||||
|
| Preset | Size |
|
||||||
|
| --- | --- |
|
||||||
|
| Phone portrait / landscape | 1080x1920, 1920x1080 |
|
||||||
|
| Tablet portrait / landscape | 1536x2048, 2048x1536 |
|
||||||
|
| Steam Deck | 1280x800 |
|
||||||
|
| Desktop 1080p | 1920x1080 |
|
||||||
|
| Ultrawide 21:9 | 2560x1080 |
|
||||||
|
| Super Game Boy border | 256x224 |
|
||||||
|
|
||||||
|
The Super Game Boy preset locks the viewport to the real screen window,
|
||||||
|
160x144 at (48,40), so an SGB border cannot be drawn out of register.
|
||||||
|
|
||||||
|
**Editing.** Click a control to select it, drag to move, eight handles to
|
||||||
|
resize. X / Y / W / H are in canvas pixels, so a control can be typed to the
|
||||||
|
coordinate its art was drawn at. Bind, hitbox shape, hit reach and idle and
|
||||||
|
pressed images are per control; the bezel, the pages and the screen cutout are
|
||||||
|
per page. The cutout is itself a draggable element with a 10:9 lock.
|
||||||
|
|
||||||
|
Each page can **Lock** to portrait or landscape. With **Match canvas** on
|
||||||
|
(the default), Next page picks a matching mock device and the canvas preset
|
||||||
|
picks a matching page. Turn Match canvas off to look at a portrait page on a
|
||||||
|
landscape device.
|
||||||
|
|
||||||
|
A RetroArch overlay whose pages are already named portrait / landscape
|
||||||
|
(the auto-rotate convention) locks those pages and turns Match canvas on
|
||||||
|
when you open it. You do not have to click Lock first.
|
||||||
|
|
||||||
|
**Art.** The **Bezel**, **Idle art** and **Pressed art** rows cycle through the
|
||||||
|
images already in the skin folder; the **Import** button beside each one opens
|
||||||
|
the host file picker (`src/core/FilePicker.lua`: osascript, PowerShell,
|
||||||
|
zenity/kdialog) and copies the chosen PNG or JPG into `img/` under the name in
|
||||||
|
the SKIN field, then assigns it to that slot. Dropping a PNG or JPG on the
|
||||||
|
window does the same for whichever slot was last touched. A new bezel does not
|
||||||
|
move the screen cutout: press **Detect screen from bezel** to measure it out of
|
||||||
|
the art's alpha.
|
||||||
|
|
||||||
|
**Testing.** **Test** makes the canvas live: clicking presses real Game Boy
|
||||||
|
buttons and the footer reports what is held. **Play** saves the skin, selects
|
||||||
|
it, and boots the game with it.
|
||||||
|
|
||||||
|
**Saving.** **Save** writes `skins/<name>/skin.lua` and copies every image the
|
||||||
|
skin names, so the folder stands alone. **Export** packs it as one zip
|
||||||
|
(`src/core/SkinZip.lua`, store-only) carrying the native `skin.lua`, the
|
||||||
|
images, and the original `.cfg` when it came from one. An exported skin drops
|
||||||
|
straight back into `skins/` and still opens in RetroArch.
|
||||||
|
|
||||||
|
## Not implemented
|
||||||
|
|
||||||
|
RetroArch's `analog_*`, `dpad_area`, `abxy_area` and `retrok_*` desc types.
|
||||||
@@ -169,6 +169,7 @@ the NX runtime modules `src/core/NxAssetOverlay.lua`, `src/core/Platform.lua`,
|
|||||||
`tests/engine/assets_version_fallback_test.lua`,
|
`tests/engine/assets_version_fallback_test.lua`,
|
||||||
`tests/engine/nx_generated_guard_test.lua`,
|
`tests/engine/nx_generated_guard_test.lua`,
|
||||||
`tests/engine/nx_yellow_boot_test.lua`,
|
`tests/engine/nx_yellow_boot_test.lua`,
|
||||||
|
`tests/engine/cache_fs_gold_nx_load_test.lua`,
|
||||||
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
`tests/engine/switch_diagnostics_test.lua`, `tests/engine/platform_nx_*`,
|
||||||
or the Switch-related workflow YAML), CI runs:
|
or the Switch-related workflow YAML), CI runs:
|
||||||
|
|
||||||
@@ -179,7 +180,8 @@ or the Switch-related workflow YAML), CI runs:
|
|||||||
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
`luajit tests/switch_transfer_docs_test.lua`, and the NX engine suites
|
||||||
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
headlessly (`luajit tests/engine/assets_version_fallback_test.lua`,
|
||||||
`luajit tests/engine/nx_generated_guard_test.lua`,
|
`luajit tests/engine/nx_generated_guard_test.lua`,
|
||||||
`luajit tests/engine/nx_yellow_boot_test.lua`).
|
`luajit tests/engine/nx_yellow_boot_test.lua`,
|
||||||
|
`luajit tests/engine/cache_fs_gold_nx_load_test.lua`).
|
||||||
2. **Fused NRO build** only on the **main** repository
|
2. **Fused NRO build** only on the **main** repository
|
||||||
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
(`bryanthaboi/gen1recomp`), on the self-hosted Mac runner
|
||||||
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
(`scripts/build_switch.sh --fetch --fused`), and only when the workflow
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
Every GitHub Release that includes Switch support ships an SD-ready zip:
|
||||||
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
`gen1recomp-*-switch.zip`. Extract it at the root of your microSD (install
|
||||||
or update, same steps), launch with **title override**, then import your
|
or update, same steps), launch with **title override**, then import your
|
||||||
own legal `.gb` ROM.
|
own legal `.gb` / `.gbc` ROM.
|
||||||
|
|
||||||
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
> You need a console that can run Switch homebrew (custom firmware / hbmenu).
|
||||||
> This project does not help you set that up.
|
> This project does not help you set that up.
|
||||||
@@ -91,13 +91,14 @@ Do **not** launch from the Album applet path for normal play.
|
|||||||
|
|
||||||
This project ships **no** game data. On first launch:
|
This project ships **no** game data. On first launch:
|
||||||
|
|
||||||
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), or Yellow
|
1. Put your own legally obtained Pokémon Red, Blue (`.gb`), Yellow, or
|
||||||
(`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
Gold (`.gbc`) dump into `switch/gen1recomp/pokemon-love2d/imports/` (the
|
||||||
launcher also shows the live save-dir path). All three can sit in the
|
launcher also shows the live save-dir path). All four can sit in the
|
||||||
same folder.
|
same folder.
|
||||||
2. Use **Scan again** on that game's tab (Red / Blue / Yellow). Rescan
|
2. Use **Scan again** on that game's tab (Red / Blue / Yellow / Gold).
|
||||||
matches by ROM SHA-1 for the open tab only. A Red dump never imports
|
Rescan matches by ROM SHA-1 for the open tab only. A Red dump never
|
||||||
from the Yellow tab (and vice versa).
|
imports from the Yellow tab (and vice versa). Gold is Beta in the
|
||||||
|
launcher; a clean US Gold dump is enough to Play.
|
||||||
|
|
||||||
## 5. Import / Export a raw `.sav`
|
## 5. Import / Export a raw `.sav`
|
||||||
|
|
||||||
@@ -109,8 +110,11 @@ SD / FTP, same transfer methods as ROMs. Paths are **per game**:
|
|||||||
| Red | `imports/saves/red/` | `exports/red/` |
|
| Red | `imports/saves/red/` | `exports/red/` |
|
||||||
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
| Blue | `imports/saves/blue/` | `exports/blue/` |
|
||||||
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
| Yellow | `imports/saves/yellow/` | `exports/yellow/` |
|
||||||
|
| Gold | `imports/saves/gold/` | `exports/gold/` |
|
||||||
|
|
||||||
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.)
|
(Under the save dir `pokemon-love2d/`. The zip already creates these folders.
|
||||||
|
Gold cart `.sav` import/export is not supported yet -- the folders exist so
|
||||||
|
MTP browsing matches the other games. Gold progress still saves in-engine.)
|
||||||
|
|
||||||
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
1. Copy a Gen 1 `.sav` (32 KB) into that game's inbox under the save dir
|
||||||
([switch-transfer.md](switch-transfer.md)).
|
([switch-transfer.md](switch-transfer.md)).
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Player install (what to download, title override) stays in
|
|||||||
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
| Loose iteration pair | `sdmc:/switch/gen1recomp/gen1recomp.nro` **and** `game.love` beside it |
|
||||||
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
| ROM inbox | LÖVE save dir → `imports/` (launcher shows the live `getSaveDirectory()` path; under MTP often `1: SD Card/<save identity>/imports/`) |
|
||||||
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
| Mod zip inbox | Same save dir → `imports/mods/` then MODS → **Scan again** |
|
||||||
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow/` then that game's SAVE FILES → **Import save** |
|
| Save `.sav` inbox | Same save dir → `imports/saves/red\|blue\|yellow\|gold/` then that game's SAVE FILES → **Import save** (Gold cart `.sav` not supported yet) |
|
||||||
| Save exports | Same save dir → `exports/red\|blue\|yellow/` (pull after **Export save**; MTP / SD / FTP) |
|
| Save exports | Same save dir → `exports/red\|blue\|yellow\|gold/` (pull after **Export save**; Gold cart `.sav` not supported yet) |
|
||||||
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
| Opt-in diagnostics | Empty `switch-debug.txt` in the save dir → `switch.log` |
|
||||||
| Lua error log | `lua-error.log` in the save dir |
|
| Lua error log | `lua-error.log` in the save dir |
|
||||||
|
|
||||||
@@ -54,8 +54,8 @@ macOS, not a Mac-only requirement.
|
|||||||
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
3. Create `switch/gen1recomp/` if needed; extract the release zip at SD root
|
||||||
(or copy NRO / `game.love` for loose).
|
(or copy NRO / `game.love` for loose).
|
||||||
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
4. For ROMs/mods/saves, open the save-dir `imports/`, `imports/mods/`,
|
||||||
`imports/saves/<red|blue|yellow>/`, or `exports/<red|blue|yellow>/` path the
|
`imports/saves/<red|blue|yellow|gold>/`, or `exports/<red|blue|yellow|gold>/`
|
||||||
launcher prints.
|
path the launcher prints.
|
||||||
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
5. Wait for the queue; refresh; exit MTP responder; title-override launch.
|
||||||
|
|
||||||
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
macOS clients often create AppleDouble sidecars (`._Something.zip`,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ bundled game, in that case.
|
|||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
|
|
||||||
- **`love.run` persists across handoff.** By the time `chainload` runs, the
|
- **`love.run` persists across handoff.** By the time `chainload` runs, the
|
||||||
bundled `love.run` has already returned its stepper to LOVE; redefining the
|
bundled `love.run` has already returned its stepper to LOVE; redefining the
|
||||||
global `love.run` from the payload's `main.lua` does not affect the loop
|
global `love.run` from the payload's `main.lua` does not affect the loop
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local Game, EditorApp, Importer, TouchEditor
|
local Game, EditorApp, Importer, TouchEditor, Studio
|
||||||
|
|
||||||
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
-- #887: quit-to-launcher state, shared by love.load and love.quit (both need
|
||||||
-- it, so it is declared here rather than next to love.quit).
|
-- it, so it is declared here rather than next to love.quit).
|
||||||
@@ -255,7 +255,43 @@ function closeTouchControlsEditor()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function bootGame(version)
|
-- ------------------------------------------------------------ skin studio
|
||||||
|
local studioHost
|
||||||
|
local closeSkinStudio
|
||||||
|
local bootGame
|
||||||
|
|
||||||
|
local function openSkinStudio(version, skinId)
|
||||||
|
local SkinStudio = require("src.ui.SkinStudio")
|
||||||
|
if not SkinStudio.available_desktop() then return end
|
||||||
|
studioHost = Importer
|
||||||
|
if Importer and Importer.prepareOverlayHandoff then
|
||||||
|
Importer:prepareOverlayHandoff()
|
||||||
|
end
|
||||||
|
Importer = nil
|
||||||
|
Studio = SkinStudio
|
||||||
|
Studio.load({
|
||||||
|
version = version,
|
||||||
|
skinId = skinId,
|
||||||
|
onClose = function() closeSkinStudio() end,
|
||||||
|
onPlay = function(v)
|
||||||
|
closeSkinStudio()
|
||||||
|
Importer = nil
|
||||||
|
bootGame(v or version)
|
||||||
|
end,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
function closeSkinStudio()
|
||||||
|
if Studio and Studio.unload then Studio.unload() end
|
||||||
|
Studio = nil
|
||||||
|
Importer = studioHost
|
||||||
|
studioHost = nil
|
||||||
|
if Importer and Importer.resumeAfterOverlay then
|
||||||
|
Importer:resumeAfterOverlay()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function bootGame(version)
|
||||||
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
-- The launcher hands us the chosen game (Red / Blue / Yellow / Gold);
|
||||||
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
-- scripted and headless runs fall back to POKEPORT_VERSION, then Red.
|
||||||
-- Set the active version and overlay its extracted cache BEFORE anything
|
-- Set the active version and overlay its extracted cache BEFORE anything
|
||||||
@@ -456,6 +492,8 @@ function love.load(args)
|
|||||||
forceImport = forceImport,
|
forceImport = forceImport,
|
||||||
onEditSave = openEditor,
|
onEditSave = openEditor,
|
||||||
onEditTouchControls = openTouchControlsEditor,
|
onEditTouchControls = openTouchControlsEditor,
|
||||||
|
onOpenSkinStudio = require("src.ui.SkinStudio").available_desktop()
|
||||||
|
and openSkinStudio or nil,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -466,7 +504,9 @@ function love.update(dt)
|
|||||||
NxDisplay.sync()
|
NxDisplay.sync()
|
||||||
if editorMode then return EditorApp.update(dt) end
|
if editorMode then return EditorApp.update(dt) end
|
||||||
if TouchEditor then return TouchEditor.update(dt) end
|
if TouchEditor then return TouchEditor.update(dt) end
|
||||||
|
if Studio then return Studio.update(dt) end
|
||||||
if Importer then return Importer:update(dt) end
|
if Importer then return Importer:update(dt) end
|
||||||
|
if not Game then return end
|
||||||
|
|
||||||
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
-- Scripted runs (autopilot / POKEPORT_DRIVER) observe and act exactly
|
||||||
-- once per Game:update, so they must keep a 1:1 relationship with the
|
-- once per Game:update, so they must keep a 1:1 relationship with the
|
||||||
@@ -522,6 +562,12 @@ function love.draw()
|
|||||||
HostDisplay.endFrame("touch_editor", TouchEditor)
|
HostDisplay.endFrame("touch_editor", TouchEditor)
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
if Studio then
|
||||||
|
HostDisplay.beginFrame("skin_studio", Studio)
|
||||||
|
local result = Studio.draw()
|
||||||
|
HostDisplay.endFrame("skin_studio", Studio)
|
||||||
|
return result
|
||||||
|
end
|
||||||
if Importer then
|
if Importer then
|
||||||
GameViewport.reset()
|
GameViewport.reset()
|
||||||
HostDisplay.beginFrame("launcher", Importer)
|
HostDisplay.beginFrame("launcher", Importer)
|
||||||
@@ -555,13 +601,16 @@ end
|
|||||||
function love.keypressed(key, scancode, isrepeat)
|
function love.keypressed(key, scancode, isrepeat)
|
||||||
if editorMode then return EditorApp.keypressed(key) end
|
if editorMode then return EditorApp.keypressed(key) end
|
||||||
if TouchEditor then return TouchEditor.keypressed(key) end
|
if TouchEditor then return TouchEditor.keypressed(key) end
|
||||||
|
if Studio then return Studio.keypressed(key) end
|
||||||
if Importer then return Importer:keypressed(key) end
|
if Importer then return Importer:keypressed(key) end
|
||||||
|
if not Game then return end
|
||||||
Game:keypressed(key)
|
Game:keypressed(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.keyreleased(key)
|
function love.keyreleased(key)
|
||||||
if editorMode or TouchEditor then return end
|
if editorMode or TouchEditor or Studio then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
|
if not Game then return end
|
||||||
Game:keyreleased(key)
|
Game:keyreleased(key)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -579,7 +628,9 @@ function love.gamepadpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then return Importer:gamepadpressed(joystick, button) end
|
if Importer then return Importer:gamepadpressed(joystick, button) end
|
||||||
|
if not Game then return end
|
||||||
Game:gamepadpressed(joystick, button)
|
Game:gamepadpressed(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -597,7 +648,9 @@ function love.gamepadreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then return Importer:gamepadreleased(joystick, button) end
|
if Importer then return Importer:gamepadreleased(joystick, button) end
|
||||||
|
if not Game then return end
|
||||||
Game:gamepadreleased(joystick, button)
|
Game:gamepadreleased(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -615,7 +668,9 @@ function love.gamepadaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
if Importer then return Importer:gamepadaxis(joystick, axis, value) end
|
||||||
|
if not Game then return end
|
||||||
Game:gamepadaxis(joystick, axis, value)
|
Game:gamepadaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -633,7 +688,9 @@ function love.joystickpressed(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then return Importer:joystickpressed(joystick, button) end
|
if Importer then return Importer:joystickpressed(joystick, button) end
|
||||||
|
if not Game then return end
|
||||||
Game:joystickpressed(joystick, button)
|
Game:joystickpressed(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -651,7 +708,9 @@ function love.joystickreleased(joystick, button)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then return Importer:joystickreleased(joystick, button) end
|
if Importer then return Importer:joystickreleased(joystick, button) end
|
||||||
|
if not Game then return end
|
||||||
Game:joystickreleased(joystick, button)
|
Game:joystickreleased(joystick, button)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -669,7 +728,9 @@ function love.joystickaxis(joystick, axis, value)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
if Importer then return Importer:joystickaxis(joystick, axis, value) end
|
||||||
|
if not Game then return end
|
||||||
Game:joystickaxis(joystick, axis, value)
|
Game:joystickaxis(joystick, axis, value)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -687,21 +748,25 @@ function love.joystickhat(joystick, hat, direction)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
if Importer then return Importer:joystickhat(joystick, hat, direction) end
|
||||||
|
if not Game then return end
|
||||||
Game:joystickhat(joystick, hat, direction)
|
Game:joystickhat(joystick, hat, direction)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.joystickadded(joystick)
|
function love.joystickadded(joystick)
|
||||||
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
SwitchDiagnostics.onJoystickEvent("joystickadded", joystick)
|
||||||
if editorMode or TouchEditor then return end
|
if editorMode or TouchEditor or Studio then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
|
if not Game then return end
|
||||||
Game:joystickadded(joystick)
|
Game:joystickadded(joystick)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.joystickremoved(joystick)
|
function love.joystickremoved(joystick)
|
||||||
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
SwitchDiagnostics.onJoystickEvent("joystickremoved", joystick)
|
||||||
if editorMode or TouchEditor then return end
|
if editorMode or TouchEditor or Studio then return end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
|
if not Game then return end
|
||||||
Game:joystickremoved(joystick)
|
Game:joystickremoved(joystick)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -710,26 +775,36 @@ end
|
|||||||
-- unfocused, so reset input on either transition rather than trust it.
|
-- unfocused, so reset input on either transition rather than trust it.
|
||||||
function love.focus(f)
|
function love.focus(f)
|
||||||
if editorMode or TouchEditor then return end
|
if editorMode or TouchEditor then return end
|
||||||
|
if Studio then
|
||||||
|
if Studio.focus then Studio.focus(f) end
|
||||||
|
return
|
||||||
|
end
|
||||||
if Importer then
|
if Importer then
|
||||||
require("src.core.Input"):reset()
|
require("src.core.Input"):reset()
|
||||||
if Importer.focus then Importer:focus(f) end
|
if Importer.focus then Importer:focus(f) end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if not Game then return end
|
||||||
Game:focus(f)
|
Game:focus(f)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- v is true when the window becomes visible again, false on minimize.
|
-- v is true when the window becomes visible again, false on minimize.
|
||||||
function love.visible(v)
|
function love.visible(v)
|
||||||
if editorMode or TouchEditor then return end
|
if editorMode or TouchEditor then return end
|
||||||
|
if Studio then
|
||||||
|
if Studio.visible then Studio.visible(v) end
|
||||||
|
return
|
||||||
|
end
|
||||||
if Importer then
|
if Importer then
|
||||||
require("src.core.Input"):reset()
|
require("src.core.Input"):reset()
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if not Game then return end
|
||||||
Game:visible(v)
|
Game:visible(v)
|
||||||
end
|
end
|
||||||
|
|
||||||
function love.lowmemory()
|
function love.lowmemory()
|
||||||
if editorMode or TouchEditor or Importer then return end
|
if editorMode or TouchEditor or Studio or Importer then return end
|
||||||
if Game then Game:onResume() end
|
if Game then Game:onResume() end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -750,12 +825,14 @@ function love.touchpressed(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchpressed(id, x, y)
|
return TouchEditor.touchpressed(id, x, y)
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
-- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are
|
||||||
-- polled inside the view; the istouch filter on mousepressed still drops
|
-- polled inside the view; the istouch filter on mousepressed still drops
|
||||||
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
-- Android's synthesized mouse twin so Import cannot double-fire (#553).
|
||||||
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
return Importer:touchpressed(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
if not Game then return end
|
||||||
Game:touchpressed(id, x, y, dx, dy, pressure)
|
Game:touchpressed(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -765,9 +842,11 @@ function love.touchmoved(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchmoved(id, x, y)
|
return TouchEditor.touchmoved(id, x, y)
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
return Importer:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
if not Game then return end
|
||||||
Game:touchmoved(id, x, y, dx, dy, pressure)
|
Game:touchmoved(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -777,9 +856,11 @@ function love.touchreleased(id, x, y, dx, dy, pressure)
|
|||||||
if love.system.getOS() == "iOS" then return end
|
if love.system.getOS() == "iOS" then return end
|
||||||
return TouchEditor.touchreleased(id, x, y)
|
return TouchEditor.touchreleased(id, x, y)
|
||||||
end
|
end
|
||||||
|
if Studio then return end
|
||||||
if Importer then
|
if Importer then
|
||||||
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
return Importer:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
if not Game then return end
|
||||||
Game:touchreleased(id, x, y, dx, dy, pressure)
|
Game:touchreleased(id, x, y, dx, dy, pressure)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -789,7 +870,9 @@ function love.wheelmoved(x, y)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if TouchEditor then return end
|
if TouchEditor then return end
|
||||||
|
if Studio then return Studio.wheelmoved(x, y) end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
|
if not Game then return end
|
||||||
Game:wheelmoved(x, y)
|
Game:wheelmoved(x, y)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -826,6 +909,7 @@ function love.mousepressed(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousepressed(x, y, button)
|
return TouchEditor.mousepressed(x, y, button)
|
||||||
end
|
end
|
||||||
|
if Studio then return Studio.mousepressed(x, y, button) end
|
||||||
if Importer then
|
if Importer then
|
||||||
-- love.touchpressed already forwards the primary touch into FlexLove for
|
-- love.touchpressed already forwards the primary touch into FlexLove for
|
||||||
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
-- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both
|
||||||
@@ -860,6 +944,7 @@ function love.mousereleased(x, y, button, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousereleased(x, y, button)
|
return TouchEditor.mousereleased(x, y, button)
|
||||||
end
|
end
|
||||||
|
if Studio then return Studio.mousereleased(x, y, button) end
|
||||||
if Importer then return end
|
if Importer then return end
|
||||||
if editorMode and EditorApp.mousereleased then
|
if editorMode and EditorApp.mousereleased then
|
||||||
return EditorApp.mousereleased(x, y, button)
|
return EditorApp.mousereleased(x, y, button)
|
||||||
@@ -877,6 +962,7 @@ function love.mousemoved(x, y, dx, dy, istouch)
|
|||||||
if love.system.getOS() == "Android" then return end
|
if love.system.getOS() == "Android" then return end
|
||||||
return TouchEditor.mousemoved(x, y)
|
return TouchEditor.mousemoved(x, y)
|
||||||
end
|
end
|
||||||
|
if Studio then return Studio.mousemoved(x, y) end
|
||||||
if editorMode or Importer then return end
|
if editorMode or Importer then return end
|
||||||
if mouseTouch then
|
if mouseTouch then
|
||||||
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
if Game and love.mouse.isDown(1) then Game:touchmoved("mouse", x, y) end
|
||||||
@@ -887,6 +973,7 @@ end
|
|||||||
|
|
||||||
function love.textinput(text)
|
function love.textinput(text)
|
||||||
if TouchEditor then return end
|
if TouchEditor then return end
|
||||||
|
if Studio then return Studio.textinput(text) end
|
||||||
if Importer then return Importer:textinput(text) end
|
if Importer then return Importer:textinput(text) end
|
||||||
if editorMode and EditorApp.textinput then
|
if editorMode and EditorApp.textinput then
|
||||||
return EditorApp.textinput(text)
|
return EditorApp.textinput(text)
|
||||||
@@ -964,6 +1051,7 @@ function love.filedropped(file)
|
|||||||
if editorMode and EditorApp and EditorApp.filedropped then
|
if editorMode and EditorApp and EditorApp.filedropped then
|
||||||
return EditorApp.filedropped(file)
|
return EditorApp.filedropped(file)
|
||||||
end
|
end
|
||||||
|
if Studio then return Studio.filedropped(file) end
|
||||||
if Importer then Importer:filedropped(file) end
|
if Importer then Importer:filedropped(file) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,55 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"version": "0.2.5",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13586158,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.5/gen1recomp++-0.2.5-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1390 switch and gold\n- #1503 Retroarch Skin Problem\n- #1508 Please check #1412 & #1414 again, we had a misunderstanding\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.4",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13582747,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.4/gen1recomp++-0.2.4-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1496 Investigate Security according to https://hdbreaker.github.io/blog/pokemon-gen1recomp-hate-cheat/\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.3",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13579254,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.3/gen1recomp++-0.2.3-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1497 skin studio needs a import file picker babyyyyyy\n\n## Contributors\n\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi\n- @thibautbus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.2",
|
||||||
|
"date": "2026-08-18",
|
||||||
|
"size": 13575387,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.2/gen1recomp++-0.2.2-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.1",
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"size": 13575320,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.1/gen1recomp++-0.2.1-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"size": 13575299,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.2.0/gen1recomp++-0.2.0-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1396 Nurse dialogue & options\n- #1398 Alignment of options for changing Pokemon\n- #1400 Flying Bug\n- #1401 [Gold] battlergfx $d9/$da load the wrong row count (jumptable crossed vs macro names)\n- #1406 Magikarp salesman dialogue issues\n- #1407 Not able to nickname Magikarp\n- #1411 No indication for stone evolutions\n- #1413 Using a stone closes menu\n- #1415 Super Nerd dialogue issues\n- #1416 (Pokémon Gold) Pokédex doesn't register other trainers' pokémon as seen\n- #1417 (Pokémon Gold) Pokémon you get in trade aren't being registered as caught\n- #1419 (Pokémon Gold) Deposited pokémon don't get healed\n- #1421 (Pokémon Gold) Bad status and catch state appears on the HUD before they should\n- #1422 (Pokémon Gold) Impossible to have the pokédex register Ditto as caught after it transforms\n- #1423 (Pokémon Gold) No save prompt before changing boxes in the PC\n- #1424 (Pokémon Gold) Quantity for owned TMs not being displayed\n- #1425 (Pokémon Gold) Items quantity in your bag should be alligned to the right\n- #1427 (Pokémon Gold) Can't switch items' position in your bag\n- #1428 (Pokémon Gold) Game doesn't show how many pokémon other trainers have\n- #1429 Pikachu not sliding in before its cry. Stuck on standard pokeball release animation.\n- #1431 Shiny sparkle does not play on your sent out shiny pokemon\n- #1432 Experimental marked mods don't install Android\n- #1433 (Pokémon Gold) Missing prompt for depositing pokémon\n- #1435 When npcs stop you to talk or when you walk up to npcs to talk to them sometimes the player has the wrong sprite\n- #1437 Issues with player sprite on map\n- #1440 hold a direction during cutscene and face the wrong way\n- #1441 Magnet Train missing animation\n- #1442 Radio dial is missing in PokeGear radio\n- #1443 Skipping production logo also skips battle scene\n- #1444 Pokemon lack type immunity to status moves\n- #1447 Soft-lock on Cinnabar Island\n- #1449 Visual error on Route 28\n- #1456 Activating all mods doesn't work properly\n- #1461 #1265 didnt got fixed.\n- #1464 Experiance shared in battle\n- #1465 Changing Touch Layout crashes launcher\n- #1466 #1403 Still Happens\n- #1467 A clearer definition of the use of AI for this reconstruction\n- #1468 [Gold] BICYCLE is broken and some pokegear bug\n- #1469 [Gold] status effects aren't shown in the party overlay or the summary screen of the pokemon\n- #1470 Mod updater doesn't work properly when AppImage is running through Steam or Game Mode (Steam Deck)\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.99",
|
||||||
|
"date": "2026-08-17",
|
||||||
|
"size": 11391467,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.99/gen1recomp++-0.1.99-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #597 Pulling mod index fails on Android\n- #1403 Save editor not allowing moves to go past ZAP_CANNON\n\n## Contributors\n\n- @1Jamie\n- @AverageConsumer\n- @bryanthaboi\n- @emre155\n- @sanjinpepic\n- @ShaneMcGovernIE\n- @syybott\n- @thibautbus"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.1.98",
|
"version": "0.1.98",
|
||||||
"date": "2026-08-16",
|
"date": "2026-08-16",
|
||||||
|
|||||||
@@ -71,15 +71,15 @@ First install or update (same steps):
|
|||||||
your saves, imported ROMs, mods, and options. Re-extracting only
|
your saves, imported ROMs, mods, and options. Re-extracting only
|
||||||
replaces the NRO(s) and these help files.
|
replaces the NRO(s) and these help files.
|
||||||
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
3. Launch with title override (hold R on HOME, open any title → hbmenu).
|
||||||
4. Copy a legal Pokemon Red/Blue .gb into:
|
4. Copy a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc into:
|
||||||
switch/gen1recomp/pokemon-love2d/imports/
|
switch/gen1recomp/pokemon-love2d/imports/
|
||||||
then use Scan again in the launcher if needed.
|
then use Scan again in the launcher if needed.
|
||||||
|
|
||||||
Inboxes (drop files here via MTP / SD / FTP):
|
Inboxes (drop files here via MTP / SD / FTP):
|
||||||
imports/ — ROM .gb / .gbc
|
imports/ — ROM .gb / .gbc
|
||||||
imports/mods/ — community mod .zip
|
imports/mods/ — community mod .zip
|
||||||
imports/saves/red|blue|yellow/ — raw .sav import
|
imports/saves/red|blue|yellow|gold/ — raw .sav import (Gold cart .sav not yet)
|
||||||
exports/red|blue|yellow/ — pull after Export save
|
exports/red|blue|yellow|gold/ — pull after Export save (Gold not yet)
|
||||||
|
|
||||||
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
Full guide: https://github.com/bryanthaboi/gen1recomp/blob/main/docs/switch-install.md
|
||||||
EOF
|
EOF
|
||||||
@@ -92,7 +92,7 @@ write_readme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
write_readme "$SAVE_ROOT/imports/README.txt" \
|
write_readme "$SAVE_ROOT/imports/README.txt" \
|
||||||
"Put a legal Pokemon Red or Blue .gb / .gbc here, then Scan again in the launcher."
|
"Put a legal Pokemon Red/Blue .gb or Yellow/Gold .gbc here, then Scan again in the launcher."
|
||||||
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
write_readme "$SAVE_ROOT/imports/mods/README.txt" \
|
||||||
"Put community mod .zip files here, then MODS → Scan again."
|
"Put community mod .zip files here, then MODS → Scan again."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/red/README.txt" \
|
||||||
@@ -101,12 +101,16 @@ write_readme "$SAVE_ROOT/imports/saves/blue/README.txt" \
|
|||||||
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
|
"Put a Blue .sav (32 KB) here, then Blue tab → SAVE FILES → Import save."
|
||||||
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
write_readme "$SAVE_ROOT/imports/saves/yellow/README.txt" \
|
||||||
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
"Put a Yellow .sav (32 KB) here, then Yellow tab → SAVE FILES → Import save."
|
||||||
|
write_readme "$SAVE_ROOT/imports/saves/gold/README.txt" \
|
||||||
|
"Gold cart .sav import is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
write_readme "$SAVE_ROOT/exports/red/README.txt" \
|
||||||
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Red), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
write_readme "$SAVE_ROOT/exports/blue/README.txt" \
|
||||||
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Blue), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
write_readme "$SAVE_ROOT/exports/yellow/README.txt" \
|
||||||
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
"After Export save (Yellow), copy the .sav out of this folder via MTP / SD / FTP."
|
||||||
|
write_readme "$SAVE_ROOT/exports/gold/README.txt" \
|
||||||
|
"Gold cart .sav export is not supported yet. Folder reserved so MTP matches the other games."
|
||||||
|
|
||||||
rm -f "$OUT_ZIP"
|
rm -f "$OUT_ZIP"
|
||||||
(
|
(
|
||||||
@@ -135,9 +139,11 @@ REQUIRED=(
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt"
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
)
|
)
|
||||||
for rel in "${REQUIRED[@]}"; do
|
for rel in "${REQUIRED[@]}"; do
|
||||||
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
printf '%s\n' "$LISTING" | grep -Fq "$rel" || fail "zip missing $rel"
|
||||||
|
|||||||
@@ -270,9 +270,11 @@ for rel in \
|
|||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/imports/saves/yellow/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/imports/saves/gold/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/red/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
"switch/gen1recomp/pokemon-love2d/exports/blue/README.txt" \
|
||||||
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt"
|
"switch/gen1recomp/pokemon-love2d/exports/yellow/README.txt" \
|
||||||
|
"switch/gen1recomp/pokemon-love2d/exports/gold/README.txt"
|
||||||
do
|
do
|
||||||
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
printf '%s\n' "$ZIP_LIST" | grep -Fq "$rel" || PACK_MISSING="${PACK_MISSING} ${rel}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ run_tier "T0 switch transfer docs gate" "$LUA" tests/switch_transfer_docs_test.l
|
|||||||
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
run_tier "T0 NX asset overlay fallback" "$LUA" tests/engine/assets_version_fallback_test.lua
|
||||||
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
|
run_tier "T0 NX generated-path static guard" "$LUA" tests/engine/nx_generated_guard_test.lua
|
||||||
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
|
run_tier "T0 NX Yellow/Blue boot (dynamic paths)" "$LUA" tests/engine/nx_yellow_boot_test.lua
|
||||||
|
run_tier "T0 NX Gold cache load (maps.lua prefix)" "$LUA" tests/engine/cache_fs_gold_nx_load_test.lua
|
||||||
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
run_tier "T0 touch-controls pad cursor" "$LUA" tests/engine/touch_controls_pad_cursor_test.lua
|
||||||
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
run_tier "T1/T2 engine invariants + parity gates" "$LUA" tests/run_engine.lua
|
||||||
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
run_tier "T4 mod-SDK" "$LUA" tests/run_modkit.lua
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
local BattleSafety = require("src.battle.BattleSafety")
|
local BattleSafety = require("src.battle.BattleSafety")
|
||||||
local Screens = require("src.ui.Screens")
|
local Screens = require("src.ui.Screens")
|
||||||
local Status = require("src.battle.Status")
|
local Status = require("src.battle.Status")
|
||||||
|
local Theme = require("src.ui.Theme")
|
||||||
local Timing = require("src.core.Timing")
|
local Timing = require("src.core.Timing")
|
||||||
local TrainerAI = require("src.battle.TrainerAI")
|
local TrainerAI = require("src.battle.TrainerAI")
|
||||||
local TurnOrder = require("src.battle.TurnOrder")
|
local TurnOrder = require("src.battle.TurnOrder")
|
||||||
@@ -983,8 +984,8 @@ end
|
|||||||
|
|
||||||
-- Message that opens YES/NO once typed out, keeping the text visible
|
-- Message that opens YES/NO once typed out, keeping the text visible
|
||||||
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
|
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
|
||||||
function BattleState:sayChoice(text, onChoose)
|
function BattleState:sayChoice(text, onChoose, opts)
|
||||||
table.insert(self.queue, { text = text, choice = onChoose })
|
table.insert(self.queue, { text = text, choice = onChoose, choiceOpts = opts })
|
||||||
end
|
end
|
||||||
|
|
||||||
function BattleState:act(fn)
|
function BattleState:act(fn)
|
||||||
@@ -1485,7 +1486,7 @@ function BattleState:updateQueue()
|
|||||||
local fn = item.choice
|
local fn = item.choice
|
||||||
battle.current = nil
|
battle.current = nil
|
||||||
fn(yes)
|
fn(yes)
|
||||||
end))
|
end, item.choiceOpts))
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
if item and item.auto then
|
if item and item.auto then
|
||||||
@@ -1843,14 +1844,7 @@ function BattleState:enter()
|
|||||||
self:say(self:sendOutText(self.player.name))
|
self:say(self:sendOutText(self.player.name))
|
||||||
-- then the POOF plays and the mon appears with its cry
|
-- then the POOF plays and the mon appears with its cry
|
||||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||||
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
|
self:queueSendOutAnim(true)
|
||||||
self:act(function()
|
|
||||||
self.sendingOut = false
|
|
||||||
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
|
||||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
|
||||||
self:startGrowIn(self.player)
|
|
||||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
|
||||||
end)
|
|
||||||
self:markParticipant()
|
self:markParticipant()
|
||||||
end
|
end
|
||||||
self.phase = "messages"
|
self.phase = "messages"
|
||||||
@@ -2680,13 +2674,7 @@ function BattleState:resolveSwitch(newMon)
|
|||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNext(self:sendOutText(self.player.name))
|
||||||
self:animNext("POOF_ANIM", false)
|
self:queueSendOutAnim(false)
|
||||||
self:actNext(function()
|
|
||||||
self.sendingOut = false
|
|
||||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
|
||||||
self:startGrowIn(self.player)
|
|
||||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
|
||||||
end)
|
|
||||||
end)
|
end)
|
||||||
self:act(function()
|
self:act(function()
|
||||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||||
@@ -3231,6 +3219,50 @@ function BattleState:startGrowIn(battler)
|
|||||||
table.insert(self.queue, self.nextInsert, { wait = 12 })
|
table.insert(self.queue, self.nextInsert, { wait = 12 })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation:
|
||||||
|
-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819
|
||||||
|
function BattleState:starterPikachuSendOut()
|
||||||
|
if not require("src.core.GameVersion").isYellow() then return false end
|
||||||
|
local mon = self.player and self.player.mon
|
||||||
|
return require("src.world.PikachuFollower")
|
||||||
|
.isStarterPikachu(self.game.save, mon)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- StarterPikachuBattleEntranceAnimation: the back pic walks in from hlcoord
|
||||||
|
-- 0,5, one column every 2 frames -- engine/battle/pikachu_entrance_anim.asm:1
|
||||||
|
function BattleState:startPikachuEntrance()
|
||||||
|
self:slidePic("playerMon", -56, 0, 8, 2)
|
||||||
|
self.nextInsert = (self.nextInsert or 0) + 1
|
||||||
|
table.insert(self.queue, self.nextInsert, { wait = 16 })
|
||||||
|
self:actNext(function()
|
||||||
|
self:slidePic("playerMon")
|
||||||
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Player send-out tail: POOF_ANIM + AnimateSendingOutMon (core.asm:1757-1762),
|
||||||
|
-- or the starter Pikachu entrance instead (pokeyellow core.asm:1798-1819)
|
||||||
|
function BattleState:queueSendOutAnim(append)
|
||||||
|
local pikachu = self:starterPikachuSendOut()
|
||||||
|
if not pikachu then
|
||||||
|
if append then
|
||||||
|
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
|
||||||
|
else
|
||||||
|
self:animNext("POOF_ANIM", false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local fn = function()
|
||||||
|
self.sendingOut = false
|
||||||
|
if pikachu then
|
||||||
|
self:startPikachuEntrance()
|
||||||
|
else
|
||||||
|
self:startGrowIn(self.player)
|
||||||
|
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if append then self:act(fn) else self:actNext(fn) end
|
||||||
|
end
|
||||||
|
|
||||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||||
@@ -3289,17 +3321,18 @@ end
|
|||||||
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
|
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
|
||||||
-- brings the beaten foe back in from the right one column every 4 frames.
|
-- brings the beaten foe back in from the right one column every 4 frames.
|
||||||
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
|
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
|
||||||
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
|
-- "back" = the player's back pic, "playerMon" = the player's mon back pic --
|
||||||
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
|
-- as a screen-pixel x offset stepped toward `to`; updateFx advances them,
|
||||||
-- queue rows that start them park a { wait } of the matching length. Call
|
-- drawPicsLayer adds them, and the queue rows that start them park a
|
||||||
-- with no target to clear a slot (#317, #282).
|
-- { wait } of the matching length. Call with no target to clear
|
||||||
function BattleState:slidePic(slot, from, to, step)
|
-- a slot (#317, #282).
|
||||||
|
function BattleState:slidePic(slot, from, to, step, hold)
|
||||||
self.picOff = self.picOff or {}
|
self.picOff = self.picOff or {}
|
||||||
if to == nil then
|
if to == nil then
|
||||||
self.picOff[slot] = nil
|
self.picOff[slot] = nil
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
|
self.picOff[slot] = { x = from or 0, to = to, step = step or 4, hold = hold }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the live x offset for a pic slot, 0 when nothing is sliding
|
-- the live x offset for a pic slot, 0 when nothing is sliding
|
||||||
@@ -3317,6 +3350,13 @@ function BattleState:updateFx()
|
|||||||
-- the battle slot) until its owner clears the slot
|
-- the battle slot) until its owner clears the slot
|
||||||
if self.picOff then
|
if self.picOff then
|
||||||
for _, p in pairs(self.picOff) do
|
for _, p in pairs(self.picOff) do
|
||||||
|
local move = true
|
||||||
|
if p.hold then
|
||||||
|
p.held = (p.held or 0) + 1
|
||||||
|
move = p.held >= p.hold
|
||||||
|
if move then p.held = 0 end
|
||||||
|
end
|
||||||
|
if move then
|
||||||
if p.x < p.to then
|
if p.x < p.to then
|
||||||
p.x = math.min(p.to, p.x + p.step)
|
p.x = math.min(p.to, p.x + p.step)
|
||||||
elseif p.x > p.to then
|
elseif p.x > p.to then
|
||||||
@@ -3324,6 +3364,7 @@ function BattleState:updateFx()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
local fx = self.fx
|
local fx = self.fx
|
||||||
if fx then
|
if fx then
|
||||||
if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end
|
if fx.shake and fx.shake > 0 then fx.shake = fx.shake - 1 end
|
||||||
@@ -4070,8 +4111,8 @@ function BattleState:awardExp()
|
|||||||
end
|
end
|
||||||
local function applyShare(mon, split, announce)
|
local function applyShare(mon, split, announce)
|
||||||
local playerId = self.game.save.player and self.game.save.player.id
|
local playerId = self.game.save.player and self.game.save.player.id
|
||||||
local traded = mon.otId ~= nil and playerId ~= nil
|
local traded = mon.traded == true
|
||||||
and mon.otId ~= playerId or mon.traded == true and mon.otId == nil
|
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
||||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||||
self.enemy.mon.level, self.kind == "trainer",
|
self.enemy.mon.level, self.kind == "trainer",
|
||||||
split, traded)
|
split, traded)
|
||||||
@@ -4231,7 +4272,7 @@ function BattleState:enemyMonFainted()
|
|||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end)
|
end, { box = Theme.trainerSwitchBox })
|
||||||
end
|
end
|
||||||
self:act(function()
|
self:act(function()
|
||||||
local previous = self.enemy
|
local previous = self.enemy
|
||||||
@@ -4295,12 +4336,7 @@ function BattleState:enemyMonFainted()
|
|||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNext(self:sendOutText(self.player.name))
|
||||||
self:animNext("POOF_ANIM", false)
|
self:queueSendOutAnim(false)
|
||||||
self:actNext(function()
|
|
||||||
self.sendingOut = false
|
|
||||||
self:startGrowIn(self.player)
|
|
||||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
|
||||||
end)
|
|
||||||
end)
|
end)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -4491,13 +4527,7 @@ function BattleState:openReplacementMenu()
|
|||||||
sendOutMonCursors(self)
|
sendOutMonCursors(self)
|
||||||
self.sendingOut = true
|
self.sendingOut = true
|
||||||
self:sayNext(self:sendOutText(self.player.name))
|
self:sayNext(self:sendOutText(self.player.name))
|
||||||
self:animNext("POOF_ANIM", false)
|
self:queueSendOutAnim(false)
|
||||||
self:actNext(function()
|
|
||||||
self.sendingOut = false
|
|
||||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
|
||||||
self:startGrowIn(self.player)
|
|
||||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
|
||||||
end)
|
|
||||||
end,
|
end,
|
||||||
})
|
})
|
||||||
end)
|
end)
|
||||||
@@ -5749,7 +5779,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
|||||||
else
|
else
|
||||||
local dx, dy = BattleState.backPlacement(img:getWidth(),
|
local dx, dy = BattleState.backPlacement(img:getWidth(),
|
||||||
img:getHeight(), pad, padL, s)
|
img:getHeight(), pad, padL, s)
|
||||||
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
|
-- picOffset: StarterPikachuBattleEntranceAnimation walking the pic in
|
||||||
|
-- from the left -- engine/battle/pikachu_entrance_anim.asm:1
|
||||||
|
self:drawBattlerPic(self.player, dx + sx + self:picOffset("playerMon"),
|
||||||
|
dy + sy, s)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if clipped then
|
if clipped then
|
||||||
|
|||||||
@@ -243,8 +243,9 @@ end
|
|||||||
|
|
||||||
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
|
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
|
||||||
|
|
||||||
C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end
|
-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row
|
||||||
C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end
|
C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end
|
||||||
|
C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end
|
||||||
|
|
||||||
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
|
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
|
||||||
C.checkpokeball = function(self)
|
C.checkpokeball = function(self)
|
||||||
|
|||||||
@@ -1868,7 +1868,11 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
if status and (def.power or 0) == 0 then
|
if status and (def.power or 0) == 0 then
|
||||||
-- A refused primary status is a failed move (effect_commands.asm:3748,
|
-- A refused primary status is a failed move (effect_commands.asm:3748,
|
||||||
-- :6656); a refused secondary already animated and stays unmarked (:3752).
|
-- :6656); a refused secondary already animated and stays unmarked (:3752).
|
||||||
if Battle.AI_FAIL_STATUSES[status]
|
if self:statusRefusedByType(defender, def.type, status) then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = "It doesn't affect " .. self:monName(defender) .. "..." })
|
||||||
|
elseif Battle.AI_FAIL_STATUSES[status]
|
||||||
and self:aiRandomFail(attacker, defender) then
|
and self:aiRandomFail(attacker, defender) then
|
||||||
self:markMissed()
|
self:markMissed()
|
||||||
self:emit({ kind = "message", text = "But it failed!" })
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
@@ -1880,7 +1884,8 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
and record.status or nil
|
and record.status or nil
|
||||||
-- engine/battle/effect_commands.asm:6325
|
-- engine/battle/effect_commands.asm:6325
|
||||||
if secondary and (defender.hp or 0) > 0
|
if secondary and (defender.hp or 0) > 0
|
||||||
and not self:safeguarded(defender) then
|
and not self:safeguarded(defender)
|
||||||
|
and not self:statusRefusedByType(defender, def.type, secondary) then
|
||||||
local chance = def.effectChance or 0
|
local chance = def.effectChance or 0
|
||||||
if chance > 0 and rand(self.random, 100) < chance then
|
if chance > 0 and rand(self.random, 100) < chance then
|
||||||
self:applyStatus(defender, secondary, attacker)
|
self:applyStatus(defender, secondary, attacker)
|
||||||
@@ -2941,6 +2946,27 @@ end
|
|||||||
|
|
||||||
-- `source` is the battler that inflicted it, carried only so
|
-- `source` is the battler that inflicted it, carried only so
|
||||||
-- battle.status_inflicted can name it the way Gen 1's does.
|
-- battle.status_inflicted can name it the way Gen 1's does.
|
||||||
|
-- BattleCommand_Paralyze and BattleCommand_Poison refuse on a zero matchup,
|
||||||
|
-- and the poison pair also refuses a POISON-type target: effect_commands.asm
|
||||||
|
-- :5788 (paralyze), :3671 (poison), :3646 / :4019 (the secondary arms).
|
||||||
|
-- Sleep, confusion and stat changes are deliberately not gated.
|
||||||
|
function Battle:statusRefusedByType(defender, moveType, status)
|
||||||
|
if not (status == "paralyze" or status == "poison" or status == "toxic") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local types = (self:speciesDef(defender) or {}).types or defender.types or {}
|
||||||
|
if moveType then
|
||||||
|
local matchups = self.data.type_chart and self.data.type_chart.matchups
|
||||||
|
if Damage.typeMultiplier(moveType, types, matchups) == 0 then return true end
|
||||||
|
end
|
||||||
|
if status == "poison" or status == "toxic" then
|
||||||
|
for _, t in ipairs(types) do
|
||||||
|
if t == "POISON" then return true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
function Battle:applyStatus(mon, status, source)
|
function Battle:applyStatus(mon, status, source)
|
||||||
if (mon.hp or 0) <= 0 then return false end
|
if (mon.hp or 0) <= 0 then return false end
|
||||||
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
|
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
|
||||||
@@ -3202,6 +3228,7 @@ end
|
|||||||
-- player's own.
|
-- player's own.
|
||||||
function Battle:isOutsider(mon)
|
function Battle:isOutsider(mon)
|
||||||
local playerId = self.save and self.save.player and self.save.player.id
|
local playerId = self.save and self.save.player and self.save.player.id
|
||||||
|
if mon.traded == true then return true end
|
||||||
if mon.otId == nil or playerId == nil then return false end
|
if mon.otId == nil or playerId == nil then return false end
|
||||||
return mon.otId ~= playerId
|
return mon.otId ~= playerId
|
||||||
end
|
end
|
||||||
@@ -3363,7 +3390,7 @@ function Battle:awardExperience(loser)
|
|||||||
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
||||||
-- accepted and ignored rather than changing what is printed. `recipients`,
|
-- accepted and ignored rather than changing what is printed. `recipients`,
|
||||||
-- `holders` and `halved` are the Gen 2 additions.
|
-- `holders` and `halved` are the Gen 2 additions.
|
||||||
if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end
|
if Runtime.wantsHook("battle.exp_award") then
|
||||||
local alive = {}
|
local alive = {}
|
||||||
for _, index in ipairs(participants) do
|
for _, index in ipairs(participants) do
|
||||||
local mon = self.party[index]
|
local mon = self.party[index]
|
||||||
@@ -3382,6 +3409,14 @@ function Battle:awardExperience(loser)
|
|||||||
applyShare = applyShare, recipients = participants, holders = holders,
|
applyShare = applyShare, recipients = participants, holders = holders,
|
||||||
halved = halved, loser = loser,
|
halved = halved, loser = loser,
|
||||||
})
|
})
|
||||||
|
else
|
||||||
|
vanillaAward()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||||
|
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||||
|
self.participants = {}
|
||||||
|
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ local function messageCopy(screen)
|
|||||||
return #lines > 0 and lines or nil
|
return #lines > 0 and lines or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function itemCopies(game, screen, catchable)
|
||||||
|
local out = {}
|
||||||
|
for id, count in pairs((game.save and game.save.inventory) or {}) do
|
||||||
|
local def = game.data.items and game.data.items[id]
|
||||||
|
if count > 0 and def and def.pocket == "BALL" then
|
||||||
|
out[#out + 1] = { id = id, name = def.name or id, count = count,
|
||||||
|
ball = true, needsTarget = false,
|
||||||
|
catchChance = catchable and screen.catchChance
|
||||||
|
and screen:catchChance(id) or nil }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(out, function(a, b) return a.name < b.name end)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
local function signature(game, screen, top)
|
local function signature(game, screen, top)
|
||||||
if not screen then return "none" end
|
if not screen then return "none" end
|
||||||
local battle = screen.battle or {}
|
local battle = screen.battle or {}
|
||||||
@@ -56,6 +71,9 @@ local function signature(game, screen, top)
|
|||||||
parts[#parts + 1] = tostring(mon.hp)
|
parts[#parts + 1] = tostring(mon.hp)
|
||||||
parts[#parts + 1] = tostring(mon.status)
|
parts[#parts + 1] = tostring(mon.status)
|
||||||
end
|
end
|
||||||
|
for _, item in ipairs(itemCopies(game, screen, false)) do
|
||||||
|
parts[#parts + 1] = item.id .. "=" .. tostring(item.count)
|
||||||
|
end
|
||||||
return table.concat(parts, "|")
|
return table.concat(parts, "|")
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -111,9 +129,9 @@ function BattleAPI:snapshot()
|
|||||||
player = monCopy(game.data, battle.player, true),
|
player = monCopy(game.data, battle.player, true),
|
||||||
enemy = monCopy(game.data, battle.enemy, true),
|
enemy = monCopy(game.data, battle.enemy, true),
|
||||||
party = party, moves = moveCopies(game, battle),
|
party = party, moves = moveCopies(game, battle),
|
||||||
-- Gold's PACK is pocketed and target selection is screen-owned. Omit it
|
-- Targeted medicine remains screen-owned, but balls are complete semantic
|
||||||
-- until the engine can expose the same semantic item records as Gen 1.
|
-- records and can safely expose the same read-only preview as Gen 1.
|
||||||
items = {} }
|
items = itemCopies(game, screen, battle.wild and not screen.tutorial) }
|
||||||
end
|
end
|
||||||
|
|
||||||
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
|
local MENU_CHOICES = { fight = true, party = true, item = true, run = true }
|
||||||
|
|||||||
@@ -305,6 +305,15 @@ function Catching.rate(opts)
|
|||||||
return math.min(255, rate), false
|
return math.min(255, rate), false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Exact stock catch probability for read-only previews. A catch.rate hook
|
||||||
|
-- may replace the roll entirely, so nil is safer than presenting a guess.
|
||||||
|
function Catching.chance(opts)
|
||||||
|
if Runtime.wantsHook("catch.rate") then return nil end
|
||||||
|
local rate, guaranteed = Catching.rate(opts)
|
||||||
|
if guaranteed or rate >= 255 then return 100 end
|
||||||
|
return rate * 100 / 256
|
||||||
|
end
|
||||||
|
|
||||||
-- The status half of the rate, off the merged `statuses` record the same way
|
-- The status half of the rate, off the merged `statuses` record the same way
|
||||||
-- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records
|
-- src/battle/Catching.lua reads record.catchBonus on Gen 1. Gold's records
|
||||||
-- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers:
|
-- live on src/battle/gen2/Battle.lua (Battle.STATUSES) and carry BOTH numbers:
|
||||||
@@ -347,14 +356,13 @@ function Catching.attempt(opts)
|
|||||||
-- that edits o.catchRate or o.status changes the roll exactly as it does
|
-- that edits o.catchRate or o.status changes the roll exactly as it does
|
||||||
-- on Red, and one that returns `caught, rate` replaces it outright.
|
-- on Red, and one that returns `caught, rate` replaces it outright.
|
||||||
--
|
--
|
||||||
-- `mon` and `def` are whatever the catch site supplied. Gold's battle
|
-- `mon` and `def` are whatever the catch site supplied, and nil rather
|
||||||
-- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table
|
-- than a stand-in a mod would read as the real mon when it supplied
|
||||||
-- -- hp, maxHp, catchRate, status, species -- rather than the mon and its
|
-- neither. Gold's battle screen (src/ui/gen2/BattleState.lua) passes them
|
||||||
-- record, so both are nil there until it passes them; nil, not a stand-in
|
-- with `battle` alongside the flat hp/maxHp/catchRate/status fields, which
|
||||||
-- a mod would read as the real mon. Passing `battle = self.battle` and
|
-- is what puts the capture tail below on the real catch: the Transform
|
||||||
-- `mon = enemy` with them is what puts the capture tail below on the real
|
-- reload and the battle.catch_exp hook both hang off the battle, and
|
||||||
-- catch: the Transform reload and the battle.catch_exp hook both hang off
|
-- neither can be reached from a flat table.
|
||||||
-- the battle, and neither can be reached from a flat table.
|
|
||||||
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
|
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
|
||||||
return Catching.vanillaAttempt(o)
|
return Catching.vanillaAttempt(o)
|
||||||
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
|
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
|
||||||
|
|||||||
@@ -388,7 +388,8 @@ function Mon.stampOT(save, mon)
|
|||||||
mon.ot = mon.ot or player.name
|
mon.ot = mon.ot or player.name
|
||||||
-- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints.
|
-- NpcTrade.lua:150: `ot` is what Breeding reads, `otName` what the summary prints.
|
||||||
mon.otName = mon.otName or mon.ot
|
mon.otName = mon.otName or mon.ot
|
||||||
mon.otId = mon.otId or player.id
|
-- engine/battle/experience.asm:69: a traded mon keeps its own OT id
|
||||||
|
if not mon.traded then mon.otId = mon.otId or player.id end
|
||||||
return mon
|
return mon
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -424,11 +424,12 @@ local function renderEffect(data, header, options)
|
|||||||
return love.audio.newSource(sd, "static")
|
return love.audio.newSource(sd, "static")
|
||||||
end
|
end
|
||||||
|
|
||||||
function ChipAudio.newSfx(data, name, pitch, tempo, header)
|
function ChipAudio.newSfx(data, name, pitch, tempo, header, plainFrames)
|
||||||
header = header or data.audio.sfx[name]
|
header = header or data.audio.sfx[name]
|
||||||
return renderEffect(data, header, {
|
return renderEffect(data, header, {
|
||||||
frequencyOffset = pitch or 0,
|
frequencyOffset = pitch or 0,
|
||||||
frameTicks = 0x80 + (tempo or 0x80),
|
frameTicks = 0x80 + (tempo or 0x80),
|
||||||
|
plainFrames = plainFrames,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ function Channel.new(engine, spec, options)
|
|||||||
allowLoops = options.allowLoops ~= false,
|
allowLoops = options.allowLoops ~= false,
|
||||||
frequencyOffset = options.frequencyOffset or 0,
|
frequencyOffset = options.frequencyOffset or 0,
|
||||||
frameTicks = options.frameTicks or FRAME_TICKS,
|
frameTicks = options.frameTicks or FRAME_TICKS,
|
||||||
|
plainTicks = (options.plainFrames or 0) * FRAME_TICKS,
|
||||||
speed = 12,
|
speed = 12,
|
||||||
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
|
noteLength = 1, -- Gen 2 CHANNEL_NOTE_LENGTH (note_type)
|
||||||
durationModifier = 0, -- Gen 2 fractional-frame carry
|
durationModifier = 0, -- Gen 2 fractional-frame carry
|
||||||
@@ -349,8 +350,9 @@ function Channel:frequencyGen2(note, octave)
|
|||||||
return bit.band(register + self.frequencyOffset, 0x7FF)
|
return bit.band(register + self.frequencyOffset, 0x7FF)
|
||||||
end
|
end
|
||||||
|
|
||||||
function Channel:durationTicks(length)
|
function Channel:durationTicks(length, plain)
|
||||||
local tempo = self.sfx and self.frameTicks or self.engine.tempo
|
local tempo = self.sfx and (plain and FRAME_TICKS or self.frameTicks)
|
||||||
|
or self.engine.tempo
|
||||||
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
local speed = self.sfx and (self.executeMusic and self.speed or 1)
|
||||||
or self.speed
|
or self.speed
|
||||||
return length * speed * tempo
|
return length * speed * tempo
|
||||||
@@ -586,6 +588,9 @@ function Channel:nextEvent()
|
|||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
local volume = bit.rshift(packed, 4)
|
local volume = bit.rshift(packed, 4)
|
||||||
local fade = fadeValue(bit.band(packed, 0x0F))
|
local fade = fadeValue(bit.band(packed, 0x0F))
|
||||||
|
-- audio/engine_2.asm:991-1013, :1015-1033, :1077-1096
|
||||||
|
local plain = self.timeTicks < self.plainTicks
|
||||||
|
local offset = plain and 0 or self.frequencyOffset
|
||||||
if self.noise then
|
if self.noise then
|
||||||
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
-- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the
|
||||||
-- frequency low byte for every channel at or past CHAN5, the noise
|
-- frequency low byte for every channel at or past CHAN5, the noise
|
||||||
@@ -595,12 +600,12 @@ function Channel:nextEvent()
|
|||||||
-- byte that noise does not use for frequency. Dropping it left the
|
-- byte that noise does not use for frequency. Dropping it left the
|
||||||
-- battle hit sounds at their unmodified pitches, where super effective
|
-- battle hit sounds at their unmodified pitches, where super effective
|
||||||
-- reads as the duller of the two (#826).
|
-- reads as the duller of the two (#826).
|
||||||
local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF)
|
local parameter = bit.band(self:byte() + offset, 0xFF)
|
||||||
return self:noiseEvent(
|
return self:noiseEvent(
|
||||||
self:durationTicks(length), volume, fade, parameter)
|
self:durationTicks(length, plain), volume, fade, parameter)
|
||||||
end
|
end
|
||||||
local register = bit.band(self:word() + self.frequencyOffset, 0x7FF)
|
local register = bit.band(self:word() + offset, 0x7FF)
|
||||||
return self:tone(self:durationTicks(length), register, volume, fade)
|
return self:tone(self:durationTicks(length, plain), register, volume, fade)
|
||||||
elseif command == 0x10 then
|
elseif command == 0x10 then
|
||||||
local packed = self:byte()
|
local packed = self:byte()
|
||||||
self.sweep = {
|
self.sweep = {
|
||||||
@@ -1260,6 +1265,7 @@ function Engine.new(data, header, options)
|
|||||||
allowLoops = options.allowLoops,
|
allowLoops = options.allowLoops,
|
||||||
frequencyOffset = options.frequencyOffset,
|
frequencyOffset = options.frequencyOffset,
|
||||||
frameTicks = frameTicks,
|
frameTicks = frameTicks,
|
||||||
|
plainFrames = options.plainFrames,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
return engine
|
return engine
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
local Platform = require("src.core.Platform")
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
|
||||||
|
local FilePicker = {}
|
||||||
|
|
||||||
|
FilePicker.IMAGE = {
|
||||||
|
label = "Image",
|
||||||
|
exts = { "png", "jpg", "jpeg" },
|
||||||
|
tempName = "pokeport_image_pick",
|
||||||
|
}
|
||||||
|
|
||||||
|
local function trim(value)
|
||||||
|
return value and value:gsub("^%s+", ""):gsub("%s+$", "") or ""
|
||||||
|
end
|
||||||
|
|
||||||
|
local function shellSafe(s)
|
||||||
|
s = tostring(s):gsub("%%", "%%%%")
|
||||||
|
return s:gsub('"', '\\"'):gsub("'", "''")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function commandOutput(command)
|
||||||
|
if not Platform.canSpawnProcess() then return nil end
|
||||||
|
local pipe = HostShell.popen(command)
|
||||||
|
if not pipe then return nil end
|
||||||
|
local result = pipe:read("*a")
|
||||||
|
HostShell.pclose(pipe)
|
||||||
|
result = trim(result)
|
||||||
|
return result ~= "" and result or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function appleTypes(exts)
|
||||||
|
local out = {}
|
||||||
|
for _, ext in ipairs(exts) do out[#out + 1] = '"' .. ext .. '"' end
|
||||||
|
return table.concat(out, ", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function windowsPatterns(exts)
|
||||||
|
local out = {}
|
||||||
|
for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end
|
||||||
|
return table.concat(out, ";")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function globPatterns(exts)
|
||||||
|
local out = {}
|
||||||
|
for _, ext in ipairs(exts) do out[#out + 1] = "*." .. ext end
|
||||||
|
return table.concat(out, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.available()
|
||||||
|
return Platform.canSpawnProcess()
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.matches(name, kind)
|
||||||
|
local lower = tostring(name or ""):lower()
|
||||||
|
for _, ext in ipairs(kind.exts) do
|
||||||
|
if lower:match("%." .. ext .. "$") then return true end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.open(prompt, kind)
|
||||||
|
if not Platform.canSpawnProcess() then return nil end
|
||||||
|
local title = shellSafe(prompt)
|
||||||
|
local platform = love.system.getOS()
|
||||||
|
if platform == "OS X" then
|
||||||
|
return commandOutput(
|
||||||
|
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]])
|
||||||
|
:format(title, appleTypes(kind.exts)))
|
||||||
|
elseif platform == "Windows" then
|
||||||
|
local script = table.concat({
|
||||||
|
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||||
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
|
"$d.Title='" .. title .. "';",
|
||||||
|
"$d.Filter='" .. kind.label .. " (" .. windowsPatterns(kind.exts) .. ")|"
|
||||||
|
.. windowsPatterns(kind.exts) .. "|All files (*.*)|*.*';",
|
||||||
|
"if($d.ShowDialog() -eq 'OK'){",
|
||||||
|
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||||
|
"$t=Join-Path $env:TEMP $n;",
|
||||||
|
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||||
|
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||||
|
"[Console]::Write($t)}",
|
||||||
|
})
|
||||||
|
return commandOutput(
|
||||||
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
|
elseif platform == "Linux" then
|
||||||
|
local path = commandOutput(
|
||||||
|
([[zenity --file-selection --title="%s" --file-filter="%s | %s" 2>/dev/null]])
|
||||||
|
:format(title, kind.label, globPatterns(kind.exts)))
|
||||||
|
if path then return path end
|
||||||
|
return commandOutput(([[kdialog --getopenfilename "$HOME" "%s|%s" 2>/dev/null]])
|
||||||
|
:format(globPatterns(kind.exts), kind.label))
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.read(path)
|
||||||
|
local file, openError = io.open(path, "rb")
|
||||||
|
if not file then return nil, openError end
|
||||||
|
local data = file:read("*a")
|
||||||
|
file:close()
|
||||||
|
if not data or data == "" then return nil, "empty file" end
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
function FilePicker.basename(path)
|
||||||
|
return tostring(path or ""):match("([^/\\]+)$") or tostring(path or "")
|
||||||
|
end
|
||||||
|
|
||||||
|
return FilePicker
|
||||||
@@ -59,6 +59,9 @@ function Game:load()
|
|||||||
|
|
||||||
self.touchControls = TouchControls
|
self.touchControls = TouchControls
|
||||||
TouchControls:init()
|
TouchControls:init()
|
||||||
|
TouchControls:setHotkeyHandler(function(action, pressed)
|
||||||
|
self:touchSkinHotkey(action, pressed)
|
||||||
|
end)
|
||||||
|
|
||||||
self.renderer = Renderer
|
self.renderer = Renderer
|
||||||
Renderer:init()
|
Renderer:init()
|
||||||
@@ -185,6 +188,60 @@ function Game:returnToTitle()
|
|||||||
self.stack:push(self:makeTitleState())
|
self.stack:push(self:makeTitleState())
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Game.SKIN_FAST_FORWARD = 4
|
||||||
|
|
||||||
|
function Game:touchSkinHotkey(action, pressed)
|
||||||
|
if action == "fast_forward_hold" then
|
||||||
|
if pressed then
|
||||||
|
self.skinSpeedSaved = self.speedOverride
|
||||||
|
self.speedOverride = Game.SKIN_FAST_FORWARD
|
||||||
|
else
|
||||||
|
self.speedOverride = self.skinSpeedSaved
|
||||||
|
self.skinSpeedSaved = nil
|
||||||
|
end
|
||||||
|
elseif action == "fast_forward_toggle" then
|
||||||
|
if pressed then self:_cycleSpeed(1) end
|
||||||
|
elseif action == "soft_reset" then
|
||||||
|
if pressed then
|
||||||
|
Input:reset()
|
||||||
|
TouchControls:reset()
|
||||||
|
self:returnToTitle()
|
||||||
|
end
|
||||||
|
elseif action == "menu" then
|
||||||
|
if pressed then
|
||||||
|
local Screens = require("src.ui.Screens")
|
||||||
|
local top = self.stack and self.stack:top()
|
||||||
|
if top and not top.isTouchSkinMenu then
|
||||||
|
local state = Screens.build(self, "OptionsMenu")
|
||||||
|
if state then
|
||||||
|
state.isTouchSkinMenu = true
|
||||||
|
self.stack:push(state)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Game:breakLink(err)
|
||||||
|
Logger.error("link: torn down after an error\n%s", tostring(err))
|
||||||
|
self.linkSession = nil
|
||||||
|
local net = self.linkNet
|
||||||
|
self.linkNet = nil
|
||||||
|
if net then pcall(net.close, net) end
|
||||||
|
pcall(ModRuntime.emit, "link.ended", { reason = "error" })
|
||||||
|
local stack = self.stack
|
||||||
|
local guard = 0
|
||||||
|
while #stack.states > 1 and stack:top() ~= self.overworld and guard < 64 do
|
||||||
|
guard = guard + 1
|
||||||
|
pcall(stack.pop, stack)
|
||||||
|
end
|
||||||
|
pcall(function()
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local TextBox = require("src.render.TextBox")
|
||||||
|
stack:push(TextBox.new(self, Strings("The link was\nbroken.")))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
function Game:step(dt)
|
function Game:step(dt)
|
||||||
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
-- Tool mods (autoplay, accessibility drivers, input visualizers) act on
|
||||||
-- the same fixed-step boundary as a physical controller. Run them before
|
-- the same fixed-step boundary as a physical controller. Run them before
|
||||||
@@ -212,9 +269,22 @@ function Game:step(dt)
|
|||||||
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
-- stall just because PartyMenu/ChoiceBox/NamingScreen is temporarily
|
||||||
-- on top of BattleState (see LinkBattle.new)
|
-- on top of BattleState (see LinkBattle.new)
|
||||||
if self.linkNet and not self.linkNet.closed then
|
if self.linkNet and not self.linkNet.closed then
|
||||||
self.linkNet:update()
|
local ok, err = pcall(self.linkNet.update, self.linkNet)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
if self.linkSession or self.linkNet then
|
||||||
|
local ok, err = xpcall(function() self.stack:update(dt) end,
|
||||||
|
function(e) return debug.traceback(tostring(e), 2) end)
|
||||||
|
if not ok then
|
||||||
|
self:breakLink(err)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
else
|
||||||
self.stack:update(dt)
|
self.stack:update(dt)
|
||||||
|
end
|
||||||
-- play time for the trainer card / save screen
|
-- play time for the trainer card / save screen
|
||||||
self.save.playTime = (self.save.playTime or 0) + dt
|
self.save.playTime = (self.save.playTime or 0) + dt
|
||||||
-- Music.update is NOT serviced here: it decrements fade counters and
|
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||||
@@ -1196,6 +1266,7 @@ function Game:restoreSave(loaded, recovered, opts)
|
|||||||
self:applyOptions(loaded.options)
|
self:applyOptions(loaded.options)
|
||||||
-- saves from before OT/ID stamping: backfill with the player's (after
|
-- saves from before OT/ID stamping: backfill with the player's (after
|
||||||
-- the scrub, so every mon the stamp loop sees is known)
|
-- the scrub, so every mon the stamp loop sees is known)
|
||||||
|
SaveData.repairTradedOtIds(loaded)
|
||||||
local stamp = require("src.battle.BattleState").stampOT
|
local stamp = require("src.battle.BattleState").stampOT
|
||||||
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
|
||||||
for _, box in ipairs(loaded.boxes or {}) do
|
for _, box in ipairs(loaded.boxes or {}) do
|
||||||
|
|||||||
@@ -120,11 +120,11 @@ local function visibleBaseState(stack)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function loadGenerated(path)
|
local function loadGenerated(path)
|
||||||
local chunk = love.filesystem.load(path)
|
-- CacheFs.loadActive, not love.filesystem.load: Gold's cache lives under
|
||||||
if not chunk then return nil end
|
-- gold/ and fused NX often cannot mount that tree onto data/generated/.
|
||||||
local ok, data = pcall(chunk)
|
local CacheFs = require("src.import.CacheFs")
|
||||||
if ok then return data end
|
local data = CacheFs.loadActive(path)
|
||||||
return nil
|
return data
|
||||||
end
|
end
|
||||||
|
|
||||||
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
|
-- NewGame (engine/menus/intro_menu.asm) calls OakSpeech, and OakSpeech's first
|
||||||
@@ -939,6 +939,8 @@ function Game2:load()
|
|||||||
self.data.gen2Maps = loadGenerated("data/generated/maps.lua")
|
self.data.gen2Maps = loadGenerated("data/generated/maps.lua")
|
||||||
self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua")
|
self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua")
|
||||||
self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua")
|
self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua")
|
||||||
|
-- engine/events/magnet_train.asm:165 DrawMagnetTrain
|
||||||
|
self.data.gen2Field = loadGenerated("data/generated/field.lua")
|
||||||
self.data.gen2Marts = loadGenerated("data/generated/marts.lua")
|
self.data.gen2Marts = loadGenerated("data/generated/marts.lua")
|
||||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||||
@@ -1965,6 +1967,8 @@ function Game2:applyOptions()
|
|||||||
haptics = options.haptics,
|
haptics = options.haptics,
|
||||||
})
|
})
|
||||||
require("src.core.VideoMode").applyOptions(options)
|
require("src.core.VideoMode").applyOptions(options)
|
||||||
|
require("src.core.FrameCap").applyOptions(options)
|
||||||
|
require("src.world.gen2.BorderFill").applyOptions(options)
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
if GBCFX.applyOptions(options) and self.save then
|
if GBCFX.applyOptions(options) and self.save then
|
||||||
-- applyOptions returns true when it had to clear an unsupported level.
|
-- applyOptions returns true when it had to clear an unsupported level.
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ local HostShell = {}
|
|||||||
-- spawn tries to link against the libraries we're shipping instead of the
|
-- spawn tries to link against the libraries we're shipping instead of the
|
||||||
-- system ones. We want to unset the var so that any system tools can find
|
-- system ones. We want to unset the var so that any system tools can find
|
||||||
-- their proper libraries. Only needed when running in an AppImage.
|
-- their proper libraries. Only needed when running in an AppImage.
|
||||||
|
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
|
||||||
|
-- 64-bit child, so ld.so prints an error into that child's output.
|
||||||
function HostShell.envPrefix()
|
function HostShell.envPrefix()
|
||||||
if os.getenv("APPIMAGE") then
|
local unset = ""
|
||||||
return "env -u LD_LIBRARY_PATH "
|
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
|
||||||
end
|
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
|
||||||
return ""
|
if unset == "" then return "" end
|
||||||
|
return "env " .. unset
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Windows: every host tool we shell out to (curl for the update and mod-index
|
-- Windows: every host tool we shell out to (curl for the update and mod-index
|
||||||
@@ -222,11 +225,20 @@ end
|
|||||||
local HTTP_MARK = "\n__gen1recomp_http__"
|
local HTTP_MARK = "\n__gen1recomp_http__"
|
||||||
local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}"
|
local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}"
|
||||||
|
|
||||||
|
local function stripLoaderNoise(out)
|
||||||
|
while out:find("^ERROR: ld%.so:") do
|
||||||
|
local nl = out:find("\n", 1, true)
|
||||||
|
if not nl then return "" end
|
||||||
|
out = out:sub(nl + 1)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
-- Split a curl pipe's output into (body, status, noise). `status` is nil
|
-- Split a curl pipe's output into (body, status, noise). `status` is nil
|
||||||
-- when curl never got far enough to have one (DNS failure, no route, a
|
-- when curl never got far enough to have one (DNS failure, no route, a
|
||||||
-- timeout), in which case `noise` carries curl's own complaint.
|
-- timeout), in which case `noise` carries curl's own complaint.
|
||||||
local function splitCurlOutput(out)
|
local function splitCurlOutput(out)
|
||||||
out = tostring(out or "")
|
out = stripLoaderNoise(tostring(out or ""))
|
||||||
local at = nil
|
local at = nil
|
||||||
local from = 1
|
local from = 1
|
||||||
while true do
|
while true do
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
-- NX-only asset overlay: fused love-nx cannot reliably mount
|
-- NX-only asset overlay: fused love-nx cannot reliably mount
|
||||||
-- blue|yellow/assets/generated onto the un-prefixed assets/generated, so
|
-- blue|yellow|gold/{assets,data}/generated onto the un-prefixed paths, so
|
||||||
-- instead of teaching every call site about versioned caches, this module
|
-- instead of teaching every call site about versioned caches, this module
|
||||||
-- wraps EVERY read-side love entry point that accepts a filesystem path
|
-- wraps EVERY read-side love entry point that accepts a filesystem path
|
||||||
-- once at boot: any string path under assets/generated/ that does not
|
-- once at boot: any string path under assets/generated/ or data/generated/
|
||||||
-- resolve falls back to the active version's prefixed copy
|
-- prefers the active version's prefixed copy (yellow|blue|gold/...) when
|
||||||
-- (yellow|blue/assets/generated/...). Covering the whole read surface --
|
-- that file exists, so leftover unprefixed Red cache cannot shadow it.
|
||||||
|
-- Covering the whole read surface --
|
||||||
-- not just the loaders we happened to need -- is what keeps future states
|
-- not just the loaders we happened to need -- is what keeps future states
|
||||||
-- and mods inside the fallback without anyone updating this file.
|
-- and mods inside the fallback without anyone updating this file.
|
||||||
--
|
--
|
||||||
@@ -18,27 +19,43 @@
|
|||||||
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
|
-- * the chip-audio worker (src/core/chip_worker.lua) is a separate Lua
|
||||||
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
|
-- state without these wrappers; ChipAudio.slimAudio hands it the prefix
|
||||||
-- explicitly as audio.programPrefix.
|
-- explicitly as audio.programPrefix.
|
||||||
-- * data/generated module loads go through CacheFs.readActive, which
|
-- * Gen 1 Data:load and Gold Game2/World go through CacheFs.readActive /
|
||||||
-- already implements the same fallback for require bytes.
|
-- CacheFs.loadActive, which implement the same fallback for Lua bytes.
|
||||||
|
-- data/generated is still rewritten here so any leftover
|
||||||
|
-- love.filesystem.load("data/generated/...") call (the Gold intro /
|
||||||
|
-- naming / maps hole on 0.2.4) stays inside the overlay.
|
||||||
|
|
||||||
local GameVersion = require("src.core.GameVersion")
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
|
||||||
local GENERATED = "assets/generated/"
|
local GENERATED_PREFIXES = {
|
||||||
|
"assets/generated/",
|
||||||
|
"data/generated/",
|
||||||
|
}
|
||||||
|
|
||||||
local NxAssetOverlay = {}
|
local NxAssetOverlay = {}
|
||||||
|
|
||||||
local originals -- raw love functions, non-nil while installed
|
local originals -- raw love functions, non-nil while installed
|
||||||
|
|
||||||
-- Resolve `path` to the versioned copy when the un-prefixed file is missing
|
-- Resolve `path` to the active version's prefixed copy when that file
|
||||||
-- and the active version (Blue/Yellow) carries it. Returns nil when the
|
-- exists (gold|yellow|blue|red/{assets,data}/generated/...). The versioned
|
||||||
-- caller's path should be used untouched (non-generated path, Red, the real
|
-- tree wins over a leftover un-prefixed file so a pre-#899 Red root cache
|
||||||
-- file exists, or no versioned copy).
|
-- (assets/generated/font.png in the save dir) cannot shadow Gold/Blue/
|
||||||
|
-- Yellow art that shares a name. Returns nil when the caller's path
|
||||||
|
-- should be used untouched (non-generated path, empty prefix, or no
|
||||||
|
-- versioned copy).
|
||||||
local function versioned(path)
|
local function versioned(path)
|
||||||
if type(path) ~= "string" then return nil end
|
if type(path) ~= "string" then return nil end
|
||||||
if path:sub(1, #GENERATED) ~= GENERATED then return nil end
|
local generated = false
|
||||||
|
for i = 1, #GENERATED_PREFIXES do
|
||||||
|
local gen = GENERATED_PREFIXES[i]
|
||||||
|
if path:sub(1, #gen) == gen then
|
||||||
|
generated = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not generated then return nil end
|
||||||
local prefix = GameVersion.cachePrefix()
|
local prefix = GameVersion.cachePrefix()
|
||||||
if prefix == "" then return nil end
|
if prefix == "" then return nil end
|
||||||
if originals.getInfo(path) then return nil end
|
|
||||||
local candidate = prefix .. path
|
local candidate = prefix .. path
|
||||||
if originals.getInfo(candidate) then return candidate end
|
if originals.getInfo(candidate) then return candidate end
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -666,11 +666,10 @@ function SaveData.setModEnabled(options, id, enabled, version)
|
|||||||
options.modsByVersion = options.modsByVersion or {}
|
options.modsByVersion = options.modsByVersion or {}
|
||||||
local bucket = options.modsByVersion[version] or {}
|
local bucket = options.modsByVersion[version] or {}
|
||||||
options.modsByVersion[version] = bucket
|
options.modsByVersion[version] = bucket
|
||||||
-- no shared flag reads as enabled, the same default the loader applies to a
|
-- with no shared flag the default is the caller's (experimental mods read as
|
||||||
-- missing entry, so a fresh install never fills the overlay with agreement
|
-- disabled), so the answer is stored outright rather than judged against one
|
||||||
local shared = options.mods and options.mods[id]
|
local shared = options.mods and options.mods[id]
|
||||||
if type(shared) ~= "boolean" then shared = true end
|
if type(shared) == "boolean" and shared == enabled then
|
||||||
if shared == enabled then
|
|
||||||
bucket[id] = nil
|
bucket[id] = nil
|
||||||
else
|
else
|
||||||
bucket[id] = enabled
|
bucket[id] = enabled
|
||||||
@@ -1924,6 +1923,26 @@ function SaveData.applyPostGameHome(save, boot)
|
|||||||
return heal
|
return heal
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- 0.1.82-0.1.9x loads stamped the player's own id onto traded mons and saved
|
||||||
|
-- it, which reads back as home-caught. A caught mon can never carry
|
||||||
|
-- traded=true, so clearing that pair is safe on every load. #1461
|
||||||
|
function SaveData.repairTradedOtIds(save)
|
||||||
|
local playerId = save and save.player and save.player.id
|
||||||
|
if playerId == nil then return 0 end
|
||||||
|
local fixed = 0
|
||||||
|
local function scrub(mon)
|
||||||
|
if mon and mon.traded == true and mon.otId == playerId then
|
||||||
|
mon.otId = nil
|
||||||
|
fixed = fixed + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, mon in ipairs(save.party or {}) do scrub(mon) end
|
||||||
|
for _, box in ipairs(save.boxes or {}) do
|
||||||
|
for _, mon in ipairs(box) do scrub(mon) end
|
||||||
|
end
|
||||||
|
return fixed
|
||||||
|
end
|
||||||
|
|
||||||
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
|
-- Softlocked 0.1.11 saves: still standing in HALL_OF_FAME after credits,
|
||||||
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
|
-- with lastOutdoor on Indigo. One-shot rescue on CONTINUE.
|
||||||
function SaveData.needsPostGameRescue(save)
|
function SaveData.needsPostGameRescue(save)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
local SkinZip = {}
|
||||||
|
|
||||||
|
local bxor
|
||||||
|
do
|
||||||
|
local ok, bit = pcall(require, "bit")
|
||||||
|
if ok and bit and bit.bxor then
|
||||||
|
bxor = function(a, b) return bit.bxor(a, b) % 0x100000000 end
|
||||||
|
else
|
||||||
|
local byteXor = {}
|
||||||
|
local function xor8(a, b)
|
||||||
|
local key = a * 256 + b
|
||||||
|
local memo = byteXor[key]
|
||||||
|
if memo then return memo end
|
||||||
|
local r, bitv = 0, 1
|
||||||
|
local x, y = a, b
|
||||||
|
for _ = 1, 8 do
|
||||||
|
if (x % 2) ~= (y % 2) then r = r + bitv end
|
||||||
|
x, y, bitv = math.floor(x / 2), math.floor(y / 2), bitv * 2
|
||||||
|
end
|
||||||
|
byteXor[key] = r
|
||||||
|
return r
|
||||||
|
end
|
||||||
|
bxor = function(a, b)
|
||||||
|
local r, mul = 0, 1
|
||||||
|
for _ = 1, 4 do
|
||||||
|
r = r + xor8(a % 256, b % 256) * mul
|
||||||
|
a, b, mul = math.floor(a / 256), math.floor(b / 256), mul * 256
|
||||||
|
end
|
||||||
|
return r
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local crcTable
|
||||||
|
local function crc32(s)
|
||||||
|
if not crcTable then
|
||||||
|
crcTable = {}
|
||||||
|
for i = 0, 255 do
|
||||||
|
local c = i
|
||||||
|
for _ = 1, 8 do
|
||||||
|
if c % 2 == 1 then
|
||||||
|
c = bxor(math.floor(c / 2), 0xEDB88320)
|
||||||
|
else
|
||||||
|
c = math.floor(c / 2)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
crcTable[i] = c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local crc = 0xFFFFFFFF
|
||||||
|
for i = 1, #s do
|
||||||
|
crc = bxor(crcTable[bxor(crc, s:byte(i)) % 256], math.floor(crc / 256))
|
||||||
|
end
|
||||||
|
return bxor(crc, 0xFFFFFFFF) % 0x100000000
|
||||||
|
end
|
||||||
|
|
||||||
|
local function le16(n)
|
||||||
|
n = math.floor(n) % 0x10000
|
||||||
|
return string.char(n % 256, math.floor(n / 256) % 256)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function le32(n)
|
||||||
|
n = math.floor(n) % 0x100000000
|
||||||
|
return string.char(n % 256, math.floor(n / 256) % 256,
|
||||||
|
math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256)
|
||||||
|
end
|
||||||
|
|
||||||
|
function SkinZip.encode(entries)
|
||||||
|
local out, central, offset = {}, {}, 0
|
||||||
|
for _, entry in ipairs(entries) do
|
||||||
|
local name, data = entry.name, entry.data or ""
|
||||||
|
local crc, size = crc32(data), #data
|
||||||
|
local local_ = "PK\3\4" .. le16(20) .. le16(0) .. le16(0)
|
||||||
|
.. le16(0) .. le16(0)
|
||||||
|
.. le32(crc) .. le32(size) .. le32(size)
|
||||||
|
.. le16(#name) .. le16(0) .. name
|
||||||
|
out[#out + 1] = local_
|
||||||
|
out[#out + 1] = data
|
||||||
|
central[#central + 1] = "PK\1\2" .. le16(20) .. le16(20) .. le16(0) .. le16(0)
|
||||||
|
.. le16(0) .. le16(0)
|
||||||
|
.. le32(crc) .. le32(size) .. le32(size)
|
||||||
|
.. le16(#name) .. le16(0) .. le16(0) .. le16(0) .. le16(0)
|
||||||
|
.. le32(0) .. le32(offset) .. name
|
||||||
|
offset = offset + #local_ + #data
|
||||||
|
end
|
||||||
|
local dir = table.concat(central)
|
||||||
|
return table.concat(out) .. dir
|
||||||
|
.. "PK\5\6" .. le16(0) .. le16(0) .. le16(#entries) .. le16(#entries)
|
||||||
|
.. le32(#dir) .. le32(offset) .. le16(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
return SkinZip
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
local Assets = require("src.render.Assets")
|
local Assets = require("src.render.Assets")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local bit = require("bit")
|
||||||
|
|
||||||
local Sound = {}
|
local Sound = {}
|
||||||
|
|
||||||
@@ -142,10 +143,10 @@ local function newFileSource(def)
|
|||||||
return s
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
local function newSfxSource(data, key, def, pitch, tempo)
|
local function newSfxSource(data, key, def, pitch, tempo, plain)
|
||||||
if isChipDef(def) then
|
if isChipDef(def) then
|
||||||
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
|
local ok, s = pcall(require("src.core.ChipAudio").newSfx,
|
||||||
data, key:match("^([^@]+)") or key, pitch, tempo, def)
|
data, key:match("^([^@]+)") or key, pitch, tempo, def, plain)
|
||||||
if not ok then return nil, tostring(s) end
|
if not ok then return nil, tostring(s) end
|
||||||
if not s then return nil, "no source" end
|
if not s then return nil, "no source" end
|
||||||
return s
|
return s
|
||||||
@@ -153,12 +154,12 @@ local function newSfxSource(data, key, def, pitch, tempo)
|
|||||||
return newFileSource(def)
|
return newFileSource(def)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function playPath(data, key, def, pitch, tempo)
|
local function playPath(data, key, def, pitch, tempo, plain)
|
||||||
if not love.audio or not def then return nil end
|
if not love.audio or not def then return nil end
|
||||||
local src = cache[key]
|
local src = cache[key]
|
||||||
if src == false then return nil end -- known bad, already logged
|
if src == false then return nil end -- known bad, already logged
|
||||||
if not src then
|
if not src then
|
||||||
local s, err = newSfxSource(data, key, def, pitch, tempo)
|
local s, err = newSfxSource(data, key, def, pitch, tempo, plain)
|
||||||
if not s then
|
if not s then
|
||||||
cache[key] = false
|
cache[key] = false
|
||||||
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
reportBadDef("sfx", key, owner(data, "sfx", key), err)
|
||||||
@@ -413,50 +414,109 @@ end
|
|||||||
-- still sounding when the second row starts, so the original never plays
|
-- still sounding when the second row starts, so the original never plays
|
||||||
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running
|
||||||
-- past the end of the animation (#844).
|
-- past the end of the animation (#844).
|
||||||
local lastMoveSfx -- { src, rank, engine, channels } of the last row sound
|
local moveSfxChannels = {} -- software channel (5-8) -> { src, address, engine }
|
||||||
|
|
||||||
local function channelsOverlap(a, b)
|
local function sourceAlive(entry)
|
||||||
if not (a and b) then return false end
|
local ok, playing = pcall(entry.src.isPlaying, entry.src)
|
||||||
for _, x in ipairs(a) do
|
return ok and playing
|
||||||
for _, y in ipairs(b) do
|
|
||||||
if x == y then return true end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function pruneMoveSfx()
|
||||||
|
for ch, cur in pairs(moveSfxChannels) do
|
||||||
|
if not sourceAlive(cur) then moveSfxChannels[ch] = nil end
|
||||||
end
|
end
|
||||||
return false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- would PlaySound start this def now? Taking a channel over also stops the
|
-- would PlaySound start this def now? Taking a channel over also stops the
|
||||||
-- sound that held it, the way .playChannel resets the channel.
|
-- sound that held it, the way .playChannel resets the channel.
|
||||||
local function sfxChannelGate(data, def)
|
local function sfxChannelGate(data, def)
|
||||||
local cur = lastMoveSfx
|
pruneMoveSfx()
|
||||||
if not cur then return true end
|
|
||||||
local ok, playing = pcall(cur.src.isPlaying, cur.src)
|
|
||||||
if not (ok and playing) then
|
|
||||||
lastMoveSfx = nil
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
-- an unrankable def (file asset, or another engine's bank) has no
|
-- an unrankable def (file asset, or another engine's bank) has no
|
||||||
-- comparable sound id: leave it to the mixer, as before
|
-- comparable sound id: leave it to the mixer, as before
|
||||||
if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then
|
if type(def) ~= "table" or not def.address then return true end
|
||||||
return true
|
|
||||||
end
|
|
||||||
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
if not channelsOverlap(channels, cur.channels) then return true end
|
if not channels then return true end
|
||||||
if def.address > cur.rank then return false end
|
local takeover
|
||||||
pcall(cur.src.stop, cur.src)
|
for _, ch in ipairs(channels) do
|
||||||
lastMoveSfx = nil
|
local cur = moveSfxChannels[ch]
|
||||||
return true
|
if cur and cur.engine == def.engine then
|
||||||
|
if def.address > cur.address then return false end
|
||||||
|
takeover = takeover or {}
|
||||||
|
takeover[cur.src] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if takeover then
|
||||||
|
for ch, cur in pairs(moveSfxChannels) do
|
||||||
|
if takeover[cur.src] then moveSfxChannels[ch] = nil end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return true, takeover
|
||||||
end
|
end
|
||||||
|
|
||||||
local function noteMoveSfx(data, def, src)
|
local function noteMoveSfx(data, def, src)
|
||||||
if not src or type(def) ~= "table" or not def.address then
|
if not src or type(def) ~= "table" or not def.address then
|
||||||
lastMoveSfx = nil
|
moveSfxChannels = {}
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
lastMoveSfx = {
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
src = src, rank = def.address, engine = def.engine,
|
if not channels then
|
||||||
channels = require("src.core.ChipSynth").effectChannels(data, def),
|
moveSfxChannels = {}
|
||||||
}
|
return
|
||||||
|
end
|
||||||
|
local entry = { src = src, address = def.address, engine = def.engine }
|
||||||
|
for _, ch in ipairs(channels) do moveSfxChannels[ch] = entry end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- constants/music_constants.asm:4
|
||||||
|
local function sfxHeaderId(def)
|
||||||
|
if type(def) ~= "table" or not def.address then return nil end
|
||||||
|
local rel = def.address - 0x4000
|
||||||
|
if rel <= 0 or rel % 3 ~= 0 then return nil end
|
||||||
|
return rel / 3
|
||||||
|
end
|
||||||
|
|
||||||
|
local function remainingFrames(src)
|
||||||
|
local ok, dur = pcall(src.getDuration, src, "seconds")
|
||||||
|
if not ok or type(dur) ~= "number" then return nil end
|
||||||
|
local pos
|
||||||
|
ok, pos = pcall(src.tell, src, "seconds")
|
||||||
|
if not ok or type(pos) ~= "number" then return nil end
|
||||||
|
return math.max(0, math.ceil((dur - pos) * 60))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- audio/engine_2.asm:1077-1096, :991-1013, :1015-1033
|
||||||
|
local function plainMoveFrames(data, def, channels)
|
||||||
|
local id = sfxHeaderId(def)
|
||||||
|
local sfx = data.audio and data.audio.sfx
|
||||||
|
if not (id and sfx and channels) then return 0 end
|
||||||
|
local first = sfxHeaderId(sfx.Peck) -- constants/music_constants.asm:178
|
||||||
|
local last = sfxHeaderId(sfx.Trainer_Appeared) -- constants/music_constants.asm:228
|
||||||
|
if not (first and last) then return 0 end
|
||||||
|
local claimed = {}
|
||||||
|
for _, ch in ipairs(channels) do claimed[ch] = true end
|
||||||
|
local base = (claimed[5] or claimed[8]) and id or 0
|
||||||
|
local others = {}
|
||||||
|
for _, ch in ipairs({ 5, 8 }) do
|
||||||
|
local cur = (not claimed[ch]) and moveSfxChannels[ch] or nil
|
||||||
|
if cur and cur.engine == def.engine and sourceAlive(cur) then
|
||||||
|
local otherId = sfxHeaderId(cur)
|
||||||
|
local rem = remainingFrames(cur.src)
|
||||||
|
if otherId and rem and rem > 0 then
|
||||||
|
others[#others + 1] = { id = otherId, rem = rem }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.sort(others, function(a, b) return a.rem < b.rem end)
|
||||||
|
local plain = 0
|
||||||
|
for drop = 0, #others do
|
||||||
|
local combined = base
|
||||||
|
for i = drop + 1, #others do
|
||||||
|
combined = bit.bor(combined, others[i].id)
|
||||||
|
end
|
||||||
|
if combined >= first and combined <= last then return plain end
|
||||||
|
if drop < #others then plain = others[drop + 1].rem end
|
||||||
|
end
|
||||||
|
return plain
|
||||||
end
|
end
|
||||||
|
|
||||||
function Sound.playMove(data, anim)
|
function Sound.playMove(data, anim)
|
||||||
@@ -466,13 +526,20 @@ function Sound.playMove(data, anim)
|
|||||||
local name = anim.sound
|
local name = anim.sound
|
||||||
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80
|
||||||
local def = sfx[name]
|
local def = sfx[name]
|
||||||
if not sfxChannelGate(data, def) then return end
|
local allowed, superseded = sfxChannelGate(data, def)
|
||||||
|
if not allowed then return end
|
||||||
local src
|
local src
|
||||||
-- a chip program synthesizes the modified variant on demand; a file def
|
-- a chip program synthesizes the modified variant on demand; a file def
|
||||||
-- can only reach for a pre-rendered one
|
-- can only reach for a pre-rendered one
|
||||||
if isChipDef(def) then
|
if isChipDef(def) then
|
||||||
src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo),
|
local plain = 0
|
||||||
def, pitch, tempo)
|
if pitch ~= 0 or tempo ~= 0x80 then
|
||||||
|
local channels = require("src.core.ChipSynth").effectChannels(data, def)
|
||||||
|
plain = plainMoveFrames(data, def, channels)
|
||||||
|
end
|
||||||
|
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||||
|
if plain > 0 then key = ("%s~%d"):format(key, plain) end
|
||||||
|
src = playPath(data, key, def, pitch, tempo, plain)
|
||||||
else
|
else
|
||||||
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
local key = ("%s@%02x%02x"):format(name, pitch, tempo)
|
||||||
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then
|
||||||
@@ -481,6 +548,12 @@ function Sound.playMove(data, anim)
|
|||||||
src = playPath(data, name, def)
|
src = playPath(data, name, def)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- audio/engine_2.asm:1537
|
||||||
|
if superseded then
|
||||||
|
for old in pairs(superseded) do
|
||||||
|
if old ~= src then pcall(old.stop, old) end
|
||||||
|
end
|
||||||
|
end
|
||||||
if src then
|
if src then
|
||||||
played("move", name)
|
played("move", name)
|
||||||
noteMoveSfx(data, def, src)
|
noteMoveSfx(data, def, src)
|
||||||
@@ -711,7 +784,7 @@ end
|
|||||||
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
-- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo
|
||||||
-- variants included) or all of them, so the next play re-resolves the def
|
-- variants included) or all of them, so the next play re-resolves the def
|
||||||
function Sound.invalidate(name)
|
function Sound.invalidate(name)
|
||||||
lastMoveSfx = nil -- its source is about to be dropped or stopped
|
moveSfxChannels = {} -- their sources are about to be dropped or stopped
|
||||||
-- Same for wCurSFX, and a reloaded table can repoint the id order.
|
-- Same for wCurSFX, and a reloaded table can repoint the id order.
|
||||||
curSfx = nil
|
curSfx = nil
|
||||||
sfxIds = nil
|
sfxIds = nil
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
|
|
||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
local SafeArea = require("src.core.SafeArea")
|
local SafeArea = require("src.core.SafeArea")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
|
||||||
local TouchControls = {}
|
local TouchControls = {}
|
||||||
|
|
||||||
@@ -189,6 +190,7 @@ function TouchControls.normalizeConfig(tc)
|
|||||||
-- no caller ever has to nil-check a bucket's scale
|
-- no caller ever has to nil-check a bucket's scale
|
||||||
if type(tc) ~= "table" then tc = {} end
|
if type(tc) ~= "table" then tc = {} end
|
||||||
if tc.enabled == false then out.enabled = false end
|
if tc.enabled == false then out.enabled = false end
|
||||||
|
if type(tc.skin) == "string" and tc.skin ~= "" then out.skin = tc.skin end
|
||||||
local saved = type(tc.layouts) == "table" and tc.layouts or nil
|
local saved = type(tc.layouts) == "table" and tc.layouts or nil
|
||||||
for _, o in ipairs(ORIENTATIONS) do
|
for _, o in ipairs(ORIENTATIONS) do
|
||||||
local b = saved and saved[o]
|
local b = saved and saved[o]
|
||||||
@@ -241,6 +243,7 @@ end
|
|||||||
|
|
||||||
function TouchControls:init()
|
function TouchControls:init()
|
||||||
self.active = wantsOverlay()
|
self.active = wantsOverlay()
|
||||||
|
TouchSkin.setOverlayLive(self.active)
|
||||||
self.enabled = true
|
self.enabled = true
|
||||||
-- vibration level for presses (#806); applyOptions overwrites it from
|
-- vibration level for presses (#806); applyOptions overwrites it from
|
||||||
-- options.haptics, this is the value a harness that never applies options
|
-- options.haptics, this is the value a harness that never applies options
|
||||||
@@ -261,6 +264,10 @@ function TouchControls:init()
|
|||||||
self.held = {}
|
self.held = {}
|
||||||
self.dpadTouch = nil
|
self.dpadTouch = nil
|
||||||
self.layoutW, self.layoutH = nil, nil
|
self.layoutW, self.layoutH = nil, nil
|
||||||
|
self.skinId = nil
|
||||||
|
self.skinError = nil
|
||||||
|
self.hotkeysHeld = {}
|
||||||
|
TouchSkin.setActive(nil)
|
||||||
self.img = nil
|
self.img = nil
|
||||||
-- Images load whenever the platform wants the overlay OR the launcher
|
-- Images load whenever the platform wants the overlay OR the launcher
|
||||||
-- editor forces a preview (desktop testing of the editor).
|
-- editor forces a preview (desktop testing of the editor).
|
||||||
@@ -285,6 +292,8 @@ function TouchControls:applyOptions(opts)
|
|||||||
-- haptics is a plain top-level option, not part of the layout config the
|
-- haptics is a plain top-level option, not part of the layout config the
|
||||||
-- launcher editor round-trips through config() (#806)
|
-- launcher editor round-trips through config() (#806)
|
||||||
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics)
|
||||||
|
TouchSkin.setOverlayLive(self.active)
|
||||||
|
self:selectSkin(cfg.skin)
|
||||||
self.layouts = cfg.layouts
|
self.layouts = cfg.layouts
|
||||||
self.layoutW, self.layoutH = nil, nil
|
self.layoutW, self.layoutH = nil, nil
|
||||||
self.layoutOx, self.layoutOy = nil, nil
|
self.layoutOx, self.layoutOy = nil, nil
|
||||||
@@ -300,7 +309,7 @@ end
|
|||||||
-- Snapshot for the editor's save path: enabled plus both orientation
|
-- Snapshot for the editor's save path: enabled plus both orientation
|
||||||
-- buckets, matching what options.lua stores (#633).
|
-- buckets, matching what options.lua stores (#633).
|
||||||
function TouchControls:config()
|
function TouchControls:config()
|
||||||
local out = { enabled = self.enabled ~= false, layouts = {} }
|
local out = { enabled = self.enabled ~= false, skin = self.skinId, layouts = {} }
|
||||||
for _, o in ipairs(ORIENTATIONS) do
|
for _, o in ipairs(ORIENTATIONS) do
|
||||||
local b = self.layouts and self.layouts[o] or nil
|
local b = self.layouts and self.layouts[o] or nil
|
||||||
out.layouts[o] = {
|
out.layouts[o] = {
|
||||||
@@ -323,11 +332,56 @@ function TouchControls:setPreview(on)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function TouchControls:visible()
|
function TouchControls:visible()
|
||||||
if self.preview then return self.img ~= nil end
|
local art = TouchSkin.active ~= nil or self.img ~= nil
|
||||||
return self.active and self.enabled ~= false and self.img ~= nil
|
if self.preview then return art end
|
||||||
and not self.controllerHidden
|
if self.enabled == false or not art then return false end
|
||||||
|
if TouchSkin.active and TouchSkin.decorativeOnly() then return true end
|
||||||
|
return self.active and not self.controllerHidden
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function surfaceRect()
|
||||||
|
local r = TouchSkin.surfaceRect
|
||||||
|
if r then return r.w, r.h, r.x, r.y end
|
||||||
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
|
local w, h = love.graphics.getDimensions()
|
||||||
|
return w, h, 0, 0
|
||||||
|
end
|
||||||
|
return 0, 0, 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
TouchControls.surfaceRect = surfaceRect
|
||||||
|
|
||||||
|
function TouchControls:selectSkin(id)
|
||||||
|
if id == self.skinId and TouchSkin.active then return TouchSkin.active end
|
||||||
|
self:reset()
|
||||||
|
self.skinError = nil
|
||||||
|
if not id or id == "" then
|
||||||
|
self.skinId = nil
|
||||||
|
TouchSkin.setActive(nil)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local skin, err = TouchSkin.select(id)
|
||||||
|
if not skin then
|
||||||
|
self.skinId = nil
|
||||||
|
self.skinError = err
|
||||||
|
TouchSkin.setActive(nil)
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
self.skinId = id
|
||||||
|
return skin
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:skin()
|
||||||
|
if not self:visible() then return nil end
|
||||||
|
return TouchSkin.active
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:setHotkeyHandler(fn)
|
||||||
|
self.hotkeyHandler = type(fn) == "function" and fn or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local skinHitSet, applySkinSet
|
||||||
|
|
||||||
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
-- Keep a control fully inside the usable rect [x0,y0]..[x1,y1].
|
||||||
local function clampZone(zone, x0, y0, x1, y1)
|
local function clampZone(zone, x0, y0, x1, y1)
|
||||||
local half = zone.w * 0.5
|
local half = zone.w * 0.5
|
||||||
@@ -451,6 +505,15 @@ end
|
|||||||
-- Which control (if any) contains (x, y). Prefer face buttons over the
|
-- Which control (if any) contains (x, y). Prefer face buttons over the
|
||||||
-- d-pad when they overlap, matching touchpressed's order.
|
-- d-pad when they overlap, matching touchpressed's order.
|
||||||
function TouchControls:hitTest(x, y)
|
function TouchControls:hitTest(x, y)
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
if page then
|
||||||
|
local ww, wh, ox, oy = surfaceRect()
|
||||||
|
for i = #page.controls, 1, -1 do
|
||||||
|
local ctl = page.controls[i]
|
||||||
|
if TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then return ctl.spec, ctl end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
local L = self:layout()
|
local L = self:layout()
|
||||||
for _, btn in ipairs(BUTTONS) do
|
for _, btn in ipairs(BUTTONS) do
|
||||||
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
|
if inCircle(L[btn], x, y, SLOP[btn]) then return btn end
|
||||||
@@ -505,6 +568,86 @@ local function setDpad(self, touch, dir)
|
|||||||
if dir then pressBtn(self, dir) end
|
if dir then pressBtn(self, dir) end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function pressKey(key, down)
|
||||||
|
local fn = down and love.keypressed or love.keyreleased
|
||||||
|
if type(fn) == "function" then pcall(fn, key, key, false) end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function fireHotkey(self, action, pressed, ctl)
|
||||||
|
if action == "overlay_next" then
|
||||||
|
if pressed then TouchSkin.nextPage(ctl and ctl.nextTarget) end
|
||||||
|
return pressed
|
||||||
|
end
|
||||||
|
if action == "overlay_prev" then
|
||||||
|
if pressed then TouchSkin.setPage(TouchSkin.pageIndex - 1) end
|
||||||
|
return pressed
|
||||||
|
end
|
||||||
|
self.hotkeysHeld = self.hotkeysHeld or {}
|
||||||
|
local n = (self.hotkeysHeld[action] or 0) + (pressed and 1 or -1)
|
||||||
|
if n < 0 then n = 0 end
|
||||||
|
self.hotkeysHeld[action] = n > 0 and n or nil
|
||||||
|
local edge = (pressed and n == 1) or (not pressed and n == 0)
|
||||||
|
if edge and self.hotkeyHandler then
|
||||||
|
pcall(self.hotkeyHandler, action, pressed)
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function enterControl(self, ctl)
|
||||||
|
local buzzed = false
|
||||||
|
for _, btn in ipairs(ctl.buttons) do
|
||||||
|
if not self.held[btn] then buzzed = true end
|
||||||
|
pressBtn(self, btn)
|
||||||
|
end
|
||||||
|
for _, key in ipairs(ctl.keys) do pressKey(key, true) end
|
||||||
|
local switched = false
|
||||||
|
for _, action in ipairs(ctl.hotkeys) do
|
||||||
|
if fireHotkey(self, action, true, ctl) then switched = true end
|
||||||
|
end
|
||||||
|
if not buzzed and (ctl.keys[1] or ctl.hotkeys[1]) then
|
||||||
|
TouchControls.buzz(self.haptics)
|
||||||
|
end
|
||||||
|
return switched
|
||||||
|
end
|
||||||
|
|
||||||
|
local function exitControl(self, ctl)
|
||||||
|
for _, btn in ipairs(ctl.buttons) do releaseBtn(self, btn) end
|
||||||
|
for _, key in ipairs(ctl.keys) do pressKey(key, false) end
|
||||||
|
for _, action in ipairs(ctl.hotkeys) do fireHotkey(self, action, false, ctl) end
|
||||||
|
end
|
||||||
|
|
||||||
|
function skinHitSet(self, x, y)
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
if not page then return nil end
|
||||||
|
local ww, wh, ox, oy = surfaceRect()
|
||||||
|
local set = nil
|
||||||
|
for _, ctl in ipairs(page.controls) do
|
||||||
|
if not ctl.decorative and TouchSkin.hits(page, ctl, ww, wh, x, y, ox, oy) then
|
||||||
|
set = set or {}
|
||||||
|
set[ctl] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return set
|
||||||
|
end
|
||||||
|
|
||||||
|
function applySkinSet(self, touch, set)
|
||||||
|
local prev = touch.set or {}
|
||||||
|
local switched = false
|
||||||
|
for ctl in pairs(prev) do
|
||||||
|
if not (set and set[ctl]) then exitControl(self, ctl) end
|
||||||
|
end
|
||||||
|
for ctl in pairs(set or {}) do
|
||||||
|
if not prev[ctl] then
|
||||||
|
if enterControl(self, ctl) then switched = true end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
touch.set = set
|
||||||
|
if switched then
|
||||||
|
for ctl in pairs(touch.set or {}) do exitControl(self, ctl) end
|
||||||
|
touch.set = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Returns true when this touch was captured by a virtual control -- the
|
-- Returns true when this touch was captured by a virtual control -- the
|
||||||
-- pad's first refusal on the gameplay pointer seam (#807). Capture is
|
-- pad's first refusal on the gameplay pointer seam (#807). Capture is
|
||||||
-- decided here, at press, and rides self.touches[id] for the touch's
|
-- decided here, at press, and rides self.touches[id] for the touch's
|
||||||
@@ -513,13 +656,22 @@ end
|
|||||||
function TouchControls:touchpressed(id, x, y)
|
function TouchControls:touchpressed(id, x, y)
|
||||||
-- preview mode is layout-edit only: never press GB buttons
|
-- preview mode is layout-edit only: never press GB buttons
|
||||||
if self.preview then return end
|
if self.preview then return end
|
||||||
if not (self.active and self.enabled ~= false and self.img) then return end
|
if not (self.active and self.enabled ~= false
|
||||||
|
and (TouchSkin.active or self.img)) then return end
|
||||||
-- a controller hid the overlay; the first touch only brings it back
|
-- a controller hid the overlay; the first touch only brings it back
|
||||||
-- (uncaptured: it began on no control, so mods may still see it)
|
-- (uncaptured: it began on no control, so mods may still see it)
|
||||||
if self.controllerHidden then
|
if self.controllerHidden then
|
||||||
self.controllerHidden = false
|
self.controllerHidden = false
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if TouchSkin.active then
|
||||||
|
local set = skinHitSet(self, x, y)
|
||||||
|
if not set then return end
|
||||||
|
local touch = { control = "skin" }
|
||||||
|
self.touches[id] = touch
|
||||||
|
applySkinSet(self, touch, set)
|
||||||
|
return true
|
||||||
|
end
|
||||||
local L = self:layout()
|
local L = self:layout()
|
||||||
for _, btn in ipairs(BUTTONS) do
|
for _, btn in ipairs(BUTTONS) do
|
||||||
if inCircle(L[btn], x, y, SLOP[btn]) then
|
if inCircle(L[btn], x, y, SLOP[btn]) then
|
||||||
@@ -544,9 +696,14 @@ end
|
|||||||
function TouchControls:touchmoved(id, x, y)
|
function TouchControls:touchmoved(id, x, y)
|
||||||
if self.preview then return end
|
if self.preview then return end
|
||||||
local touch = self.touches[id]
|
local touch = self.touches[id]
|
||||||
|
if not touch then return end
|
||||||
|
if touch.control == "skin" then
|
||||||
|
applySkinSet(self, touch, skinHitSet(self, x, y))
|
||||||
|
return
|
||||||
|
end
|
||||||
-- only the d-pad tracks movement (slide between directions without
|
-- only the d-pad tracks movement (slide between directions without
|
||||||
-- lifting); buttons hold until release wherever the finger wanders
|
-- lifting); buttons hold until release wherever the finger wanders
|
||||||
if not touch or touch.control ~= "dpad" then return end
|
if touch.control ~= "dpad" then return end
|
||||||
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
|
setDpad(self, touch, dpadDir(self:layout().dpad, x, y))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -555,7 +712,9 @@ function TouchControls:touchreleased(id, x, y)
|
|||||||
local touch = self.touches[id]
|
local touch = self.touches[id]
|
||||||
if not touch then return end
|
if not touch then return end
|
||||||
self.touches[id] = nil
|
self.touches[id] = nil
|
||||||
if touch.control == "dpad" then
|
if touch.control == "skin" then
|
||||||
|
applySkinSet(self, touch, nil)
|
||||||
|
elseif touch.control == "dpad" then
|
||||||
setDpad(self, touch, nil)
|
setDpad(self, touch, nil)
|
||||||
self.dpadTouch = nil
|
self.dpadTouch = nil
|
||||||
else
|
else
|
||||||
@@ -568,6 +727,15 @@ end
|
|||||||
-- touchreleased and would strand its button held forever. Called from
|
-- touchreleased and would strand its button held forever. Called from
|
||||||
-- Game alongside Input:reset() on focus/visibility loss.
|
-- Game alongside Input:reset() on focus/visibility loss.
|
||||||
function TouchControls:reset()
|
function TouchControls:reset()
|
||||||
|
for _, touch in pairs(self.touches or {}) do
|
||||||
|
if touch.control == "skin" and touch.set then
|
||||||
|
for ctl in pairs(touch.set) do exitControl(self, ctl) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for action in pairs(self.hotkeysHeld or {}) do
|
||||||
|
if self.hotkeyHandler then pcall(self.hotkeyHandler, action, false) end
|
||||||
|
end
|
||||||
|
self.hotkeysHeld = {}
|
||||||
for btn in pairs(self.held or {}) do
|
for btn in pairs(self.held or {}) do
|
||||||
Input:overlayReleased(btn)
|
Input:overlayReleased(btn)
|
||||||
end
|
end
|
||||||
@@ -608,12 +776,56 @@ local function drawIcon(img, zone, pressed, alphaMul)
|
|||||||
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function drawStretched(img, x, y, w, h, alpha)
|
||||||
|
if not img or alpha <= 0 then return end
|
||||||
|
local iw, ih = img:getWidth(), img:getHeight()
|
||||||
|
if iw <= 0 or ih <= 0 then return end
|
||||||
|
love.graphics.setColor(1, 1, 1, math.min(1, alpha))
|
||||||
|
love.graphics.draw(img, x, y, 0, w / iw, h / ih)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchControls:drawSkin(alphaMul)
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
if not page then return false end
|
||||||
|
local ww, wh, sox, soy = surfaceRect()
|
||||||
|
local bx, by, bw, bh = TouchSkin.pageBox(page, ww, wh, sox, soy)
|
||||||
|
local opacity = (self.skinOpacity or 1) * alphaMul
|
||||||
|
|
||||||
|
love.graphics.push("all")
|
||||||
|
love.graphics.origin()
|
||||||
|
drawStretched(page.image, bx, by, bw, bh, opacity)
|
||||||
|
|
||||||
|
local pressed = {}
|
||||||
|
for _, touch in pairs(self.touches or {}) do
|
||||||
|
for ctl in pairs(touch.set or {}) do pressed[ctl] = true end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, ctl in ipairs(page.controls) do
|
||||||
|
local down = pressed[ctl] == true
|
||||||
|
local img = (down and ctl.pressedImage) or ctl.image
|
||||||
|
if img then
|
||||||
|
local cx, cy, halfW, halfH =
|
||||||
|
TouchSkin.controlGeometry(page, ctl, ww, wh, sox, soy)
|
||||||
|
local alpha = opacity
|
||||||
|
if down and not ctl.pressedImage then alpha = opacity * ctl.alphaMod end
|
||||||
|
drawStretched(img, cx - halfW, cy - halfH, halfW * 2, halfH * 2, alpha)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
love.graphics.pop()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- OS-window space, called after GameViewport.finish so the overlay rides on
|
-- OS-window space, called after GameViewport.finish so the overlay rides on
|
||||||
-- top of the game, companion composition and post-processing without being
|
-- top of the game, companion composition and post-processing without being
|
||||||
-- captured or scaled with any game viewport. Also used by the launcher layout
|
-- captured or scaled with any game viewport. Also used by the launcher layout
|
||||||
-- editor under preview mode.
|
-- editor under preview mode.
|
||||||
function TouchControls:draw()
|
function TouchControls:draw()
|
||||||
if not self:visible() then return end
|
if not self:visible() then return end
|
||||||
|
if TouchSkin.active then
|
||||||
|
local mul = (self.preview and self.enabled == false) and 0.45 or 1
|
||||||
|
if self:drawSkin(mul) then return end
|
||||||
|
end
|
||||||
local L = self:layout()
|
local L = self:layout()
|
||||||
-- when the player disabled the overlay but the editor is previewing,
|
-- when the player disabled the overlay but the editor is previewing,
|
||||||
-- draw dimmed so the layout is still editable
|
-- draw dimmed so the layout is still editable
|
||||||
|
|||||||
@@ -0,0 +1,928 @@
|
|||||||
|
local TouchSkin = {}
|
||||||
|
|
||||||
|
TouchSkin.BUNDLED_ROOT = "assets/skins"
|
||||||
|
TouchSkin.USER_ROOT = "skins"
|
||||||
|
|
||||||
|
TouchSkin.GB_BUTTONS = {
|
||||||
|
a = "a", b = "b", start = "start", select = "select",
|
||||||
|
up = "up", down = "down", left = "left", right = "right",
|
||||||
|
}
|
||||||
|
|
||||||
|
TouchSkin.HOTKEYS = {
|
||||||
|
overlay_next = "overlay_next",
|
||||||
|
overlay_previous = "overlay_prev",
|
||||||
|
menu_toggle = "menu",
|
||||||
|
reset = "soft_reset",
|
||||||
|
hold_fast_forward = "fast_forward_hold",
|
||||||
|
fast_forward = "fast_forward_hold",
|
||||||
|
toggle_fast_forward = "fast_forward_toggle",
|
||||||
|
screenshot = "screenshot",
|
||||||
|
pause_toggle = "pause",
|
||||||
|
exit_emulator = "quit",
|
||||||
|
}
|
||||||
|
|
||||||
|
local DEFAULT_ASPECT = 16 / 9
|
||||||
|
local PORTRAIT_ASPECT = 9 / 16
|
||||||
|
|
||||||
|
local function trim(s)
|
||||||
|
return (tostring(s or ""):match("^%s*(.-)%s*$"))
|
||||||
|
end
|
||||||
|
|
||||||
|
local function unquote(s)
|
||||||
|
s = trim(s)
|
||||||
|
local inner = s:match('^"(.*)"$')
|
||||||
|
return inner or s
|
||||||
|
end
|
||||||
|
|
||||||
|
local function toBool(v)
|
||||||
|
v = trim(v):lower()
|
||||||
|
return v == "true" or v == "1"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tokens(s)
|
||||||
|
local out = {}
|
||||||
|
for tok in tostring(s or ""):gmatch("[^,%s]+") do out[#out + 1] = tok end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function num(v, fallback)
|
||||||
|
local n = tonumber(trim(v))
|
||||||
|
if not n or n ~= n then return fallback end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.parseConfig(text)
|
||||||
|
local kv = {}
|
||||||
|
for line in tostring(text or ""):gmatch("[^\r\n]+") do
|
||||||
|
if not line:match("^%s*#") then
|
||||||
|
local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$")
|
||||||
|
if key then kv[key] = unquote(value) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return kv
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseBinds(spec)
|
||||||
|
local buttons, hotkeys, keys, decorative = {}, {}, {}, true
|
||||||
|
for raw in tostring(spec or ""):gmatch("[^|]+") do
|
||||||
|
local name = trim(raw):lower()
|
||||||
|
local key = name:match("^key:(.+)$") or name:match("^retrok_(.+)$")
|
||||||
|
if name == "nul" or name == "" then
|
||||||
|
-- decoration
|
||||||
|
elseif key then
|
||||||
|
keys[#keys + 1] = key
|
||||||
|
decorative = false
|
||||||
|
elseif TouchSkin.GB_BUTTONS[name] then
|
||||||
|
buttons[#buttons + 1] = TouchSkin.GB_BUTTONS[name]
|
||||||
|
decorative = false
|
||||||
|
elseif TouchSkin.HOTKEYS[name] then
|
||||||
|
hotkeys[#hotkeys + 1] = TouchSkin.HOTKEYS[name]
|
||||||
|
decorative = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return buttons, hotkeys, keys, decorative
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseDesc(kv, prefix, page)
|
||||||
|
local spec = kv[prefix]
|
||||||
|
if not spec then return nil end
|
||||||
|
local t = tokens(spec)
|
||||||
|
if #t < 6 then return nil end
|
||||||
|
|
||||||
|
local shape = trim(t[4]):lower()
|
||||||
|
if shape ~= "radial" and shape ~= "rect" then shape = "rect" end
|
||||||
|
|
||||||
|
local buttons, hotkeys, keys, decorative = parseBinds(t[1])
|
||||||
|
local reachX = num(kv[prefix .. "_reach_x"], 1)
|
||||||
|
local reachY = num(kv[prefix .. "_reach_y"], 1)
|
||||||
|
|
||||||
|
local ctl = {
|
||||||
|
spec = t[1],
|
||||||
|
buttons = buttons,
|
||||||
|
hotkeys = hotkeys,
|
||||||
|
keys = keys,
|
||||||
|
decorative = decorative,
|
||||||
|
x = num(t[2], 0.5),
|
||||||
|
y = num(t[3], 0.5),
|
||||||
|
shape = shape,
|
||||||
|
rangeX = math.abs(num(t[5], 0.05)),
|
||||||
|
rangeY = math.abs(num(t[6], 0.05)),
|
||||||
|
rangeMod = num(kv[prefix .. "_range_mod"], page.rangeMod),
|
||||||
|
alphaMod = num(kv[prefix .. "_alpha_mod"], page.alphaMod),
|
||||||
|
reachUp = num(kv[prefix .. "_reach_up"], reachY),
|
||||||
|
reachDown = num(kv[prefix .. "_reach_down"], reachY),
|
||||||
|
reachLeft = num(kv[prefix .. "_reach_left"], reachX),
|
||||||
|
reachRight = num(kv[prefix .. "_reach_right"], reachX),
|
||||||
|
imagePath = kv[prefix .. "_overlay"],
|
||||||
|
pressedImagePath = kv[prefix .. "_overlay_pressed"],
|
||||||
|
nextTarget = kv[prefix .. "_next_target"],
|
||||||
|
}
|
||||||
|
if ctl.imagePath == "" then ctl.imagePath = nil end
|
||||||
|
if ctl.pressedImagePath == "" then ctl.pressedImagePath = nil end
|
||||||
|
return ctl
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.parse(text)
|
||||||
|
local kv = TouchSkin.parseConfig(text)
|
||||||
|
local count = math.floor(num(kv.overlays, 0))
|
||||||
|
if count <= 0 then return nil, "no overlays" end
|
||||||
|
|
||||||
|
local pages = {}
|
||||||
|
for i = 0, count - 1 do
|
||||||
|
local p = "overlay" .. i
|
||||||
|
local page = {
|
||||||
|
index = i + 1,
|
||||||
|
name = kv[p .. "_name"] or ("overlay" .. i),
|
||||||
|
imagePath = kv[p .. "_overlay"],
|
||||||
|
fullScreen = toBool(kv[p .. "_full_screen"]),
|
||||||
|
normalized = toBool(kv[p .. "_normalized"]),
|
||||||
|
rangeMod = num(kv[p .. "_range_mod"], 1),
|
||||||
|
alphaMod = num(kv[p .. "_alpha_mod"], 1),
|
||||||
|
aspect = num(kv[p .. "_aspect_ratio"], nil),
|
||||||
|
controls = {},
|
||||||
|
}
|
||||||
|
if page.imagePath == "" then page.imagePath = nil end
|
||||||
|
-- An explicit aspect_ratio is the overlay's design aspect. RetroArch
|
||||||
|
-- letterboxes to it even when full_screen is set, so range_x/range_y
|
||||||
|
-- that were authored as a circle stay a circle. #1503
|
||||||
|
page.aspectFromCfg = page.aspect ~= nil and page.aspect > 0
|
||||||
|
if not page.aspectFromCfg then
|
||||||
|
page.aspect = page.name:lower():find("portrait", 1, true)
|
||||||
|
and PORTRAIT_ASPECT or DEFAULT_ASPECT
|
||||||
|
end
|
||||||
|
|
||||||
|
local rect = tokens(kv[p .. "_rect"])
|
||||||
|
page.rect = { x = 0, y = 0, w = 1, h = 1 }
|
||||||
|
if #rect >= 4 then
|
||||||
|
page.rect = { x = num(rect[1], 0), y = num(rect[2], 0),
|
||||||
|
w = num(rect[3], 1), h = num(rect[4], 1) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local vp = tokens(kv[p .. "_viewport"])
|
||||||
|
if #vp >= 4 then
|
||||||
|
page.viewport = { x = num(vp[1], 0), y = num(vp[2], 0),
|
||||||
|
w = num(vp[3], 1), h = num(vp[4], 1) }
|
||||||
|
page.viewportFill = toBool(kv[p .. "_viewport_fill"])
|
||||||
|
page.viewportExpand = toBool(kv[p .. "_viewport_expand"])
|
||||||
|
end
|
||||||
|
|
||||||
|
local descs = math.floor(num(kv[p .. "_descs"], 0))
|
||||||
|
for d = 0, descs - 1 do
|
||||||
|
local ctl = parseDesc(kv, p .. "_desc" .. d, page)
|
||||||
|
if ctl then page.controls[#page.controls + 1] = ctl end
|
||||||
|
end
|
||||||
|
pages[#pages + 1] = page
|
||||||
|
end
|
||||||
|
|
||||||
|
-- RetroArch auto-rotate: overlay names containing portrait / landscape
|
||||||
|
-- are the lock. Stamp it so the studio does not need a second click. #1503
|
||||||
|
for _, page in ipairs(pages) do
|
||||||
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
|
end
|
||||||
|
|
||||||
|
return { pages = pages }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function readFile(path)
|
||||||
|
if love and love.filesystem and love.filesystem.read then
|
||||||
|
local ok, data = pcall(love.filesystem.read, path)
|
||||||
|
if ok and data then return data end
|
||||||
|
end
|
||||||
|
local handle = io.open(path, "rb")
|
||||||
|
if not handle then return nil end
|
||||||
|
local data = handle:read("*a")
|
||||||
|
handle:close()
|
||||||
|
return data
|
||||||
|
end
|
||||||
|
|
||||||
|
local function listDir(path)
|
||||||
|
if love and love.filesystem and love.filesystem.getDirectoryItems then
|
||||||
|
local ok, items = pcall(love.filesystem.getDirectoryItems, path)
|
||||||
|
if ok and items then return items end
|
||||||
|
end
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isDir(path)
|
||||||
|
if love and love.filesystem and love.filesystem.getInfo then
|
||||||
|
local ok, info = pcall(love.filesystem.getInfo, path)
|
||||||
|
if ok and info then return info.type == "directory" end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function joinPath(root, rel)
|
||||||
|
rel = tostring(rel or ""):gsub("^%./", ""):gsub("\\", "/")
|
||||||
|
if rel:sub(1, 1) == "/" then return rel:sub(2) end
|
||||||
|
return root .. "/" .. rel
|
||||||
|
end
|
||||||
|
|
||||||
|
TouchSkin.NATIVE_NAME = "skin.lua"
|
||||||
|
|
||||||
|
local function findConfig(root)
|
||||||
|
if readFile(root .. "/" .. TouchSkin.NATIVE_NAME) then
|
||||||
|
return root .. "/" .. TouchSkin.NATIVE_NAME, "native"
|
||||||
|
end
|
||||||
|
local named = { "overlay.cfg", "skin.cfg", "layout.cfg" }
|
||||||
|
for _, name in ipairs(named) do
|
||||||
|
if readFile(root .. "/" .. name) then return root .. "/" .. name, "retroarch" end
|
||||||
|
end
|
||||||
|
local items = listDir(root)
|
||||||
|
table.sort(items)
|
||||||
|
for _, name in ipairs(items) do
|
||||||
|
if name:match("%.cfg$") then return root .. "/" .. name, "retroarch" end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function loadDataChunk(text, name)
|
||||||
|
if loadstring then
|
||||||
|
local chunk, err = loadstring(text, name)
|
||||||
|
if not chunk then return nil, err end
|
||||||
|
return setfenv(chunk, {})
|
||||||
|
end
|
||||||
|
return load(text, name, "t", {})
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.parseNative(text)
|
||||||
|
local chunk, err = loadDataChunk(tostring(text or ""), "skin")
|
||||||
|
if not chunk then return nil, "skin.lua does not parse: " .. tostring(err) end
|
||||||
|
local ok, data = pcall(chunk)
|
||||||
|
if not ok or type(data) ~= "table" then return nil, "skin.lua returned no table" end
|
||||||
|
if type(data.pages) ~= "table" or not data.pages[1] then
|
||||||
|
return nil, "skin.lua has no pages"
|
||||||
|
end
|
||||||
|
|
||||||
|
local pages = {}
|
||||||
|
for i, raw in ipairs(data.pages) do
|
||||||
|
if type(raw) ~= "table" then return nil, "page " .. i .. " is not a table" end
|
||||||
|
local page = {
|
||||||
|
index = i,
|
||||||
|
name = tostring(raw.name or ("page" .. i)),
|
||||||
|
imagePath = raw.image,
|
||||||
|
fullScreen = raw.fullScreen ~= false,
|
||||||
|
normalized = true,
|
||||||
|
rangeMod = num(raw.rangeMod, 1),
|
||||||
|
alphaMod = num(raw.alphaMod, 1),
|
||||||
|
aspect = num(raw.aspect, DEFAULT_ASPECT),
|
||||||
|
aspectFromCfg = raw.fitAspect == true,
|
||||||
|
orient = (raw.orient == "portrait" or raw.orient == "landscape"
|
||||||
|
or raw.orient == "any") and raw.orient or nil,
|
||||||
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||||
|
controls = {},
|
||||||
|
}
|
||||||
|
if type(raw.rect) == "table" then
|
||||||
|
page.rect = { x = num(raw.rect.x, 0), y = num(raw.rect.y, 0),
|
||||||
|
w = num(raw.rect.w, 1), h = num(raw.rect.h, 1) }
|
||||||
|
end
|
||||||
|
if type(raw.viewport) == "table" then
|
||||||
|
page.viewport = { x = num(raw.viewport.x, 0), y = num(raw.viewport.y, 0),
|
||||||
|
w = num(raw.viewport.w, 1), h = num(raw.viewport.h, 1) }
|
||||||
|
page.viewportFill = raw.viewport.fill == true
|
||||||
|
page.viewportExpand = raw.viewport.expand == true
|
||||||
|
end
|
||||||
|
for _, c in ipairs(raw.controls or {}) do
|
||||||
|
local buttons, hotkeys, keys, decorative = parseBinds(c.bind or "nul")
|
||||||
|
page.controls[#page.controls + 1] = {
|
||||||
|
spec = tostring(c.bind or "nul"),
|
||||||
|
buttons = buttons, hotkeys = hotkeys, keys = keys,
|
||||||
|
decorative = decorative,
|
||||||
|
x = num(c.x, 0.5), y = num(c.y, 0.5),
|
||||||
|
shape = c.shape == "radial" and "radial" or "rect",
|
||||||
|
rangeX = math.abs(num(c.w, 0.1)) * 0.5,
|
||||||
|
rangeY = math.abs(num(c.h, 0.1)) * 0.5,
|
||||||
|
rangeMod = num(c.rangeMod, page.rangeMod),
|
||||||
|
alphaMod = num(c.alphaMod, page.alphaMod),
|
||||||
|
reachUp = num(c.reachUp, 1), reachDown = num(c.reachDown, 1),
|
||||||
|
reachLeft = num(c.reachLeft, 1), reachRight = num(c.reachRight, 1),
|
||||||
|
imagePath = c.image,
|
||||||
|
pressedImagePath = c.imagePressed,
|
||||||
|
nextTarget = c.nextTarget,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if not page.orient then page.orient = TouchSkin.pageOrient(page) end
|
||||||
|
pages[#pages + 1] = page
|
||||||
|
end
|
||||||
|
return { pages = pages, name = data.name, author = data.author,
|
||||||
|
notes = data.notes, format = "native" }
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.toNative(skin)
|
||||||
|
local out = {
|
||||||
|
name = skin.name or skin.id,
|
||||||
|
author = skin.author,
|
||||||
|
notes = skin.notes,
|
||||||
|
format = 1,
|
||||||
|
pages = {},
|
||||||
|
}
|
||||||
|
for _, page in ipairs(skin.pages or {}) do
|
||||||
|
local p = {
|
||||||
|
name = page.name,
|
||||||
|
image = page.imagePath,
|
||||||
|
fullScreen = page.fullScreen ~= false,
|
||||||
|
rangeMod = page.rangeMod,
|
||||||
|
alphaMod = page.alphaMod,
|
||||||
|
aspect = page.aspect,
|
||||||
|
fitAspect = page.aspectFromCfg or nil,
|
||||||
|
orient = (page.orient == "portrait" or page.orient == "landscape"
|
||||||
|
or page.orient == "any") and page.orient or nil,
|
||||||
|
controls = {},
|
||||||
|
}
|
||||||
|
if page.rect and (page.rect.x ~= 0 or page.rect.y ~= 0
|
||||||
|
or page.rect.w ~= 1 or page.rect.h ~= 1) then
|
||||||
|
p.rect = { x = page.rect.x, y = page.rect.y, w = page.rect.w, h = page.rect.h }
|
||||||
|
end
|
||||||
|
if page.viewport then
|
||||||
|
p.viewport = {
|
||||||
|
x = page.viewport.x, y = page.viewport.y,
|
||||||
|
w = page.viewport.w, h = page.viewport.h,
|
||||||
|
fill = page.viewportFill or nil,
|
||||||
|
expand = page.viewportExpand or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
|
p.controls[#p.controls + 1] = {
|
||||||
|
bind = ctl.spec,
|
||||||
|
x = ctl.x, y = ctl.y,
|
||||||
|
w = ctl.rangeX * 2, h = ctl.rangeY * 2,
|
||||||
|
shape = ctl.shape,
|
||||||
|
rangeMod = ctl.rangeMod ~= p.rangeMod and ctl.rangeMod or nil,
|
||||||
|
alphaMod = ctl.alphaMod ~= p.alphaMod and ctl.alphaMod or nil,
|
||||||
|
reachUp = ctl.reachUp ~= 1 and ctl.reachUp or nil,
|
||||||
|
reachDown = ctl.reachDown ~= 1 and ctl.reachDown or nil,
|
||||||
|
reachLeft = ctl.reachLeft ~= 1 and ctl.reachLeft or nil,
|
||||||
|
reachRight = ctl.reachRight ~= 1 and ctl.reachRight or nil,
|
||||||
|
image = ctl.imagePath,
|
||||||
|
imagePressed = ctl.pressedImagePath,
|
||||||
|
nextTarget = ctl.nextTarget,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
out.pages[#out.pages + 1] = p
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.serialize(skin)
|
||||||
|
return require("src.import.LuaWriter").encode(TouchSkin.toNative(skin))
|
||||||
|
end
|
||||||
|
|
||||||
|
local imageCache = setmetatable({}, { __mode = "v" })
|
||||||
|
|
||||||
|
local function loadImage(path)
|
||||||
|
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||||
|
local cached = imageCache[path]
|
||||||
|
if cached then return cached end
|
||||||
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
|
if not ok or not img then return nil end
|
||||||
|
if img.setFilter then img:setFilter("linear", "linear") end
|
||||||
|
imageCache[path] = img
|
||||||
|
return img
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.load(root, id)
|
||||||
|
local cfgPath, format = findConfig(root)
|
||||||
|
if not cfgPath then return nil, "no skin.lua or .cfg in " .. root end
|
||||||
|
local text = readFile(cfgPath)
|
||||||
|
if not text then return nil, "unreadable " .. cfgPath end
|
||||||
|
local skin, err
|
||||||
|
if format == "native" then
|
||||||
|
skin, err = TouchSkin.parseNative(text)
|
||||||
|
else
|
||||||
|
skin, err = TouchSkin.parse(text)
|
||||||
|
end
|
||||||
|
if not skin then return nil, err end
|
||||||
|
|
||||||
|
skin.id = id or root:match("([^/]+)$") or root
|
||||||
|
skin.root = root
|
||||||
|
skin.configPath = cfgPath
|
||||||
|
skin.format = format
|
||||||
|
skin.name = skin.name or skin.pages[1] and skin.pages[1].name or skin.id
|
||||||
|
|
||||||
|
for _, page in ipairs(skin.pages) do
|
||||||
|
if page.imagePath then
|
||||||
|
page.image = loadImage(joinPath(root, page.imagePath))
|
||||||
|
end
|
||||||
|
for _, ctl in ipairs(page.controls) do
|
||||||
|
if ctl.imagePath then ctl.image = loadImage(joinPath(root, ctl.imagePath)) end
|
||||||
|
if ctl.pressedImagePath then
|
||||||
|
ctl.pressedImage = loadImage(joinPath(root, ctl.pressedImagePath))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return skin
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mountZip(archive, point)
|
||||||
|
if not (love and love.filesystem and love.filesystem.mount) then return false end
|
||||||
|
local ok, mounted = pcall(love.filesystem.mount, archive, point)
|
||||||
|
return ok and mounted == true
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.list()
|
||||||
|
local out, seen = {}, {}
|
||||||
|
local function scan(root, source)
|
||||||
|
for _, name in ipairs(listDir(root)) do
|
||||||
|
local id = name:gsub("%.zip$", "")
|
||||||
|
if not seen[id] then
|
||||||
|
local path = root .. "/" .. name
|
||||||
|
if name:match("%.zip$") then
|
||||||
|
local point = TouchSkin.USER_ROOT .. "/_mounted/" .. id
|
||||||
|
if mountZip(path, point) and findConfig(point) then
|
||||||
|
seen[id] = true
|
||||||
|
out[#out + 1] = { id = id, root = point, source = source, archive = path }
|
||||||
|
end
|
||||||
|
elseif isDir(path) and findConfig(path) then
|
||||||
|
seen[id] = true
|
||||||
|
out[#out + 1] = { id = id, root = path, source = source }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if love and love.filesystem and love.filesystem.createDirectory then
|
||||||
|
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
||||||
|
end
|
||||||
|
scan(TouchSkin.USER_ROOT, "user")
|
||||||
|
scan(TouchSkin.BUNDLED_ROOT, "bundled")
|
||||||
|
table.sort(out, function(a, b) return a.id < b.id end)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drop a .zip into <save>/skins and report the id it will list under.
|
||||||
|
function TouchSkin.installArchive(name, data)
|
||||||
|
if not data or data == "" then return nil, "empty archive" end
|
||||||
|
if not (love and love.filesystem and love.filesystem.write) then
|
||||||
|
return nil, "no writable filesystem"
|
||||||
|
end
|
||||||
|
name = tostring(name or ""):match("([^/\\]+)$") or ""
|
||||||
|
name = name:gsub("[^%w%._%-]", "_")
|
||||||
|
if not name:lower():match("%.zip$") then return nil, "not a .zip" end
|
||||||
|
local id = name:gsub("%.[Zz][Ii][Pp]$", "")
|
||||||
|
if id == "" then return nil, "bad archive name" end
|
||||||
|
|
||||||
|
pcall(love.filesystem.createDirectory, TouchSkin.USER_ROOT)
|
||||||
|
local dest = TouchSkin.USER_ROOT .. "/" .. name
|
||||||
|
local ok, err = love.filesystem.write(dest, data)
|
||||||
|
if not ok then return nil, tostring(err) end
|
||||||
|
|
||||||
|
local entry = TouchSkin.find(id)
|
||||||
|
if not entry then
|
||||||
|
love.filesystem.remove(dest)
|
||||||
|
return nil, "no skin.lua or .cfg inside " .. name
|
||||||
|
end
|
||||||
|
return id
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.find(id)
|
||||||
|
if not id or id == "" then return nil end
|
||||||
|
for _, entry in ipairs(TouchSkin.list()) do
|
||||||
|
if entry.id == id then return entry end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.assetPaths(skin)
|
||||||
|
local out, seen = {}, {}
|
||||||
|
local function add(rel)
|
||||||
|
if rel and rel ~= "" and not seen[rel] then
|
||||||
|
seen[rel] = true
|
||||||
|
out[#out + 1] = rel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, page in ipairs(skin.pages or {}) do
|
||||||
|
add(page.imagePath)
|
||||||
|
for _, ctl in ipairs(page.controls or {}) do
|
||||||
|
add(ctl.imagePath)
|
||||||
|
add(ctl.pressedImagePath)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.export(skin, destPath)
|
||||||
|
if not skin then return nil, "no skin" end
|
||||||
|
local SkinZip = require("src.core.SkinZip")
|
||||||
|
local entries = { { name = TouchSkin.NATIVE_NAME, data = TouchSkin.serialize(skin) } }
|
||||||
|
local missing = {}
|
||||||
|
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
|
||||||
|
local data = readFile(joinPath(skin.root, rel))
|
||||||
|
if data then
|
||||||
|
entries[#entries + 1] = { name = rel, data = data }
|
||||||
|
else
|
||||||
|
missing[#missing + 1] = rel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if skin.configPath and skin.format == "retroarch" then
|
||||||
|
local cfg = readFile(skin.configPath)
|
||||||
|
if cfg then
|
||||||
|
entries[#entries + 1] =
|
||||||
|
{ name = skin.configPath:match("([^/]+)$") or "overlay.cfg", data = cfg }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local blob = SkinZip.encode(entries)
|
||||||
|
destPath = destPath or (TouchSkin.USER_ROOT .. "/" .. skin.id .. "-export.zip")
|
||||||
|
local absolute = destPath:sub(1, 1) == "/" or destPath:match("^%a:[/\\]") ~= nil
|
||||||
|
if not absolute and love and love.filesystem and love.filesystem.write then
|
||||||
|
local ok, err = love.filesystem.write(destPath, blob)
|
||||||
|
if not ok then return nil, tostring(err) end
|
||||||
|
else
|
||||||
|
local handle = io.open(destPath, "wb")
|
||||||
|
if not handle then return nil, "cannot write " .. destPath end
|
||||||
|
handle:write(blob)
|
||||||
|
handle:close()
|
||||||
|
end
|
||||||
|
return destPath, missing
|
||||||
|
end
|
||||||
|
|
||||||
|
TouchSkin.BINDS = {
|
||||||
|
"nul",
|
||||||
|
"up", "down", "left", "right",
|
||||||
|
"a", "b", "start", "select",
|
||||||
|
"left|up", "right|up", "left|down", "right|down",
|
||||||
|
"hold_fast_forward", "toggle_fast_forward", "reset", "menu_toggle",
|
||||||
|
"overlay_next",
|
||||||
|
}
|
||||||
|
|
||||||
|
function TouchSkin.describeBind(spec)
|
||||||
|
local buttons, hotkeys, keys, decorative = parseBinds(spec)
|
||||||
|
if decorative then return "decoration" end
|
||||||
|
local parts = {}
|
||||||
|
for _, b in ipairs(buttons) do parts[#parts + 1] = "GB " .. b:upper() end
|
||||||
|
for _, h in ipairs(hotkeys) do parts[#parts + 1] = h end
|
||||||
|
for _, k in ipairs(keys) do parts[#parts + 1] = "key " .. k end
|
||||||
|
return table.concat(parts, " + ")
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.newControl(spec, x, y, w, h, shape)
|
||||||
|
local buttons, hotkeys, keys, decorative = parseBinds(spec)
|
||||||
|
return {
|
||||||
|
spec = spec, buttons = buttons, hotkeys = hotkeys, keys = keys,
|
||||||
|
decorative = decorative,
|
||||||
|
x = x, y = y, rangeX = w * 0.5, rangeY = h * 0.5,
|
||||||
|
shape = shape == "radial" and "radial" or "rect",
|
||||||
|
rangeMod = 1, alphaMod = 1,
|
||||||
|
reachUp = 1, reachDown = 1, reachLeft = 1, reachRight = 1,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.setBind(ctl, spec)
|
||||||
|
ctl.spec = spec
|
||||||
|
ctl.buttons, ctl.hotkeys, ctl.keys, ctl.decorative = parseBinds(spec)
|
||||||
|
return ctl
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.newSkin(id)
|
||||||
|
local page = {
|
||||||
|
index = 1, name = "main", fullScreen = true, normalized = true,
|
||||||
|
rangeMod = 1, alphaMod = 1, aspect = PORTRAIT_ASPECT,
|
||||||
|
rect = { x = 0, y = 0, w = 1, h = 1 },
|
||||||
|
viewport = { x = 0, y = 0, w = 1, h = 0.5 },
|
||||||
|
viewportFill = false,
|
||||||
|
controls = {},
|
||||||
|
}
|
||||||
|
return { id = id or "new_skin", name = id or "new_skin",
|
||||||
|
root = TouchSkin.USER_ROOT .. "/" .. (id or "new_skin"),
|
||||||
|
format = "native", pages = { page } }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function copyTable(src)
|
||||||
|
if type(src) ~= "table" then return src end
|
||||||
|
local out = {}
|
||||||
|
for k, v in pairs(src) do out[k] = copyTable(v) end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.clone(skin)
|
||||||
|
if not skin then return nil end
|
||||||
|
local out = {
|
||||||
|
id = skin.id, name = skin.name, root = skin.root, format = skin.format,
|
||||||
|
author = skin.author, notes = skin.notes, configPath = skin.configPath,
|
||||||
|
source = skin.source, pages = {},
|
||||||
|
}
|
||||||
|
for i, page in ipairs(skin.pages or {}) do
|
||||||
|
local p = copyTable(page)
|
||||||
|
p.image = page.image
|
||||||
|
p.controls = {}
|
||||||
|
for j, ctl in ipairs(page.controls or {}) do
|
||||||
|
local c = copyTable(ctl)
|
||||||
|
c.image = ctl.image
|
||||||
|
c.pressedImage = ctl.pressedImage
|
||||||
|
p.controls[j] = c
|
||||||
|
end
|
||||||
|
out.pages[i] = p
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.resolveImage(root, rel)
|
||||||
|
if not rel or rel == "" then return nil end
|
||||||
|
return loadImage(joinPath(root, rel))
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.detectViewport(root, imagePath)
|
||||||
|
if not (love and love.image and love.image.newImageData) then return nil end
|
||||||
|
if not imagePath or imagePath == "" then return nil end
|
||||||
|
local ok, data = pcall(love.image.newImageData, joinPath(root, imagePath))
|
||||||
|
if not ok or not data then return nil end
|
||||||
|
local w, h = data:getWidth(), data:getHeight()
|
||||||
|
if w < 2 or h < 2 then return nil end
|
||||||
|
|
||||||
|
local function clear(x, y)
|
||||||
|
if x < 0 or y < 0 or x >= w or y >= h then return false end
|
||||||
|
local okp, _, _, _, a = pcall(data.getPixel, data, x, y)
|
||||||
|
return okp and a ~= nil and a < 0.05
|
||||||
|
end
|
||||||
|
|
||||||
|
local cx, cy = math.floor(w / 2), math.floor(h / 2)
|
||||||
|
if not clear(cx, cy) then return nil end
|
||||||
|
|
||||||
|
local left, right = cx, cx
|
||||||
|
while left > 0 and clear(left - 1, cy) do left = left - 1 end
|
||||||
|
while right < w - 1 and clear(right + 1, cy) do right = right + 1 end
|
||||||
|
local top, bottom = cy, cy
|
||||||
|
while top > 0 and clear(cx, top - 1) do top = top - 1 end
|
||||||
|
while bottom < h - 1 and clear(cx, bottom + 1) do bottom = bottom + 1 end
|
||||||
|
|
||||||
|
local rw, rh = right - left + 1, bottom - top + 1
|
||||||
|
if rw < 8 or rh < 8 then return nil end
|
||||||
|
return { x = left / w, y = top / h, w = rw / w, h = rh / h }, rw, rh
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Bring outside art into the skin's own folder. A skin still living in
|
||||||
|
-- assets/ or a mounted zip is saved out to <save>/skins/<id> first, because
|
||||||
|
-- that is the only root the engine can write to.
|
||||||
|
function TouchSkin.importImage(skin, name, data)
|
||||||
|
if not skin then return nil, "no skin" end
|
||||||
|
if not data or data == "" then return nil, "no image data" end
|
||||||
|
if not (love and love.filesystem and love.filesystem.write) then
|
||||||
|
return nil, "no writable filesystem"
|
||||||
|
end
|
||||||
|
name = tostring(name):gsub("[^%w%._%-]", "_")
|
||||||
|
if name == "" then return nil, "bad file name" end
|
||||||
|
|
||||||
|
local dest = TouchSkin.USER_ROOT .. "/" .. skin.id
|
||||||
|
if skin.root ~= dest then
|
||||||
|
local saved, err = TouchSkin.saveTo(skin, skin.id)
|
||||||
|
if not saved then return nil, tostring(err) end
|
||||||
|
end
|
||||||
|
pcall(love.filesystem.createDirectory, dest .. "/img")
|
||||||
|
local rel = "img/" .. name
|
||||||
|
local ok, err = love.filesystem.write(dest .. "/" .. rel, data)
|
||||||
|
if not ok then return nil, tostring(err) end
|
||||||
|
return rel
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.listImages(root)
|
||||||
|
local out = {}
|
||||||
|
local function scan(dir, prefix)
|
||||||
|
for _, name in ipairs(listDir(dir)) do
|
||||||
|
local path = dir .. "/" .. name
|
||||||
|
if name:lower():match("%.png$") or name:lower():match("%.jpg$") then
|
||||||
|
out[#out + 1] = prefix .. name
|
||||||
|
elseif isDir(path) and prefix == "" then
|
||||||
|
scan(path, name .. "/")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
scan(root, "")
|
||||||
|
table.sort(out)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.saveTo(skin, id)
|
||||||
|
id = id or skin.id
|
||||||
|
if not id or id == "" then return nil, "no skin id" end
|
||||||
|
if not (love and love.filesystem and love.filesystem.write) then
|
||||||
|
return nil, "no writable filesystem"
|
||||||
|
end
|
||||||
|
local dest = TouchSkin.USER_ROOT .. "/" .. id
|
||||||
|
pcall(love.filesystem.createDirectory, dest)
|
||||||
|
|
||||||
|
local copied, failed = 0, {}
|
||||||
|
for _, rel in ipairs(TouchSkin.assetPaths(skin)) do
|
||||||
|
local target = dest .. "/" .. rel
|
||||||
|
local dir = target:match("^(.*)/[^/]+$")
|
||||||
|
if dir then pcall(love.filesystem.createDirectory, dir) end
|
||||||
|
if skin.root ~= dest then
|
||||||
|
local data = readFile(joinPath(skin.root, rel))
|
||||||
|
if data then
|
||||||
|
if love.filesystem.write(target, data) then copied = copied + 1 end
|
||||||
|
else
|
||||||
|
failed[#failed + 1] = rel
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, err = love.filesystem.write(dest .. "/" .. TouchSkin.NATIVE_NAME,
|
||||||
|
TouchSkin.serialize(skin))
|
||||||
|
if not ok then return nil, tostring(err) end
|
||||||
|
skin.id, skin.root, skin.format = id, dest, "native"
|
||||||
|
return dest, failed, copied
|
||||||
|
end
|
||||||
|
|
||||||
|
TouchSkin.active = nil
|
||||||
|
TouchSkin.pageIndex = 1
|
||||||
|
-- RetroArch Auto-Rotate Overlay (1.7.9, default on mobile): a cfg whose
|
||||||
|
-- pages are named portrait / landscape is swapped to match the display.
|
||||||
|
-- The Skin Studio turns this off so PAGE and the canvas preset stay independent.
|
||||||
|
TouchSkin.autoOrient = true
|
||||||
|
|
||||||
|
TouchSkin.surfaceRect = nil
|
||||||
|
|
||||||
|
function TouchSkin.setSurface(x, y, w, h)
|
||||||
|
if not w or w <= 0 or not h or h <= 0 then
|
||||||
|
TouchSkin.surfaceRect = nil
|
||||||
|
else
|
||||||
|
TouchSkin.surfaceRect = { x = x, y = y, w = w, h = h }
|
||||||
|
end
|
||||||
|
return TouchSkin.surfaceRect
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.setActive(skin)
|
||||||
|
TouchSkin.active = skin or nil
|
||||||
|
TouchSkin.pageIndex = 1
|
||||||
|
return TouchSkin.active
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.select(id)
|
||||||
|
if not id or id == "" then return TouchSkin.setActive(nil) end
|
||||||
|
local entry = TouchSkin.find(id)
|
||||||
|
if not entry then return nil, "no skin " .. tostring(id) end
|
||||||
|
local skin, err = TouchSkin.load(entry.root, entry.id)
|
||||||
|
if not skin then return nil, err end
|
||||||
|
skin.source = entry.source
|
||||||
|
return TouchSkin.setActive(skin)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function displaySize()
|
||||||
|
local r = TouchSkin.surfaceRect
|
||||||
|
if r and r.w and r.h and r.w > 0 and r.h > 0 then return r.w, r.h end
|
||||||
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
|
return love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
return 0, 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Explicit lock (studio) wins; otherwise the page name, the RetroArch
|
||||||
|
-- auto-rotate convention. "any" means unlocked even if the name says
|
||||||
|
-- portrait or landscape.
|
||||||
|
function TouchSkin.pageOrient(page)
|
||||||
|
if not page then return nil end
|
||||||
|
if page.orient == "any" then return nil end
|
||||||
|
if page.orient == "portrait" or page.orient == "landscape" then
|
||||||
|
return page.orient
|
||||||
|
end
|
||||||
|
local n = tostring(page.name or ""):lower()
|
||||||
|
if n:find("landscape", 1, true) then return "landscape" end
|
||||||
|
if n:find("portrait", 1, true) then return "portrait" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.hasOrientPair(skin)
|
||||||
|
local saw = {}
|
||||||
|
for _, page in ipairs(skin and skin.pages or {}) do
|
||||||
|
local o = TouchSkin.pageOrient(page)
|
||||||
|
if o then saw[o] = true end
|
||||||
|
end
|
||||||
|
return saw.portrait == true and saw.landscape == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findOrientPage(skin, keyword)
|
||||||
|
for i, page in ipairs(skin.pages or {}) do
|
||||||
|
if TouchSkin.pageOrient(page) == keyword then return i end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If the current page is the wrong orientation of a portrait/landscape pair,
|
||||||
|
-- jump to the matching one. Pages locked to neither (gb_anim's GameBoy /
|
||||||
|
-- GameBoyColor) are left alone. #1503
|
||||||
|
function TouchSkin.syncOrientation(w, h)
|
||||||
|
if not TouchSkin.autoOrient then return end
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin or not w or not h or w <= 0 or h <= 0 then return end
|
||||||
|
local want = w > h and "landscape" or "portrait"
|
||||||
|
local unwant = w > h and "portrait" or "landscape"
|
||||||
|
local page = skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
||||||
|
local current = TouchSkin.pageOrient(page)
|
||||||
|
if current == want then return end
|
||||||
|
if current ~= unwant then return end
|
||||||
|
local idx = findOrientPage(skin, want)
|
||||||
|
if idx then TouchSkin.pageIndex = idx end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.page()
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin then return nil end
|
||||||
|
local w, h = displaySize()
|
||||||
|
TouchSkin.syncOrientation(w, h)
|
||||||
|
return skin.pages[TouchSkin.pageIndex] or skin.pages[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.setPage(target)
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin then return nil end
|
||||||
|
if type(target) == "number" then
|
||||||
|
local n = #skin.pages
|
||||||
|
TouchSkin.pageIndex = ((math.floor(target) - 1) % n) + 1
|
||||||
|
return TouchSkin.page()
|
||||||
|
end
|
||||||
|
for i, page in ipairs(skin.pages) do
|
||||||
|
if page.name == target then
|
||||||
|
TouchSkin.pageIndex = i
|
||||||
|
return page
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return TouchSkin.page()
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.nextPage(target)
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin then return nil end
|
||||||
|
if target and target ~= "" then return TouchSkin.setPage(target) end
|
||||||
|
return TouchSkin.setPage(TouchSkin.pageIndex + 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.pageBox(page, w, h, ox, oy)
|
||||||
|
ox, oy = ox or 0, oy or 0
|
||||||
|
if not page then return ox, oy, w, h end
|
||||||
|
local bx, by, bw, bh = ox, oy, w, h
|
||||||
|
-- full_screen means "relative to the window, not the game viewport".
|
||||||
|
-- When the cfg also names an aspect_ratio, that window is then fitted
|
||||||
|
-- to the overlay's design aspect so buttons do not stretch. #1503
|
||||||
|
local fit = ((not page.fullScreen) or page.aspectFromCfg)
|
||||||
|
and page.aspect and page.aspect > 0 and h > 0
|
||||||
|
if fit then
|
||||||
|
local displayAspect = w / h
|
||||||
|
if displayAspect > page.aspect then
|
||||||
|
bw = h * page.aspect
|
||||||
|
bx = ox + (w - bw) * 0.5
|
||||||
|
else
|
||||||
|
bh = w / page.aspect
|
||||||
|
by = oy + (h - bh) * 0.5
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local r = page.rect
|
||||||
|
return bx + r.x * bw, by + r.y * bh, r.w * bw, r.h * bh
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
||||||
|
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||||
|
local cx, cy = bx + ctl.x * bw, by + ctl.y * bh
|
||||||
|
local halfW, halfH = ctl.rangeX * bw, ctl.rangeY * bh
|
||||||
|
return cx, cy, halfW, halfH
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.hits(page, ctl, w, h, px, py, ox, oy)
|
||||||
|
local cx, cy, halfW, halfH = TouchSkin.controlGeometry(page, ctl, w, h, ox, oy)
|
||||||
|
local left = halfW * ctl.reachLeft * ctl.rangeMod
|
||||||
|
local right = halfW * ctl.reachRight * ctl.rangeMod
|
||||||
|
local up = halfH * ctl.reachUp * ctl.rangeMod
|
||||||
|
local down = halfH * ctl.reachDown * ctl.rangeMod
|
||||||
|
local dx = px - cx
|
||||||
|
local dy = py - cy
|
||||||
|
local rx = dx < 0 and left or right
|
||||||
|
local ry = dy < 0 and up or down
|
||||||
|
if rx <= 0 or ry <= 0 then return false end
|
||||||
|
if ctl.shape == "radial" then
|
||||||
|
return (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1
|
||||||
|
end
|
||||||
|
return math.abs(dx) <= rx and math.abs(dy) <= ry
|
||||||
|
end
|
||||||
|
|
||||||
|
TouchSkin.overlayLive = false
|
||||||
|
|
||||||
|
function TouchSkin.setOverlayLive(on)
|
||||||
|
TouchSkin.overlayLive = on and true or false
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.decorativeOnly()
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
if not page then return false end
|
||||||
|
for _, ctl in ipairs(page.controls) do
|
||||||
|
if not ctl.decorative then return false end
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.drawable()
|
||||||
|
if not TouchSkin.active then return false end
|
||||||
|
return TouchSkin.overlayLive or TouchSkin.decorativeOnly()
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.hasViewport()
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
return page ~= nil and page.viewport ~= nil and TouchSkin.drawable()
|
||||||
|
end
|
||||||
|
|
||||||
|
function TouchSkin.viewport(w, h, ox, oy)
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
if not page or not page.viewport or not TouchSkin.drawable() then return nil end
|
||||||
|
local v = page.viewport
|
||||||
|
local bx, by, bw, bh = TouchSkin.pageBox(page, w, h, ox, oy)
|
||||||
|
local x, y = bx + v.x * bw, by + v.y * bh
|
||||||
|
local vw, vh = v.w * bw, v.h * bh
|
||||||
|
if vw <= 0 or vh <= 0 then return nil end
|
||||||
|
return x, y, vw, vh, page.viewportFill == true, page.viewportExpand == true
|
||||||
|
end
|
||||||
|
|
||||||
|
return TouchSkin
|
||||||
@@ -101,6 +101,14 @@ function Boxes.canDeposit(save, partyIndex, boxIndex)
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon.asm:711-773): every
|
||||||
|
-- slot back to GetMaxPPOfMove, which already carries its own PP Up count.
|
||||||
|
function Boxes.restorePP(mon)
|
||||||
|
for _, move in ipairs((mon and mon.moves) or {}) do
|
||||||
|
if type(move) == "table" then move.pp = move.maxPp or move.pp end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Boxes.deposit(save, partyIndex, boxIndex)
|
function Boxes.deposit(save, partyIndex, boxIndex)
|
||||||
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
|
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
|
||||||
if not ok then return false, reason end
|
if not ok then return false, reason end
|
||||||
@@ -112,6 +120,9 @@ function Boxes.deposit(save, partyIndex, boxIndex)
|
|||||||
Mail.removeSlot(save, partyIndex)
|
Mail.removeSlot(save, partyIndex)
|
||||||
local box = Boxes.box(save, boxIndex)
|
local box = Boxes.box(save, boxIndex)
|
||||||
box[#box + 1] = mon
|
box[#box + 1] = mon
|
||||||
|
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
|
||||||
|
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
|
||||||
|
Boxes.restorePP(mon)
|
||||||
return true, mon
|
return true, mon
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -129,6 +140,15 @@ function Boxes.withdraw(save, boxIndex, slot)
|
|||||||
local ok, reason = Boxes.canWithdraw(save, boxIndex, slot)
|
local ok, reason = Boxes.canWithdraw(save, boxIndex, slot)
|
||||||
if not ok then return false, reason end
|
if not ok then return false, reason end
|
||||||
local mon = table.remove(Boxes.box(save, boxIndex), slot)
|
local mon = table.remove(Boxes.box(save, boxIndex), slot)
|
||||||
|
-- The `get mon into Party` arm alone heals: status cleared and MON_MAXHP
|
||||||
|
-- copied over MON_HP, an egg's staying 0 (move_mon.asm:666-693).
|
||||||
|
mon.status = nil
|
||||||
|
mon.statusTurns = nil
|
||||||
|
if mon.isEgg then
|
||||||
|
mon.hp = 0
|
||||||
|
else
|
||||||
|
mon.hp = mon.maxHp or mon.hp
|
||||||
|
end
|
||||||
save.party = save.party or {}
|
save.party = save.party or {}
|
||||||
save.party[#save.party + 1] = mon
|
save.party[#save.party + 1] = mon
|
||||||
return true, mon
|
return true, mon
|
||||||
@@ -142,6 +162,16 @@ function Boxes.release(save, boxIndex, slot)
|
|||||||
return true, table.remove(box, slot)
|
return true, table.remove(box, slot)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- RELEASE off the DEPOSIT screen, whose list is the party: it is
|
||||||
|
-- RemoveMonFromPartyOrBox's REMOVE_PARTY arm (bills_pc.asm:204-207).
|
||||||
|
function Boxes.releaseFromParty(save, slot)
|
||||||
|
local party = (save and save.party) or {}
|
||||||
|
if not party[slot] then return false, "There is no POKéMON there." end
|
||||||
|
local mon = table.remove(party, slot)
|
||||||
|
Mail.removeSlot(save, slot)
|
||||||
|
return true, mon
|
||||||
|
end
|
||||||
|
|
||||||
-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case).
|
-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case).
|
||||||
function Boxes.move(save, fromBox, slot, toBox)
|
function Boxes.move(save, fromBox, slot, toBox)
|
||||||
if fromBox == toBox then return false, "It's already there." end
|
if fromBox == toBox then return false, "It's already there." end
|
||||||
|
|||||||
@@ -159,6 +159,13 @@ function NpcTrade.perform(data, save, row, index)
|
|||||||
-- that slot inheriting it (src/core/gen2/Mail.lua).
|
-- that slot inheriting it (src/core/gen2/Mail.lua).
|
||||||
Mail.removeSlot(save, index)
|
Mail.removeSlot(save, index)
|
||||||
party[#party + 1] = received
|
party[#party + 1] = received
|
||||||
|
-- TryAddMonToParty's SetSeenAndCaughtMon (engine/pokemon/move_mon.asm:196),
|
||||||
|
-- which the `predef` at engine/events/npc_trade.asm:168 runs like any other.
|
||||||
|
save.pokedex = save.pokedex or {}
|
||||||
|
save.pokedex.seen = save.pokedex.seen or {}
|
||||||
|
save.pokedex.caught = save.pokedex.caught or {}
|
||||||
|
save.pokedex.seen[received.species] = true
|
||||||
|
save.pokedex.caught[received.species] = true
|
||||||
return given, received
|
return given, received
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -273,6 +273,10 @@ Save.DEFAULT_OPTIONS = {
|
|||||||
-- means something different, hence the different name.
|
-- means something different, hence the different name.
|
||||||
color = "gbc",
|
color = "gbc",
|
||||||
videoMode = "windowed",
|
videoMode = "windowed",
|
||||||
|
fpsCap = 60,
|
||||||
|
-- VOID FILL: fade | water | trees | black. fade is each map header's own
|
||||||
|
-- border block with the dissolve across a boundary (#1418).
|
||||||
|
voidFill = "fade",
|
||||||
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
||||||
sfxVol = 7, -- 0-7
|
sfxVol = 7, -- 0-7
|
||||||
musicFilter = 0, -- low-pass steps, 0 = off
|
musicFilter = 0, -- low-pass steps, 0 = off
|
||||||
|
|||||||
@@ -220,6 +220,9 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
"assets/generated/tilesets/reds_house.png",
|
"assets/generated/tilesets/reds_house.png",
|
||||||
"assets/generated/sprites/red.png",
|
"assets/generated/sprites/red.png",
|
||||||
"assets/generated/sprites/monster.png",
|
"assets/generated/sprites/monster.png",
|
||||||
|
"data/generated/maps.lua",
|
||||||
|
"data/generated/oak_speech.lua",
|
||||||
|
"data/generated/font.lua",
|
||||||
}
|
}
|
||||||
for _, path in ipairs(samples) do
|
for _, path in ipairs(samples) do
|
||||||
local versioned = prefix ~= "" and (prefix .. path) or path
|
local versioned = prefix ~= "" and (prefix .. path) or path
|
||||||
@@ -230,6 +233,12 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
|
lines[#lines + 1] = "versioned=" .. probeInfo(filesystem, versioned)
|
||||||
end
|
end
|
||||||
lines[#lines + 1] = "resolve=" .. tostring(resolved)
|
lines[#lines + 1] = "resolve=" .. tostring(resolved)
|
||||||
|
if path:sub(-4) == ".lua" then
|
||||||
|
local CacheFs = require("src.import.CacheFs")
|
||||||
|
local loaded, err = CacheFs.loadActive(path)
|
||||||
|
lines[#lines + 1] = "loadActive=" .. (loaded ~= nil and "ok"
|
||||||
|
or ("FAIL " .. tostring(err):gsub("%s+", " "):sub(1, 160)))
|
||||||
|
else
|
||||||
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
|
lines[#lines + 1] = "newImage=" .. probeOpen("image", resolved)
|
||||||
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
|
lines[#lines + 1] = "newImageData=" .. probeOpen("imageData", resolved)
|
||||||
if prefix ~= "" and resolved ~= versioned then
|
if prefix ~= "" and resolved ~= versioned then
|
||||||
@@ -237,10 +246,13 @@ function SwitchDiagnostics.probeAssets(version)
|
|||||||
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
|
lines[#lines + 1] = "newImageData_versioned=" .. probeOpen("imageData", versioned)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- Shallow listing so we can see if the extract tree exists at all.
|
-- Shallow listing so we can see if the extract tree exists at all.
|
||||||
local roots = { "yellow", "blue", "assets", "yellow/assets/generated",
|
local roots = { "yellow", "blue", "gold", "assets", "yellow/assets/generated",
|
||||||
"yellow/assets/generated/sprites", "blue/assets/generated/sprites" }
|
"yellow/assets/generated/sprites", "blue/assets/generated/sprites",
|
||||||
|
"gold/assets/generated", "gold/assets/generated/sprites",
|
||||||
|
"gold/data/generated" }
|
||||||
for _, dir in ipairs(roots) do
|
for _, dir in ipairs(roots) do
|
||||||
local info = filesystem.getInfo(dir)
|
local info = filesystem.getInfo(dir)
|
||||||
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
if info and info.type == "directory" and filesystem.getDirectoryItems then
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ local SEP = package.config:sub(1, 1)
|
|||||||
|
|
||||||
-- Cache-relative paths are prefixed with this before every read/write, so a
|
-- Cache-relative paths are prefixed with this before every read/write, so a
|
||||||
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
|
-- version's import lands under its GameVersion.cachePrefix (red/, blue/,
|
||||||
-- yellow/). The launcher sets it per import / per readiness check; it stays
|
-- yellow/, gold/). The launcher sets it per import / per readiness check; it stays
|
||||||
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
|
-- "" outside those flows. Runtime *reads* (require / newImage) do NOT go
|
||||||
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
-- through here -- CacheFs.mountVersion overlays the active version's subtree
|
||||||
-- onto the un-prefixed paths instead.
|
-- onto the un-prefixed paths instead.
|
||||||
@@ -305,7 +305,7 @@ function CacheFs.read(rel)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
|
-- Read cache-relative `rel` for the active GameVersion when PhysFS may hide
|
||||||
-- prefixed Blue/Yellow trees (fused NX mount hole). Same order Data:load
|
-- prefixed Blue/Yellow/Gold trees (fused NX mount hole). Same order Data:load
|
||||||
-- already used: active version prefix with CacheFs.prefix cleared, then
|
-- already used: active version prefix with CacheFs.prefix cleared, then
|
||||||
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
|
-- `rel` under the caller's CacheFs.prefix. Returns the bytes or nil.
|
||||||
function CacheFs.readActive(rel)
|
function CacheFs.readActive(rel)
|
||||||
@@ -322,6 +322,32 @@ function CacheFs.readActive(rel)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Load a generated Lua table the way Data:load does: versioned save-dir
|
||||||
|
-- bytes first (gold/data/generated/maps.lua), then the un-prefixed path.
|
||||||
|
-- Game2/World used love.filesystem.load("data/generated/...") which misses
|
||||||
|
-- on fused NX when the gold/ overlay mount fails -- intro art still loads
|
||||||
|
-- via NxAssetOverlay, but oak_speech.lua / font.lua / maps.lua do not.
|
||||||
|
function CacheFs.loadActive(rel)
|
||||||
|
local bytes = CacheFs.readActive(rel)
|
||||||
|
if type(bytes) == "string" then
|
||||||
|
local GameVersion = require("src.core.GameVersion")
|
||||||
|
local loader = loadstring or load
|
||||||
|
local chunk, err = loader(bytes, "@" .. GameVersion.cachePrefix() .. rel)
|
||||||
|
if not chunk then return nil, err end
|
||||||
|
local ok, value = pcall(chunk)
|
||||||
|
if not ok then return nil, value end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
if love and love.filesystem and love.filesystem.load then
|
||||||
|
local chunk, err = love.filesystem.load(rel)
|
||||||
|
if not chunk then return nil, err end
|
||||||
|
local ok, value = pcall(chunk)
|
||||||
|
if not ok then return nil, value end
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
return nil, "Could not open file " .. rel .. ". Does not exist."
|
||||||
|
end
|
||||||
|
|
||||||
-- does cache-relative `rel` exist as a file?
|
-- does cache-relative `rel` exist as a file?
|
||||||
function CacheFs.exists(rel)
|
function CacheFs.exists(rel)
|
||||||
rel = withPrefix(rel)
|
rel = withPrefix(rel)
|
||||||
|
|||||||
@@ -122,6 +122,17 @@ local function addTouchRows(rows, add, opts, hooks)
|
|||||||
end,
|
end,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if hooks and hooks.openSkinStudio then
|
||||||
|
rows[#rows + 1] = {
|
||||||
|
label = Strings("SKIN STUDIO"),
|
||||||
|
actionLabel = Strings("Open"),
|
||||||
|
action = function()
|
||||||
|
hooks.openSkinStudio()
|
||||||
|
return false
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function coreRows(opts, hooks)
|
local function coreRows(opts, hooks)
|
||||||
@@ -518,9 +529,9 @@ end
|
|||||||
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
-- Gold reads NONE of the rows above. Its OPTION screen writes a different
|
||||||
-- set of names, several of which collide with Gen 1's at a different TYPE
|
-- set of names, several of which collide with Gen 1's at a different TYPE
|
||||||
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
|
-- (battleStyle "SHIFT" vs "shift", textSpeed a label vs a frame delay), and
|
||||||
-- its renderer has no battle layout, no SGB palette packs and no void fill --
|
-- its renderer has no battle layout and no SGB palette packs -- so a gear
|
||||||
-- so a gear opened on the Gold tab used to offer a dozen controls that did
|
-- opened on the Gold tab used to offer a dozen controls that did nothing
|
||||||
-- nothing and hide the seven that the cart itself has.
|
-- and hide the seven that the cart itself has.
|
||||||
--
|
--
|
||||||
-- The block lives in options.lua under `gold`, which is exactly where
|
-- The block lives in options.lua under `gold`, which is exactly where
|
||||||
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
|
-- src/core/gen2/Save.lua loadOptions reads it, so an edit here is live on the
|
||||||
@@ -606,6 +617,21 @@ local function gen2Rows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local okFill, BorderFill = pcall(require, "src.world.gen2.BorderFill")
|
||||||
|
if okFill and BorderFill.VOID_FILLS then
|
||||||
|
add(Strings("VOID FILL"),
|
||||||
|
function() return BorderFill.voidFillLabel(opts.voidFill) end,
|
||||||
|
function(dir)
|
||||||
|
local modes = BorderFill.VOID_FILLS
|
||||||
|
local cur, idx = opts.voidFill or "fade", 1
|
||||||
|
for i, m in ipairs(modes) do
|
||||||
|
if m == cur then idx = i break end
|
||||||
|
end
|
||||||
|
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
-- Same #136 gate as the Gen 1 row and the in-game one.
|
-- Same #136 gate as the Gen 1 row and the in-game one.
|
||||||
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
||||||
if okFx and GBCFX.isSupported() then
|
if okFx and GBCFX.isSupported() then
|
||||||
@@ -627,6 +653,16 @@ local function gen2Rows(opts, hooks)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
|
||||||
|
if okCap then
|
||||||
|
add(Strings("MAX FPS"),
|
||||||
|
function() return FrameCap.label(opts.fpsCap) end,
|
||||||
|
function(dir)
|
||||||
|
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
addTouchRows(rows, add, opts, hooks)
|
addTouchRows(rows, add, opts, hooks)
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
@@ -638,6 +674,7 @@ end
|
|||||||
-- writes options while a modal covers it, so the cached table stays true.
|
-- writes options while a modal covers it, so the cached table stays true.
|
||||||
-- `hooks` carries the host actions a row cannot perform itself:
|
-- `hooks` carries the host actions a row cannot perform itself:
|
||||||
-- editTouchControls() -- hand the screen to the touch-overlay editor
|
-- editTouchControls() -- hand the screen to the touch-overlay editor
|
||||||
|
-- openSkinStudio() -- hand the screen to the desktop skin studio
|
||||||
--
|
--
|
||||||
-- `version` is the game the gear was opened on. It picks the row set, and
|
-- `version` is the game the gear was opened on. It picks the row set, and
|
||||||
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
|
-- for Gold it also picks WHICH table the rows edit: the `gold` block inside
|
||||||
|
|||||||
@@ -776,6 +776,26 @@ end
|
|||||||
|
|
||||||
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
|
-- A hand-drawn X / check: the UI font has no guaranteed glyph for either,
|
||||||
-- and the launcher ships no icon asset for them.
|
-- and the launcher ships no icon asset for them.
|
||||||
|
-- Skins tab glyph: a bezel with a screen cutout and two face buttons, drawn
|
||||||
|
-- rather than shipped as art so the tab needs no new asset.
|
||||||
|
local function drawSkinGlyph(x, y, w, h, hot)
|
||||||
|
local box = math.min(w, h)
|
||||||
|
local bx = x + (w - box) / 2
|
||||||
|
local by = y + (h - box) / 2
|
||||||
|
local pad = math.floor(box * 0.22)
|
||||||
|
local ow, oh = box - 2 * pad, box - 2 * pad
|
||||||
|
local ink = hot and PAL.inverse or PAL.ink
|
||||||
|
local a = 1
|
||||||
|
Theme.strokeRounded(bx + pad, by + pad, ow, oh, ink, a,
|
||||||
|
math.max(1, math.floor(Kit.scale)), math.floor(oh * 0.22))
|
||||||
|
local sw, sh = ow * 0.58, oh * 0.40
|
||||||
|
Theme.fillRounded(bx + pad + ow * 0.10, by + pad + oh * 0.14, sw, sh, ink, a,
|
||||||
|
math.max(1, math.floor(sh * 0.2)))
|
||||||
|
local r = math.max(1, oh * 0.09)
|
||||||
|
Theme.fillRounded(bx + pad + ow * 0.60, by + pad + oh * 0.64, r * 2, r * 2, ink, a, r)
|
||||||
|
Theme.fillRounded(bx + pad + ow * 0.80, by + pad + oh * 0.50, r * 2, r * 2, ink, a, r)
|
||||||
|
end
|
||||||
|
|
||||||
local function drawCross(x, y, size, color)
|
local function drawCross(x, y, size, color)
|
||||||
love.graphics.push("all")
|
love.graphics.push("all")
|
||||||
love.graphics.setColor(color)
|
love.graphics.setColor(color)
|
||||||
@@ -807,18 +827,46 @@ end
|
|||||||
-- frame. Their tab rows, opts tables and action closures are built once
|
-- frame. Their tab rows, opts tables and action closures are built once
|
||||||
-- instead of 60 times a second -- only `active`, `image` and the queued
|
-- instead of 60 times a second -- only `active`, `image` and the queued
|
||||||
-- action are written per frame.
|
-- action are written per frame.
|
||||||
|
-- The four cartridges used to be four tabs of their own. They are one
|
||||||
|
-- dropdown now: the tab row was seven controls wide and wrapped to two rows on
|
||||||
|
-- anything narrow, and only ever one game is being looked at.
|
||||||
|
local GAME_TABS = {
|
||||||
|
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed,
|
||||||
|
label = "Red" },
|
||||||
|
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue,
|
||||||
|
label = "Blue" },
|
||||||
|
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold,
|
||||||
|
label = "Yellow" },
|
||||||
|
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber,
|
||||||
|
label = "Gold" },
|
||||||
|
}
|
||||||
|
|
||||||
local HEADER_TABS = {
|
local HEADER_TABS = {
|
||||||
{ id = "red", key = "tab-red", letter = "R", color = PAL.railRed },
|
|
||||||
{ id = "blue", key = "tab-blue", letter = "B", color = PAL.railBlue },
|
|
||||||
{ id = "yellow", key = "tab-yellow", letter = "Y", color = PAL.railGold },
|
|
||||||
{ id = "gold", key = "tab-gold", letter = "G", color = PAL.railAmber },
|
|
||||||
{ id = "mods", key = "tab-mods" },
|
{ id = "mods", key = "tab-mods" },
|
||||||
{ id = "find", key = "tab-find" },
|
{ id = "find", key = "tab-find" },
|
||||||
|
{ id = "skins", key = "tab-skins", glyph = true },
|
||||||
}
|
}
|
||||||
for _, t in ipairs(HEADER_TABS) do
|
for _, t in ipairs(HEADER_TABS) do
|
||||||
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
|
||||||
|
if t.glyph then t.opts.drawFn = drawSkinGlyph end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Which cartridge the dropdown is showing: the open game tab, else the last
|
||||||
|
-- one visited, else Red. Kept as a function so the mods/find/skins panels
|
||||||
|
-- still answer "for which game" without a game tab being open.
|
||||||
|
local function currentGame(imp)
|
||||||
|
for _, g in ipairs(GAME_TABS) do
|
||||||
|
if imp.tab == g.id then return g end
|
||||||
|
end
|
||||||
|
for _, g in ipairs(GAME_TABS) do
|
||||||
|
if imp.modScope == g.id then return g end
|
||||||
|
end
|
||||||
|
return GAME_TABS[1]
|
||||||
|
end
|
||||||
|
|
||||||
|
LauncherView.GAME_TABS = GAME_TABS
|
||||||
|
LauncherView.currentGame = currentGame
|
||||||
|
|
||||||
local QUIT_INK_HOT = { 0, 0, 0, 1 }
|
local QUIT_INK_HOT = { 0, 0, 0, 1 }
|
||||||
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
|
local QUIT_INK_REST = { 1, 1, 1, 0.85 }
|
||||||
|
|
||||||
@@ -837,11 +885,27 @@ local function headerChrome(imp)
|
|||||||
hot and QUIT_INK_HOT or QUIT_INK_REST)
|
hot and QUIT_INK_HOT or QUIT_INK_REST)
|
||||||
end },
|
end },
|
||||||
tab = {},
|
tab = {},
|
||||||
|
game = { face = "tab", font = "tab",
|
||||||
|
action = function()
|
||||||
|
local g = currentGame(imp)
|
||||||
|
if imp.tab == g.id then
|
||||||
|
imp._gamePopup = true
|
||||||
|
else
|
||||||
|
imp:_switchTab(g.id)
|
||||||
|
end
|
||||||
|
end },
|
||||||
}
|
}
|
||||||
for _, t in ipairs(HEADER_TABS) do
|
for _, t in ipairs(HEADER_TABS) do
|
||||||
local id = t.id
|
local id = t.id
|
||||||
c.tab[id] = function() imp:_switchTab(id) end
|
c.tab[id] = function() imp:_switchTab(id) end
|
||||||
end
|
end
|
||||||
|
for _, g in ipairs(GAME_TABS) do
|
||||||
|
local id = g.id
|
||||||
|
c.tab[id] = function()
|
||||||
|
imp._gamePopup = nil
|
||||||
|
imp:_switchTab(id)
|
||||||
|
end
|
||||||
|
end
|
||||||
imp._headerChrome = c
|
imp._headerChrome = c
|
||||||
return c
|
return c
|
||||||
end
|
end
|
||||||
@@ -927,7 +991,10 @@ local function buildHeader(imp, m)
|
|||||||
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
|
-- bright cart gold; Gold (Gen 2) uses the deeper amber so the two do not
|
||||||
-- collide.
|
-- collide.
|
||||||
local tabs = HEADER_TABS
|
local tabs = HEADER_TABS
|
||||||
tabs[5].icon, tabs[6].icon = imp._modsIcon, imp._findIcon
|
for _, t in ipairs(tabs) do
|
||||||
|
if t.id == "mods" then t.icon = imp._modsIcon end
|
||||||
|
if t.id == "find" then t.icon = imp._findIcon end
|
||||||
|
end
|
||||||
local tabH = m.chip
|
local tabH = m.chip
|
||||||
local tx = m.x + m.pad
|
local tx = m.x + m.pad
|
||||||
local ty = y + math.floor(6 * m.s)
|
local ty = y + math.floor(6 * m.s)
|
||||||
@@ -935,6 +1002,40 @@ local function buildHeader(imp, m)
|
|||||||
local tabRight = m.x + m.w - m.pad
|
local tabRight = m.x + m.w - m.pad
|
||||||
local tabGap = math.floor(6 * m.s)
|
local tabGap = math.floor(6 * m.s)
|
||||||
local tabRowGap = math.floor(4 * m.s)
|
local tabRowGap = math.floor(4 * m.s)
|
||||||
|
|
||||||
|
-- the cartridge dropdown, sized to its longest label so switching games
|
||||||
|
-- never reflows the row
|
||||||
|
local chrome0 = headerChrome(imp)
|
||||||
|
local game = currentGame(imp)
|
||||||
|
local labelW = 0
|
||||||
|
for _, g in ipairs(GAME_TABS) do
|
||||||
|
labelW = math.max(labelW, Kit.textWidth("tab", Strings(g.label)))
|
||||||
|
end
|
||||||
|
local dropW = math.min(tabRight - tabLeft,
|
||||||
|
tabH + labelW + math.floor(34 * m.s))
|
||||||
|
chrome0.game.color = game.color
|
||||||
|
chrome0.game.letter = game.letter
|
||||||
|
chrome0.game.active = imp.tab == game.id
|
||||||
|
local gameHot = Kit.hover(tx, ty, dropW, tabH)
|
||||||
|
local gameDown = gameHot and Kit.mouseDown
|
||||||
|
-- face "tab" inverts on hover as well as when active, so the caret has to
|
||||||
|
-- flip with it or it vanishes into the cartridge colour
|
||||||
|
local gameInvert = chrome0.game.active or gameHot
|
||||||
|
chrome0.game.ring = gameHot and not chrome0.game.active or nil
|
||||||
|
btn(imp, tx, ty, dropW, tabH, "tab-game", Strings(game.label), chrome0.game)
|
||||||
|
do
|
||||||
|
local cw = math.floor(7 * m.s)
|
||||||
|
local ccx = tx + dropW - math.floor(14 * m.s)
|
||||||
|
local ccy = ty + tabH / 2 + (gameDown and math.floor(1 * m.s) or 0)
|
||||||
|
if love.graphics.polygon then
|
||||||
|
Theme.col(gameInvert and PAL.inverse or PAL.ink, gameDown and 1 or 0.9)
|
||||||
|
love.graphics.polygon("fill",
|
||||||
|
ccx - cw, ccy - cw * 0.5, ccx + cw, ccy - cw * 0.5, ccx, ccy + cw * 0.8)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tx = tx + dropW + tabGap
|
||||||
|
|
||||||
for _, t in ipairs(tabs) do
|
for _, t in ipairs(tabs) do
|
||||||
local w = tabH
|
local w = tabH
|
||||||
if tx > tabLeft and tx + w > tabRight then
|
if tx > tabLeft and tx + w > tabRight then
|
||||||
@@ -1525,13 +1626,17 @@ end
|
|||||||
-- The persisted sort choice both mod panels share. The chooser itself is a
|
-- The persisted sort choice both mod panels share. The chooser itself is a
|
||||||
-- popup (buildSortModal); panels just read the current key and offer a
|
-- popup (buildSortModal); panels just read the current key and offer a
|
||||||
-- "Sort" button, which is what freed the chip row's two lines of space.
|
-- "Sort" button, which is what freed the chip row's two lines of space.
|
||||||
local function sortDefs()
|
local function sortDefs(scope)
|
||||||
return {
|
local defs = {
|
||||||
{ key = "name", label = Strings("Name") },
|
{ key = "name", label = Strings("Name") },
|
||||||
{ key = "popularity", label = Strings("Popularity") },
|
{ key = "popularity", label = Strings("Most downloaded") },
|
||||||
{ key = "release", label = Strings("Release date") },
|
|
||||||
{ key = "updated", label = Strings("Last updated") },
|
|
||||||
}
|
}
|
||||||
|
if scope == "find" then
|
||||||
|
defs[#defs + 1] = { key = "trending", label = Strings("Trending") }
|
||||||
|
end
|
||||||
|
defs[#defs + 1] = { key = "release", label = Strings("Release date") }
|
||||||
|
defs[#defs + 1] = { key = "updated", label = Strings("Last updated") }
|
||||||
|
return defs
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
|
-- Sorting is decorate-sort-undecorate: the key is computed once per entry
|
||||||
@@ -1583,7 +1688,7 @@ local function sortCacheOk(cache, src, key, rev, pending)
|
|||||||
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
|
return pending and (Kit.time - (cache.at or 0)) < RESORT_DEBOUNCE
|
||||||
end
|
end
|
||||||
|
|
||||||
local function currentSort(imp)
|
local function currentSort(imp, scope)
|
||||||
local sortKey = imp.modSort
|
local sortKey = imp.modSort
|
||||||
if sortKey == nil then
|
if sortKey == nil then
|
||||||
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
|
local ok, opts = pcall(require("src.core.SaveData").loadOptions)
|
||||||
@@ -1593,6 +1698,7 @@ local function currentSort(imp)
|
|||||||
sortKey = sortKey or "popularity"
|
sortKey = sortKey or "popularity"
|
||||||
imp.modSort = sortKey
|
imp.modSort = sortKey
|
||||||
end
|
end
|
||||||
|
if sortKey == "trending" and scope ~= "find" then return "popularity" end
|
||||||
return sortKey
|
return sortKey
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1659,7 +1765,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
action = function() imp:_syncModUpdateInfo(true) end })
|
action = function() imp:_syncModUpdateInfo(true) end })
|
||||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "mods" end })
|
||||||
elseif medReq <= w then
|
elseif medReq <= w then
|
||||||
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
|
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
|
||||||
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
|
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
|
||||||
@@ -1670,7 +1776,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
action = function() imp:_syncModUpdateInfo(true) end })
|
action = function() imp:_syncModUpdateInfo(true) end })
|
||||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "mods" end })
|
||||||
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._modHeaderActionsPopup = true end })
|
action = function() imp._modHeaderActionsPopup = true end })
|
||||||
@@ -1686,7 +1792,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
action = function() imp:chooseMod() end })
|
action = function() imp:chooseMod() end })
|
||||||
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "mods" end })
|
||||||
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._modHeaderActionsPopup = true end })
|
action = function() imp._modHeaderActionsPopup = true end })
|
||||||
@@ -1718,7 +1824,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local sortKey = currentSort(imp)
|
local sortKey = currentSort(imp, "mods")
|
||||||
|
|
||||||
-- Immediate mode paints this panel every frame; re-sorting the whole list
|
-- Immediate mode paints this panel every frame; re-sorting the whole list
|
||||||
-- per frame (with lowercased-string allocations in the comparator) fed the
|
-- per frame (with lowercased-string allocations in the comparator) fed the
|
||||||
@@ -1909,6 +2015,157 @@ end
|
|||||||
|
|
||||||
-- ---------------------------------------------------------- find mods panel
|
-- ---------------------------------------------------------- find mods panel
|
||||||
|
|
||||||
|
-- SKINS tab: pick the on-screen skin, import one, or open the desktop studio.
|
||||||
|
local function buildSkinsPanel(imp, x, y, w, availH, m)
|
||||||
|
local skins = imp:_ensureSkins()
|
||||||
|
local active = imp:_activeSkin()
|
||||||
|
local gap = m.gap
|
||||||
|
local cy = y
|
||||||
|
|
||||||
|
local title = Strings("Skins/Borders")
|
||||||
|
local bh = m.btnH
|
||||||
|
local importLabel = imp:_skinsImportButtonLabel()
|
||||||
|
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
|
||||||
|
if Kit.textWidth("button", title) + importW + math.floor(24 * m.s) > w then
|
||||||
|
importLabel = Strings("Import")
|
||||||
|
importW = Kit.textWidth("small", importLabel) + math.floor(20 * m.s)
|
||||||
|
end
|
||||||
|
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
|
||||||
|
btn(imp, place(importW), cy, importW, bh, "skins-import", importLabel, {
|
||||||
|
kind = "accent", font = "small",
|
||||||
|
action = function() imp:chooseSkin() end })
|
||||||
|
Kit.text("button", Kit.ellipsize("button", title,
|
||||||
|
math.max(0, w - importW - math.floor(12 * m.s))), x,
|
||||||
|
cy + math.floor((bh - Kit.textHeight("button")) / 2), PAL.heading)
|
||||||
|
cy = cy + bh + math.floor(8 * m.s)
|
||||||
|
|
||||||
|
if imp._skinNotice then
|
||||||
|
cy = cy + Kit.textWrapped("small", imp._skinNotice.text, x, cy, w,
|
||||||
|
imp._skinNotice.ok and PAL.green or PAL.red, 2) + math.floor(8 * m.s)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Studio button. Desktop only: the host supplies the hook nowhere else.
|
||||||
|
if imp.onOpenSkinStudio then
|
||||||
|
local label = Strings("Open Skin Studio")
|
||||||
|
local bw = math.min(w, Kit.textWidth("small", label) + math.floor(40 * m.s))
|
||||||
|
btn(imp, x, cy, bw, m.btnH, "skins-studio", label, {
|
||||||
|
kind = "accent", font = "small",
|
||||||
|
action = function()
|
||||||
|
-- the studio boots the game on Play, so hand it a real cartridge
|
||||||
|
imp.onOpenSkinStudio(imp.modScope or "red")
|
||||||
|
end })
|
||||||
|
local hint = Strings("Design bezels and button layouts, then test them.")
|
||||||
|
Kit.text("small", Kit.ellipsize("small", hint,
|
||||||
|
w - bw - math.floor(12 * m.s)), x + bw + math.floor(12 * m.s),
|
||||||
|
cy + math.floor((m.btnH - Kit.textHeight("small")) / 2), PAL.muted)
|
||||||
|
cy = cy + m.btnH + gap
|
||||||
|
end
|
||||||
|
|
||||||
|
Kit.caption(x, cy, Strings("INSTALLED"))
|
||||||
|
cy = cy + Kit.textHeight("small") + math.floor(6 * m.s)
|
||||||
|
|
||||||
|
local rowH = math.max(Kit.tapMin(), math.floor(44 * m.s))
|
||||||
|
imp._skinGear = imp._skinGear
|
||||||
|
or love.graphics.newImage("assets/launcher/gear.png")
|
||||||
|
|
||||||
|
-- The row itself is "use this skin"; the gear beside it configures that
|
||||||
|
-- entry -- the built-in pad opens the drag-a-button layout editor, a skin
|
||||||
|
-- opens the studio, so neither lands on a screen that cannot edit it.
|
||||||
|
local function skinRow(key, id, title, detail, selected, configure)
|
||||||
|
local gearW = configure and rowH or 0
|
||||||
|
local rowW = w - (gearW > 0 and (gearW + math.floor(6 * m.s)) or 0)
|
||||||
|
local ink = rowHit(imp, x, cy, rowW, rowH, selected, key,
|
||||||
|
function() imp:_useSkin(id) end)
|
||||||
|
local tagW = selected
|
||||||
|
and (Kit.textWidth("small", Strings("IN USE")) + math.floor(20 * m.s))
|
||||||
|
or math.floor(12 * m.s)
|
||||||
|
local textW = rowW - math.floor(24 * m.s) - tagW
|
||||||
|
local tx = x + math.floor(12 * m.s)
|
||||||
|
local ty = cy + math.floor(7 * m.s)
|
||||||
|
Kit.text("mono", Kit.ellipsize("mono", title, textW), tx, ty,
|
||||||
|
ink or PAL.heading)
|
||||||
|
Kit.text("small", Kit.ellipsize("small", detail, textW),
|
||||||
|
tx, ty + Kit.textHeight("mono"), ink or PAL.muted)
|
||||||
|
if selected then
|
||||||
|
Kit.textRight("small", Strings("IN USE"), x + rowW - math.floor(12 * m.s),
|
||||||
|
cy + math.floor((rowH - Kit.textHeight("small")) / 2), ink or PAL.green)
|
||||||
|
end
|
||||||
|
if configure then
|
||||||
|
btn(imp, x + w - gearW, cy, gearW, gearW, key .. "-cfg", "", {
|
||||||
|
face = "invert", image = imp._skinGear, action = configure })
|
||||||
|
end
|
||||||
|
cy = cy + rowH + math.floor(4 * m.s)
|
||||||
|
end
|
||||||
|
|
||||||
|
local entries = { false }
|
||||||
|
for _, entry in ipairs(skins) do entries[#entries + 1] = entry end
|
||||||
|
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local hint = Strings(
|
||||||
|
"You can also drop a skin .zip on this window, or put a folder in %s/ of your save directory. RetroArch overlay .cfg files work as-is.",
|
||||||
|
TouchSkin.USER_ROOT)
|
||||||
|
local hintH = Kit.wrapHeight("small", hint, w, 3)
|
||||||
|
local importH = math.floor(10 * m.s) + hintH
|
||||||
|
|
||||||
|
local rowGap = math.floor(4 * m.s)
|
||||||
|
local pagerH = math.max(Kit.tapMin(), math.floor(30 * m.s))
|
||||||
|
local listTop = cy
|
||||||
|
local listH = availH - (cy - y) - importH
|
||||||
|
local perPage = Kit.rowsThatFit(listH, rowH, rowGap, 1, 20)
|
||||||
|
if #entries > perPage then
|
||||||
|
perPage = Kit.rowsThatFit(listH - pagerH - gap, rowH, rowGap, 1, 20)
|
||||||
|
end
|
||||||
|
local first, last, cur, pages = Kit.pageBounds(page(imp, "skins"),
|
||||||
|
#entries, perPage)
|
||||||
|
setPage(imp, "skins", cur)
|
||||||
|
setPage(imp, "skins",
|
||||||
|
Kit.wheelPage(x, listTop, w, listH, cur, #entries, perPage))
|
||||||
|
|
||||||
|
for i = first, last do
|
||||||
|
local entry = entries[i]
|
||||||
|
if not entry then
|
||||||
|
skinRow("skin-none", nil, Strings("Built-in pad"),
|
||||||
|
Strings("The default on-screen buttons."), active == nil,
|
||||||
|
imp.onEditTouchControls and function()
|
||||||
|
imp.onEditTouchControls(imp.modScope or "red")
|
||||||
|
end or nil)
|
||||||
|
else
|
||||||
|
local bits = {}
|
||||||
|
bits[#bits + 1] = entry.source == "user" and Strings("installed")
|
||||||
|
or Strings("bundled")
|
||||||
|
if entry.controls > 0 then
|
||||||
|
bits[#bits + 1] = entry.controls .. " " .. Strings("buttons")
|
||||||
|
else
|
||||||
|
bits[#bits + 1] = Strings("bezel only")
|
||||||
|
end
|
||||||
|
if entry.pages > 1 then
|
||||||
|
bits[#bits + 1] = entry.pages .. " " .. Strings("pages")
|
||||||
|
end
|
||||||
|
if entry.screen then bits[#bits + 1] = Strings("screen cutout") end
|
||||||
|
local configure = imp.onOpenSkinStudio and function()
|
||||||
|
imp.onOpenSkinStudio(imp.modScope or "red", entry.id)
|
||||||
|
end or nil
|
||||||
|
skinRow("skin-" .. entry.id, entry.id, entry.id,
|
||||||
|
table.concat(bits, " \194\183 "), active == entry.id, configure)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if #skins == 0 then
|
||||||
|
Kit.emptyBox(x, cy, w, math.floor(72 * m.s),
|
||||||
|
Strings("No skins installed yet."))
|
||||||
|
cy = cy + math.floor(72 * m.s) + gap
|
||||||
|
end
|
||||||
|
|
||||||
|
if pages > 1 then
|
||||||
|
setPage(imp, "skins",
|
||||||
|
Kit.pager(x, cy, w, cur, #entries, perPage, "skins"))
|
||||||
|
cy = cy + pagerH + gap
|
||||||
|
end
|
||||||
|
|
||||||
|
cy = cy + math.floor(10 * m.s)
|
||||||
|
Kit.textWrapped("small", hint, x, cy, w, PAL.muted, 3)
|
||||||
|
end
|
||||||
|
|
||||||
local function buildFindPanel(imp, x, y, w, availH, m)
|
local function buildFindPanel(imp, x, y, w, availH, m)
|
||||||
imp:_ensureFind()
|
imp:_ensureFind()
|
||||||
imp:_ensureMods()
|
imp:_ensureMods()
|
||||||
@@ -1958,7 +2215,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
|
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(20 * m.s)
|
||||||
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
|
btn(imp, place(sw), cy, sw, fieldH, "find-sort", Strings("Sort"), {
|
||||||
font = "small",
|
font = "small",
|
||||||
action = function() imp._sortPopup = true end })
|
action = function() imp._sortPopup = "find" end })
|
||||||
-- The Filter button carries its state: blue while a category is active,
|
-- The Filter button carries its state: blue while a category is active,
|
||||||
-- so a filtered-down list never reads as "the index shrank".
|
-- so a filtered-down list never reads as "the index shrank".
|
||||||
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
|
local fw = Kit.textWidth("small", Strings("Filter")) + math.floor(20 * m.s)
|
||||||
@@ -1978,7 +2235,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local sortKey = currentSort(imp)
|
local sortKey = currentSort(imp, "find")
|
||||||
|
|
||||||
-- Same caching rule as the MODS tab: the comparator allocates, so only
|
-- Same caching rule as the MODS tab: the comparator allocates, so only
|
||||||
-- re-sort when the inputs actually change.
|
-- re-sort when the inputs actually change.
|
||||||
@@ -1997,6 +2254,7 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
-- fetch for every entry in the index (see _findStatsCached).
|
-- fetch for every entry in the index (see _findStatsCached).
|
||||||
local stats = imp:_findStatsCached(entry)
|
local stats = imp:_findStatsCached(entry)
|
||||||
if sortKey == "popularity" then return stats and stats.total or -1 end
|
if sortKey == "popularity" then return stats and stats.total or -1 end
|
||||||
|
if sortKey == "trending" then return stats and stats.recent or -1 end
|
||||||
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
|
if sortKey == "release" then return stats and stats.first or "0000-00-00" end
|
||||||
return stats and stats.latest or "0000-00-00"
|
return stats and stats.latest or "0000-00-00"
|
||||||
end,
|
end,
|
||||||
@@ -2084,13 +2342,14 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
bx, ly, PAL.heading)
|
bx, ly, PAL.heading)
|
||||||
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
|
local by2 = ly + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||||
-- meta and stats on one line, the download count first (and green)
|
-- meta and stats on one line, the download count first (and green)
|
||||||
-- because it is what the default Popularity sort is ordering by: a
|
-- because it is what the default Most-downloaded sort is ordering by: a
|
||||||
-- narrow window ellipsizes the tail, and the count must survive that.
|
-- narrow window ellipsizes the tail, and the count must survive that.
|
||||||
local stats = imp:_findStats(entry)
|
local stats = imp:_findStats(entry)
|
||||||
local baseCol = note and PAL.green or PAL.detail
|
local baseCol = note and PAL.green or PAL.detail
|
||||||
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
||||||
if note then lead = lead .. " - " .. note end
|
if note then lead = lead .. " - " .. note end
|
||||||
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
|
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
|
||||||
|
local hasCount = stats ~= nil and stats.total ~= nil
|
||||||
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
|
local dates = stats and ModUpdate.datesLine(stats.first, stats.latest)
|
||||||
or nil
|
or nil
|
||||||
local rest = {}
|
local rest = {}
|
||||||
@@ -2100,11 +2359,13 @@ local function buildFindPanel(imp, x, y, w, availH, m)
|
|||||||
end
|
end
|
||||||
if dates then
|
if dates then
|
||||||
rest[#rest + 1] = dates
|
rest[#rest + 1] = dates
|
||||||
elseif not dl and (entry.summary or "") ~= "" then
|
elseif not hasCount and (entry.summary or "") ~= "" then
|
||||||
rest[#rest + 1] = entry.summary
|
rest[#rest + 1] = entry.summary
|
||||||
end
|
end
|
||||||
local segs = { { lead, baseCol } }
|
local segs = { { lead, baseCol } }
|
||||||
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
|
if dl then
|
||||||
|
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
|
||||||
|
end
|
||||||
if #rest > 0 then
|
if #rest > 0 then
|
||||||
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
|
segs[#segs + 1] = { " - " .. table.concat(rest, " - "), baseCol }
|
||||||
end
|
end
|
||||||
@@ -2692,7 +2953,7 @@ local function buildModHeaderActionsModal(imp, m)
|
|||||||
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
|
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
|
||||||
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
|
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
|
||||||
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
|
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
|
||||||
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = true end },
|
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
|
||||||
}
|
}
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||||
+ #btns * (m.btnH + gap) + m.btnH + pad
|
+ #btns * (m.btnH + gap) + m.btnH + pad
|
||||||
@@ -2720,7 +2981,8 @@ end
|
|||||||
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
|
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
|
||||||
-- persisted key, so one popup serves both).
|
-- persisted key, so one popup serves both).
|
||||||
local function buildSortModal(imp, m)
|
local function buildSortModal(imp, m)
|
||||||
local defs = sortDefs()
|
local scope = imp._sortPopup
|
||||||
|
local defs = sortDefs(scope)
|
||||||
local pad = math.floor(18 * m.s)
|
local pad = math.floor(18 * m.s)
|
||||||
local w = math.floor(360 * m.s)
|
local w = math.floor(360 * m.s)
|
||||||
local gap = math.floor(8 * m.s)
|
local gap = math.floor(8 * m.s)
|
||||||
@@ -2730,7 +2992,7 @@ local function buildSortModal(imp, m)
|
|||||||
local cy = py + pad
|
local cy = py + pad
|
||||||
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
|
Kit.text("button", Strings("Sort by"), px + pad, cy, PAL.heading)
|
||||||
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||||
local cur = currentSort(imp)
|
local cur = currentSort(imp, scope)
|
||||||
for _, s in ipairs(defs) do
|
for _, s in ipairs(defs) do
|
||||||
local key = s.key
|
local key = s.key
|
||||||
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, {
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "sortpop-" .. key, s.label, {
|
||||||
@@ -2780,6 +3042,32 @@ local function buildModScopeModal(imp, m)
|
|||||||
action = function() imp._modScopePopup = nil end })
|
action = function() imp._modScopePopup = nil end })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The cartridge dropdown's list. Replaces the four R/B/Y/G tabs, so it is
|
||||||
|
-- also what a controller reaches after the tab row.
|
||||||
|
local function buildGameModal(imp, m)
|
||||||
|
local pad = math.floor(18 * m.s)
|
||||||
|
local gap = math.floor(8 * m.s)
|
||||||
|
local w = math.floor(360 * m.s)
|
||||||
|
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||||
|
+ #GAME_TABS * (m.btnH + gap) + m.btnH + pad
|
||||||
|
local px, py, pw = modalPanel(m, w, h)
|
||||||
|
local cy = py + pad
|
||||||
|
Kit.text("button", Strings("Choose game"), px + pad, cy, PAL.heading)
|
||||||
|
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
|
||||||
|
local chrome = headerChrome(imp)
|
||||||
|
for _, g in ipairs(GAME_TABS) do
|
||||||
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-" .. g.id,
|
||||||
|
Strings(g.label), {
|
||||||
|
face = "tab", font = "small", letter = g.letter, color = g.color,
|
||||||
|
active = imp.tab == g.id,
|
||||||
|
action = chrome.tab[g.id] })
|
||||||
|
cy = cy + m.btnH + gap
|
||||||
|
end
|
||||||
|
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "gamepop-close",
|
||||||
|
Strings("Close"), { font = "small",
|
||||||
|
action = function() imp._gamePopup = nil end })
|
||||||
|
end
|
||||||
|
|
||||||
-- Category filter for FIND MODS. Two columns, because an index can list
|
-- Category filter for FIND MODS. Two columns, because an index can list
|
||||||
-- enough categories to overflow a single stacked column on a short window.
|
-- enough categories to overflow a single stacked column on a short window.
|
||||||
local function buildFilterModal(imp, m)
|
local function buildFilterModal(imp, m)
|
||||||
@@ -3084,25 +3372,43 @@ local function buildFindEntryModal(imp, m)
|
|||||||
local gap = math.floor(8 * m.s)
|
local gap = math.floor(8 * m.s)
|
||||||
local nBtns = 3 -- install row, details/source row, close row
|
local nBtns = 3 -- install row, details/source row, close row
|
||||||
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
|
local noteH = note and (Kit.textHeight("small") + math.floor(4 * m.s)) or 0
|
||||||
|
local stats = imp:_findStats(entry)
|
||||||
|
local trend = {}
|
||||||
|
local trendLine = stats and ModUpdate.trendingLine(stats.recent, stats.windowDays)
|
||||||
|
if trendLine then trend[#trend + 1] = trendLine end
|
||||||
|
if stats and stats.asOf then
|
||||||
|
trend[#trend + 1] = Strings("counts approximate, as of %s",
|
||||||
|
tostring(stats.asOf):match("^%d%d%d%d%-%d%d%-%d%d") or stats.asOf)
|
||||||
|
end
|
||||||
|
trend = (#trend > 0) and table.concat(trend, " - ") or nil
|
||||||
|
local trendH = trend and (Kit.textHeight("small") + math.floor(2 * m.s)) or 0
|
||||||
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||||
+ Kit.textHeight("small") + noteH + math.floor(12 * m.s)
|
+ Kit.textHeight("small") + trendH + noteH + math.floor(12 * m.s)
|
||||||
+ nBtns * (m.btnH + gap) - gap + pad
|
+ nBtns * (m.btnH + gap) - gap + pad
|
||||||
local px, py, pw = modalPanel(m, w, h)
|
local px, py, pw = modalPanel(m, w, h)
|
||||||
local cy = py + pad
|
local cy = py + pad
|
||||||
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
|
Kit.text("button", Kit.ellipsize("button", entry.title or entry.id,
|
||||||
pw - 2 * pad), px + pad, cy, PAL.heading)
|
pw - 2 * pad), px + pad, cy, PAL.heading)
|
||||||
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
|
||||||
local stats = imp:_findStats(entry)
|
|
||||||
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
local lead = "v" .. tostring(ModIndex.displayVersion(entry))
|
||||||
if entry.author then lead = lead .. " - " .. entry.author end
|
if entry.author then lead = lead .. " - " .. entry.author end
|
||||||
if entry.categories and entry.categories[1] then
|
if entry.categories and entry.categories[1] then
|
||||||
lead = lead .. " - " .. entry.categories[1]
|
lead = lead .. " - " .. entry.categories[1]
|
||||||
end
|
end
|
||||||
local dl = stats and ModUpdate.downloadsLine(stats.total) or nil
|
local dl = stats and ModUpdate.downloadsShort(stats.total) or nil
|
||||||
|
local hasCount = stats ~= nil and stats.total ~= nil
|
||||||
local segs = { { lead, PAL.detail } }
|
local segs = { { lead, PAL.detail } }
|
||||||
if dl then segs[#segs + 1] = { " - " .. dl, PAL.green } end
|
if dl then
|
||||||
|
segs[#segs + 1] = { " - " .. dl, hasCount and PAL.green or PAL.faint }
|
||||||
|
end
|
||||||
segLine("small", segs, px + pad, cy, pw - 2 * pad)
|
segLine("small", segs, px + pad, cy, pw - 2 * pad)
|
||||||
cy = cy + Kit.textHeight("small")
|
cy = cy + Kit.textHeight("small")
|
||||||
|
if trend then
|
||||||
|
cy = cy + math.floor(2 * m.s)
|
||||||
|
Kit.text("small", Kit.ellipsize("small", trend, pw - 2 * pad),
|
||||||
|
px + pad, cy, PAL.muted)
|
||||||
|
cy = cy + Kit.textHeight("small")
|
||||||
|
end
|
||||||
if note then
|
if note then
|
||||||
cy = cy + math.floor(4 * m.s)
|
cy = cy + math.floor(4 * m.s)
|
||||||
Kit.text("small", note, px + pad, cy, PAL.green)
|
Kit.text("small", note, px + pad, cy, PAL.green)
|
||||||
@@ -3612,6 +3918,7 @@ local function modalUp(imp)
|
|||||||
or imp._appPatchNotes
|
or imp._appPatchNotes
|
||||||
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
|
||||||
or imp._filterPopup or imp._modScopePopup or imp._indexManage
|
or imp._filterPopup or imp._modScopePopup or imp._indexManage
|
||||||
|
or imp._gamePopup
|
||||||
or imp._modActions or imp._modImports
|
or imp._modActions or imp._modImports
|
||||||
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
|
||||||
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
|
||||||
@@ -3753,6 +4060,7 @@ local function buildModals(imp, m)
|
|||||||
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
|
||||||
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
|
||||||
if imp._sortPopup then buildSortModal(imp, m) return true end
|
if imp._sortPopup then buildSortModal(imp, m) return true end
|
||||||
|
if imp._gamePopup then buildGameModal(imp, m) return true end
|
||||||
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
|
if imp._modScopePopup then buildModScopeModal(imp, m) return true end
|
||||||
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
if imp._filterPopup then buildFilterModal(imp, m) return true end
|
||||||
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
if imp._indexManage then buildIndexesModal(imp, m) return true end
|
||||||
@@ -3906,6 +4214,8 @@ function LauncherView.draw(imp)
|
|||||||
buildModsPanel(imp, x, contentY, w, availH, m)
|
buildModsPanel(imp, x, contentY, w, availH, m)
|
||||||
elseif imp.tab == "find" then
|
elseif imp.tab == "find" then
|
||||||
buildFindPanel(imp, x, contentY, w, availH, m)
|
buildFindPanel(imp, x, contentY, w, availH, m)
|
||||||
|
elseif imp.tab == "skins" then
|
||||||
|
buildSkinsPanel(imp, x, contentY, w, availH, m)
|
||||||
else
|
else
|
||||||
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
|
buildGamePanel(imp, x, contentY, w, availH, m, imp.tab)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
|
|||||||
-- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit
|
-- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit
|
||||||
-- pointer, so the bank has to come from here.
|
-- pointer, so the bank has to come from here.
|
||||||
local PAL_MAP_BANK = 0x02
|
local PAL_MAP_BANK = 0x02
|
||||||
|
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
|
||||||
|
-- carries no manifest symbol to resolve it through.
|
||||||
|
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
|
||||||
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
|
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
|
||||||
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
|
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
|
||||||
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
|
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
|
||||||
@@ -4912,6 +4915,14 @@ function RomExtractorGen2:extractMenuGfx()
|
|||||||
hud.expBarFirstTile = 0x55
|
hud.expBarFirstTile = 0x55
|
||||||
hud.expBarCells = 9
|
hud.expBarCells = 9
|
||||||
|
|
||||||
|
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
|
||||||
|
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
|
||||||
|
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX
|
||||||
|
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8,
|
||||||
|
"battle/hud/balls.png", true)
|
||||||
|
hud.balls = "assets/generated/battle/hud/balls.png"
|
||||||
|
hud.ballsFirstTile = 0x31
|
||||||
|
|
||||||
-- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on-
|
-- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on-
|
||||||
-- transparent font page: the bar's rule is shade 3 (black) while its fill is
|
-- transparent font page: the bar's rule is shade 3 (black) while its fill is
|
||||||
-- shade 1/2 (the HP colour), so flattening it to one ink loses the very
|
-- shade 1/2 (the HP colour), so flattening it to one ink loses the very
|
||||||
|
|||||||
@@ -192,20 +192,28 @@ local PAL = {
|
|||||||
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
chipInkGold = { 58, 44, 0 }, -- #3a2c00 dark "Y" on the gold chip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Per-version required cache files. Gold replaces the Gen 1 list entirely
|
||||||
|
-- (VERSION_REQUIRED_FILES_OVERRIDE); Yellow adds a few extra markers.
|
||||||
|
local function requiredFilesFor(version)
|
||||||
|
local override = VERSION_REQUIRED_FILES_OVERRIDE[version]
|
||||||
|
if override then return override, true end
|
||||||
|
return REQUIRED_FILES, false
|
||||||
|
end
|
||||||
|
|
||||||
-- CacheFs.exists checks the game folder directly for a portable install,
|
-- CacheFs.exists checks the game folder directly for a portable install,
|
||||||
-- otherwise the save directory through love.filesystem. It honors
|
-- otherwise the save directory through love.filesystem. It honors
|
||||||
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
-- CacheFs.prefix, so we point it at the version's cache subtree (red/,
|
||||||
-- blue/, yellow/).
|
-- blue/, yellow/, gold/).
|
||||||
local function allRequiredFilesExist(version)
|
local function allRequiredFilesExist(version)
|
||||||
local CacheFs = require("src.import.CacheFs")
|
local CacheFs = require("src.import.CacheFs")
|
||||||
local saved = CacheFs.prefix
|
local saved = CacheFs.prefix
|
||||||
CacheFs.prefix = GameVersion.cachePrefix(version)
|
CacheFs.prefix = GameVersion.cachePrefix(version)
|
||||||
local ok = true
|
local ok = true
|
||||||
local required = VERSION_REQUIRED_FILES_OVERRIDE[version] or REQUIRED_FILES
|
local required, isOverride = requiredFilesFor(version)
|
||||||
for _, path in ipairs(required) do
|
for _, path in ipairs(required) do
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
if not CacheFs.exists(path) then ok = false; break end
|
||||||
end
|
end
|
||||||
if ok and not VERSION_REQUIRED_FILES_OVERRIDE[version] then
|
if ok and not isOverride then
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
if not CacheFs.exists(path) then ok = false; break end
|
if not CacheFs.exists(path) then ok = false; break end
|
||||||
end
|
end
|
||||||
@@ -215,20 +223,23 @@ local function allRequiredFilesExist(version)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- A developer checkout / Python build leaves generated data in the physfs
|
-- A developer checkout / Python build leaves generated data in the physfs
|
||||||
-- source: Red at the historical root, Blue/Yellow in their versioned trees.
|
-- source: Red at the historical root, Blue/Yellow/Gold in their versioned
|
||||||
-- Imported Red caches still live under red/. Check source paths directly so
|
-- trees. Imported Red caches still live under red/. Check source paths
|
||||||
-- that cache prefix cannot hide Red's source tree, and keep save-dir caches
|
-- directly so that cache prefix cannot hide Red's source tree, and keep
|
||||||
-- from counting as current source data.
|
-- save-dir caches from counting as current source data.
|
||||||
local function sourceTreeHasData(version)
|
local function sourceTreeHasData(version)
|
||||||
if not love.filesystem.getRealDirectory then return false end
|
if not love.filesystem.getRealDirectory then return false end
|
||||||
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
local prefix = version == "red" and "" or GameVersion.cachePrefix(version)
|
||||||
for _, path in ipairs(REQUIRED_FILES) do
|
local required, isOverride = requiredFilesFor(version)
|
||||||
|
for _, path in ipairs(required) do
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
end
|
end
|
||||||
|
if not isOverride then
|
||||||
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
for _, path in ipairs(VERSION_REQUIRED_FILES[version] or {}) do
|
||||||
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
if love.filesystem.getInfo(prefix .. path, "file") == nil then return false end
|
||||||
end
|
end
|
||||||
local path = prefix .. REQUIRED_FILES[1]
|
end
|
||||||
|
local path = prefix .. required[1]
|
||||||
local real = love.filesystem.getRealDirectory(path)
|
local real = love.filesystem.getRealDirectory(path)
|
||||||
return real == love.filesystem.getSource()
|
return real == love.filesystem.getSource()
|
||||||
end
|
end
|
||||||
@@ -285,7 +296,7 @@ local function purgeSaveDirCache()
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
-- Purge each version's stale save-directory copy (under its red/ / blue/
|
-- Purge each version's stale save-directory copy (under its red/ / blue/
|
||||||
-- / yellow/ prefix) so it cannot shadow the portable game-folder cache.
|
-- / yellow/ / gold/ prefix) so it cannot shadow the portable game-folder cache.
|
||||||
for _, version in ipairs(GameVersion.ORDER) do
|
for _, version in ipairs(GameVersion.ORDER) do
|
||||||
local prefix = GameVersion.cachePrefix(version)
|
local prefix = GameVersion.cachePrefix(version)
|
||||||
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
if saveDirHas(prefix .. MARKER_PATH) or saveDirHas(prefix .. REQUIRED_FILES[1]) then
|
||||||
@@ -988,8 +999,18 @@ local function pickerHasKind(kind)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
local function findPendingRequiredImport()
|
local function findPendingRequiredImport(self)
|
||||||
local names = { "picked_required_import.bin", "picked_stadium.z64" }
|
local names = { "picked_required_import.bin", "picked_stadium.z64" }
|
||||||
|
-- Builds released before required_import was added to the Android JNI bridge
|
||||||
|
-- only understand the long-standing "rom" picker kind. While a required
|
||||||
|
-- import request is in flight, it is safe to treat its staging name as a
|
||||||
|
-- dependency file: the pending IDs below select the same validation/copy
|
||||||
|
-- path as a current bridge. Never scan picked_rom.gb otherwise, since that
|
||||||
|
-- remains reserved for an ordinary game-ROM import.
|
||||||
|
if self and self.requiredImportLegacyRomPick
|
||||||
|
and self.pickerPendingKind == "required_import" then
|
||||||
|
names[#names + 1] = "picked_rom.gb"
|
||||||
|
end
|
||||||
for _, name in ipairs(names) do
|
for _, name in ipairs(names) do
|
||||||
if love.filesystem.getInfo(name, "file") then return name end
|
if love.filesystem.getInfo(name, "file") then return name end
|
||||||
end
|
end
|
||||||
@@ -1089,6 +1110,39 @@ local function chooseZip()
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function chooseSkinZip()
|
||||||
|
local prompt = shellSafe(Strings("Choose a skin .zip"))
|
||||||
|
local platform = love.system.getOS()
|
||||||
|
if platform == "OS X" then
|
||||||
|
return commandOutput(
|
||||||
|
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"zip"})' 2>/dev/null]])
|
||||||
|
:format(prompt))
|
||||||
|
elseif platform == "Windows" then
|
||||||
|
local script = table.concat({
|
||||||
|
"Add-Type -AssemblyName System.Windows.Forms;",
|
||||||
|
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
|
||||||
|
"$d.Title='" .. prompt .. "';",
|
||||||
|
"$d.Filter='Skin archive (*.zip)|*.zip|All files (*.*)|*.*';",
|
||||||
|
"if($d.ShowDialog() -eq 'OK'){",
|
||||||
|
"$n=[IO.Path]::GetFileName($d.FileName) -replace '[^\\x20-\\x7E]','_';",
|
||||||
|
"$t=Join-Path $env:TEMP $n;",
|
||||||
|
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
|
||||||
|
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
|
||||||
|
"[Console]::Write($t)}",
|
||||||
|
})
|
||||||
|
return commandOutput(
|
||||||
|
'powershell -NoProfile -STA -Command "' .. script .. '"')
|
||||||
|
elseif platform == "Linux" then
|
||||||
|
local path = commandOutput(
|
||||||
|
([[zenity --file-selection --title="%s" --file-filter="Skin archive | *.zip" 2>/dev/null]])
|
||||||
|
:format(prompt))
|
||||||
|
if path then return path end
|
||||||
|
return commandOutput(
|
||||||
|
[[kdialog --getopenfilename "$HOME" "*.zip|Skin archive" 2>/dev/null]])
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
|
-- Open a native picker for a raw .sav battery save (mirrors chooseZip's per-OS
|
||||||
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
-- dialogs). Returns the chosen absolute path or nil. Android uses
|
||||||
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
-- love.system.pickFile("sav") instead -- see RomImporter:chooseSaveImport.
|
||||||
@@ -1224,6 +1278,7 @@ function RomImporter.new(onComplete, opts)
|
|||||||
forceImport = opts.forceImport or false,
|
forceImport = opts.forceImport or false,
|
||||||
onEditSave = opts.onEditSave,
|
onEditSave = opts.onEditSave,
|
||||||
onEditTouchControls = opts.onEditTouchControls,
|
onEditTouchControls = opts.onEditTouchControls,
|
||||||
|
onOpenSkinStudio = opts.onOpenSkinStudio,
|
||||||
isNX = isNX,
|
isNX = isNX,
|
||||||
romImportMode = romImportMode,
|
romImportMode = romImportMode,
|
||||||
mobileFileBridge = mobileFileBridge,
|
mobileFileBridge = mobileFileBridge,
|
||||||
@@ -1470,14 +1525,23 @@ function RomImporter:focus(f)
|
|||||||
local text = "Could not read the picked file. Reopen the picker and choose "
|
local text = "Could not read the picked file. Reopen the picker and choose "
|
||||||
.. "it with the Files (Documents) app, or copy it into: "
|
.. "it with the Files (Documents) app, or copy it into: "
|
||||||
.. love.filesystem.getSaveDirectory()
|
.. love.filesystem.getSaveDirectory()
|
||||||
|
local legacyRequiredPick = self.requiredImportLegacyRomPick
|
||||||
|
and self.pickerPendingKind == "required_import"
|
||||||
if pickError:find("picked_required_import", 1, true)
|
if pickError:find("picked_required_import", 1, true)
|
||||||
or pickError:find("picked_stadium", 1, true) then
|
or pickError:find("picked_stadium", 1, true)
|
||||||
|
or (legacyRequiredPick and pickError:find("picked_rom", 1, true)) then
|
||||||
self.modNotice = { ok = false, text = text }
|
self.modNotice = { ok = false, text = text }
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
self.pickerPendingModId = nil
|
self.pickerPendingModId = nil
|
||||||
self.pickerPendingImportId = nil
|
self.pickerPendingImportId = nil
|
||||||
|
self.requiredImportLegacyRomPick = nil
|
||||||
elseif pickError:find("picked_mod", 1, true) then
|
elseif pickError:find("picked_mod", 1, true) then
|
||||||
|
if self.pickerPendingKind == "skin" then
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self._skinNotice = { ok = false, text = text }
|
||||||
|
else
|
||||||
self.modNotice = { ok = false, text = text }
|
self.modNotice = { ok = false, text = text }
|
||||||
|
end
|
||||||
elseif pickError:find("picked_save", 1, true) then
|
elseif pickError:find("picked_save", 1, true) then
|
||||||
local version = self.androidPendingVersion or self:_savedropTarget()
|
local version = self.androidPendingVersion or self:_savedropTarget()
|
||||||
self.androidPendingVersion = nil
|
self.androidPendingVersion = nil
|
||||||
@@ -1487,11 +1551,12 @@ function RomImporter:focus(f)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
local requiredName = findPendingRequiredImport()
|
local requiredName = findPendingRequiredImport(self)
|
||||||
if requiredName then
|
if requiredName then
|
||||||
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||||
|
self.requiredImportLegacyRomPick = nil
|
||||||
local imported = modId and importId
|
local imported = modId and importId
|
||||||
and self:_importRequiredSource(modId, importId, requiredName)
|
and self:_importRequiredSource(modId, importId, requiredName)
|
||||||
consumePick(self, requiredName, requiredName, imported)
|
consumePick(self, requiredName, requiredName, imported)
|
||||||
@@ -1503,6 +1568,13 @@ function RomImporter:focus(f)
|
|||||||
end
|
end
|
||||||
local modName = findPendingMod(false, self.pickSkip)
|
local modName = findPendingMod(false, self.pickSkip)
|
||||||
if modName then
|
if modName then
|
||||||
|
if self.pickerPendingKind == "skin" then
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self:_installSkinZip(modName)
|
||||||
|
consumePick(self, modName, "picked_mod.zip",
|
||||||
|
self._skinNotice and self._skinNotice.ok)
|
||||||
|
return
|
||||||
|
end
|
||||||
self:_installMod(modName)
|
self:_installMod(modName)
|
||||||
consumePick(self, modName, "picked_mod.zip",
|
consumePick(self, modName, "picked_mod.zip",
|
||||||
self.modNotice and self.modNotice.ok)
|
self.modNotice and self.modNotice.ok)
|
||||||
@@ -1784,7 +1856,12 @@ function RomImporter:filedropped(file)
|
|||||||
-- readDroppedFile does here.
|
-- readDroppedFile does here.
|
||||||
local name = file:getFilename() or ""
|
local name = file:getFilename() or ""
|
||||||
if name:lower():match("%.zip$") then
|
if name:lower():match("%.zip$") then
|
||||||
|
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
|
||||||
|
if self.tab == "skins" then
|
||||||
|
self:_installSkinZip(file)
|
||||||
|
else
|
||||||
self:_installMod(file)
|
self:_installMod(file)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- A dropped .sav is a battery save: import it to a new slot for the active
|
-- A dropped .sav is a battery save: import it to a new slot for the active
|
||||||
@@ -2018,7 +2095,17 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
if self.nativePicker then
|
if self.nativePicker then
|
||||||
if self.mobileFileBridge and not pickerHasKind("required_import") then
|
-- Android 13+ uses the Storage Access Framework for both paths. Some
|
||||||
|
-- Android 15 installs carry the newer Lua launcher with an older native
|
||||||
|
-- bridge, however, so they do not advertise required_import yet. Fall
|
||||||
|
-- back to that bridge's known "rom" picker and quarantine its result by
|
||||||
|
-- the pending required-import IDs. iOS has a different asynchronous
|
||||||
|
-- bridge and deliberately keeps the explicit capability requirement.
|
||||||
|
local legacyAndroidPicker = self.mobileFileBridge
|
||||||
|
and love.system.getOS() == "Android"
|
||||||
|
and not pickerHasKind("required_import")
|
||||||
|
if self.mobileFileBridge and not pickerHasKind("required_import")
|
||||||
|
and not legacyAndroidPicker then
|
||||||
requiredImportNotice(self, modId, importId,
|
requiredImportNotice(self, modId, importId,
|
||||||
"This app build cannot pick required mod files yet. Update the app and try again.")
|
"This app build cannot pick required mod files yet. Update the app and try again.")
|
||||||
self.modNotice = nil
|
self.modNotice = nil
|
||||||
@@ -2027,10 +2114,12 @@ function RomImporter:chooseRequiredImport(modId, importId)
|
|||||||
self.pickerPendingKind = "required_import"
|
self.pickerPendingKind = "required_import"
|
||||||
self.pickerPendingModId = modId
|
self.pickerPendingModId = modId
|
||||||
self.pickerPendingImportId = importId
|
self.pickerPendingImportId = importId
|
||||||
if not pickFile("required_import") then
|
self.requiredImportLegacyRomPick = legacyAndroidPicker or nil
|
||||||
|
if not pickFile(legacyAndroidPicker and "rom" or "required_import") then
|
||||||
self.pickerPendingKind = nil
|
self.pickerPendingKind = nil
|
||||||
self.pickerPendingModId = nil
|
self.pickerPendingModId = nil
|
||||||
self.pickerPendingImportId = nil
|
self.pickerPendingImportId = nil
|
||||||
|
self.requiredImportLegacyRomPick = nil
|
||||||
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
|
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
|
||||||
self.modNotice = nil
|
self.modNotice = nil
|
||||||
elseif self.android then
|
elseif self.android then
|
||||||
@@ -2492,6 +2581,11 @@ function RomImporter:update(dt)
|
|||||||
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
|
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
end
|
end
|
||||||
|
elseif kind == "skin" then
|
||||||
|
self:_installSkinZip(path)
|
||||||
|
if Platform.isUWP() and self._skinNotice and self._skinNotice.ok then
|
||||||
|
os.remove(path)
|
||||||
|
end
|
||||||
elseif kind == "sav" then
|
elseif kind == "sav" then
|
||||||
local target = version or self:_savedropTarget()
|
local target = version or self:_savedropTarget()
|
||||||
self:_importSave(target, path)
|
self:_importSave(target, path)
|
||||||
@@ -2514,6 +2608,8 @@ function RomImporter:update(dt)
|
|||||||
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
|
||||||
elseif kind == "mod" then
|
elseif kind == "mod" then
|
||||||
self.modNotice = { ok = false, text = errorText }
|
self.modNotice = { ok = false, text = errorText }
|
||||||
|
elseif kind == "skin" then
|
||||||
|
self._skinNotice = { ok = false, text = errorText }
|
||||||
elseif kind == "sav" then
|
elseif kind == "sav" then
|
||||||
self.saveNotice[version] = { ok = false, text = errorText }
|
self.saveNotice[version] = { ok = false, text = errorText }
|
||||||
else
|
else
|
||||||
@@ -2621,7 +2717,7 @@ function RomImporter:resumeAfterOverlay()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function RomImporter:_cycleTab(delta)
|
function RomImporter:_cycleTab(delta)
|
||||||
local order = { "red", "blue", "yellow", "gold", "mods", "find" }
|
local order = { "red", "blue", "yellow", "gold", "mods", "find", "skins" }
|
||||||
local idx = 1
|
local idx = 1
|
||||||
for i, id in ipairs(order) do
|
for i, id in ipairs(order) do
|
||||||
if id == self.tab then idx = i; break end
|
if id == self.tab then idx = i; break end
|
||||||
@@ -2987,11 +3083,137 @@ function RomImporter:_switchTab(id)
|
|||||||
self.tab = id
|
self.tab = id
|
||||||
self._findSearchFocus = false
|
self._findSearchFocus = false
|
||||||
self:_disarmTextInput()
|
self:_disarmTextInput()
|
||||||
|
-- the skins list is cheap and can change behind the launcher's back
|
||||||
|
-- (an export, a hand-dropped folder), so re-read it on every visit
|
||||||
|
if id == "skins" then self:_ensureSkins(true) end
|
||||||
if GameVersion.VERSIONS[id] then
|
if GameVersion.VERSIONS[id] then
|
||||||
self:_setModScope(id)
|
self:_setModScope(id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- ------- skins tab (touch skins + the desktop Skin Studio)
|
||||||
|
|
||||||
|
function RomImporter:_ensureSkins(force)
|
||||||
|
if self._skins and not force then return self._skins end
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local out = {}
|
||||||
|
for _, entry in ipairs(TouchSkin.list()) do
|
||||||
|
local skin = TouchSkin.load(entry.root, entry.id)
|
||||||
|
local page = skin and skin.pages[1]
|
||||||
|
local controls = 0
|
||||||
|
for _, ctl in ipairs(page and page.controls or {}) do
|
||||||
|
if not ctl.decorative then controls = controls + 1 end
|
||||||
|
end
|
||||||
|
out[#out + 1] = {
|
||||||
|
id = entry.id,
|
||||||
|
source = entry.source,
|
||||||
|
pages = skin and #skin.pages or 0,
|
||||||
|
controls = controls,
|
||||||
|
screen = page ~= nil and page.viewport ~= nil,
|
||||||
|
ok = skin ~= nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
self._skins = out
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_activeSkin()
|
||||||
|
local opts = require("src.core.SaveData").loadOptions()
|
||||||
|
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||||
|
return tc.skin
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_useSkin(id)
|
||||||
|
local SaveData = require("src.core.SaveData")
|
||||||
|
local opts = SaveData.loadOptions()
|
||||||
|
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
||||||
|
tc.enabled = true
|
||||||
|
tc.skin = id
|
||||||
|
opts.touchControls = tc
|
||||||
|
SaveData.saveOptions(opts)
|
||||||
|
self._skinNotice = {
|
||||||
|
ok = true,
|
||||||
|
text = id and ("Now using " .. id) or "Now using the built-in pad",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_installSkinZip(source)
|
||||||
|
if self.workState == "working" then return end
|
||||||
|
self.tab = "skins"
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
local name, data, readError
|
||||||
|
if type(source) == "string" then
|
||||||
|
name = source
|
||||||
|
if not source:match("^/") and not source:match("^%a:[/\\]")
|
||||||
|
and not source:match("^[Ss][Dd][Mm][Cc]:") then
|
||||||
|
data = love.filesystem.read(source)
|
||||||
|
end
|
||||||
|
if not data then data, readError = readExternalPath(source) end
|
||||||
|
if not data then data = love.filesystem.read(source) end
|
||||||
|
else
|
||||||
|
name = source:getFilename() or ""
|
||||||
|
data, readError = readDroppedFile(source)
|
||||||
|
end
|
||||||
|
if not data then
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = "Could not read the skin archive: "
|
||||||
|
.. tostring(readError or name) }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local id, err = TouchSkin.installArchive(name, data)
|
||||||
|
self:_ensureSkins(true)
|
||||||
|
if not id then
|
||||||
|
self._skinNotice = { ok = false, text = "Import failed: " .. tostring(err) }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self._skinNotice = { ok = true, text = "Imported " .. id }
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:_skinsImportButtonLabel()
|
||||||
|
if self.isNX then return Strings("Scan again") end
|
||||||
|
return Strings("Import skin .zip")
|
||||||
|
end
|
||||||
|
|
||||||
|
function RomImporter:chooseSkin()
|
||||||
|
if self.workState == "working" then return end
|
||||||
|
if self.isNX then
|
||||||
|
local found = #self:_ensureSkins(true)
|
||||||
|
self._skinNotice = { ok = true, text = Strings(
|
||||||
|
"%d skins found. Copy a skin .zip into %s/ over MTP, then scan again.",
|
||||||
|
found, require("src.core.TouchSkin").USER_ROOT) }
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.nativePicker and love.system.getPickedFile then
|
||||||
|
self.pickerPendingKind = "skin"
|
||||||
|
if not pickFile("mod") then
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self._skinNotice = { ok = false, text = "Could not open the file picker." }
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if self.android then
|
||||||
|
local name = findPendingMod(true, self.pickSkip)
|
||||||
|
if name then
|
||||||
|
self:_installSkinZip(name)
|
||||||
|
consumePick(self, name, "picked_mod.zip",
|
||||||
|
self._skinNotice and self._skinNotice.ok)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.pickerPendingKind = "skin"
|
||||||
|
if not pickFile("mod") then
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self._skinNotice = { ok = false,
|
||||||
|
text = "Could not open the file picker. Copy a skin .zip via USB." }
|
||||||
|
else
|
||||||
|
self.pickPending = true
|
||||||
|
self.pickTimer = 0
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local path = chooseSkinZip()
|
||||||
|
if path then self:_installSkinZip(path) end
|
||||||
|
end
|
||||||
|
|
||||||
function RomImporter:_toggleFindSearchFocus()
|
function RomImporter:_toggleFindSearchFocus()
|
||||||
self._findSearchFocus = not self._findSearchFocus
|
self._findSearchFocus = not self._findSearchFocus
|
||||||
if self._findSearchFocus then
|
if self._findSearchFocus then
|
||||||
@@ -3018,6 +3240,13 @@ function RomImporter:_openSettings()
|
|||||||
self.onEditTouchControls(version)
|
self.onEditTouchControls(version)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
if self.onOpenSkinStudio then
|
||||||
|
local version = self.tab
|
||||||
|
hooks.openSkinStudio = function(skinId)
|
||||||
|
self:_closeSettings()
|
||||||
|
self.onOpenSkinStudio(version, skinId)
|
||||||
|
end
|
||||||
|
end
|
||||||
-- The tab the gear was opened on decides the row set: Gold reads a
|
-- The tab the gear was opened on decides the row set: Gold reads a
|
||||||
-- different option block entirely, and offering it Gen 1's rows meant a
|
-- different option block entirely, and offering it Gen 1's rows meant a
|
||||||
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
|
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
|
||||||
@@ -3514,7 +3743,17 @@ function RomImporter:_setAllMods(want, confirmed)
|
|||||||
local LauncherMods = require("src.mods.LauncherMods")
|
local LauncherMods = require("src.mods.LauncherMods")
|
||||||
local ids, experimental = {}, false
|
local ids, experimental = {}, false
|
||||||
for _, m in ipairs(self.mods or {}) do
|
for _, m in ipairs(self.mods or {}) do
|
||||||
if m.enabled ~= want then
|
local mismatched = m.enabled ~= want
|
||||||
|
if not self.modScope and type(m.enabledByVersion) == "table" then
|
||||||
|
mismatched = false
|
||||||
|
for _, game in ipairs(GameVersion.ORDER) do
|
||||||
|
if m.enabledByVersion[game] ~= want then
|
||||||
|
mismatched = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if mismatched then
|
||||||
ids[#ids + 1] = m.id
|
ids[#ids + 1] = m.id
|
||||||
if want and m.experimental then experimental = true end
|
if want and m.experimental then experimental = true end
|
||||||
end
|
end
|
||||||
@@ -4096,25 +4335,12 @@ function RomImporter:_findRows()
|
|||||||
category = self.findCategory,
|
category = self.findCategory,
|
||||||
})
|
})
|
||||||
if self.modScope then
|
if self.modScope then
|
||||||
|
local ModTargets = require("src.mods.ModTargets")
|
||||||
local gen = GameVersion.generation(self.modScope)
|
local gen = GameVersion.generation(self.modScope)
|
||||||
local kept = {}
|
local kept = {}
|
||||||
for _, entry in ipairs(rows) do
|
for _, entry in ipairs(rows) do
|
||||||
local has1, has2 = false, false
|
local versions = ModTargets.normalize(entry.games)
|
||||||
local function note(s)
|
if #versions == 0 or ModTargets.covers(versions, gen) then
|
||||||
s = tostring(s or ""):lower()
|
|
||||||
if s == "gen1" or s == "gen 1" or s == "red" or s == "blue"
|
|
||||||
or s == "yellow" then
|
|
||||||
has1 = true
|
|
||||||
end
|
|
||||||
if s == "gen2" or s == "gen 2" or s == "gold" then
|
|
||||||
has2 = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
for _, cat in ipairs(entry.categories or {}) do note(cat) end
|
|
||||||
for _, tag in ipairs(entry.tags or {}) do note(tag) end
|
|
||||||
if (not has1 and not has2)
|
|
||||||
or (gen == 2 and has2)
|
|
||||||
or (gen ~= 2 and has1) then
|
|
||||||
kept[#kept + 1] = entry
|
kept[#kept + 1] = entry
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4223,9 +4449,14 @@ function RomImporter:_findStatsCached(entry)
|
|||||||
end
|
end
|
||||||
return nil -- retry window open; _requestFindStats decides what to do
|
return nil -- retry window open; _requestFindStats decides what to do
|
||||||
end
|
end
|
||||||
if entry.downloads ~= nil or entry.first_release or entry.last_release then
|
local ModIndex = require("src.mods.ModIndex")
|
||||||
cached = { total = entry.downloads, first = entry.first_release,
|
local dl = ModIndex.downloadStats(entry)
|
||||||
latest = entry.last_release, done = true }
|
local dates = ModIndex.releaseDates(entry)
|
||||||
|
if dl or dates then
|
||||||
|
cached = { total = dl and dl.total, recent = dl and dl.recent,
|
||||||
|
windowDays = dl and dl.window_days, asOf = dl and dl.as_of,
|
||||||
|
first = dates and dates.first, latest = dates and dates.latest,
|
||||||
|
done = true }
|
||||||
self._findStatsCache[entry.id] = cached
|
self._findStatsCache[entry.id] = cached
|
||||||
return cached
|
return cached
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function SaveFileIO.exportActiveSlot(version)
|
|||||||
fs.createDirectory("exports")
|
fs.createDirectory("exports")
|
||||||
fs.createDirectory("exports/" .. version)
|
fs.createDirectory("exports/" .. version)
|
||||||
end
|
end
|
||||||
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow).
|
-- Per-game folder so MTP browsing matches inbox layout (red/blue/yellow/gold).
|
||||||
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
local rel = ("exports/%s/gen1recomp-%s-%s.sav"):format(version, version, slotId)
|
||||||
local ok, writeErr = fs.write(rel, bytes)
|
local ok, writeErr = fs.write(rel, bytes)
|
||||||
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
if not ok then return false, "could not write the export: " .. tostring(writeErr) end
|
||||||
|
|||||||
@@ -68,14 +68,20 @@ end
|
|||||||
|
|
||||||
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
|
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
|
||||||
-- saves from before the order existed, then maintained incrementally.
|
-- saves from before the order existed, then maintained incrementally.
|
||||||
function Bag.order(save)
|
function Bag.order(save, data)
|
||||||
local order = save.bagOrder
|
local order = save.bagOrder
|
||||||
if not order then
|
if not order then
|
||||||
|
local items = (data or require("src.core.Data")).items
|
||||||
order = {}
|
order = {}
|
||||||
for id in pairs(save.inventory) do
|
for id in pairs(save.inventory) do
|
||||||
if not isBadge(id) then table.insert(order, id) end
|
if not isBadge(id) then table.insert(order, id) end
|
||||||
end
|
end
|
||||||
table.sort(order)
|
table.sort(order, function(a, b)
|
||||||
|
local ia = (items and items[a] and items[a].index) or math.huge
|
||||||
|
local ib = (items and items[b] and items[b].index) or math.huge
|
||||||
|
if ia ~= ib then return ia < ib end
|
||||||
|
return a < b
|
||||||
|
end)
|
||||||
save.bagOrder = order
|
save.bagOrder = order
|
||||||
end
|
end
|
||||||
-- drop stale ids, append unknown ones (defensive against direct
|
-- drop stale ids, append unknown ones (defensive against direct
|
||||||
@@ -95,6 +101,29 @@ function Bag.order(save)
|
|||||||
return order
|
return order
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the
|
||||||
|
-- rotate the PACK's SELECT performs, over the rows of ONE pocket.
|
||||||
|
function Bag.move(save, id, pocket, toIndex, data)
|
||||||
|
local order = Bag.order(save, data)
|
||||||
|
local slots, ids = {}, {}
|
||||||
|
for i = 1, #order do
|
||||||
|
if pocketOf(order[i], data) == pocket then
|
||||||
|
slots[#slots + 1] = i
|
||||||
|
ids[#ids + 1] = order[i]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local from
|
||||||
|
for i = 1, #ids do
|
||||||
|
if ids[i] == id then from = i break end
|
||||||
|
end
|
||||||
|
if not from then return false end
|
||||||
|
local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #ids))
|
||||||
|
if to == from then return false end
|
||||||
|
table.insert(ids, to, table.remove(ids, from))
|
||||||
|
for i = 1, #slots do order[slots[i]] = ids[i] end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||||
-- is needed and the bag is full, or when the stack would pass 99
|
-- is needed and the bag is full, or when the stack would pass 99
|
||||||
-- (AddItemToInventory's per-slot quantity cap).
|
-- (AddItemToInventory's per-slot quantity cap).
|
||||||
|
|||||||
@@ -270,7 +270,10 @@ end
|
|||||||
|
|
||||||
local function index(mods)
|
local function index(mods)
|
||||||
local byId = {}
|
local byId = {}
|
||||||
for _, mod in ipairs(mods or {}) do byId[tostring(mod.id)] = mod end
|
if type(mods) ~= "table" then return byId end
|
||||||
|
for _, mod in ipairs(mods) do
|
||||||
|
if type(mod) == "table" then byId[tostring(mod.id)] = mod end
|
||||||
|
end
|
||||||
return byId
|
return byId
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -327,7 +330,8 @@ end
|
|||||||
-- lines for the incompatibility screen: what differs, then what still works
|
-- lines for the incompatibility screen: what differs, then what still works
|
||||||
function Handshake.describe(localHello, remoteHello, verdict, mode)
|
function Handshake.describe(localHello, remoteHello, verdict, mode)
|
||||||
local lines = {}
|
local lines = {}
|
||||||
local peer = (remoteHello and remoteHello.name) or "THEY"
|
local peerName = remoteHello and remoteHello.name
|
||||||
|
local peer = type(peerName) == "string" and peerName or "THEY"
|
||||||
if verdict == "refused" then
|
if verdict == "refused" then
|
||||||
-- checked before the v1 arm for the same reason checkCompat checks it
|
-- checked before the v1 arm for the same reason checkCompat checks it
|
||||||
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
|
-- first: a Gen 1 peer meeting a Gen 2 one has no `protocol` to read yet
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
local Json = {}
|
local Json = {}
|
||||||
|
|
||||||
|
Json.MAX_DEPTH = 64
|
||||||
|
|
||||||
local function encodeValue(v, out)
|
local function encodeValue(v, out)
|
||||||
local t = type(v)
|
local t = type(v)
|
||||||
if v == nil then
|
if v == nil then
|
||||||
@@ -109,7 +111,9 @@ local function decodeString(s, i)
|
|||||||
error("unterminated string")
|
error("unterminated string")
|
||||||
end
|
end
|
||||||
|
|
||||||
decodeValue = function(s, i)
|
decodeValue = function(s, i, depth)
|
||||||
|
depth = (depth or 0) + 1
|
||||||
|
assert(depth <= Json.MAX_DEPTH, "json nested too deeply")
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local c = s:sub(i, i)
|
local c = s:sub(i, i)
|
||||||
if c == '"' then
|
if c == '"' then
|
||||||
@@ -124,7 +128,7 @@ decodeValue = function(s, i)
|
|||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
assert(s:sub(i, i) == ":", "expected :")
|
assert(s:sub(i, i) == ":", "expected :")
|
||||||
local val
|
local val
|
||||||
val, i = decodeValue(s, i + 1)
|
val, i = decodeValue(s, i + 1, depth)
|
||||||
obj[key] = val
|
obj[key] = val
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local d = s:sub(i, i)
|
local d = s:sub(i, i)
|
||||||
@@ -138,7 +142,7 @@ decodeValue = function(s, i)
|
|||||||
if s:sub(i, i) == "]" then return arr, i + 1 end
|
if s:sub(i, i) == "]" then return arr, i + 1 end
|
||||||
while true do
|
while true do
|
||||||
local val
|
local val
|
||||||
val, i = decodeValue(s, i)
|
val, i = decodeValue(s, i, depth)
|
||||||
arr[#arr + 1] = val
|
arr[#arr + 1] = val
|
||||||
i = skipWs(s, i)
|
i = skipWs(s, i)
|
||||||
local d = s:sub(i, i)
|
local d = s:sub(i, i)
|
||||||
@@ -162,9 +166,13 @@ decodeValue = function(s, i)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Json.decode(s)
|
function Json.decode(s, maxLength)
|
||||||
|
if type(s) ~= "string" then return nil, "json input is not a string" end
|
||||||
|
if maxLength and #s > maxLength then
|
||||||
|
return nil, ("json input is %d bytes (max %d)"):format(#s, maxLength)
|
||||||
|
end
|
||||||
local ok, v = pcall(function()
|
local ok, v = pcall(function()
|
||||||
local val = select(1, decodeValue(s, 1))
|
local val = select(1, decodeValue(s, 1, 0))
|
||||||
return val
|
return val
|
||||||
end)
|
end)
|
||||||
if ok then return v end
|
if ok then return v end
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ local LinkBattle = {}
|
|||||||
-- Deterministic Park-Miller PRNG: both sides must roll identical
|
-- Deterministic Park-Miller PRNG: both sides must roll identical
|
||||||
-- streams, so love.math.random can't be used.
|
-- streams, so love.math.random can't be used.
|
||||||
local function makeRng(seed)
|
local function makeRng(seed)
|
||||||
local s = seed % 2147483647
|
local s = tonumber(seed) or 1
|
||||||
|
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
|
||||||
|
s = math.floor(s) % 2147483647
|
||||||
if s <= 0 then s = s + 2147483646 end
|
if s <= 0 then s = s + 2147483646 end
|
||||||
return function(a, b)
|
return function(a, b)
|
||||||
s = (s * 16807) % 2147483647
|
s = (s * 16807) % 2147483647
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ Net.__index = Net
|
|||||||
Net.DEFAULT_PORT = 7777
|
Net.DEFAULT_PORT = 7777
|
||||||
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
|
||||||
|
|
||||||
|
Net.MAX_LINE = 256 * 1024
|
||||||
|
Net.MAX_RX_PER_FRAME = 512 * 1024
|
||||||
|
Net.ENET_BANDWIDTH = 256 * 1024
|
||||||
|
|
||||||
function Net.available()
|
function Net.available()
|
||||||
return enet ~= nil
|
return enet ~= nil
|
||||||
end
|
end
|
||||||
@@ -115,7 +119,8 @@ function Net:host(port)
|
|||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
port = tonumber(port) or Net.defaultPort()
|
port = tonumber(port) or Net.defaultPort()
|
||||||
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1)
|
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1,
|
||||||
|
Net.ENET_BANDWIDTH, Net.ENET_BANDWIDTH)
|
||||||
if not ok or not h then
|
if not ok or not h then
|
||||||
self.error = ("can't open UDP port %d (%s)"):format(
|
self.error = ("can't open UDP port %d (%s)"):format(
|
||||||
port, tostring(ok and err or h))
|
port, tostring(ok and err or h))
|
||||||
@@ -255,7 +260,7 @@ local function handleGenericRelayControl(self, msg)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Net:handleTCPLine(line)
|
function Net:handleTCPLine(line)
|
||||||
local msg = Json.decode(line)
|
local msg = Json.decode(line, Net.MAX_LINE)
|
||||||
if msg == nil then
|
if msg == nil then
|
||||||
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
Logger.warn("link: bad relay message %q", line:sub(1, 60))
|
||||||
return
|
return
|
||||||
@@ -277,6 +282,11 @@ function Net:drainLines()
|
|||||||
self.rxBuf = self.rxBuf:sub(nl + 1)
|
self.rxBuf = self.rxBuf:sub(nl + 1)
|
||||||
if #line > 0 then self:handleTCPLine(line) end
|
if #line > 0 then self:handleTCPLine(line) end
|
||||||
end
|
end
|
||||||
|
if #self.rxBuf > Net.MAX_LINE then
|
||||||
|
self.rxBuf = ""
|
||||||
|
self.error = Strings("The other side\nsent bad data.")
|
||||||
|
self.closed = true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
-- non-blocking pump for the relay TCP backend: flush queued writes, drain
|
||||||
@@ -300,10 +310,14 @@ function Net:updateTCP()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
while true do
|
local budget = Net.MAX_RX_PER_FRAME
|
||||||
|
while budget > 0 do
|
||||||
local data, err, partial = sock:receive(8192)
|
local data, err, partial = sock:receive(8192)
|
||||||
local chunk = data or partial or ""
|
local chunk = data or partial or ""
|
||||||
if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end
|
if #chunk > 0 then
|
||||||
|
self.rxBuf = self.rxBuf .. chunk
|
||||||
|
budget = budget - #chunk
|
||||||
|
end
|
||||||
if err == "closed" then
|
if err == "closed" then
|
||||||
self.closed = true
|
self.closed = true
|
||||||
break
|
break
|
||||||
@@ -350,7 +364,7 @@ function Net:update()
|
|||||||
for _, msg in ipairs(queued) do self:send(msg) end
|
for _, msg in ipairs(queued) do self:send(msg) end
|
||||||
end
|
end
|
||||||
elseif event.type == "receive" then
|
elseif event.type == "receive" then
|
||||||
local msg = Json.decode(event.data)
|
local msg = Json.decode(event.data, Net.MAX_LINE)
|
||||||
if msg ~= nil then
|
if msg ~= nil then
|
||||||
table.insert(self.inbox, msg)
|
table.insert(self.inbox, msg)
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -17,6 +17,25 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
|
|
||||||
local Protocol = {}
|
local Protocol = {}
|
||||||
|
|
||||||
|
local MAX_WIRE_NAME = 40
|
||||||
|
|
||||||
|
local function num(v, default)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n or n == math.huge or n == -math.huge then
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tbl(v)
|
||||||
|
return type(v) == "table" and v or {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function text(v)
|
||||||
|
if type(v) ~= "string" then return nil end
|
||||||
|
return v:sub(1, MAX_WIRE_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
Protocol.hello = Handshake.hello
|
Protocol.hello = Handshake.hello
|
||||||
Protocol.checkCompat = Handshake.checkCompat
|
Protocol.checkCompat = Handshake.checkCompat
|
||||||
|
|
||||||
@@ -78,6 +97,10 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
local Stats = require("src.pokemon.Stats")
|
local Stats = require("src.pokemon.Stats")
|
||||||
local Growth = require("src.pokemon.Growth")
|
local Growth = require("src.pokemon.Growth")
|
||||||
local strict = opts and opts.strict
|
local strict = opts and opts.strict
|
||||||
|
if type(packed) ~= "table" then
|
||||||
|
if strict then return nil, "unknown POKéMON" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
|
||||||
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
|
||||||
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
-- the LinkState/Tournament side (see levelForWire) that must mean "no
|
||||||
@@ -91,7 +114,7 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if strict then return nil, "unknown POKéMON" end
|
if strict then return nil, "unknown POKéMON" end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(2, math.min(100, math.floor(packed.level or 5)))
|
local level = math.max(2, math.min(100, math.floor(num(packed.level, 5))))
|
||||||
-- "auto-level" tournaments/matches: every participant's real level is
|
-- "auto-level" tournaments/matches: every participant's real level is
|
||||||
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
-- ignored and everyone rebuilds at the same fixed level instead, so a
|
||||||
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
|
||||||
@@ -99,25 +122,27 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
if forceLevel then
|
if forceLevel then
|
||||||
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
level = math.max(2, math.min(100, math.floor(forceLevel)))
|
||||||
end
|
end
|
||||||
|
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||||
dvs[k] = math.max(0, math.min(15, math.floor((packed.dvs or {})[k] or 0)))
|
dvs[k] = math.max(0, math.min(15, math.floor(num(packedDvs[k], 0))))
|
||||||
end
|
end
|
||||||
local statExp = {}
|
local statExp = {}
|
||||||
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
|
||||||
statExp[k] = math.max(0, math.min(65535, math.floor((packed.statExp or {})[k] or 0)))
|
statExp[k] = math.max(0, math.min(65535, math.floor(num(packedStatExp[k], 0))))
|
||||||
end
|
end
|
||||||
local stats = Stats.calc(def, level, dvs, statExp)
|
local stats = Stats.calc(def, level, dvs, statExp)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
for _, mv in ipairs(packed.moves or {}) do
|
for _, entry in ipairs(tbl(packed.moves)) do
|
||||||
|
local mv = tbl(entry)
|
||||||
local mdef = data.moves[mv.id]
|
local mdef = data.moves[mv.id]
|
||||||
if mdef and #moves < 4 then
|
if mdef and #moves < 4 then
|
||||||
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
|
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
|
||||||
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
|
local maxPP = mdef.pp + ppUps * math.floor(mdef.pp / 5)
|
||||||
local entry = { id = mv.id,
|
local move = { id = mv.id,
|
||||||
pp = math.max(0, math.min(maxPP, math.floor(mv.pp or 0))) }
|
pp = math.max(0, math.min(maxPP, math.floor(num(mv.pp, 0)))) }
|
||||||
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
if mv.ppUps ~= nil then move.ppUps = ppUps end
|
||||||
table.insert(moves, entry)
|
table.insert(moves, move)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #moves == 0 then
|
if #moves == 0 then
|
||||||
@@ -132,27 +157,29 @@ function Protocol.unpackMon(data, packed, opts)
|
|||||||
-- same as a standardized tournament format would
|
-- same as a standardized tournament format would
|
||||||
local forced = forceLevel
|
local forced = forceLevel
|
||||||
local hp = forced and stats.hp
|
local hp = forced and stats.hp
|
||||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
|
||||||
local status = forced and nil or packed.status
|
local status = forced and nil or text(packed.status)
|
||||||
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
|
||||||
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
|
||||||
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
|
||||||
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
|
||||||
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
|
||||||
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
-- becomes a no-op so the sender's identity survives save/reload (#215).
|
||||||
local otId = packed.otId
|
local packedOtId = num(packed.otId)
|
||||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
local otId = packedOtId
|
||||||
|
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
|
||||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||||
return {
|
return {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
level = level,
|
level = level,
|
||||||
exp = math.max(0, math.floor(packed.exp or Growth.expForLevel(def.growthRate, level))),
|
exp = math.max(0, math.floor(num(packed.exp,
|
||||||
|
Growth.expForLevel(def.growthRate, level)))),
|
||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
hp = hp,
|
hp = hp,
|
||||||
status = status,
|
status = status,
|
||||||
nickname = packed.nickname,
|
nickname = text(packed.nickname),
|
||||||
ot = ot,
|
ot = ot,
|
||||||
otId = otId,
|
otId = otId,
|
||||||
moves = moves,
|
moves = moves,
|
||||||
@@ -246,6 +273,10 @@ end
|
|||||||
function Protocol.unpackMon2(data, packed, opts)
|
function Protocol.unpackMon2(data, packed, opts)
|
||||||
local Mon = require("src.battle.gen2.Mon")
|
local Mon = require("src.battle.gen2.Mon")
|
||||||
local strict = opts and opts.strict
|
local strict = opts and opts.strict
|
||||||
|
if type(packed) ~= "table" then
|
||||||
|
if strict then return nil, "unknown POKéMON" end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
local forceLevel = opts and tonumber(opts.forceLevel) or nil
|
||||||
local def = data and data.pokemon and data.pokemon[packed.species]
|
local def = data and data.pokemon and data.pokemon[packed.species]
|
||||||
if not def then
|
if not def then
|
||||||
@@ -253,31 +284,33 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
local level = math.max(1, math.min(Mon.MAX_LEVEL,
|
local level = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||||
math.floor(packed.level or 5)))
|
math.floor(num(packed.level, 5))))
|
||||||
if forceLevel then
|
if forceLevel then
|
||||||
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
|
level = math.max(1, math.min(Mon.MAX_LEVEL, math.floor(forceLevel)))
|
||||||
end
|
end
|
||||||
|
local packedDvs, packedStatExp = tbl(packed.dvs), tbl(packed.statExp)
|
||||||
local dvs = {}
|
local dvs = {}
|
||||||
for _, k in ipairs(GEN2_DVS) do
|
for _, k in ipairs(GEN2_DVS) do
|
||||||
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
|
dvs[k] = math.max(0, math.min(Mon.MAX_DV,
|
||||||
math.floor((packed.dvs or {})[k] or 0)))
|
math.floor(num(packedDvs[k], 0))))
|
||||||
end
|
end
|
||||||
-- derived, never taken from the packet (see GEN2_DVS above)
|
-- derived, never taken from the packet (see GEN2_DVS above)
|
||||||
dvs.hp = Mon.hpDV(dvs)
|
dvs.hp = Mon.hpDV(dvs)
|
||||||
local statExp = {}
|
local statExp = {}
|
||||||
for _, k in ipairs(GEN2_STAT_EXP) do
|
for _, k in ipairs(GEN2_STAT_EXP) do
|
||||||
statExp[k] = math.max(0, math.min(65535,
|
statExp[k] = math.max(0, math.min(65535,
|
||||||
math.floor((packed.statExp or {})[k] or 0)))
|
math.floor(num(packedStatExp[k], 0))))
|
||||||
end
|
end
|
||||||
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
local stats = Mon.stats(def.baseStats, dvs, level, statExp)
|
||||||
local moves = {}
|
local moves = {}
|
||||||
for _, mv in ipairs(packed.moves or {}) do
|
for _, packedMove in ipairs(tbl(packed.moves)) do
|
||||||
|
local mv = tbl(packedMove)
|
||||||
local mdef = data.moves and data.moves[mv.id]
|
local mdef = data.moves and data.moves[mv.id]
|
||||||
if mdef and #moves < 4 then
|
if mdef and #moves < 4 then
|
||||||
local ppUps = math.max(0, math.min(3, math.floor(mv.ppUps or 0)))
|
local ppUps = math.max(0, math.min(3, math.floor(num(mv.ppUps, 0))))
|
||||||
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
|
local maxPp = (mdef.pp or 0) + ppUps * math.floor((mdef.pp or 0) / 5)
|
||||||
local entry = { id = mv.id, maxPp = maxPp,
|
local entry = { id = mv.id, maxPp = maxPp,
|
||||||
pp = math.max(0, math.min(maxPp, math.floor(mv.pp or 0))) }
|
pp = math.max(0, math.min(maxPp, math.floor(num(mv.pp, 0)))) }
|
||||||
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
if mv.ppUps ~= nil then entry.ppUps = ppUps end
|
||||||
table.insert(moves, entry)
|
table.insert(moves, entry)
|
||||||
end
|
end
|
||||||
@@ -296,26 +329,27 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
-- an item the peer cannot represent), and the Gen 2 arm of
|
-- an item the peer cannot represent), and the Gen 2 arm of
|
||||||
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
|
-- Protocol.eligibleParty is what keeps it from ever reaching here on a
|
||||||
-- negotiated trade.
|
-- negotiated trade.
|
||||||
local item = packed.item
|
local item = type(packed.item) == "string" and packed.item or nil
|
||||||
if item ~= nil and not (data.items and data.items[item]) then
|
if item ~= nil and not (data.items and data.items[item]) then
|
||||||
if strict then return nil, "unknown item" end
|
if strict then return nil, "unknown item" end
|
||||||
item = nil
|
item = nil
|
||||||
end
|
end
|
||||||
local forced = forceLevel
|
local forced = forceLevel
|
||||||
local hp = forced and stats.hp
|
local hp = forced and stats.hp
|
||||||
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
|
or math.max(0, math.min(stats.hp, math.floor(num(packed.hp, stats.hp))))
|
||||||
local status = forced and nil or packed.status
|
local status = forced and nil or text(packed.status)
|
||||||
local otId = packed.otId
|
local packedOtId = num(packed.otId)
|
||||||
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
|
local otId = packedOtId
|
||||||
|
and math.max(0, math.min(65535, math.floor(packedOtId))) or nil
|
||||||
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
|
||||||
local growth = Mon.growthFor(data, def.growthRate)
|
local growth = Mon.growthFor(data, def.growthRate)
|
||||||
local mon = {
|
local mon = {
|
||||||
species = packed.species,
|
species = packed.species,
|
||||||
name = def.name or packed.species,
|
name = def.name or packed.species,
|
||||||
nickname = packed.nickname,
|
nickname = text(packed.nickname),
|
||||||
level = level,
|
level = level,
|
||||||
experience = math.max(0, math.floor(packed.experience
|
experience = math.max(0, math.floor(num(packed.experience,
|
||||||
or Mon.experienceForLevel(growth, level))),
|
Mon.experienceForLevel(growth, level)))),
|
||||||
dvs = dvs,
|
dvs = dvs,
|
||||||
statExp = statExp,
|
statExp = statExp,
|
||||||
stats = stats,
|
stats = stats,
|
||||||
@@ -328,10 +362,10 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
|
-- GiveEgg starts a hatched mon at 120 and a caught one at 70; a traded mon
|
||||||
-- keeps what it arrived with, clamped to the byte the cart stores it in
|
-- keeps what it arrived with, clamped to the byte the cart stores it in
|
||||||
happiness = math.max(0, math.min(255,
|
happiness = math.max(0, math.min(255,
|
||||||
math.floor(packed.happiness or 70))),
|
math.floor(num(packed.happiness, 70)))),
|
||||||
pokerus = math.max(0, math.min(255, math.floor(packed.pokerus or 0))),
|
pokerus = math.max(0, math.min(255, math.floor(num(packed.pokerus, 0)))),
|
||||||
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
|
caughtLevel = math.max(1, math.min(Mon.MAX_LEVEL,
|
||||||
math.floor(packed.caughtLevel or level))),
|
math.floor(num(packed.caughtLevel, level)))),
|
||||||
ot = ot,
|
ot = ot,
|
||||||
otName = ot,
|
otName = ot,
|
||||||
otId = otId,
|
otId = otId,
|
||||||
@@ -339,7 +373,7 @@ function Protocol.unpackMon2(data, packed, opts)
|
|||||||
}
|
}
|
||||||
if packed.isEgg then
|
if packed.isEgg then
|
||||||
mon.isEgg = true
|
mon.isEgg = true
|
||||||
mon.eggSteps = math.max(0, math.floor(packed.eggSteps or 0))
|
mon.eggSteps = math.max(0, math.floor(num(packed.eggSteps, 0)))
|
||||||
end
|
end
|
||||||
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
|
-- Derived from the DVs on the RECEIVING side, exactly as they were derived on
|
||||||
-- the sending one: shininess, gender and an Unown's letter are all functions
|
-- the sending one: shininess, gender and an Unown's letter are all functions
|
||||||
@@ -401,17 +435,19 @@ end
|
|||||||
-- which slots are in play and a pick can never land on a different mon.
|
-- which slots are in play and a pick can never land on a different mon.
|
||||||
function Protocol.eligibleParty(party, myRecords, theirRecords)
|
function Protocol.eligibleParty(party, myRecords, theirRecords)
|
||||||
local eligible, reasons = {}, {}
|
local eligible, reasons = {}, {}
|
||||||
theirRecords = theirRecords or {}
|
theirRecords = tbl(theirRecords)
|
||||||
local theirSpecies = theirRecords.pokemon or {}
|
myRecords = tbl(myRecords)
|
||||||
local theirMoves = theirRecords.moves or {}
|
local theirSpecies = tbl(theirRecords.pokemon)
|
||||||
local mySpecies = (myRecords or {}).pokemon or {}
|
local theirMoves = tbl(theirRecords.moves)
|
||||||
local myMoves = (myRecords or {}).moves or {}
|
local mySpecies = tbl(myRecords.pokemon)
|
||||||
|
local myMoves = tbl(myRecords.moves)
|
||||||
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
|
-- Gen 2 only, and absent on both sides of a Gen 1 trade, which is what keeps
|
||||||
-- the loop below unchanged for Red: a mon with no `item` never reaches the
|
-- the loop below unchanged for Red: a mon with no `item` never reaches the
|
||||||
-- held-item arm at all.
|
-- held-item arm at all.
|
||||||
local theirHeld = theirRecords.heldItems
|
local theirHeld = theirRecords.heldItems ~= nil
|
||||||
local myHeld = (myRecords or {}).heldItems or {}
|
and tbl(theirRecords.heldItems) or nil
|
||||||
for i, mon in ipairs(party or {}) do
|
local myHeld = tbl(myRecords.heldItems)
|
||||||
|
for i, mon in ipairs(tbl(party)) do
|
||||||
local reason
|
local reason
|
||||||
if not theirSpecies[mon.species] then
|
if not theirSpecies[mon.species] then
|
||||||
reason = "not on the other game"
|
reason = "not on the other game"
|
||||||
@@ -551,7 +587,7 @@ function TradeSession:handle(msg)
|
|||||||
end
|
end
|
||||||
if self.stage == "waitParty" then self.stage = "picking" end
|
if self.stage == "waitParty" then self.stage = "picking" end
|
||||||
elseif msg.type == "pick" then
|
elseif msg.type == "pick" then
|
||||||
self.theirPick = msg.index
|
self.theirPick = num(msg.index)
|
||||||
self:advance()
|
self:advance()
|
||||||
elseif msg.type == "confirm" then
|
elseif msg.type == "confirm" then
|
||||||
self.theirConfirm = msg.ok
|
self.theirConfirm = msg.ok
|
||||||
@@ -583,7 +619,18 @@ function TradeSession:confirm(ok)
|
|||||||
return { type = "confirm", ok = ok }
|
return { type = "confirm", ok = ok }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function TradeSession:pickResolves()
|
||||||
|
local index = self.theirPick
|
||||||
|
if type(index) ~= "number" then return false end
|
||||||
|
return self.theirParty ~= nil and self.theirParty[index] ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
function TradeSession:advance()
|
function TradeSession:advance()
|
||||||
|
if self.theirPick ~= nil and self.theirParty and not self:pickResolves() then
|
||||||
|
self.stage = "cancelled"
|
||||||
|
self.error = "the other game picked a POKéMON that isn't there"
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.stage == "picking" and self.myPick then
|
if self.stage == "picking" and self.myPick then
|
||||||
self.stage = self.theirPick and "confirming" or "waitPick"
|
self.stage = self.theirPick and "confirming" or "waitPick"
|
||||||
elseif self.stage == "waitPick" and self.theirPick then
|
elseif self.stage == "waitPick" and self.theirPick then
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
local Logger = require("src.core.Logger")
|
||||||
|
local Wire = require("src.link.Wire")
|
||||||
|
|
||||||
local Session = {}
|
local Session = {}
|
||||||
Session.__index = Session
|
Session.__index = Session
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ function Session.new(transport, options)
|
|||||||
_status = "connecting",
|
_status = "connecting",
|
||||||
_terminal = nil,
|
_terminal = nil,
|
||||||
_transportCloseCalled = false,
|
_transportCloseCalled = false,
|
||||||
|
dropped = 0,
|
||||||
paired = false,
|
paired = false,
|
||||||
closed = false,
|
closed = false,
|
||||||
error = nil,
|
error = nil,
|
||||||
@@ -120,16 +124,15 @@ function Session:update()
|
|||||||
end
|
end
|
||||||
else
|
else
|
||||||
for index = 1, #messages do
|
for index = 1, #messages do
|
||||||
local message = messages[index]
|
local raw = messages[index]
|
||||||
if type(message) ~= "table" or type(message.type) ~= "string" then
|
local ok, message = pcall(Wire.sanitize, raw)
|
||||||
if not failureReason then
|
if ok and message then
|
||||||
failureReason = "protocol_error"
|
|
||||||
failureDetail = ("message %d must be a table with string type")
|
|
||||||
:format(index)
|
|
||||||
end
|
|
||||||
break
|
|
||||||
end
|
|
||||||
self._inbox[#self._inbox + 1] = message
|
self._inbox[#self._inbox + 1] = message
|
||||||
|
else
|
||||||
|
self.dropped = (self.dropped or 0) + 1
|
||||||
|
local label = type(raw) == "table" and tostring(raw.type) or type(raw)
|
||||||
|
Logger.warn("link: dropped malformed message (%s)", label)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
local Wire = {}
|
||||||
|
|
||||||
|
local MAX_INT = 2147483647
|
||||||
|
local MAX_STRING = 64
|
||||||
|
local MAX_NAME = 40
|
||||||
|
local MAX_LIST = 64
|
||||||
|
local MAX_PARTY = 32
|
||||||
|
local MAX_MOVES = 8
|
||||||
|
local MAX_MODS = 256
|
||||||
|
local MAX_RECORDS = 4096
|
||||||
|
local MAX_EXTRA_DEPTH = 8
|
||||||
|
local MAX_ROUNDS = 16
|
||||||
|
local MAX_MATCHES = 128
|
||||||
|
|
||||||
|
function Wire.num(v, default, min, max)
|
||||||
|
local n = tonumber(v)
|
||||||
|
if n == nil or n ~= n then return default end
|
||||||
|
min = min or -MAX_INT
|
||||||
|
max = max or MAX_INT
|
||||||
|
if n < min then return min end
|
||||||
|
if n > max then return max end
|
||||||
|
return math.floor(n)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.str(v, default, maxLen)
|
||||||
|
if type(v) ~= "string" then return default end
|
||||||
|
maxLen = maxLen or MAX_STRING
|
||||||
|
if #v > maxLen then return v:sub(1, maxLen) end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.bool(v, default)
|
||||||
|
if type(v) == "boolean" then return v end
|
||||||
|
return default
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.list(v, maxN, fn)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
local n = math.min(#v, maxN or MAX_LIST)
|
||||||
|
for i = 1, n do
|
||||||
|
local entry = fn(v[i])
|
||||||
|
if entry ~= nil then out[#out + 1] = entry end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.records(v)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
local n = 0
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
if type(k) == "string" then
|
||||||
|
out[k] = Wire.str(val, nil, MAX_STRING) or tostring(Wire.num(val, 0))
|
||||||
|
n = n + 1
|
||||||
|
if n >= MAX_RECORDS then break end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
function Wire.plain(v, depth)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
depth = depth or 0
|
||||||
|
if depth > MAX_EXTRA_DEPTH then return nil end
|
||||||
|
local out = {}
|
||||||
|
for k, val in pairs(v) do
|
||||||
|
local kt, vt = type(k), type(val)
|
||||||
|
if kt == "string" or kt == "number" then
|
||||||
|
if vt == "string" then out[k] = Wire.str(val, nil, MAX_STRING)
|
||||||
|
elseif vt == "number" or vt == "boolean" then out[k] = val
|
||||||
|
elseif vt == "table" then out[k] = Wire.plain(val, depth + 1) end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local STAT_KEYS = { "hp", "attack", "defense", "speed", "special" }
|
||||||
|
|
||||||
|
local function statMap(v)
|
||||||
|
local out = {}
|
||||||
|
if type(v) ~= "table" then return out end
|
||||||
|
for _, k in ipairs(STAT_KEYS) do
|
||||||
|
out[k] = Wire.num(v[k], nil, 0, 65535)
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function move(v)
|
||||||
|
if type(v) ~= "table" then return { id = nil } end
|
||||||
|
return {
|
||||||
|
id = Wire.str(v.id, nil, MAX_STRING),
|
||||||
|
pp = Wire.num(v.pp, nil, 0, 255),
|
||||||
|
ppUps = Wire.num(v.ppUps, nil, 0, 255),
|
||||||
|
maxPp = Wire.num(v.maxPp, nil, 0, 255),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function mon(v)
|
||||||
|
if type(v) ~= "table" then return {} end
|
||||||
|
return {
|
||||||
|
species = Wire.str(v.species, nil, MAX_STRING),
|
||||||
|
level = Wire.num(v.level, nil, 0, 65535),
|
||||||
|
exp = Wire.num(v.exp, nil, 0, MAX_INT),
|
||||||
|
experience = Wire.num(v.experience, nil, 0, MAX_INT),
|
||||||
|
hp = Wire.num(v.hp, nil, 0, 65535),
|
||||||
|
status = Wire.str(v.status, nil, MAX_STRING),
|
||||||
|
nickname = Wire.str(v.nickname, nil, MAX_NAME),
|
||||||
|
dvs = statMap(v.dvs),
|
||||||
|
statExp = statMap(v.statExp),
|
||||||
|
moves = Wire.list(v.moves, MAX_MOVES, move),
|
||||||
|
ot = Wire.str(v.ot, nil, MAX_NAME),
|
||||||
|
otId = Wire.num(v.otId, nil, 0, MAX_INT),
|
||||||
|
item = Wire.str(v.item, nil, MAX_STRING),
|
||||||
|
happiness = Wire.num(v.happiness, nil, 0, 65535),
|
||||||
|
pokerus = Wire.num(v.pokerus, nil, 0, 65535),
|
||||||
|
caughtLevel = Wire.num(v.caughtLevel, nil, 0, 65535),
|
||||||
|
isEgg = Wire.bool(v.isEgg, nil),
|
||||||
|
eggSteps = Wire.num(v.eggSteps, nil, 0, MAX_INT),
|
||||||
|
extra = Wire.plain(v.extra),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function modEntry(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
id = Wire.str(v.id, nil, MAX_NAME),
|
||||||
|
version = Wire.str(v.version, nil, MAX_NAME)
|
||||||
|
or Wire.num(v.version, nil, 0, MAX_INT),
|
||||||
|
affectsLink = Wire.bool(v.affectsLink, nil),
|
||||||
|
language = Wire.bool(v.language, nil),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function name(v)
|
||||||
|
return Wire.str(v, nil, MAX_NAME)
|
||||||
|
end
|
||||||
|
|
||||||
|
local sanitize
|
||||||
|
|
||||||
|
local SCHEMAS = {}
|
||||||
|
|
||||||
|
SCHEMAS.hello = function(m)
|
||||||
|
return {
|
||||||
|
protocol = Wire.num(m.protocol, nil, 0, MAX_INT),
|
||||||
|
name = name(m.name),
|
||||||
|
mode = Wire.str(m.mode, nil, MAX_STRING),
|
||||||
|
engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING),
|
||||||
|
apiVersion = Wire.str(m.apiVersion, nil, MAX_STRING),
|
||||||
|
generation = Wire.num(m.generation, nil, 0, 255),
|
||||||
|
fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING),
|
||||||
|
linkModified = Wire.bool(m.linkModified, nil),
|
||||||
|
mods = Wire.list(m.mods, MAX_MODS, modEntry),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.records = function(m)
|
||||||
|
return {
|
||||||
|
pokemon = Wire.records(m.pokemon),
|
||||||
|
moves = Wire.records(m.moves),
|
||||||
|
heldItems = m.heldItems ~= nil and Wire.records(m.heldItems) or nil,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.party = function(m)
|
||||||
|
return {
|
||||||
|
mons = Wire.list(m.mons, MAX_PARTY, mon),
|
||||||
|
seed = Wire.num(m.seed, nil, 0, MAX_INT),
|
||||||
|
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.pick = function(m)
|
||||||
|
return { index = Wire.num(m.index, nil, -MAX_INT, MAX_INT) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.confirm = function(m)
|
||||||
|
return { ok = Wire.bool(m.ok, false) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.action = function(m)
|
||||||
|
return {
|
||||||
|
kind = Wire.str(m.kind, "", MAX_STRING),
|
||||||
|
slot = Wire.num(m.slot, nil, 1, MAX_MOVES),
|
||||||
|
index = Wire.num(m.index, nil, 1, MAX_PARTY),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.hash = function(m)
|
||||||
|
local parts
|
||||||
|
if type(m.parts) == "table" then
|
||||||
|
parts = {
|
||||||
|
actives = Wire.str(m.parts.actives, nil, MAX_STRING),
|
||||||
|
volatile = Wire.str(m.parts.volatile, nil, MAX_STRING),
|
||||||
|
bench = Wire.str(m.parts.bench, nil, MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return {
|
||||||
|
turn = Wire.num(m.turn, 0, 0, MAX_INT),
|
||||||
|
value = Wire.str(m.value, nil, MAX_STRING),
|
||||||
|
parts = parts,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.replace = function(m)
|
||||||
|
return { index = Wire.num(m.index, 1, 1, MAX_PARTY) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.bye = function() return {} end
|
||||||
|
SCHEMAS.forfeit = function() return {} end
|
||||||
|
|
||||||
|
SCHEMAS.hosted = function(m)
|
||||||
|
return { code = Wire.str(m.code, nil, MAX_NAME) }
|
||||||
|
end
|
||||||
|
SCHEMAS.paired = function() return {} end
|
||||||
|
SCHEMAS.peer_gone = function() return {} end
|
||||||
|
SCHEMAS.join_error = function(m)
|
||||||
|
return { reason = Wire.str(m.reason, "", MAX_STRING) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function rule(m)
|
||||||
|
return {
|
||||||
|
requiredPartySize = Wire.num(m.requiredPartySize, nil, 0, 255),
|
||||||
|
minLevel = Wire.num(m.minLevel, nil, 0, 65535),
|
||||||
|
maxLevel = Wire.num(m.maxLevel, nil, 0, 65535),
|
||||||
|
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
|
||||||
|
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_hosted = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.code = Wire.str(m.code, nil, MAX_NAME)
|
||||||
|
out.participating = Wire.bool(m.participating, nil)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_host_error = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.reason = Wire.str(m.reason, "", MAX_STRING)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_join_error = SCHEMAS.tournament_host_error
|
||||||
|
|
||||||
|
SCHEMAS.tournament_roster = function(m)
|
||||||
|
local out = rule(m)
|
||||||
|
out.players = Wire.list(m.players, MAX_MATCHES, name)
|
||||||
|
out.spectators = Wire.list(m.spectators, MAX_MATCHES, name)
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local function match(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
a = name(v.a), b = name(v.b), winner = name(v.winner),
|
||||||
|
bye = Wire.bool(v.bye, false),
|
||||||
|
state = Wire.str(v.state, nil, MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function round(v)
|
||||||
|
if type(v) ~= "table" then return nil end
|
||||||
|
return {
|
||||||
|
round = Wire.num(v.round, 0, 0, MAX_ROUNDS),
|
||||||
|
matches = Wire.list(v.matches, MAX_MATCHES, match),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.bracket_update = function(m)
|
||||||
|
local t = type(m.tournament) == "table" and m.tournament or {}
|
||||||
|
local out = rule(t)
|
||||||
|
out.code = Wire.str(t.code, nil, MAX_NAME)
|
||||||
|
out.status = Wire.str(t.status, nil, MAX_STRING)
|
||||||
|
out.round = Wire.num(t.round, 0, 0, MAX_ROUNDS)
|
||||||
|
out.champion = name(t.champion)
|
||||||
|
out.rounds = Wire.list(t.rounds, MAX_ROUNDS, round)
|
||||||
|
return { tournament = out }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.match_start = function(m)
|
||||||
|
return {
|
||||||
|
opponent = name(m.opponent),
|
||||||
|
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
|
||||||
|
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
|
||||||
|
role = Wire.str(m.role, "", MAX_STRING),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.match_start_spectate = function(m)
|
||||||
|
return {
|
||||||
|
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
|
||||||
|
playerHost = name(m.playerHost),
|
||||||
|
playerGuest = name(m.playerGuest),
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_bye = function(m)
|
||||||
|
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) }
|
||||||
|
end
|
||||||
|
|
||||||
|
SCHEMAS.tournament_over = function(m)
|
||||||
|
return { champion = name(m.champion) }
|
||||||
|
end
|
||||||
|
|
||||||
|
local SPECTATABLE = {
|
||||||
|
action = true, replace = true, bye = true, forfeit = true,
|
||||||
|
hello = true, party = true, hash = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
SCHEMAS.spectate = function(m)
|
||||||
|
if type(m.msg) ~= "table" or not SPECTATABLE[m.msg.type] then return nil end
|
||||||
|
local inner = sanitize(m.msg)
|
||||||
|
if not inner then return nil end
|
||||||
|
return { side = Wire.str(m.side, "", MAX_STRING), msg = inner }
|
||||||
|
end
|
||||||
|
|
||||||
|
Wire.SCHEMAS = SCHEMAS
|
||||||
|
|
||||||
|
local function passthrough(m)
|
||||||
|
local out = Wire.plain(m) or {}
|
||||||
|
out.type = nil
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
sanitize = function(msg)
|
||||||
|
if type(msg) ~= "table" then return nil end
|
||||||
|
local kind = msg.type
|
||||||
|
if type(kind) ~= "string" or #kind > MAX_STRING then return nil end
|
||||||
|
local schema = SCHEMAS[kind]
|
||||||
|
local out
|
||||||
|
if schema then
|
||||||
|
out = schema(msg)
|
||||||
|
if not out then return nil end
|
||||||
|
else
|
||||||
|
out = passthrough(msg)
|
||||||
|
end
|
||||||
|
out.type = kind
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
Wire.sanitize = sanitize
|
||||||
|
|
||||||
|
return Wire
|
||||||
@@ -26,6 +26,7 @@ local ModIndex = {}
|
|||||||
-- single repo's releases; a whole index is heavier and changes more slowly.
|
-- single repo's releases; a whole index is heavier and changes more slowly.
|
||||||
ModIndex.CACHE_TTL = 24 * 60 * 60
|
ModIndex.CACHE_TTL = 24 * 60 * 60
|
||||||
ModIndex.SCHEMA_VERSION = 1
|
ModIndex.SCHEMA_VERSION = 1
|
||||||
|
ModIndex.CACHE_VERSION = 2
|
||||||
|
|
||||||
-- ------- pure: source resolution
|
-- ------- pure: source resolution
|
||||||
|
|
||||||
@@ -149,6 +150,43 @@ local function parseLatest(raw)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function parseDownloads(raw)
|
||||||
|
if type(raw) == "number" or type(raw) == "string" then
|
||||||
|
local n = tonumber(raw)
|
||||||
|
return n and { total = n } or nil
|
||||||
|
end
|
||||||
|
if type(raw) ~= "table" then return nil end
|
||||||
|
local out = {
|
||||||
|
total = tonumber(raw.total),
|
||||||
|
recent = tonumber(raw.recent),
|
||||||
|
window_days = tonumber(raw.window_days),
|
||||||
|
as_of = str(raw.as_of),
|
||||||
|
}
|
||||||
|
if out.total == nil and out.recent == nil then return nil end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- downloadStats(entry) -> { total, recent, window_days, as_of } | nil
|
||||||
|
function ModIndex.downloadStats(entry)
|
||||||
|
if type(entry) ~= "table" then return nil end
|
||||||
|
return parseDownloads(entry.downloads)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isoDay(v)
|
||||||
|
if type(v) ~= "string" then return nil end
|
||||||
|
return v:match("^(%d%d%d%d%-%d%d%-%d%d)")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- releaseDates(entry) -> { first, latest } | nil
|
||||||
|
function ModIndex.releaseDates(entry)
|
||||||
|
if type(entry) ~= "table" then return nil end
|
||||||
|
local first = isoDay(entry.first_release)
|
||||||
|
local latest = isoDay(entry.last_release)
|
||||||
|
or isoDay(entry.latest and entry.latest.published_at)
|
||||||
|
if not (first or latest) then return nil end
|
||||||
|
return { first = first, latest = latest }
|
||||||
|
end
|
||||||
|
|
||||||
local function parseEntry(raw)
|
local function parseEntry(raw)
|
||||||
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
if type(raw) ~= "table" or not str(raw.id) then return nil end
|
||||||
return {
|
return {
|
||||||
@@ -160,6 +198,7 @@ local function parseEntry(raw)
|
|||||||
summary = str(raw.summary) or "",
|
summary = str(raw.summary) or "",
|
||||||
categories = strArray(raw.categories),
|
categories = strArray(raw.categories),
|
||||||
tags = strArray(raw.tags),
|
tags = strArray(raw.tags),
|
||||||
|
games = strArray(raw.games),
|
||||||
license = str(raw.license),
|
license = str(raw.license),
|
||||||
repo = str(raw.repo),
|
repo = str(raw.repo),
|
||||||
github = str(raw.github),
|
github = str(raw.github),
|
||||||
@@ -174,11 +213,11 @@ local function parseEntry(raw)
|
|||||||
conflicts = raw.conflicts,
|
conflicts = raw.conflicts,
|
||||||
thumbnail = str(raw.thumbnail),
|
thumbnail = str(raw.thumbnail),
|
||||||
description_url = str(raw.description_url),
|
description_url = str(raw.description_url),
|
||||||
-- Optional release stats a feed author can publish: total downloads
|
-- Optional release stats a feed author can publish: download counts
|
||||||
-- across all releases plus first/last release dates. Additive-only,
|
-- across all releases plus first/last release dates. Additive-only,
|
||||||
-- so a feed that carries them stays readable by every build that
|
-- so a feed that carries them stays readable by every build that
|
||||||
-- predates them (and one that does not still renders fine here).
|
-- predates them (and one that does not still renders fine here).
|
||||||
downloads = tonumber(raw.downloads),
|
downloads = parseDownloads(raw.downloads),
|
||||||
first_release = str(raw.first_release),
|
first_release = str(raw.first_release),
|
||||||
last_release = str(raw.last_release),
|
last_release = str(raw.last_release),
|
||||||
latest = parseLatest(raw.latest),
|
latest = parseLatest(raw.latest),
|
||||||
@@ -515,6 +554,7 @@ function ModIndex.cacheFresh(entry, now, ttl)
|
|||||||
now = now or os.time()
|
now = now or os.time()
|
||||||
ttl = ttl or ModIndex.CACHE_TTL
|
ttl = ttl or ModIndex.CACHE_TTL
|
||||||
return entry ~= nil and type(entry.checkedAt) == "number"
|
return entry ~= nil and type(entry.checkedAt) == "number"
|
||||||
|
and entry.version == ModIndex.CACHE_VERSION
|
||||||
and (now - entry.checkedAt) < ttl
|
and (now - entry.checkedAt) < ttl
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -526,6 +566,7 @@ function ModIndex.writeCache(feed, index)
|
|||||||
opts.modIndexCache = opts.modIndexCache or {}
|
opts.modIndexCache = opts.modIndexCache or {}
|
||||||
opts.modIndexCache[feed] = {
|
opts.modIndexCache[feed] = {
|
||||||
checkedAt = os.time(),
|
checkedAt = os.time(),
|
||||||
|
version = ModIndex.CACHE_VERSION,
|
||||||
generatedAt = index.generatedAt,
|
generatedAt = index.generatedAt,
|
||||||
categories = index.categories,
|
categories = index.categories,
|
||||||
mods = index.mods,
|
mods = index.mods,
|
||||||
|
|||||||
@@ -265,9 +265,42 @@ function ModUpdate.downloadsLine(total)
|
|||||||
ModUpdate.formatCount(total))
|
ModUpdate.formatCount(total))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
ModUpdate.NO_COUNT = "?"
|
||||||
|
|
||||||
|
function ModUpdate.abbrevCount(n)
|
||||||
|
n = tonumber(n)
|
||||||
|
if not n or n ~= n or n < 0 then return ModUpdate.NO_COUNT end
|
||||||
|
n = math.floor(n)
|
||||||
|
if n < 1000 then return tostring(n) end
|
||||||
|
local units, idx, value = { "k", "M", "B" }, 0, n
|
||||||
|
repeat
|
||||||
|
value = value / 1000
|
||||||
|
idx = idx + 1
|
||||||
|
until idx >= #units or math.floor(value * 10 + 0.5) / 10 < 1000
|
||||||
|
local scaled = math.floor(value * 10 + 0.5) / 10
|
||||||
|
local shown = (scaled % 1 == 0) and tostring(math.floor(scaled))
|
||||||
|
or string.format("%.1f", scaled)
|
||||||
|
return shown .. units[idx]
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.downloadsShort(total)
|
||||||
|
return Strings("%s downloads", ModUpdate.abbrevCount(total))
|
||||||
|
end
|
||||||
|
|
||||||
|
function ModUpdate.trendingLine(recent, windowDays)
|
||||||
|
if recent == nil then return nil end
|
||||||
|
if not windowDays then
|
||||||
|
return Strings("%s recently", ModUpdate.abbrevCount(recent))
|
||||||
|
end
|
||||||
|
return Strings("%s in the last %d days", ModUpdate.abbrevCount(recent),
|
||||||
|
math.floor(windowDays))
|
||||||
|
end
|
||||||
|
|
||||||
function ModUpdate.datesLine(first, latest)
|
function ModUpdate.datesLine(first, latest)
|
||||||
if not (first or latest) then return nil end
|
if not (first or latest) then return nil end
|
||||||
return Strings("Released %s - Updated %s", first or "?", latest or "?")
|
if not first then return Strings("Updated %s", latest) end
|
||||||
|
if not latest then return Strings("Released %s", first) end
|
||||||
|
return Strings("Released %s - Updated %s", first, latest)
|
||||||
end
|
end
|
||||||
|
|
||||||
function ModUpdate.statsLine(total, first, latest)
|
function ModUpdate.statsLine(total, first, latest)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ local Runtime = require("src.mods.Runtime")
|
|||||||
local GameViewport = require("src.render.GameViewport")
|
local GameViewport = require("src.render.GameViewport")
|
||||||
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
||||||
local FaithfulRes = require("src.core.FaithfulRes")
|
local FaithfulRes = require("src.core.FaithfulRes")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
|
|
||||||
local Renderer = {}
|
local Renderer = {}
|
||||||
|
|
||||||
@@ -84,7 +85,13 @@ local function displayMetrics()
|
|||||||
end
|
end
|
||||||
if dpiX < 1e-6 then dpiX = 1 end
|
if dpiX < 1e-6 then dpiX = 1 end
|
||||||
if dpiY < 1e-6 then dpiY = 1 end
|
if dpiY < 1e-6 then dpiY = 1 end
|
||||||
return ww, wh, pw, ph, dpiX, dpiY
|
local vx, vy = 0, 0
|
||||||
|
local sx, sy, sw, sh = TouchSkin.viewport(pw, ph)
|
||||||
|
if sw and sw >= 1 and sh >= 1 then
|
||||||
|
vx, vy = math.floor(sx), math.floor(sy)
|
||||||
|
pw, ph = math.floor(sw), math.floor(sh)
|
||||||
|
end
|
||||||
|
return ww, wh, pw, ph, dpiX, dpiY, vx, vy
|
||||||
end
|
end
|
||||||
|
|
||||||
function Renderer:init()
|
function Renderer:init()
|
||||||
@@ -274,6 +281,10 @@ function Renderer:worldViewSize()
|
|||||||
-- this is the same sum with the viewport standing in for the window, so
|
-- this is the same sum with the viewport standing in for the window, so
|
||||||
-- both platforms show the same map area at the same zoom.
|
-- both platforms show the same map area at the same zoom.
|
||||||
local cap = FaithfulRes.scaleCap()
|
local cap = FaithfulRes.scaleCap()
|
||||||
|
if not cap and TouchSkin.hasViewport() then
|
||||||
|
local page = TouchSkin.page()
|
||||||
|
if not page.viewportExpand then cap = self:fitScale() end
|
||||||
|
end
|
||||||
if cap then
|
if cap then
|
||||||
local uiw, uih = self:uiSize()
|
local uiw, uih = self:uiSize()
|
||||||
pw, ph = uiw * cap, uih * cap
|
pw, ph = uiw * cap, uih * cap
|
||||||
@@ -745,7 +756,9 @@ end
|
|||||||
-- presented through the GBC FX shader as a final pass.
|
-- presented through the GBC FX shader as a final pass.
|
||||||
function Renderer:endFrame(zones, worldZones)
|
function Renderer:endFrame(zones, worldZones)
|
||||||
GameViewport.setTarget()
|
GameViewport.setTarget()
|
||||||
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
local ww, wh, pw, ph, dpiX, dpiY, vx, vy = displayMetrics()
|
||||||
|
local vux, vuy = vx / dpiX, vy / dpiY
|
||||||
|
local vuw, vuh = pw / dpiX, ph / dpiY
|
||||||
-- Sp = integer framebuffer pixels per GB pixel;
|
-- Sp = integer framebuffer pixels per GB pixel;
|
||||||
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
||||||
local Sp = self:fitScale()
|
local Sp = self:fitScale()
|
||||||
@@ -753,8 +766,8 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local uiw, uih = self:uiSize()
|
local uiw, uih = self:uiSize()
|
||||||
local vpw, vph = uiw * Sx, uih * Sy
|
local vpw, vph = uiw * Sx, uih * Sy
|
||||||
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
||||||
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
local ox = (vx + math.floor((pw - uiw * Sp) / 2)) / dpiX
|
||||||
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
local oy = (vy + math.floor((ph - uih * Sp) / 2)) / dpiY
|
||||||
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
||||||
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
||||||
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
||||||
@@ -770,8 +783,8 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
end
|
end
|
||||||
local Ux, Uy = Up / dpiX, Up / dpiY
|
local Ux, Uy = Up / dpiX, Up / dpiY
|
||||||
local uvpw, uvph = uiw * Ux, uih * Uy
|
local uvpw, uvph = uiw * Ux, uih * Uy
|
||||||
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
|
local uox = (vx + math.floor((pw - uiw * Up) / 2)) / dpiX
|
||||||
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
|
local uoy = (vy + math.floor((ph - uih * Up) / 2)) / dpiY
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||||
@@ -921,7 +934,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- skipped entirely (nothing drew into it). The UI blit below still
|
-- skipped entirely (nothing drew into it). The UI blit below still
|
||||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.setScissor(0, 0, ww, wh)
|
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||||
local loveMajor = love.getVersion()
|
local loveMajor = love.getVersion()
|
||||||
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
||||||
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
||||||
@@ -933,7 +946,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local fade = self.worldFadeAlpha
|
local fade = self.worldFadeAlpha
|
||||||
if fade and fade > 0 then
|
if fade and fade > 0 then
|
||||||
love.graphics.setColor(0, 0, 0, fade)
|
love.graphics.setColor(0, 0, 0, fade)
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
elseif self.worldActive then
|
elseif self.worldActive then
|
||||||
@@ -941,8 +954,8 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local sx, sy = sp / dpiX, sp / dpiY
|
local sx, sy = sp / dpiX, sp / dpiY
|
||||||
local wvw = self.worldCanvas:getWidth()
|
local wvw = self.worldCanvas:getWidth()
|
||||||
local wvh = self.worldCanvas:getHeight()
|
local wvh = self.worldCanvas:getHeight()
|
||||||
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
|
local wox = (vx + math.floor((pw - wvw * sp) / 2)) / dpiX
|
||||||
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
|
local woy = (vy + math.floor((ph - wvh * sp) / 2)) / dpiY
|
||||||
-- Tilt mode projects the ground world pass through the perspective mesh
|
-- Tilt mode projects the ground world pass through the perspective mesh
|
||||||
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
||||||
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
||||||
@@ -953,9 +966,9 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
||||||
if not projected then
|
if not projected then
|
||||||
if worldZones then
|
if worldZones then
|
||||||
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
|
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, vux, vuy, vuw, vuh)
|
||||||
else
|
else
|
||||||
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
|
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, vux, vuy, vuw, vuh)
|
||||||
end
|
end
|
||||||
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
||||||
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
||||||
@@ -964,7 +977,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local redraws = PaletteFX.spriteRedraws()
|
local redraws = PaletteFX.spriteRedraws()
|
||||||
if redraws[1] then
|
if redraws[1] then
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.setScissor(0, 0, ww, wh)
|
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||||
local activeShader = nil
|
local activeShader = nil
|
||||||
for _, r in ipairs(redraws) do
|
for _, r in ipairs(redraws) do
|
||||||
local wanted = r.colors
|
local wanted = r.colors
|
||||||
@@ -996,7 +1009,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
if self.uprightActive then
|
if self.uprightActive then
|
||||||
local M = self.UPRIGHT_MARGIN
|
local M = self.UPRIGHT_MARGIN
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.setScissor(0, 0, ww, wh)
|
love.graphics.setScissor(vux, vuy, vuw, vuh)
|
||||||
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
|
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
end
|
end
|
||||||
@@ -1007,7 +1020,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
local fade = self.worldFadeAlpha
|
local fade = self.worldFadeAlpha
|
||||||
if fade and fade > 0 then
|
if fade and fade > 0 then
|
||||||
love.graphics.setColor(0, 0, 0, fade)
|
love.graphics.setColor(0, 0, 0, fade)
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", vux, vuy, vuw, vuh)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1029,7 +1042,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- (and its duplicate #772).
|
-- (and its duplicate #772).
|
||||||
if self.battleDim and self.battleDim > 0 then
|
if self.battleDim and self.battleDim > 0 then
|
||||||
love.graphics.setColor(0, 0, 0, self.battleDim)
|
love.graphics.setColor(0, 0, 0, self.battleDim)
|
||||||
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
|
for _, r in ipairs(subtractRect({ { vux, vuy, vuw, vuh } }, uox, uoy, uvpw, uvph)) do
|
||||||
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
|
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
|||||||
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
||||||
-- the box then closes and choice(yes) runs instead of onDone.
|
-- the box then closes and choice(yes) runs instead of onDone.
|
||||||
-- opts.defaultNo starts the cursor on NO.
|
-- opts.defaultNo starts the cursor on NO.
|
||||||
|
-- opts.choiceLabels / opts.choiceBox: data/yes_no_menu_strings.asm:16
|
||||||
-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like
|
-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like
|
||||||
-- _UsedStrengthText) never wait for a button: once the last page has
|
-- _UsedStrengthText) never wait for a button: once the last page has
|
||||||
-- typed out, auto.sound() runs (returning an audio source blocks like
|
-- typed out, auto.sound() runs (returning an audio source blocks like
|
||||||
@@ -51,6 +52,8 @@ function TextBox.new(game, text, onDone, opts)
|
|||||||
self.choice = opts and opts.choice
|
self.choice = opts and opts.choice
|
||||||
self.defaultNo = opts and opts.defaultNo
|
self.defaultNo = opts and opts.defaultNo
|
||||||
self.choiceNoSound = opts and opts.noSound
|
self.choiceNoSound = opts and opts.noSound
|
||||||
|
self.choiceLabels = opts and opts.choiceLabels
|
||||||
|
self.choiceBox = opts and opts.choiceBox
|
||||||
self.money = opts and opts.money
|
self.money = opts and opts.money
|
||||||
self.auto = opts and opts.auto
|
self.auto = opts and opts.auto
|
||||||
self.stay = opts and opts.stay
|
self.stay = opts and opts.stay
|
||||||
@@ -340,6 +343,7 @@ function TextBox:update(dt)
|
|||||||
self.game.stack:pop() -- this text box, under the choice
|
self.game.stack:pop() -- this text box, under the choice
|
||||||
self.choice(yes)
|
self.choice(yes)
|
||||||
end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
|
end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
|
||||||
|
labels = self.choiceLabels, box = self.choiceBox,
|
||||||
-- this box is anchored below it; the pair moves together
|
-- this box is anchored below it; the pair moves together
|
||||||
anchor = "bottom" }))
|
anchor = "bottom" }))
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
|
|||||||
opts[k] = v
|
opts[k] = v
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet
|
||||||
|
if opts and opts.money == true then
|
||||||
|
opts.money = function() return ctx.save.money end
|
||||||
|
end
|
||||||
ctx.game.stack:push(TextBox.new(ctx.game, text, function()
|
ctx.game.stack:push(TextBox.new(ctx.game, text, function()
|
||||||
runner:resume()
|
runner:resume()
|
||||||
end, opts))
|
end, opts))
|
||||||
@@ -780,6 +784,16 @@ function Commands.give_money(ctx, amount)
|
|||||||
ctx.save.money = math.max(0, ctx.save.money + amount)
|
ctx.save.money = math.max(0, ctx.save.money + amount)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- check_money <amount>: HasEnoughMoney (home/money.asm:1)
|
||||||
|
function Commands.check_money(ctx, amount)
|
||||||
|
ctx.lastCheck = (ctx.save.money or 0) >= (amount or 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- take_money <amount>: SubBCDPredef (engine/math/bcd.asm:193)
|
||||||
|
function Commands.take_money(ctx, amount)
|
||||||
|
ctx.save.money = math.max(0, (ctx.save.money or 0) - (amount or 0))
|
||||||
|
end
|
||||||
|
|
||||||
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
|
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
|
||||||
-- live overworld memory in sync so a scripted home warp from the HoF PC
|
-- live overworld memory in sync so a scripted home warp from the HoF PC
|
||||||
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
|
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
end
|
end
|
||||||
list.index = math.min(list.index, math.max(1, #list.items))
|
list.index = math.min(list.index, math.max(1, #list.items))
|
||||||
if extra and extra.evolveTo then
|
if extra and extra.evolveTo then
|
||||||
list:close()
|
-- engine/menus/start_sub_menus.asm:408 .useItem_partyMenu
|
||||||
local Evolution = require("src.pokemon.Evolution")
|
local Evolution = require("src.pokemon.Evolution")
|
||||||
-- item_effects.asm ItemUseEvoStone sets wForceEvolution before
|
-- item_effects.asm ItemUseEvoStone sets wForceEvolution before
|
||||||
-- TryEvolvingMon, so a stone evolution's B press is read and
|
-- TryEvolvingMon, so a stone evolution's B press is read and
|
||||||
@@ -428,11 +428,12 @@ local function pickTargetAndUse(game, battle, id, list)
|
|||||||
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
||||||
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
|
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
|
||||||
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
|
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
|
||||||
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
|
-- party_menu.asm TM/HM type). #210 Stones get the same ABLE / NOT ABLE
|
||||||
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
|
-- column: ItemUseEvoStone sets EVO_STONE_PARTY_MENU (party_menu.asm:114).
|
||||||
-- strictly on def.machine. #210
|
|
||||||
if def and def.machine then
|
if def and def.machine then
|
||||||
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
|
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
|
||||||
|
elseif ItemEffects.isStone(id) then
|
||||||
|
opts.evoStone = id
|
||||||
end
|
end
|
||||||
require("src.ui.Screens").push(game, "PartyMenu", opts)
|
require("src.ui.Screens").push(game, "PartyMenu", opts)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ function ChoiceBox.new(game, onChoose, opts)
|
|||||||
-- and docking it to the window edge instead tears it off that screen by
|
-- and docking it to the window edge instead tears it off that screen by
|
||||||
-- however far the letterbox sits from the edge.
|
-- however far the letterbox sits from the edge.
|
||||||
self.anchor = opts and opts.anchor or nil
|
self.anchor = opts and opts.anchor or nil
|
||||||
local box = Theme.choiceBox
|
local box = (opts and opts.box) or Theme.choiceBox
|
||||||
self.tx = (opts and opts.tx) or box.tx
|
self.tx = (opts and opts.tx) or box.tx
|
||||||
self.ty = (opts and opts.ty) or box.ty
|
self.ty = (opts and opts.ty) or box.ty
|
||||||
self.tw = (opts and opts.tw) or box.tw
|
self.tw = (opts and opts.tw) or box.tw
|
||||||
self.th = (opts and opts.th) or box.th
|
self.th = (opts and opts.th) or box.th
|
||||||
|
-- TwoOptionMenuStrings rows carry their own labels and a "blank line
|
||||||
|
-- before first menu item?" flag (data/yes_no_menu_strings.asm:8-16)
|
||||||
|
self.labels = (opts and opts.labels) or { "YES", "NO" }
|
||||||
|
self.firstItem = (opts and opts.firstItem) or box.firstItem or 1
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -81,10 +85,12 @@ function ChoiceBox:draw()
|
|||||||
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
|
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
|
||||||
Font.drawBox(tx, ty, tw, th, paper)
|
Font.drawBox(tx, ty, tw, th, paper)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
-- <NEXT> advances 2 * SCREEN_WIDTH -- home/text.asm:64
|
||||||
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
local row = self.firstItem
|
||||||
|
Font.draw(Strings(self.labels[1]), (tx + 2) * 8, (ty + row) * 8)
|
||||||
|
Font.draw(Strings(self.labels[2]), (tx + 2) * 8, (ty + row + 2) * 8)
|
||||||
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||||
(ty + (self.index == 1 and 1 or 3)) * 8)
|
(ty + row + (self.index == 1 and 0 or 2)) * 8)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -166,7 +166,8 @@ function EvolutionState:draw()
|
|||||||
if sprite then
|
if sprite then
|
||||||
local x = math.floor((160 - sprite:getWidth()) / 2)
|
local x = math.floor((160 - sprite:getWidth()) / 2)
|
||||||
local y = math.max(8, 64 - sprite:getHeight())
|
local y = math.max(8, 64 - sprite:getHeight())
|
||||||
love.graphics.draw(sprite, x, y)
|
-- engine/movie/evolution.asm:103
|
||||||
|
love.graphics.draw(sprite, x + sprite:getWidth(), y, 0, -1, 1)
|
||||||
if spriteTrueColor then
|
if spriteTrueColor then
|
||||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -293,23 +293,30 @@ function IntroMovie:update(dt)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("a") or input:wasPressed("start") then
|
|
||||||
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
||||||
-- on a fresh START or A -- B alone never skips the intro.
|
-- on a fresh START or A -- B alone never skips the intro.
|
||||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
local skip = input:wasPressed("a") or input:wasPressed("start")
|
||||||
-- white hold stands in for that beat before the title is built.
|
|
||||||
self:exitToTitle()
|
|
||||||
return
|
|
||||||
end
|
|
||||||
self.timer = self.timer + 1
|
self.timer = self.timer + 1
|
||||||
if self.phase == 1 then
|
if self.phase == 1 then
|
||||||
|
-- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311)
|
||||||
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
|
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
|
||||||
elseif self.phase == 2 then
|
elseif self.phase == 2 then
|
||||||
if self.timer == STAR_START then
|
if self.timer == STAR_START then
|
||||||
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
|
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
|
||||||
end
|
end
|
||||||
|
-- intro.asm:325 `jr c, .next`
|
||||||
|
if skip and self.timer >= STAR_START and self.timer < WAVES_END then
|
||||||
|
self:startPhase(3)
|
||||||
|
return
|
||||||
|
end
|
||||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||||
else
|
else
|
||||||
|
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||||
|
-- white hold stands in for that beat before the title is built.
|
||||||
|
if skip then
|
||||||
|
self:exitToTitle()
|
||||||
|
return
|
||||||
|
end
|
||||||
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
|
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
|
||||||
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
|
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
|
||||||
-- own frame
|
-- own frame
|
||||||
|
|||||||
@@ -313,6 +313,9 @@ function PartyMenu.new(game, opts)
|
|||||||
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
|
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
|
||||||
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
||||||
self.tmhm = opts.tmhm
|
self.tmhm = opts.tmhm
|
||||||
|
-- Evolution stones: opts.evoStone = item id gives Gen 1's
|
||||||
|
-- EVO_STONE_PARTY_MENU ABLE / NOT ABLE display (party_menu.asm:114). #1411
|
||||||
|
self.evoStone = opts.evoStone
|
||||||
self.forceSwitch = opts.forceSwitch
|
self.forceSwitch = opts.forceSwitch
|
||||||
self.battle = opts.battle
|
self.battle = opts.battle
|
||||||
self.party = party -- link/scoped battles pass their local party view
|
self.party = party -- link/scoped battles pass their local party view
|
||||||
@@ -800,6 +803,20 @@ function PartyMenu:draw()
|
|||||||
else
|
else
|
||||||
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||||
end
|
end
|
||||||
|
elseif self.evoStone then
|
||||||
|
-- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching
|
||||||
|
-- wEvoStoneItemID, printed in the TM/HM strings' row+1 column+9 slot
|
||||||
|
local can = false
|
||||||
|
for _, evo in ipairs(def.evolutions or {}) do
|
||||||
|
if evo.method == "ITEM" and evo.item == self.evoStone then
|
||||||
|
can = true break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if can then
|
||||||
|
Font.draw(Strings("ABLE"), 120, y + 8)
|
||||||
|
else
|
||||||
|
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
if mon.hp <= 0 then
|
if mon.hp <= 0 then
|
||||||
Font.draw(Strings("FNT"), 136, y)
|
Font.draw(Strings("FNT"), 136, y)
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ local Theme = {
|
|||||||
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
|
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
|
||||||
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
|
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
|
||||||
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
|
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
|
||||||
|
-- YesNoChoicePokeCenter: hlcoord 11, 6 (HEAL_CANCEL_MENU 7x4, blank line
|
||||||
|
-- before the first item) -- home/yes_no.asm:21, data/yes_no_menu_strings.asm:16
|
||||||
|
healCancelBox = { tx = 11, ty = 6, tw = 9, th = 6, firstItem = 2 },
|
||||||
|
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
|
||||||
|
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
|
||||||
|
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||||
}
|
}
|
||||||
|
|
||||||
function Theme.load(data)
|
function Theme.load(data)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
|
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
local TouchControls = require("src.core.TouchControls")
|
local TouchControls = require("src.core.TouchControls")
|
||||||
|
local TouchSkin = require("src.core.TouchSkin")
|
||||||
local PadCursor = require("src.ui.PadCursor")
|
local PadCursor = require("src.ui.PadCursor")
|
||||||
local GamepadMap = require("src.core.GamepadMap")
|
local GamepadMap = require("src.core.GamepadMap")
|
||||||
|
|
||||||
@@ -46,6 +47,15 @@ local function roundRect(mode, x, y, w, h, r)
|
|||||||
love.graphics.rectangle(mode, x, y, w, h, r, r)
|
love.graphics.rectangle(mode, x, y, w, h, r, r)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function fitText(font, text, maxW)
|
||||||
|
text = tostring(text)
|
||||||
|
if maxW <= 0 or font:getWidth(text) <= maxW then return text end
|
||||||
|
while #text > 1 and font:getWidth(text .. "...") > maxW do
|
||||||
|
text = text:sub(1, #text - 1)
|
||||||
|
end
|
||||||
|
return text .. "..."
|
||||||
|
end
|
||||||
|
|
||||||
function Editor.load(opts)
|
function Editor.load(opts)
|
||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
Editor.onClose = opts.onClose
|
Editor.onClose = opts.onClose
|
||||||
@@ -53,6 +63,7 @@ function Editor.load(opts)
|
|||||||
Editor.hostPoll = opts.hostPoll == true
|
Editor.hostPoll = opts.hostPoll == true
|
||||||
Editor.drag = nil
|
Editor.drag = nil
|
||||||
Editor.rects = {}
|
Editor.rects = {}
|
||||||
|
Editor._closed = false
|
||||||
Editor._hostMouse = false
|
Editor._hostMouse = false
|
||||||
Editor._hostTouches = nil
|
Editor._hostTouches = nil
|
||||||
Editor.fonts = {
|
Editor.fonts = {
|
||||||
@@ -74,9 +85,52 @@ function Editor.load(opts)
|
|||||||
TouchControls:applyOptions(applied)
|
TouchControls:applyOptions(applied)
|
||||||
TouchControls:setPreview(true)
|
TouchControls:setPreview(true)
|
||||||
Editor.enabled = TouchControls.enabled ~= false
|
Editor.enabled = TouchControls.enabled ~= false
|
||||||
|
Editor.skins = TouchSkin.list()
|
||||||
|
Editor.exportMsg = nil
|
||||||
PadCursor.reset()
|
PadCursor.reset()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Editor.skinIndex()
|
||||||
|
for i, entry in ipairs(Editor.skins or {}) do
|
||||||
|
if entry.id == TouchControls.skinId then return i + 1 end
|
||||||
|
end
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.skinLabel()
|
||||||
|
local entry = (Editor.skins or {})[Editor.skinIndex() - 1]
|
||||||
|
if not entry then return "Built-in pad" end
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
local pages = skin and #skin.pages or 1
|
||||||
|
if pages > 1 then return entry.id .. " (" .. pages .. " pages)" end
|
||||||
|
return entry.id
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.cycleSkin(dir)
|
||||||
|
local n = #(Editor.skins or {}) + 1
|
||||||
|
local idx = ((Editor.skinIndex() - 1 + (dir or 1)) % n) + 1
|
||||||
|
local entry = Editor.skins[idx - 1]
|
||||||
|
Editor.exportMsg = nil
|
||||||
|
TouchControls:selectSkin(entry and entry.id or nil)
|
||||||
|
TouchControls:ensureImages()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Editor.exportSkin()
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin then return end
|
||||||
|
local path, missing = TouchSkin.export(skin)
|
||||||
|
if not path then
|
||||||
|
Editor.exportMsg = "Export failed: " .. tostring(missing)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
Editor.exportMsg = "Exported to " .. path .. " in your save folder."
|
||||||
|
if type(missing) == "table" and missing[1] then
|
||||||
|
Editor.exportMsg = Editor.exportMsg
|
||||||
|
.. " " .. #missing .. " image(s) were missing and left out."
|
||||||
|
end
|
||||||
|
Editor.skins = TouchSkin.list()
|
||||||
|
end
|
||||||
|
|
||||||
function Editor.unload()
|
function Editor.unload()
|
||||||
TouchControls:setPreview(false)
|
TouchControls:setPreview(false)
|
||||||
TouchControls:reset()
|
TouchControls:reset()
|
||||||
@@ -84,6 +138,7 @@ function Editor.unload()
|
|||||||
Editor.drag = nil
|
Editor.drag = nil
|
||||||
Editor.onClose = nil
|
Editor.onClose = nil
|
||||||
Editor.version = nil
|
Editor.version = nil
|
||||||
|
Editor.rects = {}
|
||||||
Editor._hostMouse = false
|
Editor._hostMouse = false
|
||||||
Editor._hostTouches = nil
|
Editor._hostTouches = nil
|
||||||
end
|
end
|
||||||
@@ -93,6 +148,7 @@ local function persist()
|
|||||||
local cfg = TouchControls:config()
|
local cfg = TouchControls:config()
|
||||||
local block = {
|
local block = {
|
||||||
enabled = cfg.enabled,
|
enabled = cfg.enabled,
|
||||||
|
skin = cfg.skin,
|
||||||
layouts = cfg.layouts,
|
layouts = cfg.layouts,
|
||||||
}
|
}
|
||||||
if Editor.version == "gold" then
|
if Editor.version == "gold" then
|
||||||
@@ -105,6 +161,8 @@ local function persist()
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function close()
|
local function close()
|
||||||
|
if Editor._closed then return end
|
||||||
|
Editor._closed = true
|
||||||
persist()
|
persist()
|
||||||
local cb = Editor.onClose
|
local cb = Editor.onClose
|
||||||
Editor.unload()
|
Editor.unload()
|
||||||
@@ -158,6 +216,8 @@ function Editor.draw()
|
|||||||
col(PAL.bgTop, 0.85)
|
col(PAL.bgTop, 0.85)
|
||||||
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
love.graphics.circle("fill", ox + ww * 0.5, oy + wh * 0.15, math.max(ww, wh) * 0.55)
|
||||||
|
|
||||||
|
if TouchSkin.active then TouchControls:draw() end
|
||||||
|
|
||||||
local pad = 18 * s
|
local pad = 18 * s
|
||||||
local barH = 56 * s
|
local barH = 56 * s
|
||||||
local btnH = 40 * s
|
local btnH = 40 * s
|
||||||
@@ -170,16 +230,18 @@ function Editor.draw()
|
|||||||
love.graphics.setLineWidth(1)
|
love.graphics.setLineWidth(1)
|
||||||
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
love.graphics.line(0, oy + barH + pad, fullW, oy + barH + pad)
|
||||||
|
|
||||||
love.graphics.setFont(Editor.fonts.title)
|
|
||||||
col(PAL.white)
|
|
||||||
love.graphics.print("Touch Controls", ox + pad, oy + pad + 4 * s)
|
|
||||||
|
|
||||||
-- Done / Reset
|
-- Done / Reset
|
||||||
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
local done = { x = ox + ww - pad - btnW, y = oy + pad + (barH - btnH) / 2,
|
||||||
w = btnW, h = btnH }
|
w = btnW, h = btnH }
|
||||||
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
local reset = { x = done.x - 10 * s - btnW, y = done.y, w = btnW, h = btnH }
|
||||||
Editor.rects.done, Editor.rects.reset = done, reset
|
Editor.rects.done, Editor.rects.reset = done, reset
|
||||||
|
|
||||||
|
love.graphics.setFont(Editor.fonts.title)
|
||||||
|
col(PAL.white)
|
||||||
|
love.graphics.print(
|
||||||
|
fitText(Editor.fonts.title, "Touch Controls", reset.x - 10 * s - (ox + pad)),
|
||||||
|
ox + pad, oy + pad + 4 * s)
|
||||||
|
|
||||||
local function chromeBtn(r, label, fill)
|
local function chromeBtn(r, label, fill)
|
||||||
col(fill, 0.9)
|
col(fill, 0.9)
|
||||||
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
|
roundRect("fill", r.x, r.y, r.w, r.h, 8 * s)
|
||||||
@@ -223,9 +285,48 @@ function Editor.draw()
|
|||||||
chromeBtn(toggle, on and "Disable" or "Enable",
|
chromeBtn(toggle, on and "Disable" or "Enable",
|
||||||
on and PAL.red or PAL.green)
|
on and PAL.red or PAL.green)
|
||||||
|
|
||||||
|
local skinY = cardY + cardH + 10 * s
|
||||||
|
col(PAL.card, 0.88)
|
||||||
|
roundRect("fill", cardX, skinY, cardW, cardH, 12 * s)
|
||||||
|
col(PAL.stroke, 0.4)
|
||||||
|
roundRect("line", cardX, skinY, cardW, cardH, 12 * s)
|
||||||
|
|
||||||
|
local stepW = 52 * s
|
||||||
|
local skinNext = { x = cardX + cardW - 16 * s - stepW,
|
||||||
|
y = skinY + (cardH - btnH) / 2, w = stepW, h = btnH }
|
||||||
|
local skinPrev = { x = skinNext.x - 10 * s - stepW, y = skinNext.y,
|
||||||
|
w = stepW, h = btnH }
|
||||||
|
Editor.rects.skinNext, Editor.rects.skinPrev = skinNext, skinPrev
|
||||||
|
|
||||||
|
local leftmost = skinPrev.x
|
||||||
|
if TouchSkin.active then
|
||||||
|
local expW = 86 * s
|
||||||
|
local export = { x = skinPrev.x - 10 * s - expW, y = skinPrev.y,
|
||||||
|
w = expW, h = btnH }
|
||||||
|
Editor.rects.export = export
|
||||||
|
leftmost = export.x
|
||||||
|
else
|
||||||
|
Editor.rects.export = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local skinTextW = leftmost - 10 * s - (cardX + 16 * s)
|
||||||
|
love.graphics.setFont(Editor.fonts.body)
|
||||||
|
col(PAL.label)
|
||||||
|
love.graphics.print("Skin", cardX + 16 * s, skinY + 12 * s)
|
||||||
|
love.graphics.setFont(Editor.fonts.btn)
|
||||||
|
col(TouchControls.skinId and PAL.green or PAL.white)
|
||||||
|
love.graphics.print(fitText(Editor.fonts.btn, Editor.skinLabel(), skinTextW),
|
||||||
|
cardX + 16 * s, skinY + 34 * s)
|
||||||
|
|
||||||
|
chromeBtn(skinPrev, "<", { 60, 70, 110 })
|
||||||
|
chromeBtn(skinNext, ">", { 60, 70, 110 })
|
||||||
|
if Editor.rects.export then
|
||||||
|
chromeBtn(Editor.rects.export, "Export", { 60, 70, 110 })
|
||||||
|
end
|
||||||
|
|
||||||
-- size card (#633): -/+ resize every control in the orientation on
|
-- size card (#633): -/+ resize every control in the orientation on
|
||||||
-- screen; the heading names it so it is plain the other one is untouched
|
-- screen; the heading names it so it is plain the other one is untouched
|
||||||
local sizeY = cardY + cardH + 10 * s
|
local sizeY = skinY + cardH + 10 * s
|
||||||
col(PAL.card, 0.88)
|
col(PAL.card, 0.88)
|
||||||
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
|
roundRect("fill", cardX, sizeY, cardW, cardH, 12 * s)
|
||||||
col(PAL.stroke, 0.4)
|
col(PAL.stroke, 0.4)
|
||||||
@@ -243,7 +344,6 @@ function Editor.draw()
|
|||||||
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
|
string.format("%d%%", math.floor((bucket.scale or 1) * 100 + 0.5)),
|
||||||
cardX + 16 * s, sizeY + 34 * s)
|
cardX + 16 * s, sizeY + 34 * s)
|
||||||
|
|
||||||
local stepW = 52 * s
|
|
||||||
local plus = { x = cardX + cardW - 16 * s - stepW,
|
local plus = { x = cardX + cardW - 16 * s - stepW,
|
||||||
y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
|
y = sizeY + (cardH - btnH) / 2, w = stepW, h = btnH }
|
||||||
local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
|
local minus = { x = plus.x - 10 * s - stepW, y = plus.y, w = stepW, h = btnH }
|
||||||
@@ -254,13 +354,19 @@ function Editor.draw()
|
|||||||
-- hint
|
-- hint
|
||||||
love.graphics.setFont(Editor.fonts.body)
|
love.graphics.setFont(Editor.fonts.body)
|
||||||
col(PAL.label, 0.9)
|
col(PAL.label, 0.9)
|
||||||
local hint = on
|
local hint
|
||||||
and "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
|
if Editor.exportMsg then
|
||||||
or "Controls are hidden in-game. Enable them to show and edit the layout."
|
hint = Editor.exportMsg
|
||||||
|
elseif not on then
|
||||||
|
hint = "Controls are hidden in-game. Enable them to show and edit the layout."
|
||||||
|
elseif TouchSkin.active then
|
||||||
|
hint = "This skin owns its own art, hitboxes and screen position, so size and dragging are off. Drop a RetroArch overlay folder or .zip into the skins folder of your save directory to add more."
|
||||||
|
else
|
||||||
|
hint = "Drag each button to reposition, -/+ to resize. Portrait and landscape are saved separately when you tap Done."
|
||||||
|
end
|
||||||
love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
|
love.graphics.printf(hint, ox + pad, sizeY + cardH + 12 * s, ww - 2 * pad, "left")
|
||||||
|
|
||||||
-- the overlay itself (preview mode; dimmed when disabled)
|
if not TouchSkin.active then TouchControls:draw() end
|
||||||
TouchControls:draw()
|
|
||||||
|
|
||||||
-- highlight the control under drag
|
-- highlight the control under drag
|
||||||
if Editor.drag then
|
if Editor.drag then
|
||||||
@@ -282,6 +388,9 @@ local function beginDrag(id, x, y)
|
|||||||
if inside(Editor.rects.done, x, y) then close(); return end
|
if inside(Editor.rects.done, x, y) then close(); return end
|
||||||
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
|
if inside(Editor.rects.reset, x, y) then resetLayout(); return end
|
||||||
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
|
if inside(Editor.rects.toggle, x, y) then toggleEnabled(); return end
|
||||||
|
if inside(Editor.rects.skinPrev, x, y) then Editor.cycleSkin(-1); return end
|
||||||
|
if inside(Editor.rects.skinNext, x, y) then Editor.cycleSkin(1); return end
|
||||||
|
if inside(Editor.rects.export, x, y) then Editor.exportSkin(); return end
|
||||||
if inside(Editor.rects.sizeDown, x, y) then
|
if inside(Editor.rects.sizeDown, x, y) then
|
||||||
TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
|
TouchControls:nudgeScale(-TouchControls.SCALE_STEP); return
|
||||||
end
|
end
|
||||||
@@ -289,6 +398,7 @@ local function beginDrag(id, x, y)
|
|||||||
TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
|
TouchControls:nudgeScale(TouchControls.SCALE_STEP); return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if TouchSkin.active then return end
|
||||||
local name = TouchControls:hitTest(x, y)
|
local name = TouchControls:hitTest(x, y)
|
||||||
if not name then return end
|
if not name then return end
|
||||||
local zone = TouchControls:layout()[name]
|
local zone = TouchControls:layout()[name]
|
||||||
@@ -321,6 +431,7 @@ function Editor.pollHostPointers()
|
|||||||
if not Editor._hostMouse then
|
if not Editor._hostMouse then
|
||||||
Editor._hostMouse = true
|
Editor._hostMouse = true
|
||||||
beginDrag("mouse", x, y)
|
beginDrag("mouse", x, y)
|
||||||
|
if Editor._closed then return end
|
||||||
else
|
else
|
||||||
moveDrag("mouse", x, y)
|
moveDrag("mouse", x, y)
|
||||||
end
|
end
|
||||||
@@ -340,6 +451,7 @@ function Editor.pollHostPointers()
|
|||||||
if not Editor._hostTouches[id] then
|
if not Editor._hostTouches[id] then
|
||||||
Editor._hostTouches[id] = true
|
Editor._hostTouches[id] = true
|
||||||
beginDrag(id, tx, ty)
|
beginDrag(id, tx, ty)
|
||||||
|
if Editor._closed then return end
|
||||||
else
|
else
|
||||||
moveDrag(id, tx, ty)
|
moveDrag(id, tx, ty)
|
||||||
end
|
end
|
||||||
@@ -439,6 +551,12 @@ function Editor.keypressed(key)
|
|||||||
close()
|
close()
|
||||||
elseif key == "r" then
|
elseif key == "r" then
|
||||||
resetLayout()
|
resetLayout()
|
||||||
|
elseif key == "[" then
|
||||||
|
Editor.cycleSkin(-1)
|
||||||
|
elseif key == "]" then
|
||||||
|
Editor.cycleSkin(1)
|
||||||
|
elseif key == "e" then
|
||||||
|
Editor.exportSkin()
|
||||||
-- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
|
-- desktop shortcuts for the size -/+ (POKEPORT_TOUCH=1 testing, #633)
|
||||||
elseif key == "-" or key == "kp-" then
|
elseif key == "-" or key == "kp-" then
|
||||||
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
|
TouchControls:nudgeScale(-TouchControls.SCALE_STEP)
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
-- This is what the party-menu FLY field move opens (#195).
|
-- This is what the party-menu FLY field move opens (#195).
|
||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
|
local PaletteFX = require("src.render.PaletteFX")
|
||||||
local Sound = require("src.core.Sound")
|
local Sound = require("src.core.Sound")
|
||||||
|
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||||
|
|
||||||
local TownMap = {}
|
local TownMap = {}
|
||||||
TownMap.__index = TownMap
|
TownMap.__index = TownMap
|
||||||
@@ -227,7 +229,21 @@ function TownMap.new(game, opts)
|
|||||||
local sprites = game.data.sprites or {}
|
local sprites = game.data.sprites or {}
|
||||||
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
||||||
or sprites.SPRITE_RED
|
or sprites.SPRITE_RED
|
||||||
local ok, img = pcall(love.graphics.newImage, red and red.image)
|
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
|
||||||
|
-- palette and shade-0 keying -- engine/items/town_map.asm:342
|
||||||
|
local colors, group
|
||||||
|
if PaletteFX.usesGbcPack() then
|
||||||
|
colors, group = PaletteFX.spriteObp(red, "player")
|
||||||
|
end
|
||||||
|
if not colors then
|
||||||
|
if PaletteFX.usesSpriteObp() then
|
||||||
|
colors, group = PaletteFX.ogObj()
|
||||||
|
else
|
||||||
|
colors, group = PaletteFX.dmgObj()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local ok, img = pcall(SpriteRenderer.obpImage,
|
||||||
|
red and red.image, colors, group)
|
||||||
if ok and img then
|
if ok and img then
|
||||||
self.playerSheet = img
|
self.playerSheet = img
|
||||||
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
||||||
@@ -316,6 +332,13 @@ function TownMap:update(dt)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
|
||||||
|
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
|
||||||
|
function TownMap:markPlayerRedraw(x, y)
|
||||||
|
if not PaletteFX.usesSpriteObp() then return end
|
||||||
|
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
|
||||||
|
end
|
||||||
|
|
||||||
local function drawSquare(loc)
|
local function drawSquare(loc)
|
||||||
if isRoute(loc) then
|
if isRoute(loc) then
|
||||||
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
|
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
|
||||||
@@ -364,6 +387,7 @@ function TownMap:draw()
|
|||||||
local x, y = markerXY(self.playerLoc)
|
local x, y = markerXY(self.playerLoc)
|
||||||
if self.playerSheet then
|
if self.playerSheet then
|
||||||
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
|
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
|
||||||
|
self:markPlayerRedraw(x - 4, y - 3)
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||||
@@ -406,6 +430,8 @@ function TownMap:draw()
|
|||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.draw(self.playerSheet, self.playerQuad,
|
love.graphics.draw(self.playerSheet, self.playerQuad,
|
||||||
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
|
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
|
||||||
|
self:markPlayerRedraw(self.playerLoc.x * 8 - 4,
|
||||||
|
self.playerLoc.y * 8 - 3)
|
||||||
else
|
else
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||||
|
|||||||
@@ -445,7 +445,8 @@ function TradeAnim:draw()
|
|||||||
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
|
-- mon pic on the BG at hlcoord 7, 2; info box on the window at
|
||||||
-- hWY $50, so it sits in the bottom half of the screen
|
-- hWY $50, so it sits in the bottom half of the screen
|
||||||
if self.monVisible and self.sentSprite then
|
if self.monVisible and self.sentSprite then
|
||||||
love.graphics.draw(self.sentSprite, 56, 16)
|
-- engine/movie/trade.asm:751
|
||||||
|
love.graphics.draw(self.sentSprite, 56 + self.sentSprite:getWidth(), 16, 0, -1, 1)
|
||||||
if self.sentSpriteTrueColor then
|
if self.sentSpriteTrueColor then
|
||||||
require("src.render.PaletteFX").markTrueColor(
|
require("src.render.PaletteFX").markTrueColor(
|
||||||
56 - self.scx, 16, self.sentSprite:getDimensions())
|
56 - self.scx, 16, self.sentSprite:getDimensions())
|
||||||
@@ -501,7 +502,8 @@ function TradeAnim:draw()
|
|||||||
|
|
||||||
elseif p == "show_enemy" then
|
elseif p == "show_enemy" then
|
||||||
if self.monVisible and self.recvSprite then
|
if self.monVisible and self.recvSprite then
|
||||||
love.graphics.draw(self.recvSprite, 56, 16)
|
-- engine/movie/trade.asm:751
|
||||||
|
love.graphics.draw(self.recvSprite, 56 + self.recvSprite:getWidth(), 16, 0, -1, 1)
|
||||||
if self.recvSpriteTrueColor then
|
if self.recvSpriteTrueColor then
|
||||||
require("src.render.PaletteFX").markTrueColor(
|
require("src.render.PaletteFX").markTrueColor(
|
||||||
56, 16, self.recvSprite:getDimensions())
|
56, 16, self.recvSprite:getDimensions())
|
||||||
|
|||||||
@@ -256,10 +256,7 @@ function YellowIntro.new(game, onDone)
|
|||||||
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
|
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
|
||||||
self.bgCanvas = ok and canvas or nil
|
self.bgCanvas = ok and canvas or nil
|
||||||
|
|
||||||
-- Yellow boots exactly like Red up to the attract movie: the copyright
|
-- intro.asm:311 copyright card, splash.asm:29 shooting star
|
||||||
-- card and the GAME FREAK shooting-star splash play first. Reuse
|
|
||||||
-- IntroMovie's phases 1-2 and take over where its Gengar fight (phase
|
|
||||||
-- 3) would begin; a skip press during the pre-roll skips everything.
|
|
||||||
local IntroMovie = require("src.ui.IntroMovie")
|
local IntroMovie = require("src.ui.IntroMovie")
|
||||||
local pre = IntroMovie.new(game, nil)
|
local pre = IntroMovie.new(game, nil)
|
||||||
local baseStart = pre.startPhase
|
local baseStart = pre.startPhase
|
||||||
@@ -644,8 +641,8 @@ end
|
|||||||
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
|
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
|
||||||
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
|
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
|
||||||
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
|
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
|
||||||
-- buffers before the title screen is built (#523). The pre-roll's own skip
|
-- buffers before the title screen is built (#523). A press during the
|
||||||
-- is IntroMovie's path, not this one, and still finishes immediately.
|
-- pre-roll splash starts the scenes instead of reaching here.
|
||||||
function YellowIntro:exitToTitle()
|
function YellowIntro:exitToTitle()
|
||||||
if self.finished or self.exiting then return end
|
if self.finished or self.exiting then return end
|
||||||
self.exiting = true
|
self.exiting = true
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ local TILE_PLAYER_BOTTOM_LEFT = 0x6f
|
|||||||
-- (engine/battle/trainer_huds.asm:143-152).
|
-- (engine/battle/trainer_huds.asm:143-152).
|
||||||
local TILE_CAUGHT = 0x5d
|
local TILE_CAUGHT = 0x5d
|
||||||
|
|
||||||
|
-- The ball icons StageBallTilesData stages, one per party slot
|
||||||
|
-- (engine/battle/trainer_huds.asm:47-99).
|
||||||
|
local TILE_BALL_NORMAL = 0x31
|
||||||
|
local TILE_BALL_STATUSED = 0x32
|
||||||
|
local TILE_BALL_FAINTED = 0x33
|
||||||
|
local TILE_BALL_EMPTY = 0x34
|
||||||
|
-- DrawPlayerPartyIconHUDBorder's corner (trainer_huds.asm:118-132), which is
|
||||||
|
-- DrawPlayerHUDBorder's with $77 swapped for $5c.
|
||||||
|
local TILE_PARTY_ICON_BOTTOM_RIGHT = 0x5c
|
||||||
|
|
||||||
|
BattleHud.PARTY_LENGTH = 6
|
||||||
|
|
||||||
function BattleHud.new(menuGfx, palettes)
|
function BattleHud.new(menuGfx, palettes)
|
||||||
local self = setmetatable({}, BattleHud)
|
local self = setmetatable({}, BattleHud)
|
||||||
self.gfx = menuGfx and menuGfx.battleHud or nil
|
self.gfx = menuGfx and menuGfx.battleHud or nil
|
||||||
@@ -184,18 +196,25 @@ local TILE_EXP_FULL = 0x6a -- FontBattleExtra
|
|||||||
local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra
|
local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra
|
||||||
local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX
|
local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX
|
||||||
|
|
||||||
|
-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11)
|
||||||
|
-- (engine/gfx/cgb_layouts.asm:142-145).
|
||||||
|
function BattleHud:expColors()
|
||||||
|
local pal = self.palettes and self.palettes.expBar
|
||||||
|
if not pal then return nil end
|
||||||
|
return {
|
||||||
|
{ 255, 255, 255 },
|
||||||
|
{ pal[1][1], pal[1][2], pal[1][3] },
|
||||||
|
{ pal[2][1], pal[2][2], pal[2][3] },
|
||||||
|
{ 0, 0, 0 },
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
function BattleHud:drawExpBar(fraction, tx, ty)
|
function BattleHud:drawExpBar(fraction, tx, ty)
|
||||||
if not self:image("hpBar") then return false end
|
if not self:image("hpBar") then return false end
|
||||||
fraction = math.max(0, math.min(1, fraction or 0))
|
fraction = math.max(0, math.min(1, fraction or 0))
|
||||||
local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX)
|
local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX)
|
||||||
-- The whole row wears the exp bar's palette, full and empty cells included.
|
-- The whole row wears the exp bar's palette, full and empty cells included.
|
||||||
local pal = self.palettes and self.palettes.expBar
|
local colors = self:expColors()
|
||||||
local colors = pal and {
|
|
||||||
{ 255, 255, 255 },
|
|
||||||
{ pal[1][1], pal[1][2], pal[1][3] },
|
|
||||||
{ pal[2][1], pal[2][2], pal[2][3] },
|
|
||||||
{ 0, 0, 0 },
|
|
||||||
} or nil
|
|
||||||
|
|
||||||
local remaining = pixels
|
local remaining = pixels
|
||||||
for cell = BattleHud.EXP_CELLS - 1, 0, -1 do
|
for cell = BattleHud.EXP_CELLS - 1, 0, -1 do
|
||||||
@@ -269,9 +288,7 @@ end
|
|||||||
-- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f /
|
-- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f /
|
||||||
-- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub
|
-- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub
|
||||||
-- is two rows tall.
|
-- is two rows tall.
|
||||||
function BattleHud:drawPlayerFrame()
|
local PLAYER_FRAME_TILES = {
|
||||||
self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9)
|
|
||||||
self:placeBorder({
|
|
||||||
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
|
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
|
||||||
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
|
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
|
||||||
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
|
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
|
||||||
@@ -281,7 +298,46 @@ function BattleHud:drawPlayerFrame()
|
|||||||
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
|
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
|
||||||
farCorner = TILE_PLAYER_BOTTOM_LEFT,
|
farCorner = TILE_PLAYER_BOTTOM_LEFT,
|
||||||
bottom = TILE_BOTTOM_SIDE,
|
bottom = TILE_BOTTOM_SIDE,
|
||||||
}, 18, 10, -1)
|
}
|
||||||
|
|
||||||
|
function BattleHud:drawPlayerFrame()
|
||||||
|
self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9)
|
||||||
|
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- DrawPlayerPartyIconHUDBorder (engine/battle/trainer_huds.asm:118-132): the
|
||||||
|
-- player border with $5c for the bottom right, and no bar at (18,9).
|
||||||
|
function BattleHud:drawPartyIconFrame()
|
||||||
|
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
|
||||||
|
return self:drawTile("expBar", self.gfx and self.gfx.expBarFirstTile,
|
||||||
|
TILE_PARTY_ICON_BOTTOM_RIGHT, 18, 11, self:expColors())
|
||||||
|
end
|
||||||
|
|
||||||
|
-- StageBallTilesData's .GetHUDTile, and the $34 it stages past the party
|
||||||
|
-- count (engine/battle/trainer_huds.asm:47-100).
|
||||||
|
local function ballTile(mon)
|
||||||
|
if not mon then return TILE_BALL_EMPTY end
|
||||||
|
if (mon.hp or 0) <= 0 then return TILE_BALL_FAINTED end
|
||||||
|
return mon.status and TILE_BALL_STATUSED or TILE_BALL_NORMAL
|
||||||
|
end
|
||||||
|
|
||||||
|
-- PAL_BATTLE_OB_YELLOW (engine/battle/trainer_huds.asm:213-214).
|
||||||
|
function BattleHud:ballColors()
|
||||||
|
local pals = self.palettes and self.palettes.battleObjects
|
||||||
|
return pals and pals.PAL_BATTLE_OB_YELLOW or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- LoadTrainerHudOAM (engine/battle/trainer_huds.asm:203-223): six sprites
|
||||||
|
-- from (tx, ty), each one tile further along `step`.
|
||||||
|
function BattleHud:drawBallRow(party, tx, ty, step)
|
||||||
|
if not self:image("balls") then return false end
|
||||||
|
local first = self.gfx.ballsFirstTile or TILE_BALL_NORMAL
|
||||||
|
local colors = self:ballColors()
|
||||||
|
for slot = 1, BattleHud.PARTY_LENGTH do
|
||||||
|
self:drawTile("balls", first, ballTile(party and party[slot]),
|
||||||
|
tx + (slot - 1) * step, ty, colors)
|
||||||
|
end
|
||||||
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
BattleHud.TILE_HP_LABEL = TILE_HP_LABEL
|
BattleHud.TILE_HP_LABEL = TILE_HP_LABEL
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ local BattleState = {}
|
|||||||
BattleState.__index = BattleState
|
BattleState.__index = BattleState
|
||||||
BattleState.isOpaque = true
|
BattleState.isOpaque = true
|
||||||
|
|
||||||
|
function BattleState:moveGridNavigation()
|
||||||
|
if not Runtime.wantsHook("battle.move_grid_navigation") then return false end
|
||||||
|
return Runtime.call("battle.move_grid_navigation", function() return false end,
|
||||||
|
self) == true
|
||||||
|
end
|
||||||
|
|
||||||
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
|
-- Armed while a battle line waits for PromptButton (home/text.asm). Any
|
||||||
-- positive value means "hold until A/B"; the cart never times these out, so
|
-- positive value means "hold until A/B"; the cart never times these out, so
|
||||||
-- the victory jingle can keep looping through the post-win prompts.
|
-- the victory jingle can keep looping through the post-win prompts.
|
||||||
@@ -314,6 +320,13 @@ function BattleState.new(game, opts)
|
|||||||
self.showEnemyHud = false
|
self.showEnemyHud = false
|
||||||
self.showPlayerHud = false
|
self.showPlayerHud = false
|
||||||
|
|
||||||
|
-- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the
|
||||||
|
-- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733).
|
||||||
|
self.ballRows = {
|
||||||
|
player = true,
|
||||||
|
enemy = not (self.battle and self.battle.wild),
|
||||||
|
}
|
||||||
|
|
||||||
-- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7
|
-- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7
|
||||||
-- frontpic in the enemy pic box BEFORE the intro slide, and it stays there
|
-- frontpic in the enemy pic box BEFORE the intro slide, and it stays there
|
||||||
-- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The
|
-- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The
|
||||||
@@ -349,6 +362,7 @@ function BattleState.new(game, opts)
|
|||||||
|
|
||||||
local enemy = self.battle and self.battle.enemy
|
local enemy = self.battle and self.battle.enemy
|
||||||
self:noteFirstUnown(enemy)
|
self:noteFirstUnown(enemy)
|
||||||
|
self:markSeen(enemy)
|
||||||
if enemy then
|
if enemy then
|
||||||
if self.battle.wild then
|
if self.battle.wild then
|
||||||
-- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's
|
-- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's
|
||||||
@@ -399,6 +413,13 @@ function BattleState.new(game, opts)
|
|||||||
-- "X fainted!" is even displayed. The replacement arrives with its own
|
-- "X fainted!" is even displayed. The replacement arrives with its own
|
||||||
-- `send` event, which is where the cart's send-out animation sits.
|
-- `send` event, which is where the cart's send-out animation sits.
|
||||||
self.shownMon = { player = player, enemy = enemy }
|
self.shownMon = { player = player, enemy = enemy }
|
||||||
|
-- home/battle.asm:150 UpdateBattleHuds
|
||||||
|
self.shownStatus = {
|
||||||
|
player = (player and player.status) or false,
|
||||||
|
enemy = (enemy and enemy.status) or false,
|
||||||
|
}
|
||||||
|
-- engine/battle/trainer_huds.asm:142-151
|
||||||
|
self.caughtMark = self:dexCaught(enemy)
|
||||||
-- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is
|
-- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is
|
||||||
-- only advanced inside its level loop, right after that level's bar has
|
-- only advanced inside its level loop, right after that level's bar has
|
||||||
-- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor
|
-- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor
|
||||||
@@ -443,6 +464,16 @@ function BattleState:noteFirstUnown(mon)
|
|||||||
save.firstUnownSeen = Unown.monLetter(mon)
|
save.firstUnownSeen = Unown.monLetter(mon)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- LoadEnemyMon's "Saw this mon" (engine/battle/core.asm:6203-6209): the seen
|
||||||
|
-- flag is stamped for every battle mode, so a trainer's mon counts too.
|
||||||
|
function BattleState:markSeen(mon)
|
||||||
|
local save = self.save
|
||||||
|
if not (save and mon and mon.species) then return end
|
||||||
|
save.pokedex = save.pokedex or { seen = {}, caught = {} }
|
||||||
|
save.pokedex.seen = save.pokedex.seen or {}
|
||||||
|
save.pokedex.seen[mon.species] = true
|
||||||
|
end
|
||||||
|
|
||||||
-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the
|
-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the
|
||||||
-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu
|
-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu
|
||||||
-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes
|
-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes
|
||||||
@@ -936,6 +967,25 @@ function BattleState:dexCaught(mon)
|
|||||||
return (mon and caught and caught[mon.species]) and true or false
|
return (mon and caught and caught[mon.species]) and true or false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- The status the HUD prints, one drain behind the engine the way shownHp is:
|
||||||
|
-- UpdateBattleHuds runs after the animation and its line (home/battle.asm:150).
|
||||||
|
function BattleState:hudStatus(mon, side)
|
||||||
|
local shown = side and self.shownStatus and self.shownStatus[side]
|
||||||
|
if shown == nil then return mon and mon.status or nil end
|
||||||
|
return shown or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- home/battle.asm:150, for the clears no queued event carries (a mon waking,
|
||||||
|
-- a thaw, a bag cure): the tags catch up once the queue is idle.
|
||||||
|
function BattleState:syncShownStatus()
|
||||||
|
local shown, battle = self.shownStatus, self.battle
|
||||||
|
if not (shown and battle) then return end
|
||||||
|
for _, side in ipairs({ "player", "enemy" }) do
|
||||||
|
local mon = battle[side]
|
||||||
|
shown[side] = (mon and mon.status) or false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531)
|
-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531)
|
||||||
-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a
|
-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a
|
||||||
-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0,
|
-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0,
|
||||||
@@ -1153,7 +1203,7 @@ function BattleState:stepAnim(input)
|
|||||||
-- Cart still reaches the after-anim arm after a move script ends; a skip
|
-- Cart still reaches the after-anim arm after a move script ends; a skip
|
||||||
-- of the move should not drop the hit shake that follows it.
|
-- of the move should not drop the hit shake that follows it.
|
||||||
if self:startPendingAfterAnim() then return end
|
if self:startPendingAfterAnim() then return end
|
||||||
return self:endSendOutAnim()
|
return self:endSendOutAnim(true)
|
||||||
end
|
end
|
||||||
if not self.anim:step() then
|
if not self.anim:step() then
|
||||||
self:latchCaughtPic()
|
self:latchCaughtPic()
|
||||||
@@ -1170,10 +1220,17 @@ end
|
|||||||
|
|
||||||
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
|
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
|
||||||
-- HUD update run the moment its animation is done, cut short or not.
|
-- HUD update run the moment its animation is done, cut short or not.
|
||||||
function BattleState:endSendOutAnim()
|
function BattleState:endSendOutAnim(skipped)
|
||||||
local after = self.afterSendOut
|
local after = self.afterSendOut
|
||||||
if not after then return end
|
if not after then return end
|
||||||
self.afterSendOut = nil
|
self.afterSendOut = nil
|
||||||
|
if after.shiny and not skipped then
|
||||||
|
after.shiny = nil
|
||||||
|
if self:animForId("ANIM_SEND_OUT_MON", after.side, 1) then
|
||||||
|
self.afterSendOut = after
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
self:finishSendOut(after)
|
self:finishSendOut(after)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1202,6 +1259,7 @@ function BattleState:advanceQueue()
|
|||||||
self.introTextShown = nil
|
self.introTextShown = nil
|
||||||
end
|
end
|
||||||
if not event then
|
if not event then
|
||||||
|
self:syncShownStatus()
|
||||||
-- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no
|
-- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no
|
||||||
-- turn (engine/battle/core.asm:2955-2963).
|
-- turn (engine/battle/core.asm:2955-2963).
|
||||||
if self.shiftSwitchIndex then
|
if self.shiftSwitchIndex then
|
||||||
@@ -1315,6 +1373,14 @@ function BattleState:advanceQueue()
|
|||||||
self.shownHp[event.side] = event.mon.hp or 0
|
self.shownHp[event.side] = event.mon.hp or 0
|
||||||
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
|
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
|
||||||
end
|
end
|
||||||
|
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
|
||||||
|
-- to the incoming mon.
|
||||||
|
if self.shownStatus and event.side
|
||||||
|
and (event.kind == "status"
|
||||||
|
or (event.kind == "send" and event.mon)) then
|
||||||
|
self.shownStatus[event.side] =
|
||||||
|
(event.kind == "send" and event.mon.status or event.status) or false
|
||||||
|
end
|
||||||
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
|
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
|
||||||
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
|
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
|
||||||
-- ahead of the commit at :6889-6901), so the bar crawls from the figures
|
-- ahead of the commit at :6889-6901), so the bar crawls from the figures
|
||||||
@@ -1339,7 +1405,12 @@ function BattleState:advanceQueue()
|
|||||||
-- is still on screen for its own line and the replacement arrives here.
|
-- is still on screen for its own line and the replacement arrives here.
|
||||||
if self.shownMon then self.shownMon[event.side] = event.mon end
|
if self.shownMon then self.shownMon[event.side] = event.mon end
|
||||||
-- The second of the cart's two wFirstUnownSeen writes (core.asm:3251).
|
-- The second of the cart's two wFirstUnownSeen writes (core.asm:3251).
|
||||||
if event.side == "enemy" then self:noteFirstUnown(event.mon) end
|
if event.side == "enemy" then
|
||||||
|
self:noteFirstUnown(event.mon)
|
||||||
|
self:markSeen(event.mon)
|
||||||
|
-- engine/battle/trainer_huds.asm:142-151
|
||||||
|
self.caughtMark = self:dexCaught(event.mon)
|
||||||
|
end
|
||||||
if event.side == "player" then
|
if event.side == "player" then
|
||||||
-- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back
|
-- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back
|
||||||
-- to back (engine/battle/core.asm:3809), so a switched-in mon opens on
|
-- to back (engine/battle/core.asm:3809), so a switched-in mon opens on
|
||||||
@@ -1535,12 +1606,18 @@ end
|
|||||||
-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD
|
-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD
|
||||||
-- come after the animation, not with it.
|
-- come after the animation, not with it.
|
||||||
function BattleState:startSendOut(side, mon)
|
function BattleState:startSendOut(side, mon)
|
||||||
local after = { side = side, mon = mon }
|
-- BattleCheckPlayerShininess / BattleCheckEnemyShininess replay the anim's
|
||||||
|
-- `.Shiny` arm before the cry (core.asm:3826-3831, :3371-3377).
|
||||||
|
local after = { side = side, mon = mon, shiny = mon and mon.shiny and true }
|
||||||
-- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic
|
-- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic
|
||||||
-- into the box before they play the animation, which is the one thing that
|
-- into the box before they play the animation, which is the one thing that
|
||||||
-- undoes a cleared box.
|
-- undoes a cleared box.
|
||||||
self.picHidden[side] = false
|
self.picHidden[side] = false
|
||||||
self.faintSlide = nil
|
self.faintSlide = nil
|
||||||
|
-- The rows are shadow OAM (engine/battle/trainer_huds.asm:203-223), which
|
||||||
|
-- this animation's own sprites overwrite.
|
||||||
|
self.ballRows.player = false
|
||||||
|
self.ballRows.enemy = false
|
||||||
if self:animForId("ANIM_SEND_OUT_MON", side) then
|
if self:animForId("ANIM_SEND_OUT_MON", side) then
|
||||||
self.afterSendOut = after
|
self.afterSendOut = after
|
||||||
return true
|
return true
|
||||||
@@ -1972,7 +2049,22 @@ function BattleState:update(_dt)
|
|||||||
|
|
||||||
if self.phase == "moves" then
|
if self.phase == "moves" then
|
||||||
local moves = self:playerMoves()
|
local moves = self:playerMoves()
|
||||||
if input:wasPressed("up") then
|
local grid
|
||||||
|
if self:moveGridNavigation() then
|
||||||
|
local index, count = self.moveIndex, #moves
|
||||||
|
if input:wasPressed("left") or input:wasPressed("right") then
|
||||||
|
local other = math.floor((index - 1) / 2) * 2
|
||||||
|
+ (1 - (index - 1) % 2) + 1
|
||||||
|
grid = other <= count and other or index
|
||||||
|
elseif input:wasPressed("up") or input:wasPressed("down") then
|
||||||
|
local other = (1 - math.floor((index - 1) / 2)) * 2
|
||||||
|
+ (index - 1) % 2 + 1
|
||||||
|
grid = other <= count and other or index
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if grid then
|
||||||
|
self.moveIndex = grid
|
||||||
|
elseif input:wasPressed("up") then
|
||||||
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
self.moveIndex = self.moveIndex > 1 and self.moveIndex - 1 or #moves
|
||||||
elseif input:wasPressed("down") then
|
elseif input:wasPressed("down") then
|
||||||
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
self.moveIndex = self.moveIndex < #moves and self.moveIndex + 1 or 1
|
||||||
@@ -2569,6 +2661,9 @@ function BattleState:pushCaught(enemy, itemId)
|
|||||||
-- in the box", item_effects.asm:624). Boxes.deposit stays an append: the
|
-- in the box", item_effects.asm:624). Boxes.deposit stays an append: the
|
||||||
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
|
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
|
||||||
table.insert(box, 1, enemy)
|
table.insert(box, 1, enemy)
|
||||||
|
-- SendMonIntoBox refills the boxed slot's PP before it closes SRAM
|
||||||
|
-- (move_mon.asm:1062-1063).
|
||||||
|
Boxes.restorePP(enemy)
|
||||||
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
|
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
|
||||||
-- BATTLERESULT_BOX_FULL when the box has just filled
|
-- BATTLERESULT_BOX_FULL when the box has just filled
|
||||||
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
|
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
|
||||||
@@ -2638,6 +2733,9 @@ end
|
|||||||
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
|
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
|
||||||
function BattleState:offerShiftSwitch(mon)
|
function BattleState:offerShiftSwitch(mon)
|
||||||
self.shiftIndex = 1
|
self.shiftIndex = 1
|
||||||
|
-- HandleEnemySwitch farcalls EnemySwitch_TrainerHud before the prompt
|
||||||
|
-- (engine/battle/core.asm:2246, engine/battle/trainer_huds.asm:11-15).
|
||||||
|
self.ballRows.enemy = true
|
||||||
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
|
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
|
||||||
local player = (self.save and self.save.player and self.save.player.name)
|
local player = (self.save and self.save.player and self.save.player.name)
|
||||||
or "GOLD"
|
or "GOLD"
|
||||||
@@ -2868,6 +2966,37 @@ function BattleState:openDexEntry(species)
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function BattleState:catchOptions(itemId)
|
||||||
|
local data = self.game and self.game.data or {}
|
||||||
|
local battle = self.battle
|
||||||
|
local enemy = battle and battle.enemy
|
||||||
|
if not enemy then return nil end
|
||||||
|
local enemyDef = data.pokemon and data.pokemon[enemy.species]
|
||||||
|
local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species]
|
||||||
|
local evolveItem
|
||||||
|
for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do
|
||||||
|
if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end
|
||||||
|
end
|
||||||
|
local player = battle.player
|
||||||
|
return {
|
||||||
|
battle = battle, mon = enemy, def = enemyDef,
|
||||||
|
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp), hp = enemy.hp,
|
||||||
|
catchRate = enemyDef and enemyDef.catchRate or 45, ball = itemId,
|
||||||
|
status = enemy.status, random = battle.random,
|
||||||
|
weight = dexEntry and dexEntry.weight, level = enemy.level,
|
||||||
|
playerLevel = player and player.level,
|
||||||
|
fishing = battle.battleType == "fish", species = enemy.species,
|
||||||
|
gender = enemy.gender, playerSpecies = player and player.species,
|
||||||
|
playerGender = player and player.gender, evolveItem = evolveItem,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:catchChance(itemId)
|
||||||
|
if self.tutorial then return 100 end
|
||||||
|
local opts = self:catchOptions(itemId)
|
||||||
|
return opts and Catching.chance(opts) or nil
|
||||||
|
end
|
||||||
|
|
||||||
-- Items in battle: balls try a catch, the stat items apply their stage, and
|
-- Items in battle: balls try a catch, the stat items apply their stage, and
|
||||||
-- everything with a ported party effect runs the same item_effects.asm routine
|
-- everything with a ported party effect runs the same item_effects.asm routine
|
||||||
-- the field pack runs. Anything else reports that it cannot be used, which is
|
-- the field pack runs. Anything else reports that it cannot be used, which is
|
||||||
@@ -2897,7 +3026,6 @@ function BattleState:useItem(itemId)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
local enemy = self.battle.enemy
|
local enemy = self.battle.enemy
|
||||||
local enemyDef = data.pokemon and data.pokemon[enemy.species]
|
|
||||||
local caught, rate
|
local caught, rate
|
||||||
if self.tutorial then
|
if self.tutorial then
|
||||||
-- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL /
|
-- `ld a, [wBattleType] / cp BATTLETYPE_TUTORIAL /
|
||||||
@@ -2911,32 +3039,8 @@ function BattleState:useItem(itemId)
|
|||||||
caught, rate = true, 255
|
caught, rate = true, 255
|
||||||
else
|
else
|
||||||
-- The specialty-ball conditions (BallMultiplierFunctionTable): each one
|
-- The specialty-ball conditions (BallMultiplierFunctionTable): each one
|
||||||
-- is something this screen already knows. Heavy Ball reads the dex
|
-- is also used by the read-only preview, so both paths stay exact.
|
||||||
-- weight, Moon Ball the species' stone row, Love Ball both genders,
|
caught, rate = Catching.attempt(self:catchOptions(itemId))
|
||||||
-- Level Ball the two levels, Lure Ball wBattleType.
|
|
||||||
local dexEntry = data.gen2Pokedex and data.gen2Pokedex[enemy.species]
|
|
||||||
local evolveItem
|
|
||||||
for _, entry in ipairs((enemyDef and enemyDef.evolutions) or {}) do
|
|
||||||
if entry.method == "EVOLVE_ITEM" then evolveItem = entry.item end
|
|
||||||
end
|
|
||||||
local player = self.battle.player
|
|
||||||
caught, rate = Catching.attempt({
|
|
||||||
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
|
|
||||||
hp = enemy.hp,
|
|
||||||
catchRate = enemyDef and enemyDef.catchRate or 45,
|
|
||||||
ball = itemId,
|
|
||||||
status = enemy.status,
|
|
||||||
random = self.battle.random,
|
|
||||||
weight = dexEntry and dexEntry.weight,
|
|
||||||
level = enemy.level,
|
|
||||||
playerLevel = player and player.level,
|
|
||||||
fishing = self.battle.battleType == "fish",
|
|
||||||
species = enemy.species,
|
|
||||||
gender = enemy.gender,
|
|
||||||
playerSpecies = player and player.species,
|
|
||||||
playerGender = player and player.gender,
|
|
||||||
evolveItem = evolveItem,
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
-- wWildMon carries the answer through the animation, and
|
-- wWildMon carries the answer through the animation, and
|
||||||
-- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per
|
-- wThrownBallWobbleCount is the counter GetPokeBallWobble bumps once per
|
||||||
@@ -3231,13 +3335,13 @@ function BattleState:drawHud()
|
|||||||
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
|
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
|
||||||
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
|
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
|
||||||
-- the level entirely while one is up.
|
-- the level entirely while one is up.
|
||||||
Chrome.print(self:statusTag(enemy) or ("<LV>" .. tostring(enemy.level or 1)),
|
Chrome.print(self:statusTag(enemy, "enemy")
|
||||||
6, 1)
|
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
|
||||||
local enemyGender = self:genderSymbol(enemy)
|
local enemyGender = self:genderSymbol(enemy)
|
||||||
if enemyGender then Chrome.print(enemyGender, 9, 1) end
|
if enemyGender then Chrome.print(enemyGender, 9, 1) end
|
||||||
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
|
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
|
||||||
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
|
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
|
||||||
if self.battle and self.battle.wild and self:dexCaught(enemy) then
|
if self.battle and self.battle.wild and self.caughtMark then
|
||||||
self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"),
|
self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"),
|
||||||
enemy.maxHp or (enemy.stats and enemy.stats.hp))
|
enemy.maxHp or (enemy.stats and enemy.stats.hp))
|
||||||
end
|
end
|
||||||
@@ -3249,6 +3353,14 @@ function BattleState:drawHud()
|
|||||||
self:drawFrame(1, 3, 10, false)
|
self:drawFrame(1, 3, 10, false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
-- ShowOTTrainerMonsRemaining (engine/battle/trainer_huds.asm:32-45): balls
|
||||||
|
-- walking LEFT from OAM (72, 32), which the -8/-16 offset puts at (8, 2).
|
||||||
|
if showStatus and self.ballRows.enemy then
|
||||||
|
if not self.showEnemyHud and self.hud:available() then
|
||||||
|
self.hud:drawEnemyFrame()
|
||||||
|
end
|
||||||
|
self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1)
|
||||||
|
end
|
||||||
|
|
||||||
self:drawPic(enemy, false)
|
self:drawPic(enemy, false)
|
||||||
|
|
||||||
@@ -3257,6 +3369,14 @@ function BattleState:drawHud()
|
|||||||
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
|
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
|
||||||
-- border from (18,10) going left, and the exp bar at (10,11).
|
-- border from (18,10) going left, and the exp bar at (10,11).
|
||||||
self:drawPic(player, true)
|
self:drawPic(player, true)
|
||||||
|
-- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls
|
||||||
|
-- walking RIGHT from OAM (96, 96), i.e. tile (11, 10).
|
||||||
|
if showStatus and self.ballRows.player then
|
||||||
|
if not self.showPlayerHud and self.hud:available() then
|
||||||
|
self.hud:drawPartyIconFrame()
|
||||||
|
end
|
||||||
|
self.hud:drawBallRow(self.battle and self.battle.party, 11, 10, 1)
|
||||||
|
end
|
||||||
-- No player HUD in the catching tutorial: DrawPlayerHUD lives in
|
-- No player HUD in the catching tutorial: DrawPlayerHUD lives in
|
||||||
-- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and
|
-- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and
|
||||||
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
|
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
|
||||||
@@ -3270,7 +3390,7 @@ function BattleState:drawHud()
|
|||||||
Chrome.print(self:name(player), 10, 7)
|
Chrome.print(self:name(player), 10, 7)
|
||||||
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
|
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
|
||||||
-- while it is up.
|
-- while it is up.
|
||||||
Chrome.print(self:statusTag(player)
|
Chrome.print(self:statusTag(player, "player")
|
||||||
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
|
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
|
||||||
local playerGender = self:genderSymbol(player)
|
local playerGender = self:genderSymbol(player)
|
||||||
if playerGender then Chrome.print(playerGender, 17, 8) end
|
if playerGender then Chrome.print(playerGender, 17, 8) end
|
||||||
@@ -3303,8 +3423,8 @@ local STATUS_TAGS = {
|
|||||||
paralyze = "PAR", sleep = "SLP",
|
paralyze = "PAR", sleep = "SLP",
|
||||||
}
|
}
|
||||||
|
|
||||||
function BattleState:statusTag(mon)
|
function BattleState:statusTag(mon, side)
|
||||||
return mon and STATUS_TAGS[mon.status] or nil
|
return mon and STATUS_TAGS[self:hudStatus(mon, side)] or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD
|
-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD
|
||||||
|
|||||||
@@ -78,8 +78,12 @@ local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" }
|
|||||||
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
|
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
|
||||||
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
|
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
|
||||||
|
|
||||||
|
-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240).
|
||||||
|
local DEPOSIT_SUBMENU = { "DEPOSIT", "STATS", "RELEASE", "CANCEL" }
|
||||||
|
|
||||||
function BoxMenu:submenuRows()
|
function BoxMenu:submenuRows()
|
||||||
if self.mode == "move" then return MOVE_SUBMENU end
|
if self.mode == "move" then return MOVE_SUBMENU end
|
||||||
|
if self.mode == "deposit" then return DEPOSIT_SUBMENU end
|
||||||
return WITHDRAW_SUBMENU
|
return WITHDRAW_SUBMENU
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -219,21 +223,12 @@ function BoxMenu:act()
|
|||||||
if self.onClose then self.onClose() end
|
if self.onClose then self.onClose() end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
|
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu,
|
||||||
if self.mode == "move" or self.mode == "withdraw" then
|
-- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102).
|
||||||
if not self:selected() then return end
|
if not self:selected() then return end
|
||||||
self.phase = "submenu"
|
self.phase = "submenu"
|
||||||
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
|
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row.
|
||||||
self.submenuIndex = 1
|
self.submenuIndex = 1
|
||||||
return
|
|
||||||
end
|
|
||||||
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
|
||||||
if not ok then
|
|
||||||
self.message = result
|
|
||||||
return
|
|
||||||
end
|
|
||||||
self.message = nil
|
|
||||||
self:clampIndex()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------ MOVE, step 2
|
-- ------------------------------------------------------------ MOVE, step 2
|
||||||
@@ -243,8 +238,9 @@ end
|
|||||||
-- the PARTY, so a boxed mon walks straight past. Returns true, or false and
|
-- the PARTY, so a boxed mon walks straight past. Returns true, or false and
|
||||||
-- the string the cart places.
|
-- the string the cart places.
|
||||||
function BoxMenu:checkMailPreventBlackout()
|
function BoxMenu:checkMailPreventBlackout()
|
||||||
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`.
|
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes
|
||||||
if not self:isParty() then return true end
|
-- that byte too, so its list is the party (bills_pc.asm:17-18).
|
||||||
|
if not (self:isParty() or self.mode == "deposit") then return true end
|
||||||
local party = self.save.party or {}
|
local party = self.save.party or {}
|
||||||
-- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send
|
-- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send
|
||||||
-- one away at all, however healthy the rest of it is.
|
-- one away at all, however healthy the rest of it is.
|
||||||
@@ -311,10 +307,25 @@ function BoxMenu:doWithdraw()
|
|||||||
self:clampIndex()
|
self:clampIndex()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit
|
||||||
|
function BoxMenu:doDeposit()
|
||||||
|
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||||
|
if not ok then
|
||||||
|
self.message = result
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self.message = nil
|
||||||
|
self.phase = nil
|
||||||
|
self.index, self.scroll = 1, 0
|
||||||
|
self:clampIndex()
|
||||||
|
end
|
||||||
|
|
||||||
function BoxMenu:chooseSubmenu()
|
function BoxMenu:chooseSubmenu()
|
||||||
local row = self:submenuRows()[self.submenuIndex]
|
local row = self:submenuRows()[self.submenuIndex]
|
||||||
if row == "MOVE" then
|
if row == "MOVE" then
|
||||||
self:beginMove()
|
self:beginMove()
|
||||||
|
elseif row == "DEPOSIT" then
|
||||||
|
self:doDeposit()
|
||||||
elseif row == "WITHDRAW" then
|
elseif row == "WITHDRAW" then
|
||||||
self:doWithdraw()
|
self:doWithdraw()
|
||||||
elseif row == "STATS" then
|
elseif row == "STATS" then
|
||||||
@@ -393,6 +404,9 @@ function BoxMenu:insertMon()
|
|||||||
target = target - 1
|
target = target - 1
|
||||||
end
|
end
|
||||||
table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon)
|
table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon)
|
||||||
|
-- .CopyToBox is InsertPokemonIntoBox, which tails into
|
||||||
|
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37).
|
||||||
|
if not self:isParty(destIndex) then Boxes.restorePP(mon) end
|
||||||
self.phase = nil
|
self.phase = nil
|
||||||
self.moveFrom, self.backup = nil, nil
|
self.moveFrom, self.backup = nil, nil
|
||||||
self.index, self.scroll = 1, 0
|
self.index, self.scroll = 1, 0
|
||||||
@@ -521,6 +535,16 @@ function BoxMenu:askRelease()
|
|||||||
if self:isCancel() then return end
|
if self:isCancel() then return end
|
||||||
local mon = self:selected()
|
local mon = self:selected()
|
||||||
if not mon then return end
|
if not mon then return end
|
||||||
|
-- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check
|
||||||
|
-- (engine/pokemon/bills_pc.asm:183-187).
|
||||||
|
if self.mode == "deposit" then
|
||||||
|
local allowed, refusal = self:checkMailPreventBlackout()
|
||||||
|
if not allowed then
|
||||||
|
self.phase = nil
|
||||||
|
self.message = refusal
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
-- Both release paths run BillsPC_IsMonAnEgg first, so the question is never
|
-- Both release paths run BillsPC_IsMonAnEgg first, so the question is never
|
||||||
-- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428).
|
-- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428).
|
||||||
if mon.isEgg then
|
if mon.isEgg then
|
||||||
@@ -534,7 +558,12 @@ function BoxMenu:askRelease()
|
|||||||
local name = mon.nickname or mon.name or mon.species or "?"
|
local name = mon.nickname or mon.name or mon.species or "?"
|
||||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||||
if not yes then return end
|
if not yes then return end
|
||||||
local ok, err = Boxes.release(self.save, self.boxIndex, self.index)
|
local ok, err
|
||||||
|
if self.mode == "deposit" then
|
||||||
|
ok, err = Boxes.releaseFromParty(self.save, self.index)
|
||||||
|
else
|
||||||
|
ok, err = Boxes.release(self.save, self.boxIndex, self.index)
|
||||||
|
end
|
||||||
if not ok then
|
if not ok then
|
||||||
self.message = err
|
self.message = err
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -518,8 +518,9 @@ function ItemPcMenu:drawList()
|
|||||||
local entry = self.rows[i]
|
local entry = self.rows[i]
|
||||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||||
Chrome.print(entry.name, 6, ty)
|
Chrome.print(entry.name, 6, ty)
|
||||||
-- PlaceMenuItemQuantity: the xNN is the entry's second line.
|
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
|
||||||
Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1)
|
-- entry's second line, right-aligned in a blank-padded 2-digit field.
|
||||||
|
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||||
elseif i == self:listTotal() then
|
elseif i == self:listTotal() then
|
||||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||||
Chrome.print("CANCEL", 6, ty)
|
Chrome.print("CANCEL", 6, ty)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
-- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18
|
-- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18
|
||||||
-- vertical strip repeated across all 32 columns, and MagnetTrainTilemap,
|
-- vertical strip repeated across all 32 columns, and MagnetTrainTilemap,
|
||||||
-- the 20x4 train laid over rows 6-9) come from the extracted cache at
|
-- the 20x4 train laid over rows 6-9) come from the extracted cache at
|
||||||
-- data.field.magnetTrain. A cache built before the extractor learned to
|
-- data.gen2Field.magnetTrain. A cache built before the extractor
|
||||||
-- follow them has neither, and then the ride runs with a blank screen
|
-- learned to follow them has neither, and then the ride runs with a
|
||||||
-- rather than with invented art.
|
-- blank screen rather than with invented art.
|
||||||
-- * The background is baked into a 256x144 canvas once, because the only
|
-- * The background is baked into a 256x144 canvas once, because the only
|
||||||
-- thing that changes per frame is the per-band SCX.
|
-- thing that changes per frame is the per-band SCX.
|
||||||
-- * SetMagnetTrainPals gives the four bush rows and the four bottom rows
|
-- * SetMagnetTrainPals gives the four bush rows and the four bottom rows
|
||||||
@@ -57,7 +57,7 @@ function MagnetTrainRide.new(game, opts)
|
|||||||
self.onDone = opts.onDone
|
self.onDone = opts.onDone
|
||||||
self.finished = false
|
self.finished = false
|
||||||
|
|
||||||
local field = self.data and self.data.field
|
local field = self.data and self.data.gen2Field
|
||||||
local gfx = field and field.magnetTrain
|
local gfx = field and field.magnetTrain
|
||||||
self.ride = MagnetTrain.new({
|
self.ride = MagnetTrain.new({
|
||||||
toGoldenrod = opts.toGoldenrod,
|
toGoldenrod = opts.toGoldenrod,
|
||||||
@@ -65,8 +65,8 @@ function MagnetTrainRide.new(game, opts)
|
|||||||
fgTilemap = gfx and gfx.tilemap,
|
fgTilemap = gfx and gfx.tilemap,
|
||||||
})
|
})
|
||||||
|
|
||||||
self.tileset = self.data and self.data.tilesets
|
self.tileset = self.data and self.data.gen2Tilesets
|
||||||
and self.data.tilesets.TILESET_TRAIN_STATION
|
and self.data.gen2Tilesets.TILESET_TRAIN_STATION
|
||||||
self.palettes = self:bgPalettes()
|
self.palettes = self:bgPalettes()
|
||||||
self.spriteSheet = self:playerSheet()
|
self.spriteSheet = self:playerSheet()
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ end
|
|||||||
-- even though both stations are INDOOR maps.
|
-- even though both stations are INDOOR maps.
|
||||||
function MagnetTrainRide:bgPalettes()
|
function MagnetTrainRide:bgPalettes()
|
||||||
local data = self.data
|
local data = self.data
|
||||||
local palettes = data and data.palettes
|
local palettes = data and data.gen2Palettes
|
||||||
if not palettes then return nil end
|
if not palettes then return nil end
|
||||||
return Palettes.bgSet(palettes, { environment = "TOWN" },
|
return Palettes.bgSet(palettes, { environment = "TOWN" },
|
||||||
Palettes.clockDaytime())
|
Palettes.clockDaytime())
|
||||||
@@ -129,7 +129,7 @@ end
|
|||||||
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
|
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
|
||||||
-- the OAM data addresses the four tiles of a frame individually.
|
-- the OAM data addresses the four tiles of a frame individually.
|
||||||
function MagnetTrainRide:playerSheet()
|
function MagnetTrainRide:playerSheet()
|
||||||
local sprites = self.data and self.data.sprites
|
local sprites = self.data and self.data.gen2Sprites
|
||||||
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
|
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
|
||||||
local path = def and def.image
|
local path = def and def.image
|
||||||
if not path then return nil end
|
if not path then return nil end
|
||||||
@@ -259,8 +259,8 @@ end
|
|||||||
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
|
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
|
||||||
-- names.
|
-- names.
|
||||||
function MagnetTrainRide:playerPalette()
|
function MagnetTrainRide:playerPalette()
|
||||||
local palettes = self.data and self.data.palettes
|
local palettes = self.data and self.data.gen2Palettes
|
||||||
local sprites = self.data and self.data.sprites
|
local sprites = self.data and self.data.gen2Sprites
|
||||||
if not palettes then return nil end
|
if not palettes then return nil end
|
||||||
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
|
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
|
||||||
sprites and sprites.SPRITE_CHRIS)
|
sprites and sprites.SPRITE_CHRIS)
|
||||||
|
|||||||
@@ -24,14 +24,18 @@
|
|||||||
-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and
|
-- MoneyTopRightMenuHeader menu_coords 11, 0, 19, 2 -- a 9x3 box, and
|
||||||
-- PlaceMoneyTextbox writes the amount at
|
-- PlaceMoneyTextbox writes the amount at
|
||||||
-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1)
|
-- MenuBoxCoord2Tile + SCREEN_WIDTH + 1 = (12,1)
|
||||||
-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 box holding 4
|
-- MenuHeader_Buy menu_coords 1, 3, 19, 11 -- a 19x9 rect holding 4
|
||||||
-- entries of two rows. ScrollingMenu_UpdateDisplay
|
-- entries of two rows. BuyMenuLoop copies that
|
||||||
-- starts at (2,4); PlaceMenuItemName prints the name
|
-- header and calls ScrollingMenu; it never calls
|
||||||
-- there and .PrintBCDPrices is handed that origin
|
-- InitScrollingMenu or MenuBox, so the rect is NOT
|
||||||
-- plus the menu's own width (8) plus SCREEN_WIDTH, so
|
-- a framed window. ScrollingMenu_UpdateDisplay
|
||||||
-- the price lands at (10,5). The ▲ sits on the box's
|
-- calls ClearWholeMenuBox on it (spaces, no border)
|
||||||
-- top right corner (19,3) and the ▼ on its bottom
|
-- and starts printing at (2,4); PlaceMenuItemName
|
||||||
-- right (19,11).
|
-- prints the name there and .PrintBCDPrices is
|
||||||
|
-- handed that origin plus the menu's own width (8)
|
||||||
|
-- plus SCREEN_WIDTH, so the price lands at (10,5).
|
||||||
|
-- The ▲ sits on the rect's top right (19,3) and
|
||||||
|
-- the ▼ on its bottom right (19,11).
|
||||||
-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with
|
-- UpdateItemDescription Textbox (0,12) interior 18x4 -- a 20x6 box -- with
|
||||||
-- the description at (1,14)
|
-- the description at (1,14)
|
||||||
-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and
|
-- BuyItem_MenuHeader menu_coords 7, 15, 19, 17 -- a 13x3 box, and
|
||||||
@@ -75,7 +79,9 @@ local SFX_TRANSACTION = "Sfx_Transaction"
|
|||||||
|
|
||||||
local MartMenu = {}
|
local MartMenu = {}
|
||||||
MartMenu.__index = MartMenu
|
MartMenu.__index = MartMenu
|
||||||
-- engine/items/mart.asm:54
|
-- The top menu overlays the mart (StandardMart .HowMayIHelpYou /
|
||||||
|
-- .AnythingElse: LoadStandardMenuHeader + PrintText). BuyMenu is
|
||||||
|
-- FadeToMenu + BlankScreen, so enterBuy shadows this for the list.
|
||||||
MartMenu.isOpaque = false
|
MartMenu.isOpaque = false
|
||||||
|
|
||||||
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
|
-- constants/mart_constants.asm. The `pokemart` macro emits this as one byte
|
||||||
@@ -563,6 +569,10 @@ function MartMenu:enterBuy()
|
|||||||
self.phase = "buy"
|
self.phase = "buy"
|
||||||
self.index = 1
|
self.index = 1
|
||||||
self.scroll = 0
|
self.scroll = 0
|
||||||
|
-- BuyMenu (engine/items/mart.asm): `call FadeToMenu / farcall BlankScreen`.
|
||||||
|
-- BlankScreen fills the tilemap with spaces and the palettes with white, so
|
||||||
|
-- the mart must not keep drawing in the letterbox under the list.
|
||||||
|
self.isOpaque = (self.phase == "buy")
|
||||||
end
|
end
|
||||||
|
|
||||||
function MartMenu:total()
|
function MartMenu:total()
|
||||||
@@ -618,6 +628,8 @@ end
|
|||||||
-- BuyMenu returns into StandardMart .Buy, which falls through to
|
-- BuyMenu returns into StandardMart .Buy, which falls through to
|
||||||
-- .AnythingElse; the other three dialog kinds end on their come-again line.
|
-- .AnythingElse; the other three dialog kinds end on their come-again line.
|
||||||
function MartMenu:leaveBuy()
|
function MartMenu:leaveBuy()
|
||||||
|
-- CloseSubmenu restores the map before .AnythingElse / the come-again line.
|
||||||
|
self.isOpaque = nil
|
||||||
if self.martType == "STANDARD" then
|
if self.martType == "STANDARD" then
|
||||||
self:enterTop(self.text.askMore)
|
self:enterTop(self.text.askMore)
|
||||||
else
|
else
|
||||||
@@ -885,11 +897,10 @@ function MartMenu:description()
|
|||||||
return def and def.description or nil
|
return def and def.description or nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The bottom visible entry's price row IS the box's bottom border row
|
-- The bottom visible entry's price row is MenuHeader_Buy's last line
|
||||||
-- (entry row 10 + SCREEN_WIDTH = 11, MenuHeader_Buy's own last line), and a
|
-- (entry row 10 + SCREEN_WIDTH = 11). A GB glyph REPLACES the tile it
|
||||||
-- GB glyph REPLACES the tile it prints over, border and all. White under
|
-- prints over, so white under the seven money tiles first is that
|
||||||
-- the seven money tiles first is that replacement; on the three rows clear
|
-- replacement. On a BlankScreen field it is white over white.
|
||||||
-- of the border it repaints white over white.
|
|
||||||
local function printPriceOpaque(amount, ty)
|
local function printPriceOpaque(amount, ty)
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
G.setColor(1, 1, 1, 1)
|
G.setColor(1, 1, 1, 1)
|
||||||
@@ -899,8 +910,10 @@ local function printPriceOpaque(amount, ty)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function MartMenu:drawBuyList()
|
function MartMenu:drawBuyList()
|
||||||
-- engine/items/mart.asm:542
|
-- ScrollingMenu_UpdateDisplay (engine/menus/scrolling_menu.asm) calls
|
||||||
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H)
|
-- ClearWholeMenuBox, not MenuBox: the MenuHeader_Buy rect is a cleared
|
||||||
|
-- field, not a framed window. BlankScreen already filled the tilemap
|
||||||
|
-- with spaces, so the names just land on white.
|
||||||
for row = 1, VISIBLE_ROWS do
|
for row = 1, VISIBLE_ROWS do
|
||||||
local i = row + self.scroll
|
local i = row + self.scroll
|
||||||
local ty = LIST_Y + (row - 1) * LIST_SPACING
|
local ty = LIST_Y + (row - 1) * LIST_SPACING
|
||||||
@@ -960,6 +973,10 @@ function MartMenu:drawUnder()
|
|||||||
self:drawTopMenu()
|
self:drawTopMenu()
|
||||||
self:drawTextBox(self.topLines)
|
self:drawTextBox(self.topLines)
|
||||||
elseif phase == "buy" or phase == "buyQuantity" then
|
elseif phase == "buy" or phase == "buyQuantity" then
|
||||||
|
-- BlankScreen (engine/overworld/player_object.asm): the whole tilemap
|
||||||
|
-- is spaces before PlaceMoneyTopRight / the scrolling list / the
|
||||||
|
-- description textbox go down. Only those last two are framed.
|
||||||
|
Chrome.clear()
|
||||||
self:drawMoneyBox()
|
self:drawMoneyBox()
|
||||||
self:drawBuyList()
|
self:drawBuyList()
|
||||||
self:drawDescription()
|
self:drawDescription()
|
||||||
|
|||||||
@@ -134,6 +134,19 @@ local ROWS = {
|
|||||||
text = function(options)
|
text = function(options)
|
||||||
return require("src.render.Zoom").offsetLabel(options.zoom or 0)
|
return require("src.render.Zoom").offsetLabel(options.zoom or 0)
|
||||||
end },
|
end },
|
||||||
|
-- VOID FILL: FADE is each map's own border block with the dissolve across
|
||||||
|
-- a boundary; WATER / TREES force one outdoor block; BLACK is a flat void.
|
||||||
|
-- #1418. Same key the Gen 1 OPTION screen uses, different ladder (FADE
|
||||||
|
-- is Gold's default because that is already what the maps call for).
|
||||||
|
{ label = "VOID FILL", key = "voidFill", port = true,
|
||||||
|
cycle = function(options, delta)
|
||||||
|
local BorderFill = require("src.world.gen2.BorderFill")
|
||||||
|
BorderFill.setVoidFill(options.voidFill or "fade")
|
||||||
|
options.voidFill = BorderFill.cycle(delta)
|
||||||
|
end,
|
||||||
|
text = function(options)
|
||||||
|
return require("src.world.gen2.BorderFill").voidFillLabel(options.voidFill)
|
||||||
|
end },
|
||||||
{ label = "TILT", key = "tilt", port = true,
|
{ label = "TILT", key = "tilt", port = true,
|
||||||
cycle = function(options, delta)
|
cycle = function(options, delta)
|
||||||
local Tilt = require("src.render.Tilt")
|
local Tilt = require("src.render.Tilt")
|
||||||
@@ -211,6 +224,15 @@ local ROWS = {
|
|||||||
TC.buzz(options.haptics)
|
TC.buzz(options.haptics)
|
||||||
if game and game.persistOptions then game:persistOptions() end
|
if game and game.persistOptions then game:persistOptions() end
|
||||||
end },
|
end },
|
||||||
|
{ label = "MAX FPS", key = "fpsCap", port = true,
|
||||||
|
cycle = function(options, delta)
|
||||||
|
local FrameCap = require("src.core.FrameCap")
|
||||||
|
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
|
||||||
|
FrameCap.apply(options.fpsCap)
|
||||||
|
end,
|
||||||
|
text = function(options)
|
||||||
|
return require("src.core.FrameCap").label(options.fpsCap)
|
||||||
|
end },
|
||||||
{ label = "CANCEL", cancel = true },
|
{ label = "CANCEL", cancel = true },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||