Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 69100301a1 | |||
| a5b674f9da | |||
| 4356b94483 | |||
| c8bd205d0c | |||
| 995444774b | |||
| 360b692963 | |||
| 051040371f | |||
| c5ff95edcf | |||
| d6627eda4c | |||
| 6e28cd5dca | |||
| 08ddb882af | |||
| 2f6f094559 | |||
| 6ac425144d | |||
| 73f561e256 | |||
| b739fa76c0 | |||
| 0b00faf38e | |||
| b20b1370ab | |||
| 467566c799 | |||
| d6ddf23f97 | |||
| 9423337bcc | |||
| c280119d03 | |||
| 24d0c6528d | |||
| 1fc34da2a9 | |||
| 70b9def0b0 | |||
| d03d2af5f8 | |||
| c23f85cba9 | |||
| 455ff21aff | |||
| 2da2168dac | |||
| f62b1268c8 | |||
| 82ae667611 | |||
| 2b5229e73f | |||
| 881670db91 | |||
| a542ed90ba | |||
| e9a4a592a4 | |||
| 70f7d5c028 | |||
| 46f73b7bb3 | |||
| 99d9908017 | |||
| b72d1d34b5 | |||
| f3619a00c2 | |||
| 524138ff27 | |||
| fdffb12571 | |||
| 0c941cecd4 | |||
| dd59175c71 | |||
| 114352b75f | |||
| 0e40a7a1f4 | |||
| fe6a580e20 | |||
| a910b65434 | |||
| 1f3d13adaf | |||
| 393a1013e4 | |||
| a3a20a07e1 | |||
| 1151c188a7 | |||
| b39e11b7cd | |||
| 65128e13a4 | |||
| d74662ba99 | |||
| 32e6e4cef0 | |||
| fc841f7525 | |||
| 12fdfa1e88 | |||
| 90eb53b00c | |||
| 6780393f45 | |||
| 530f2bdd15 |
@@ -37,12 +37,16 @@ body:
|
|||||||
id: os
|
id: os
|
||||||
attributes:
|
attributes:
|
||||||
label: Which build are you running
|
label: Which build are you running
|
||||||
|
description: Official release targets. Pick Multiple platforms if you saw it on more than one.
|
||||||
options:
|
options:
|
||||||
- macOS
|
- macOS
|
||||||
- Windows
|
- Windows
|
||||||
- Linux
|
- Linux
|
||||||
- Android
|
- Android
|
||||||
- iOS
|
- iOS
|
||||||
|
- Nintendo Switch
|
||||||
|
- Xbox
|
||||||
|
- Anbernic RG34XXSP
|
||||||
- Multiple platforms
|
- Multiple platforms
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -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,6 +4,10 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,18 +6,30 @@ function love.conf(t)
|
|||||||
|
|
||||||
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
local editor = os.getenv("POKEPORT_EDITOR") == "1"
|
||||||
local developer = os.getenv("POKEPORT_DEV") == "1"
|
local developer = os.getenv("POKEPORT_DEV") == "1"
|
||||||
|
local companion = nil
|
||||||
if arg then
|
if arg then
|
||||||
for _, a in ipairs(arg) do
|
for _, a in ipairs(arg) do
|
||||||
if a == "--editor" then editor = true end
|
if a == "--editor" then editor = true end
|
||||||
if a == "--developer" then developer = true end
|
if a == "--developer" then developer = true end
|
||||||
|
local port, token = a:match("^%-%-display%-companion=(%d+),([%w]+)$")
|
||||||
|
if port then companion = { port = tonumber(port), token = token } end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
-- main.lua runs in the same Lua state right after conf.lua; stash the
|
||||||
-- decision in a global so it doesn't need to reparse `arg`.
|
-- decision in a global so it doesn't need to reparse `arg`.
|
||||||
_G.POKEPORT_EDITOR_MODE = editor
|
_G.POKEPORT_EDITOR_MODE = editor
|
||||||
_G.POKEPORT_DEV_MODE = developer
|
_G.POKEPORT_DEV_MODE = developer
|
||||||
|
_G.POKEPORT_DISPLAY_COMPANION = companion
|
||||||
|
|
||||||
if editor then
|
if companion then
|
||||||
|
t.identity = "pokemon-love2d-companion"
|
||||||
|
t.window.title = "gen1recomp Secondary Display"
|
||||||
|
t.window.width = 640
|
||||||
|
t.window.height = 576
|
||||||
|
t.window.minwidth = 160
|
||||||
|
t.window.minheight = 144
|
||||||
|
t.window.resizable = true
|
||||||
|
elseif editor then
|
||||||
-- Same identity as the game, deliberately: the editor edits the game's
|
-- Same identity as the game, deliberately: the editor edits the game's
|
||||||
-- saves and reads the game's ROM cache, both of which live under this
|
-- saves and reads the game's ROM cache, both of which live under this
|
||||||
-- folder. A private editor identity would point love.filesystem at an
|
-- folder. A private editor identity would point love.filesystem at an
|
||||||
@@ -51,7 +63,8 @@ function love.conf(t)
|
|||||||
end
|
end
|
||||||
t.version = love._os == "iOS" and "12.0" or "11.5"
|
t.version = love._os == "iOS" and "12.0" or "11.5"
|
||||||
t.window.vsync = 1
|
t.window.vsync = 1
|
||||||
t.modules.joystick = true
|
t.modules.audio = not companion
|
||||||
|
t.modules.joystick = not companion
|
||||||
t.modules.physics = false
|
t.modules.physics = false
|
||||||
|
|
||||||
-- love.system is not loaded during love.conf; love._os is set by the
|
-- love.system is not loaded during love.conf; love._os is set by the
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
-- guide and SUPER_NERD2 garden nerd.
|
-- guide and SUPER_NERD2 garden nerd.
|
||||||
--
|
--
|
||||||
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
-- The YOUNGSTER's gym escort (talk + east-exit onStep) lives in
|
||||||
-- story5.lua so the lockstep RLE walk is not overwritten by this
|
-- story5.lua; SUPER_NERD1's museum escort (scripts/PewterCity.asm:47-113)
|
||||||
-- flavor merge. SUPER_NERD1's museum escort is not ported; only the
|
-- is below.
|
||||||
-- YES/NO-branched flavor text is here.
|
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -25,11 +24,138 @@ local function ask(game, s, cb)
|
|||||||
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
game.stack:push(TextBox.new(game, s, nil, { choice = cb }))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204)
|
||||||
|
local museumGuySteps = {
|
||||||
|
"up", "up", "up", "up", "up", "up",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left", "left", "left",
|
||||||
|
"up", "up", "up",
|
||||||
|
"left",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- RLEList_PewterMuseumPlayer (engine/overworld/auto_movement.asm:192-197)
|
||||||
|
local museumPlayerRle = {
|
||||||
|
"NO",
|
||||||
|
"up", "up", "up",
|
||||||
|
"left", "left", "left", "left", "left", "left", "left", "left",
|
||||||
|
"left", "left", "left", "left", "left",
|
||||||
|
"up", "up", "up", "up", "up", "up",
|
||||||
|
}
|
||||||
|
|
||||||
|
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75)
|
||||||
|
local museumPreambles = {
|
||||||
|
["27,18"] = { "up", "up" },
|
||||||
|
["27,16"] = { "right", "left" },
|
||||||
|
["26,17"] = { "up", "right" },
|
||||||
|
["28,17"] = { "up", "left" },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- PewterGuys (engine/events/pewter_guys.asm:1-49), same transform as
|
||||||
|
-- pewterEscort.playerPlan in story5.lua
|
||||||
|
local function museumPlan(x, y)
|
||||||
|
local pre = museumPreambles[x .. "," .. y]
|
||||||
|
if not pre then return nil end
|
||||||
|
local buf = {}
|
||||||
|
for i, d in ipairs(museumPlayerRle) do buf[i] = d end
|
||||||
|
buf[#buf] = pre[1]
|
||||||
|
for i = 2, #pre do buf[#buf + 1] = pre[i] end
|
||||||
|
local path = {}
|
||||||
|
for i = #buf, 1, -1 do path[#path + 1] = buf[i] end
|
||||||
|
local head = 0
|
||||||
|
while path[head + 1] == "NO" do head = head + 1 end
|
||||||
|
local tail = #path
|
||||||
|
while tail > head and path[tail] == "NO" do tail = tail - 1 end
|
||||||
|
local steps = {}
|
||||||
|
for i = head + 1, tail do steps[#steps + 1] = path[i] end
|
||||||
|
return { steps = steps, guyHeadStart = math.floor(head / 8) }
|
||||||
|
end
|
||||||
|
|
||||||
|
-- PewterCitySuperNerd1ShowsPlayerMuseumScript (scripts/PewterCity.asm:47-113)
|
||||||
|
local function museumEscortWalk(game, ow)
|
||||||
|
if ow.runner:isRunning() or #ow.scriptMoves > 0 then return false end
|
||||||
|
local plan = museumPlan(ow.player.cellX, ow.player.cellY)
|
||||||
|
if not plan then return false end
|
||||||
|
local Music = require("src.core.Music")
|
||||||
|
local t = text(game)
|
||||||
|
local guy = ow:npcByIndex(3) -- PEWTERCITY_SUPER_NERD1
|
||||||
|
local head = plan.guyHeadStart
|
||||||
|
|
||||||
|
-- SetSpritePosition2 + ShowObject back on his spawn (27,17), the same
|
||||||
|
-- snap walkHome does in story5.lua (scripts/PewterCity.asm:102-113)
|
||||||
|
local function walkOut()
|
||||||
|
if not guy then return end
|
||||||
|
local i = 0
|
||||||
|
local function tick()
|
||||||
|
i = i + 1
|
||||||
|
if i > 4 then
|
||||||
|
guy.cellX, guy.cellY = 27, 17
|
||||||
|
guy.px, guy.py = 27 * 16, 17 * 16
|
||||||
|
guy.moving = false
|
||||||
|
guy.targetX, guy.targetY = nil, nil
|
||||||
|
guy.facing = "down"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
ow:scriptMove(guy, "down", 1, tick)
|
||||||
|
end
|
||||||
|
tick()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SetSpritePosition1 pins him beside the museum door (map (17,12) minus
|
||||||
|
-- the +4 border offset = (13,8)), then MovementData_PewterMuseumGuyExit
|
||||||
|
local function afterWalk()
|
||||||
|
if guy then
|
||||||
|
guy.stepFrames = nil
|
||||||
|
guy.cellX, guy.cellY = 13, 8
|
||||||
|
guy.px, guy.py = 13 * 16, 8 * 16
|
||||||
|
guy.moving = false
|
||||||
|
guy.targetX, guy.targetY = nil, nil
|
||||||
|
guy.facing = "up"
|
||||||
|
end
|
||||||
|
Music.playMap(game.data, "PEWTER_CITY")
|
||||||
|
push(game, t._PewterCitySuperNerd1ItsRightHereText
|
||||||
|
or "It's right here!", walkOut)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function lockstep()
|
||||||
|
local i = 0
|
||||||
|
local function tick()
|
||||||
|
i = i + 1
|
||||||
|
local ps = plan.steps[i]
|
||||||
|
if not ps then
|
||||||
|
afterWalk()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local gs = museumGuySteps[head + i]
|
||||||
|
if guy and gs then ow:scriptMove(guy, gs, 1) end
|
||||||
|
ow:scriptMove(ow.player, ps, 1, tick)
|
||||||
|
end
|
||||||
|
tick()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/overworld/movement.asm:737 (DoScriptedNPCMovement)
|
||||||
|
if guy then
|
||||||
|
guy.stepFrames = ow.player.stepFramesCur or ow.player.stepFrames
|
||||||
|
end
|
||||||
|
Music.play(game.data, "Music_MuseumGuy")
|
||||||
|
if guy and head > 0 then
|
||||||
|
local h = 0
|
||||||
|
local function headTick()
|
||||||
|
h = h + 1
|
||||||
|
if h > head then lockstep(); return end
|
||||||
|
ow:scriptMove(guy, museumGuySteps[h], 1, headTick)
|
||||||
|
end
|
||||||
|
headTick()
|
||||||
|
else
|
||||||
|
lockstep()
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
M.PEWTER_CITY = {
|
M.PEWTER_CITY = {
|
||||||
|
museumEscort = { plan = museumPlan, guySteps = museumGuySteps },
|
||||||
talk = {
|
talk = {
|
||||||
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm): asks if you
|
-- PewterCitySuperNerd1Text (scripts/PewterCity.asm:209-237): YES ->
|
||||||
-- checked out the museum; YES -> fossils comment, NO -> "you have
|
-- fossils comment, NO -> "you have to go" and the museum escort
|
||||||
-- to go" (which in pokered also kicks off the escort script).
|
|
||||||
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
|
TEXT_PEWTERCITY_SUPER_NERD1 = function(game, ow, npc, done)
|
||||||
local t = text(game)
|
local t = text(game)
|
||||||
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
|
ask(game, t._PewterCitySuperNerd1DidYouCheckOutMuseumText
|
||||||
@@ -39,7 +165,10 @@ M.PEWTER_CITY = {
|
|||||||
or "Weren't those\nfossils from MT.\nMOON amazing?", done)
|
or "Weren't those\nfossils from MT.\nMOON amazing?", done)
|
||||||
else
|
else
|
||||||
push(game, t._PewterCitySuperNerd1YouHaveToGoText
|
push(game, t._PewterCitySuperNerd1YouHaveToGoText
|
||||||
or "Really?\nYou absolutely\nhave to go!", done)
|
or "Really?\nYou absolutely\nhave to go!", function()
|
||||||
|
museumEscortWalk(game, ow)
|
||||||
|
if done then done() end
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end,
|
end,
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ return {
|
|||||||
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
-- fanfare for the taunt/challenge exchange, same as the Yellow port
|
||||||
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
-- (oaks_lab_yellow.lua); it was silently dropped here (#596).
|
||||||
local rows = {
|
local rows = {
|
||||||
|
{ "face_object", 1, "down" }, -- scripts/OaksLab.asm:347-351
|
||||||
{ "face_player_dir", "up" },
|
{ "face_player_dir", "up" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetRival" },
|
{ "play_music", "Music_MeetRival" },
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ return {
|
|||||||
local rival = ow:npcByIndex(RIVAL)
|
local rival = ow:npcByIndex(RIVAL)
|
||||||
if not rival then return false end
|
if not rival then return false end
|
||||||
local rows = {
|
local rows = {
|
||||||
|
{ "face_object", RIVAL, "down" }, -- pokeyellow scripts/OaksLab.asm:311-315
|
||||||
{ "face_player_dir", "up" },
|
{ "face_player_dir", "up" },
|
||||||
{ "stop_music" },
|
{ "stop_music" },
|
||||||
{ "play_music", "Music_MeetRival" },
|
{ "play_music", "Music_MeetRival" },
|
||||||
|
|||||||
@@ -140,6 +140,10 @@ M.VIRIDIAN_CITY = {
|
|||||||
-- Daisy hands over the TOWN MAP once Oak's errand is under way
|
-- Daisy hands over the TOWN MAP once Oak's errand is under way
|
||||||
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
|
-- (scripts/BluesHouse.asm BluesHouseDaisySittingText)
|
||||||
M.BLUES_HOUSE = {
|
M.BLUES_HOUSE = {
|
||||||
|
-- scripts/BluesHouse.asm:12-16
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
game.save.flags.EVENT_ENTERED_BLUES_HOUSE = true
|
||||||
|
end,
|
||||||
talk = {
|
talk = {
|
||||||
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
TEXT_BLUESHOUSE_DAISY_SITTING = {
|
||||||
{ "face_player" },
|
{ "face_player" },
|
||||||
|
|||||||
@@ -87,6 +87,18 @@ end
|
|||||||
M.PALLET_TOWN = {
|
M.PALLET_TOWN = {
|
||||||
talk = require("data.scripts.pallet_town").talk,
|
talk = require("data.scripts.pallet_town").talk,
|
||||||
escort = escort,
|
escort = escort,
|
||||||
|
-- scripts/PalletTown.asm:133-144
|
||||||
|
onEnter = function(game, ow)
|
||||||
|
local f = game.save.flags
|
||||||
|
if f.EVENT_GOT_TOWN_MAP and f.EVENT_ENTERED_BLUES_HOUSE
|
||||||
|
and not f.EVENT_DAISY_WALKING then
|
||||||
|
f.EVENT_DAISY_WALKING = true
|
||||||
|
local Commands = require("src.script.Commands")
|
||||||
|
local ctx = { save = game.save, game = game, overworld = ow }
|
||||||
|
Commands.hide_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY1")
|
||||||
|
Commands.show_object(ctx, "BLUES_HOUSE", "BLUESHOUSE_DAISY2")
|
||||||
|
end
|
||||||
|
end,
|
||||||
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
-- Red: stop at y==1 from (8,5). Yellow: stop at y==0 from (10,4),
|
||||||
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
-- then a wild Pikachu battle before the lab escort (pokeyellow
|
||||||
-- PalletTownPikachuBattleScript).
|
-- PalletTownPikachuBattleScript).
|
||||||
@@ -690,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(
|
||||||
@@ -704,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
|
||||||
@@ -722,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
|
||||||
|
|||||||
@@ -937,15 +937,10 @@ M.VERMILION_DOCK = {
|
|||||||
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end)
|
||||||
end
|
end
|
||||||
puff(3, 15)
|
puff(3, 15)
|
||||||
-- VermilionDock_EraseSSAnne deliberately leaves the blocks under the
|
-- scripts/VermilionDock.asm:182-203
|
||||||
-- player alone ("south of the player and won't be redrawn"), so skip
|
|
||||||
-- his own block: he must not spend the walk-out standing on water
|
|
||||||
local pbx = math.floor(ow.player.cellX / 2)
|
|
||||||
local pby = math.floor(ow.player.cellY / 2)
|
|
||||||
local rows = {}
|
local rows = {}
|
||||||
local function setBlock(bx, by, block)
|
local function setBlock(bx, by, block)
|
||||||
if bx < 1 or bx > 8 then return end
|
if bx < 1 or bx > 8 then return end
|
||||||
if bx == pbx and by == pby then return end
|
|
||||||
rows[#rows + 1] = { "replace_block", bx, by, block }
|
rows[#rows + 1] = { "replace_block", bx, by, block }
|
||||||
end
|
end
|
||||||
rows[#rows + 1] = { "wait", 120 }
|
rows[#rows + 1] = { "wait", 120 }
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -554,7 +555,7 @@ gains a field instead of the name gaining a prefix.
|
|||||||
passes `game`; positions 2-4 (mon, row, trigger) match.
|
passes `game`; positions 2-4 (mon, row, trigger) match.
|
||||||
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
|
||||||
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
|
||||||
`render.letterbox`, `render.hud`. Each sits
|
`render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
|
||||||
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
|
||||||
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
-- the logic tick before the pad is read, a pointer the touch overlay gets
|
||||||
first refusal on, the palette zone list handed to the present pass, the
|
first refusal on, the palette zone list handed to the present pass, the
|
||||||
|
|||||||
@@ -223,20 +223,29 @@ scripts, battles, and transitions leave the party untouched.
|
|||||||
start at the player's current position. Both games expose `bicycle`, `fish`,
|
start at the player's current position. Both games expose `bicycle`, `fish`,
|
||||||
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
`cut`, `surf`, `strength`, `flash`, `dig`, and `teleport`; Gold additionally
|
||||||
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
exposes `headbutt`, `whirlpool`, `waterfall`, `sweet_scent`, and the
|
||||||
contextual `squirtbottle` key item. Fishing rows include the owned rods that
|
contextual `squirtbottle` key item. Red additionally exposes `softboiled` with
|
||||||
are valid choices. The list is empty while the world is busy, and omits an
|
eligible `sources`; each source contains its eligible `targets`. Fishing rows
|
||||||
action whenever its item, move, badge, terrain, or engine state forbids it.
|
include the owned rods that are valid choices. The list is empty while the
|
||||||
|
world is busy, and omits an action whenever its item, move, badge, terrain, or
|
||||||
|
engine state forbids it.
|
||||||
The optional second return is `"world is busy"` during transient input locks
|
The optional second return is `"world is busy"` during transient input locks
|
||||||
or `"no overworld"` before a playable world exists.
|
or `"no overworld"` before a playable world exists.
|
||||||
|
|
||||||
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
Call `mod.world:useFieldAction(id, opts)` to perform a listed action through
|
||||||
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
the active game's own field-item path. Fishing accepts `{ rod = "OLD_ROD" }`
|
||||||
and chooses automatically when only one rod is available. Invalid, stale, and
|
and chooses automatically when only one rod is available. Red's `softboiled`
|
||||||
busy requests return `nil` plus a reason without changing game state. Mods do
|
accepts one-based `{ sourceSlot, targetSlot }` values copied from its action
|
||||||
not need generation-specific badge, terrain, bike, fishing, or field-move
|
record. Invalid, stale, and busy requests return `nil` plus a reason without
|
||||||
|
changing game state. Mods do not need generation-specific badge, terrain,
|
||||||
|
bike, fishing, or field-move
|
||||||
logic. Action lists are extensible; callers should render the records they
|
logic. Action lists are extensible; callers should render the records they
|
||||||
understand and ignore unknown ids rather than assuming a fixed list length.
|
understand and ignore unknown ids rather than assuming a fixed list length.
|
||||||
|
|
||||||
|
Red exposes FLY separately because it requires a destination picker:
|
||||||
|
`mod.world:canFly()` reports whether FLY is eligible at the current location,
|
||||||
|
and `mod.world:flyTo(mapId)` accepts only a visited destination from the native
|
||||||
|
Fly town list. Gold does not expose these two methods yet.
|
||||||
|
|
||||||
## Read-only battle snapshots
|
## Read-only battle snapshots
|
||||||
|
|
||||||
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
`mod.battle:snapshot()` returns `nil` outside a battle and a copied battle
|
||||||
@@ -259,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
|
||||||
|
|
||||||
@@ -278,10 +287,16 @@ The shared Red, Blue, Yellow, and Gold intents are:
|
|||||||
- `{ kind = "move", slot = 1..4 }`
|
- `{ kind = "move", slot = 1..4 }`
|
||||||
- `{ kind = "back" }` while the move menu is active
|
- `{ kind = "back" }` while the move menu is active
|
||||||
|
|
||||||
|
Red, Blue, and Yellow also expose their generation-specific choices:
|
||||||
|
|
||||||
|
- `{ kind = "safari", action = "ball" }` (`bait`, `rock`, and `run` are the
|
||||||
|
other accepted actions)
|
||||||
|
- `{ kind = "mimic", index = 1 }` using an entry's snapshot `index`
|
||||||
|
|
||||||
Menu choices and moves use the same engine methods as the native controls;
|
Menu choices and moves use the same engine methods as the native controls;
|
||||||
`party` and `item` open the native screens rather than exposing or duplicating
|
`party` and `item` open the native screens rather than exposing or duplicating
|
||||||
their mutable logic. Tutorial, link, Safari, forced, stale, and covered battle
|
their mutable logic. Tutorial, link, forced, stale, and covered battle states
|
||||||
states refuse these core intents. Use `mod.input` for ordinary text advance.
|
refuse core intents. Use `mod.input` for ordinary text advance.
|
||||||
|
|
||||||
## Rendering pipelines
|
## Rendering pipelines
|
||||||
|
|
||||||
@@ -654,11 +669,14 @@ the wrapper is visible during that same fixed step. The callback receives
|
|||||||
|
|
||||||
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
`input.pointer` delivers uncaptured gameplay pointer events -- touches and
|
||||||
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
real mouse input alike. The callback receives `(next, game, ev)` where `ev`
|
||||||
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is
|
is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure,
|
||||||
|
button }`: `phase` is
|
||||||
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
|
||||||
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
|
||||||
are LOVE window units, the same space `render.hud`'s viewport and the touch
|
`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
|
||||||
overlay lay out in. The on-screen touch controls keep first refusal: a
|
active game viewport and `insideGame` says whether the pointer is inside it.
|
||||||
|
Without a custom viewport both coordinate pairs are identical. The on-screen
|
||||||
|
touch controls keep first refusal: a
|
||||||
pointer that begins on a virtual control belongs to the pad for its whole
|
pointer that begins on a virtual control belongs to the pad for its whole
|
||||||
lifecycle and never reaches the hook, while one that begins outside stays
|
lifecycle and never reaches the hook, while one that begins outside stays
|
||||||
visible even if it later crosses a control. A real mouse reaches the hook
|
visible even if it later crosses a control. A real mouse reaches the hook
|
||||||
@@ -691,6 +709,22 @@ composited and before touch controls draw. The window-space viewport contains
|
|||||||
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
and `dpiY`, so a tool can use the letterbox margins without drawing over the
|
||||||
playfield or pushing an updating game state.
|
playfield or pushing an updating game state.
|
||||||
|
|
||||||
|
`render.viewport` lets a layout mod reserve the window-space rectangle in which
|
||||||
|
the game renders. It receives `(next, ctx)` with the full window's `width`,
|
||||||
|
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
|
||||||
|
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
|
||||||
|
window and makes game layout, safe-area calculations, and rendering use it as
|
||||||
|
their display. Set `capture = true` to request a composition canvas even when
|
||||||
|
the rectangle fills the window. With no subscriber, no canvas is allocated and
|
||||||
|
the normal presentation path is unchanged.
|
||||||
|
|
||||||
|
When a viewport is active, `render.window` receives `(next, game, ctx)` after
|
||||||
|
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
|
||||||
|
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
|
||||||
|
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
|
||||||
|
a wrapper may instead compose that canvas with its own UI. Touch controls remain
|
||||||
|
full-size OS-window chrome and draw after this hook.
|
||||||
|
|
||||||
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
|
||||||
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
|
||||||
the mod full control of the window, while calling `next` runs the engine's
|
the mod full control of the window, while calling `next` runs the engine's
|
||||||
@@ -699,11 +733,24 @@ the finished `worldCanvas` and `uiCanvas` with their SGB `zones` / `worldZones`,
|
|||||||
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
`worldActive`, the frame metrics (`ww`, `wh`, `pw`, `ph`, `ox`, `oy`, `vpw`,
|
||||||
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
`vph`, `scale`, `Sx`, `Sy`, `dpiX`, `dpiY`), `renderer:blitCanvas(...)` for a
|
||||||
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
palette-correct blit of either canvas into an arbitrary screen rect, and the
|
||||||
`secondScreen` bridge (`available()` / `push(imageData, w, h)` / `pollTouch()` /
|
`secondScreen` bridge (`available()` / `detected()` / `push(...)` /
|
||||||
`setEnabled`) for driving a second physical display. `pollTouch()` returns the
|
`pollTouch()` / `setEnabled`) for driving a second physical display.
|
||||||
oldest queued event as `"action,x,y"` in submitted-frame coordinates, or `nil`.
|
`detected()` reports a connected target even while its output is being created;
|
||||||
|
`available()` means it can accept a frame now. `push(imageData, w, h)` retains
|
||||||
|
the original contract. Its optional `background` (`0xRRGGBB`) and `preference`
|
||||||
|
arguments request an extended presentation; a preference ending in `:cover`
|
||||||
|
fills and crops the target, while other values preserve the whole frame.
|
||||||
|
Android also accepts `handheld` or `secondary` (with an optional `:cover`
|
||||||
|
suffix) as routing hints; unsupported or unavailable targets fall back to the
|
||||||
|
other connected display.
|
||||||
|
`pollTouch()` returns the oldest queued event as `"action,x,y"` in submitted-frame
|
||||||
|
coordinates, or `nil`.
|
||||||
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
This is what lets a mod lay the two passes out as two stacked Game Boy screens,
|
||||||
or push one onto a second screen, without the engine knowing the layout.
|
or push one onto a second screen, without the engine knowing the layout.
|
||||||
|
On process-capable Windows, Linux and macOS hosts without a native display
|
||||||
|
bridge, enabling this facade opens a second resizable app window instead. It
|
||||||
|
uses the same `available`, `detected`, `push`, `pollTouch` and `setEnabled`
|
||||||
|
contract, so a mod does not need a desktop-specific rendering path.
|
||||||
|
|
||||||
`render.output_enabled` and `render.output` are the later, whole-window seam
|
`render.output_enabled` and `render.output` are the later, whole-window seam
|
||||||
for mods that need the engine's normal composite rather than its separate
|
for mods that need the engine's normal composite rather than its separate
|
||||||
|
|||||||
@@ -11,28 +11,10 @@ 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**
|
|
||||||
* **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,186 @@
|
|||||||
|
# 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` | fallback aspect when not 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.
|
||||||
|
|
||||||
|
**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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
-- opens the editor on that slot's file, and restores the launcher when
|
-- opens the editor on that slot's file, and restores the launcher when
|
||||||
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
-- the editor's Close button is pressed (openEditor / closeEditor below)
|
||||||
|
|
||||||
|
if POKEPORT_DISPLAY_COMPANION then
|
||||||
|
return require("src.render.DesktopCompanion").install(
|
||||||
|
POKEPORT_DISPLAY_COMPANION)
|
||||||
|
end
|
||||||
|
|
||||||
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
local editorMode = os.getenv("POKEPORT_EDITOR") == "1" or POKEPORT_EDITOR_MODE == true
|
||||||
|
|
||||||
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
local SwitchDiagnostics = require("src.debug.SwitchDiagnostics")
|
||||||
@@ -15,6 +20,7 @@ local LaunchOptions = require("src.core.LaunchOptions")
|
|||||||
local NxDisplay = require("src.core.NxDisplay")
|
local NxDisplay = require("src.core.NxDisplay")
|
||||||
local PlatformHooks = require("src.core.PlatformHooks")
|
local PlatformHooks = require("src.core.PlatformHooks")
|
||||||
local HostDisplay = require("src.core.HostDisplay")
|
local HostDisplay = require("src.core.HostDisplay")
|
||||||
|
local GameViewport = require("src.render.GameViewport")
|
||||||
|
|
||||||
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
-- Lua errors: persist a redacted trace in the save dir and surface a hint.
|
||||||
do
|
do
|
||||||
@@ -30,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).
|
||||||
@@ -249,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
|
||||||
@@ -450,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
|
||||||
|
|
||||||
@@ -460,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
|
||||||
@@ -503,24 +549,36 @@ end
|
|||||||
|
|
||||||
function love.draw()
|
function love.draw()
|
||||||
if editorMode then
|
if editorMode then
|
||||||
|
GameViewport.reset()
|
||||||
HostDisplay.beginFrame("editor", EditorApp)
|
HostDisplay.beginFrame("editor", EditorApp)
|
||||||
local result = EditorApp.draw()
|
local result = EditorApp.draw()
|
||||||
HostDisplay.endFrame("editor", EditorApp)
|
HostDisplay.endFrame("editor", EditorApp)
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
if TouchEditor then
|
if TouchEditor then
|
||||||
|
GameViewport.reset()
|
||||||
HostDisplay.beginFrame("touch_editor", TouchEditor)
|
HostDisplay.beginFrame("touch_editor", TouchEditor)
|
||||||
local result = TouchEditor.draw()
|
local result = TouchEditor.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()
|
||||||
HostDisplay.beginFrame("launcher", Importer)
|
HostDisplay.beginFrame("launcher", Importer)
|
||||||
local result = Importer:draw()
|
local result = Importer:draw()
|
||||||
HostDisplay.endFrame("launcher", Importer)
|
HostDisplay.endFrame("launcher", Importer)
|
||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
if not Game then return end
|
if not Game then
|
||||||
|
GameViewport.reset()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
HostDisplay.beginFrame("game", Game)
|
HostDisplay.beginFrame("game", Game)
|
||||||
Game:draw()
|
Game:draw()
|
||||||
@@ -543,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
|
||||||
|
|
||||||
@@ -567,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
|
||||||
|
|
||||||
@@ -585,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
|
||||||
|
|
||||||
@@ -603,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
|
||||||
|
|
||||||
@@ -621,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
|
||||||
|
|
||||||
@@ -639,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
|
||||||
|
|
||||||
@@ -657,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
|
||||||
|
|
||||||
@@ -675,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
|
||||||
|
|
||||||
@@ -698,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
|
||||||
|
|
||||||
@@ -738,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
|
||||||
|
|
||||||
@@ -753,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
|
||||||
|
|
||||||
@@ -765,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
|
||||||
|
|
||||||
@@ -777,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
|
||||||
|
|
||||||
@@ -814,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
|
||||||
@@ -848,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)
|
||||||
@@ -865,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
|
||||||
@@ -875,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)
|
||||||
@@ -952,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
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@
|
|||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@drawable/love"
|
android:icon="@drawable/love"
|
||||||
android:label="${NAME}" >
|
android:label="${NAME}" >
|
||||||
|
<meta-data
|
||||||
|
android:name="android.allow_multiple_resumed_activities"
|
||||||
|
android:value="true" />
|
||||||
<activity
|
<activity
|
||||||
android:name="org.love2d.android.GameActivity"
|
android:name="org.love2d.android.GameActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -49,5 +52,15 @@
|
|||||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="org.love2d.android.GameActivity$SecondaryActivity"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:resizeableActivity="false"
|
||||||
|
android:screenOrientation="${ORIENTATION}"
|
||||||
|
android:taskAffinity="${applicationId}.secondary"
|
||||||
|
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -325,6 +325,55 @@ bool httpDownload(const char *url, const char *destPath, const char *userAgent,
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent)
|
||||||
|
{
|
||||||
|
if (url == nullptr || body == nullptr || bodyLen < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
// Same resolution rule as httpDownload: the activity's own class via
|
||||||
|
// SDL_AndroidGetActivity, never FindClass -- this bridge is called off
|
||||||
|
// the main thread (love.thread workers), whose class loader cannot see
|
||||||
|
// app classes.
|
||||||
|
jobject activityObj = (jobject) SDL_AndroidGetActivity();
|
||||||
|
if (activityObj == nullptr)
|
||||||
|
return false;
|
||||||
|
jclass activity = env->GetObjectClass(activityObj);
|
||||||
|
env->DeleteLocalRef(activityObj);
|
||||||
|
|
||||||
|
// Old APK / new liblove skew: report "no transport" the same way a
|
||||||
|
// missing curl does, instead of aborting on a missing method (#597).
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "httpPost",
|
||||||
|
"(Ljava/lang/String;[BLjava/lang/String;Ljava/lang/String;)Z");
|
||||||
|
if (method == nullptr)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
jstring jurl = env->NewStringUTF(url);
|
||||||
|
// raw bytes across the bridge: a log ring can carry arbitrary UTF-8,
|
||||||
|
// and a jstring would run it through modified UTF-8
|
||||||
|
jbyteArray jbody = env->NewByteArray(bodyLen);
|
||||||
|
if (jbody != nullptr)
|
||||||
|
env->SetByteArrayRegion(jbody, 0, bodyLen, (const jbyte*) body);
|
||||||
|
jstring jct = contentType != nullptr ? env->NewStringUTF(contentType) : nullptr;
|
||||||
|
jstring jua = userAgent != nullptr ? env->NewStringUTF(userAgent) : nullptr;
|
||||||
|
|
||||||
|
jboolean result = env->CallStaticBooleanMethod(activity, method, jurl, jbody, jct, jua);
|
||||||
|
|
||||||
|
env->DeleteLocalRef(jurl);
|
||||||
|
if (jbody != nullptr)
|
||||||
|
env->DeleteLocalRef(jbody);
|
||||||
|
if (jct != nullptr)
|
||||||
|
env->DeleteLocalRef(jct);
|
||||||
|
if (jua != nullptr)
|
||||||
|
env->DeleteLocalRef(jua);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
* TLS sockets. Same resolution rule as httpDownload above -- the activity's
|
||||||
* own class, never FindClass -- and the same tolerance for an old APK: a
|
* own class, never FindClass -- and the same tolerance for an old APK: a
|
||||||
@@ -1180,6 +1229,68 @@ void love_android_secondary_enable(int on)
|
|||||||
env->DeleteLocalRef(activity);
|
env->DeleteLocalRef(activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extern "C" __attribute__((visibility("default")))
|
||||||
|
void love_android_secondary_target(int target)
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity,
|
||||||
|
"setSecondaryDisplayTarget", "(I)V");
|
||||||
|
if (method)
|
||||||
|
env->CallStaticVoidMethod(activity, method, target);
|
||||||
|
else
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" __attribute__((visibility("default")))
|
||||||
|
int love_android_secondary_detected()
|
||||||
|
{
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity,
|
||||||
|
"hasSecondaryDisplayCandidate", "()Z");
|
||||||
|
jboolean detected = JNI_FALSE;
|
||||||
|
if (method)
|
||||||
|
detected = env->CallStaticBooleanMethod(activity, method);
|
||||||
|
else
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return detected ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
extern "C" __attribute__((visibility("default")))
|
||||||
|
int love_android_present_secondary(const void *rgba, int width, int height,
|
||||||
|
unsigned int background, int cover)
|
||||||
|
{
|
||||||
|
if (!rgba || width <= 0 || height <= 0)
|
||||||
|
return 0;
|
||||||
|
jlong size = (jlong) width * (jlong) height * 4;
|
||||||
|
if (size <= 0)
|
||||||
|
return 0;
|
||||||
|
JNIEnv *env = (JNIEnv*) SDL_AndroidGetJNIEnv();
|
||||||
|
jclass activity = env->FindClass("org/love2d/android/GameActivity");
|
||||||
|
jmethodID method = env->GetStaticMethodID(activity, "presentSecondaryFrame",
|
||||||
|
"(Ljava/nio/ByteBuffer;IIIZ)Z");
|
||||||
|
if (!method)
|
||||||
|
{
|
||||||
|
env->ExceptionClear();
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
jobject frame = env->NewDirectByteBuffer((void *) rgba, size);
|
||||||
|
if (!frame)
|
||||||
|
{
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
jboolean shown = env->CallStaticBooleanMethod(activity, method, frame,
|
||||||
|
width, height, (jint) background, cover ? JNI_TRUE : JNI_FALSE);
|
||||||
|
env->DeleteLocalRef(frame);
|
||||||
|
env->DeleteLocalRef(activity);
|
||||||
|
return shown ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
extern "C" __attribute__((visibility("default")))
|
extern "C" __attribute__((visibility("default")))
|
||||||
const char *love_android_poll_secondary_touch()
|
const char *love_android_poll_secondary_touch()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -98,6 +98,14 @@ bool restartApp();
|
|||||||
**/
|
**/
|
||||||
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
bool httpDownload(const char *url, const char *destPath, const char *userAgent, const char *accept);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS POST of a raw byte body (GameActivity.httpPost). The
|
||||||
|
* mirror of httpDownload for mod.postLog log sends, which need POST and
|
||||||
|
* have no curl on Android. contentType / userAgent may be null. Returns
|
||||||
|
* whether the server accepted the send (2xx).
|
||||||
|
**/
|
||||||
|
bool httpPost(const char *url, const char *body, int bodyLen, const char *contentType, const char *userAgent);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
* TLS client sockets (GameActivity.tls*, implemented by TlsSocket.java).
|
||||||
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
* LuaSocket, which is what LOVE ships, does TCP only, so wss:// is otherwise
|
||||||
|
|||||||
@@ -259,6 +259,21 @@ bool System::httpDownload(const char *url, const char *destPath,
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool System::httpPost(const char *url, const char *body, int bodyLen,
|
||||||
|
const char *contentType, const char *userAgent) const
|
||||||
|
{
|
||||||
|
#ifdef LOVE_ANDROID
|
||||||
|
return love::android::httpPost(url, body, bodyLen, contentType, userAgent);
|
||||||
|
#else
|
||||||
|
LOVE_UNUSED(url);
|
||||||
|
LOVE_UNUSED(body);
|
||||||
|
LOVE_UNUSED(bodyLen);
|
||||||
|
LOVE_UNUSED(contentType);
|
||||||
|
LOVE_UNUSED(userAgent);
|
||||||
|
return false;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
int System::tlsOpen(const char *host, int port) const
|
int System::tlsOpen(const char *host, int port) const
|
||||||
{
|
{
|
||||||
#ifdef LOVE_ANDROID
|
#ifdef LOVE_ANDROID
|
||||||
|
|||||||
@@ -151,6 +151,14 @@ public:
|
|||||||
virtual bool httpDownload(const char *url, const char *destPath,
|
virtual bool httpDownload(const char *url, const char *destPath,
|
||||||
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
const char *userAgent = nullptr, const char *accept = nullptr) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS POST of a raw byte body (Android only; false
|
||||||
|
* elsewhere). The mirror of httpDownload for mod.postLog log sends,
|
||||||
|
* which need POST and have no curl on Android (#597).
|
||||||
|
**/
|
||||||
|
virtual bool httpPost(const char *url, const char *body, int bodyLen,
|
||||||
|
const char *contentType = nullptr, const char *userAgent = nullptr) const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TLS client sockets (Android only; every call fails elsewhere, where
|
* TLS client sockets (Android only; every call fails elsewhere, where
|
||||||
* LuaSec or another provider is the answer). Non-blocking by contract:
|
* LuaSec or another provider is the answer). Non-blocking by contract:
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ int w_httpDownload(lua_State *L)
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int w_httpPost(lua_State *L)
|
||||||
|
{
|
||||||
|
const char *url = luaL_checkstring(L, 1);
|
||||||
|
size_t bodyLen = 0;
|
||||||
|
const char *body = luaL_checklstring(L, 2, &bodyLen);
|
||||||
|
const char *ct = luaL_optstring(L, 3, nullptr);
|
||||||
|
const char *ua = luaL_optstring(L, 4, nullptr);
|
||||||
|
luax_pushboolean(L, instance()->httpPost(url, body, (int) bodyLen, ct, ua));
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
int w_hasBackgroundMusic(lua_State *L)
|
int w_hasBackgroundMusic(lua_State *L)
|
||||||
{
|
{
|
||||||
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
lua_pushboolean(L, instance()->hasBackgroundMusic());
|
||||||
@@ -233,6 +244,7 @@ static const luaL_Reg functions[] =
|
|||||||
{ "syncHealthSteps", w_syncHealthSteps },
|
{ "syncHealthSteps", w_syncHealthSteps },
|
||||||
{ "restartApp", w_restartApp },
|
{ "restartApp", w_restartApp },
|
||||||
{ "httpDownload", w_httpDownload },
|
{ "httpDownload", w_httpDownload },
|
||||||
|
{ "httpPost", w_httpPost },
|
||||||
{ "tlsOpen", w_tlsOpen },
|
{ "tlsOpen", w_tlsOpen },
|
||||||
{ "tlsStatus", w_tlsStatus },
|
{ "tlsStatus", w_tlsStatus },
|
||||||
{ "tlsSend", w_tlsSend },
|
{ "tlsSend", w_tlsSend },
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import android.os.Environment;
|
|||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.Vibrator;
|
import android.os.Vibrator;
|
||||||
|
import android.provider.Settings;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.DisplayMetrics;
|
import android.util.DisplayMetrics;
|
||||||
import android.view.*;
|
import android.view.*;
|
||||||
@@ -387,10 +388,23 @@ public class GameActivity extends SDLActivity {
|
|||||||
public void onResume() {
|
public void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
onHostResume();
|
onHostResume();
|
||||||
|
refreshDualScreenDisplayMode();
|
||||||
if (secondaryEnabled) registerSecondaryDisplayListener();
|
if (secondaryEnabled) registerSecondaryDisplayListener();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchKeyEvent(KeyEvent event) {
|
||||||
|
// AYN's panel toggle emits virtual Right Shift, which SDL maps to a
|
||||||
|
// gameplay button. The setting is absent on other Android devices.
|
||||||
|
if (secondaryEnabled && dualScreenDisplayMode != -1
|
||||||
|
&& event.getKeyCode() == KeyEvent.KEYCODE_SHIFT_RIGHT
|
||||||
|
&& event.getDeviceId() == KeyCharacterMap.VIRTUAL_KEYBOARD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.dispatchKeyEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SDL decides the activity's requested orientation at window creation
|
* SDL decides the activity's requested orientation at window creation
|
||||||
* (SDLActivity.setOrientationBis). With a resizable window and no
|
* (SDLActivity.setOrientationBis). With a resizable window and no
|
||||||
@@ -741,6 +755,76 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocking HTTPS POST, exposed as love.system.httpPost and used by
|
||||||
|
* src/core/HostShell.lua for mod.postLog. The GET bridge above covers
|
||||||
|
* downloads; log sends need POST, and Android ships no curl, so this is
|
||||||
|
* the only POST transport the platform has. Strictly one-way, matching
|
||||||
|
* the curl branch it mirrors: the response body is drained and
|
||||||
|
* discarded, and only the 2xx verdict comes back.
|
||||||
|
*
|
||||||
|
* Same rules as httpDownload: https only, redirects followed by hand
|
||||||
|
* (re-POSTing the body on each hop, the way curl -X POST behaves), and
|
||||||
|
* the call is blocking on the Lua/worker thread -- never the UI thread.
|
||||||
|
* The body arrives as raw bytes (a jbyteArray across the JNI) because a
|
||||||
|
* log ring can carry arbitrary UTF-8; a String would risk modified-UTF-8
|
||||||
|
* corruption on characters outside the BMP.
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public static boolean httpPost(String url, byte[] body, String contentType, String userAgent) {
|
||||||
|
if (url == null || body == null) return false;
|
||||||
|
HttpURLConnection conn = null;
|
||||||
|
try {
|
||||||
|
String current = url;
|
||||||
|
for (int hop = 0; hop < 5; hop++) {
|
||||||
|
URL parsed = new URL(current);
|
||||||
|
if (!"https".equalsIgnoreCase(parsed.getProtocol())) return false;
|
||||||
|
conn = (HttpURLConnection) parsed.openConnection();
|
||||||
|
conn.setInstanceFollowRedirects(false);
|
||||||
|
conn.setConnectTimeout(15000);
|
||||||
|
conn.setReadTimeout(60000);
|
||||||
|
conn.setRequestMethod("POST");
|
||||||
|
conn.setDoOutput(true);
|
||||||
|
conn.setRequestProperty("User-Agent",
|
||||||
|
userAgent == null ? "gen1recomp" : userAgent);
|
||||||
|
conn.setRequestProperty("Content-Type",
|
||||||
|
contentType == null ? "text/plain" : contentType);
|
||||||
|
OutputStream out = new BufferedOutputStream(conn.getOutputStream());
|
||||||
|
try {
|
||||||
|
out.write(body);
|
||||||
|
} finally {
|
||||||
|
try { out.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
int code = conn.getResponseCode();
|
||||||
|
if (code == 301 || code == 302 || code == 303 || code == 307 || code == 308) {
|
||||||
|
String next = conn.getHeaderField("Location");
|
||||||
|
conn.disconnect();
|
||||||
|
conn = null;
|
||||||
|
if (next == null) return false;
|
||||||
|
current = new URL(parsed, next).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code < 200 || code > 299) return false;
|
||||||
|
// drain and discard, so a slow server cannot wedge the
|
||||||
|
// worker on a full socket buffer
|
||||||
|
InputStream in = new BufferedInputStream(conn.getInputStream());
|
||||||
|
try {
|
||||||
|
byte[] buf = new byte[16384];
|
||||||
|
while (in.read(buf) > 0) {}
|
||||||
|
} finally {
|
||||||
|
try { in.close(); } catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.d("GameActivity", "httpPost failed: " + e.getMessage());
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
if (conn != null) conn.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
* Shows ACTION_CREATE_DOCUMENT so the player can save a staged export
|
||||||
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
* (pending_export.sav in the app save identity) to Downloads / Drive /
|
||||||
@@ -1406,9 +1490,38 @@ public class GameActivity extends SDLActivity {
|
|||||||
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
// Dual-screen: mirror the engine's bottom-screen canvas onto a secondary
|
||||||
// physical display. Driven from the engine through love_android_secondary_*
|
// physical display. Driven from the engine through love_android_secondary_*
|
||||||
// in src/jni/love/src/common/android.cpp.
|
// in src/jni/love/src/common/android.cpp.
|
||||||
|
private static final int SECONDARY_TARGET_AUTO = 0;
|
||||||
|
private static final int SECONDARY_TARGET_HANDHELD = 1;
|
||||||
|
private static final int SECONDARY_TARGET_EXTERNAL = 2;
|
||||||
|
// AYN keeps disabled panels registered as ON. This optional setting is the
|
||||||
|
// usable-state signal: 0 = both, 1 = main only, 2 = second only.
|
||||||
|
private static final String DUAL_SCREEN_DISPLAY_MODE = "dual_screen_display_mode";
|
||||||
|
private static final String AYN_SECOND_SCREEN = "Screen-2";
|
||||||
private static volatile SecondaryPresentation secondaryPresentation;
|
private static volatile SecondaryPresentation secondaryPresentation;
|
||||||
|
private static volatile SecondaryActivity secondaryActivity;
|
||||||
|
private static volatile boolean secondaryActivityPending;
|
||||||
|
private static volatile int secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
|
private static volatile long secondaryRetryAfter;
|
||||||
private static volatile boolean secondaryEnabled = false;
|
private static volatile boolean secondaryEnabled = false;
|
||||||
|
private static volatile int secondaryTarget = SECONDARY_TARGET_AUTO;
|
||||||
|
private static volatile int dualScreenDisplayMode = -1;
|
||||||
|
private static volatile byte[] secondaryFrame;
|
||||||
|
private static volatile int secondaryFrameWidth;
|
||||||
|
private static volatile int secondaryFrameHeight;
|
||||||
|
private static volatile int secondaryBackground;
|
||||||
|
private static volatile boolean secondaryFrameCover;
|
||||||
|
private static final Object secondaryFrameLock = new Object();
|
||||||
|
private static volatile long secondaryDetectionAt;
|
||||||
|
private static volatile boolean secondaryDetected;
|
||||||
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
private SecondaryDisplayMonitor secondaryDisplayMonitor;
|
||||||
|
private boolean dualScreenModeObserverRegistered;
|
||||||
|
private final android.database.ContentObserver dualScreenModeObserver =
|
||||||
|
new android.database.ContentObserver(new Handler(Looper.getMainLooper())) {
|
||||||
|
@Override public void onChange(boolean selfChange, Uri uri) {
|
||||||
|
refreshDualScreenDisplayMode();
|
||||||
|
rebindSecondaryDisplay();
|
||||||
|
}
|
||||||
|
};
|
||||||
private static final int MAX_SECONDARY_TOUCHES = 32;
|
private static final int MAX_SECONDARY_TOUCHES = 32;
|
||||||
private static final java.util.ArrayDeque<String> secondaryTouches =
|
private static final java.util.ArrayDeque<String> secondaryTouches =
|
||||||
new java.util.ArrayDeque<>();
|
new java.util.ArrayDeque<>();
|
||||||
@@ -1421,86 +1534,206 @@ public class GameActivity extends SDLActivity {
|
|||||||
self.runOnUiThread(new Runnable() {
|
self.runOnUiThread(new Runnable() {
|
||||||
@Override public void run() {
|
@Override public void run() {
|
||||||
if (on) {
|
if (on) {
|
||||||
|
self.refreshDualScreenDisplayMode();
|
||||||
self.registerSecondaryDisplayListener();
|
self.registerSecondaryDisplayListener();
|
||||||
setupSecondaryDisplay();
|
rebindSecondaryDisplay();
|
||||||
} else {
|
} else {
|
||||||
self.unregisterSecondaryDisplayListener();
|
self.unregisterSecondaryDisplayListener();
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
|
secondaryRetryAfter = 0;
|
||||||
|
synchronized (secondaryFrameLock) { secondaryFrame = null; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static void setSecondaryDisplayTarget(int target) {
|
||||||
|
int normalized = target == SECONDARY_TARGET_HANDHELD
|
||||||
|
|| target == SECONDARY_TARGET_EXTERNAL ? target : SECONDARY_TARGET_AUTO;
|
||||||
|
if (secondaryTarget == normalized) return;
|
||||||
|
secondaryTarget = normalized;
|
||||||
|
secondaryDetectionAt = 0;
|
||||||
|
rebindSecondaryDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refreshDualScreenDisplayMode() {
|
||||||
|
int mode = Settings.System.getInt(
|
||||||
|
getContentResolver(), DUAL_SCREEN_DISPLAY_MODE, -1);
|
||||||
|
if (dualScreenDisplayMode != mode) secondaryDetectionAt = 0;
|
||||||
|
dualScreenDisplayMode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
private void registerSecondaryDisplayListener() {
|
private void registerSecondaryDisplayListener() {
|
||||||
if (secondaryDisplayMonitor != null || android.os.Build.VERSION.SDK_INT < 17) return;
|
if (secondaryDisplayMonitor == null && android.os.Build.VERSION.SDK_INT >= 17) {
|
||||||
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
SecondaryDisplayMonitor monitor = new SecondaryDisplayMonitor(this);
|
||||||
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
if (monitor.register()) secondaryDisplayMonitor = monitor;
|
||||||
|
}
|
||||||
|
if (dualScreenDisplayMode != -1 && !dualScreenModeObserverRegistered) {
|
||||||
|
getContentResolver().registerContentObserver(
|
||||||
|
Settings.System.getUriFor(DUAL_SCREEN_DISPLAY_MODE), false,
|
||||||
|
dualScreenModeObserver);
|
||||||
|
dualScreenModeObserverRegistered = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void unregisterSecondaryDisplayListener() {
|
private void unregisterSecondaryDisplayListener() {
|
||||||
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
|
SecondaryDisplayMonitor monitor = secondaryDisplayMonitor;
|
||||||
secondaryDisplayMonitor = null;
|
secondaryDisplayMonitor = null;
|
||||||
if (monitor != null) monitor.unregister();
|
if (monitor != null) monitor.unregister();
|
||||||
|
if (dualScreenModeObserverRegistered) {
|
||||||
|
getContentResolver().unregisterContentObserver(dualScreenModeObserver);
|
||||||
|
dualScreenModeObserverRegistered = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void refreshSecondaryDisplay() {
|
private static boolean secondaryOutputIsPreferred(GameActivity self) {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
Display preferred = findSecondaryDisplay(self, false);
|
||||||
if (self == null || !secondaryEnabled) return;
|
if (preferred == null) return false;
|
||||||
SecondaryPresentation current = secondaryPresentation;
|
SecondaryPresentation presentation = secondaryPresentation;
|
||||||
Display display = current == null ? null : current.getDisplay();
|
Display display = presentation == null
|
||||||
|
? null : presentation.getDisplay();
|
||||||
|
if (display == null) {
|
||||||
|
SecondaryActivity activity = secondaryActivity;
|
||||||
|
display = activity == null ? null : getActivityDisplay(activity);
|
||||||
|
}
|
||||||
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
|
SecondaryDisplayMonitor monitor = self.secondaryDisplayMonitor;
|
||||||
if (current == null) {
|
if (display == null || (monitor != null
|
||||||
setupSecondaryDisplay();
|
&& !monitor.hasDisplay(display.getDisplayId()))) return false;
|
||||||
} else if (display == null || monitor == null
|
return display.getDisplayId() == preferred.getDisplayId();
|
||||||
|| !monitor.hasDisplay(display.getDisplayId())) {
|
}
|
||||||
|
|
||||||
|
private static void rebindSecondaryDisplay() {
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null || !secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||||
|
self.runOnUiThread(() -> {
|
||||||
|
if (!secondaryEnabled || secondaryOutputIsPreferred(self)) return;
|
||||||
teardownSecondaryDisplay();
|
teardownSecondaryDisplay();
|
||||||
setupSecondaryDisplay();
|
setupSecondaryDisplay();
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void setupSecondaryDisplay() {
|
private static void setupSecondaryDisplay() {
|
||||||
GameActivity self = (GameActivity) mSingleton;
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
if (self == null || !secondaryEnabled || secondaryPresentation != null) return;
|
if (self == null || !secondaryEnabled || secondaryPresentation != null
|
||||||
|
|| secondaryActivity != null || secondaryActivityPending
|
||||||
|
|| android.os.SystemClock.elapsedRealtime() < secondaryRetryAfter) return;
|
||||||
try {
|
try {
|
||||||
android.hardware.display.DisplayManager dm =
|
Display chosen = findSecondaryDisplay(self, true);
|
||||||
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
|
||||||
if (dm == null) return;
|
|
||||||
Display chosen = null;
|
|
||||||
for (Display d : dm.getDisplays()) {
|
|
||||||
android.graphics.Point size = new android.graphics.Point();
|
|
||||||
d.getRealSize(size);
|
|
||||||
Log.d("GameActivity", "display id=" + d.getDisplayId() + " name=" + d.getName()
|
|
||||||
+ " size=" + size.x + "x" + size.y);
|
|
||||||
if (chosen == null && d.getDisplayId() != Display.DEFAULT_DISPLAY) {
|
|
||||||
chosen = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chosen == null) {
|
|
||||||
Display[] pres =
|
|
||||||
dm.getDisplays(android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
|
||||||
if (pres != null && pres.length > 0) chosen = pres[0];
|
|
||||||
}
|
|
||||||
if (chosen == null) {
|
if (chosen == null) {
|
||||||
Log.d("GameActivity", "no secondary display found");
|
Log.d("GameActivity", "no secondary display found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!isPresentationDisplay(chosen)) {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < 29) return;
|
||||||
|
secondaryActivityPending = true;
|
||||||
|
secondaryActivityTarget = chosen.getDisplayId();
|
||||||
|
Intent intent = new Intent(self, SecondaryActivity.class)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_ANIMATION);
|
||||||
|
android.app.ActivityOptions options = android.app.ActivityOptions.makeBasic();
|
||||||
|
options.setLaunchDisplayId(secondaryActivityTarget);
|
||||||
|
self.startActivity(intent, options.toBundle());
|
||||||
|
final int requestedDisplay = secondaryActivityTarget;
|
||||||
|
new Handler(Looper.getMainLooper()).postDelayed(() -> {
|
||||||
|
if (secondaryActivityPending
|
||||||
|
&& secondaryActivityTarget == requestedDisplay) {
|
||||||
|
secondaryActivityPending = false;
|
||||||
|
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
SecondaryPresentation p = new SecondaryPresentation(self, chosen);
|
||||||
|
p.setOnDismissListener(dialog -> {
|
||||||
|
if (secondaryPresentation == p) {
|
||||||
|
secondaryPresentation = null;
|
||||||
|
rebindSecondaryDisplay();
|
||||||
|
}
|
||||||
|
});
|
||||||
p.show();
|
p.show();
|
||||||
secondaryPresentation = p;
|
secondaryPresentation = p;
|
||||||
|
secondaryRetryAfter = 0;
|
||||||
|
synchronized (secondaryFrameLock) {
|
||||||
|
if (secondaryFrame != null) {
|
||||||
|
p.setBackground(secondaryBackground);
|
||||||
|
p.updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||||
|
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||||
|
}
|
||||||
|
}
|
||||||
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
Log.d("GameActivity", "secondary display presentation started on id=" + chosen.getDisplayId());
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
Log.d("GameActivity", "secondary display setup failed: " + t);
|
Log.d("GameActivity", "secondary display setup failed: " + t);
|
||||||
secondaryPresentation = null;
|
secondaryActivityPending = false;
|
||||||
|
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
|
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||||
|
teardownSecondaryDisplay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Display findSecondaryDisplay(GameActivity self, boolean logDisplays) {
|
||||||
|
android.hardware.display.DisplayManager dm =
|
||||||
|
(android.hardware.display.DisplayManager) self.getSystemService(Context.DISPLAY_SERVICE);
|
||||||
|
if (dm == null || android.os.Build.VERSION.SDK_INT < 17) return null;
|
||||||
|
Display gameDisplay = getActivityDisplay(self);
|
||||||
|
int gameDisplayId = gameDisplay == null
|
||||||
|
? Display.DEFAULT_DISPLAY : gameDisplay.getDisplayId();
|
||||||
|
Display handheld = dm.getDisplay(Display.DEFAULT_DISPLAY);
|
||||||
|
boolean handheldAvailable = android.os.Build.VERSION.SDK_INT >= 29
|
||||||
|
&& gameDisplayId != Display.DEFAULT_DISPLAY && isDisplayUsable(handheld);
|
||||||
|
Display external = null;
|
||||||
|
Display[] presentations = dm.getDisplays(
|
||||||
|
android.hardware.display.DisplayManager.DISPLAY_CATEGORY_PRESENTATION);
|
||||||
|
for (Display d : presentations) {
|
||||||
|
if (logDisplays) {
|
||||||
|
android.graphics.Point size = new android.graphics.Point();
|
||||||
|
d.getRealSize(size);
|
||||||
|
Log.d("GameActivity", "display id=" + d.getDisplayId()
|
||||||
|
+ " name=" + d.getName() + " size=" + size.x + "x" + size.y);
|
||||||
|
}
|
||||||
|
if (external == null && d.getDisplayId() != gameDisplayId
|
||||||
|
&& isDisplayUsable(d)) external = d;
|
||||||
|
}
|
||||||
|
if (secondaryTarget == SECONDARY_TARGET_HANDHELD && handheldAvailable) return handheld;
|
||||||
|
if (secondaryTarget == SECONDARY_TARGET_EXTERNAL && external != null) return external;
|
||||||
|
return handheldAvailable ? handheld : external;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Display getActivityDisplay(android.app.Activity activity) {
|
||||||
|
return android.os.Build.VERSION.SDK_INT >= 30
|
||||||
|
? activity.getDisplay() : activity.getWindowManager().getDefaultDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPresentationDisplay(Display display) {
|
||||||
|
if (display == null || display.getDisplayId() == Display.DEFAULT_DISPLAY) return false;
|
||||||
|
return android.os.Build.VERSION.SDK_INT < 20
|
||||||
|
|| (display.getFlags() & Display.FLAG_PRESENTATION) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isDisplayUsable(Display display) {
|
||||||
|
if (display == null) return false;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 20
|
||||||
|
&& display.getState() == Display.STATE_OFF) return false;
|
||||||
|
if (dualScreenDisplayMode == 1 && AYN_SECOND_SCREEN.equals(display.getName())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return dualScreenDisplayMode != 2
|
||||||
|
|| display.getDisplayId() != Display.DEFAULT_DISPLAY;
|
||||||
|
}
|
||||||
|
|
||||||
private static void teardownSecondaryDisplay() {
|
private static void teardownSecondaryDisplay() {
|
||||||
SecondaryPresentation p = secondaryPresentation;
|
SecondaryPresentation p = secondaryPresentation;
|
||||||
secondaryPresentation = null;
|
secondaryPresentation = null;
|
||||||
|
SecondaryActivity a = secondaryActivity;
|
||||||
|
secondaryActivity = null;
|
||||||
|
secondaryActivityPending = false;
|
||||||
|
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
synchronized (secondaryTouches) { secondaryTouches.clear(); }
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
try { p.dismiss(); } catch (Throwable t) {}
|
try { p.dismiss(); } catch (Throwable t) {}
|
||||||
}
|
}
|
||||||
|
if (a != null) {
|
||||||
|
try { a.finish(); } catch (Throwable t) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@android.annotation.TargetApi(17)
|
@android.annotation.TargetApi(17)
|
||||||
@@ -1527,21 +1760,85 @@ public class GameActivity extends SDLActivity {
|
|||||||
return manager.getDisplay(displayId) != null;
|
return manager.getDisplay(displayId) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override public void onDisplayAdded(int displayId) { refreshSecondaryDisplay(); }
|
private void changed() {
|
||||||
@Override public void onDisplayRemoved(int displayId) { refreshSecondaryDisplay(); }
|
secondaryDetectionAt = 0;
|
||||||
@Override public void onDisplayChanged(int displayId) { refreshSecondaryDisplay(); }
|
rebindSecondaryDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void onDisplayAdded(int displayId) { changed(); }
|
||||||
|
@Override public void onDisplayRemoved(int displayId) { changed(); }
|
||||||
|
@Override public void onDisplayChanged(int displayId) { changed(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public static boolean hasSecondaryDisplay() {
|
public static boolean hasSecondaryDisplay() {
|
||||||
return secondaryPresentation != null;
|
return secondaryPresentation != null || secondaryActivity != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static boolean hasSecondaryDisplayCandidate() {
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self == null) return false;
|
||||||
|
if (secondaryPresentation != null || secondaryActivity != null) return true;
|
||||||
|
self.refreshDualScreenDisplayMode();
|
||||||
|
long now = android.os.SystemClock.uptimeMillis();
|
||||||
|
if (secondaryDetectionAt != 0 && now - secondaryDetectionAt < 500) {
|
||||||
|
return secondaryDetected;
|
||||||
|
}
|
||||||
|
secondaryDetected = findSecondaryDisplay(self, false) != null;
|
||||||
|
secondaryDetectionAt = now;
|
||||||
|
return secondaryDetected;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Keep
|
||||||
|
public static boolean presentSecondaryFrame(
|
||||||
|
java.nio.ByteBuffer rgba, int width, int height,
|
||||||
|
int backgroundColor, boolean cover) {
|
||||||
|
long bytes = (long) width * height * 4;
|
||||||
|
if (rgba == null || width <= 0 || height <= 0
|
||||||
|
|| bytes <= 0 || bytes > Integer.MAX_VALUE
|
||||||
|
|| rgba.capacity() < bytes) return false;
|
||||||
|
synchronized (secondaryFrameLock) {
|
||||||
|
if (secondaryFrame == null || secondaryFrame.length != (int) bytes) {
|
||||||
|
secondaryFrame = new byte[(int) bytes];
|
||||||
|
}
|
||||||
|
rgba.rewind();
|
||||||
|
rgba.get(secondaryFrame, 0, (int) bytes);
|
||||||
|
rgba.rewind();
|
||||||
|
secondaryFrameWidth = width;
|
||||||
|
secondaryFrameHeight = height;
|
||||||
|
secondaryBackground = backgroundColor;
|
||||||
|
secondaryFrameCover = cover;
|
||||||
|
SecondaryPresentation p = secondaryPresentation;
|
||||||
|
SecondaryActivity a = secondaryActivity;
|
||||||
|
if (p == null && a == null) return false;
|
||||||
|
try {
|
||||||
|
if (p != null) {
|
||||||
|
p.setBackground(backgroundColor);
|
||||||
|
p.updateFrame(rgba, width, height, cover);
|
||||||
|
} else {
|
||||||
|
a.setBackground(backgroundColor);
|
||||||
|
a.updateFrame(rgba, width, height, cover);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
GameActivity self = (GameActivity) mSingleton;
|
||||||
|
if (self != null) self.runOnUiThread(() -> {
|
||||||
|
teardownSecondaryDisplay();
|
||||||
|
setupSecondaryDisplay();
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Keep
|
@Keep
|
||||||
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
public static void updateSecondaryFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||||
SecondaryPresentation p = secondaryPresentation;
|
SecondaryPresentation p = secondaryPresentation;
|
||||||
if (p != null && buf != null && w > 0 && h > 0) {
|
SecondaryActivity a = secondaryActivity;
|
||||||
p.updateFrame(buf, w, h);
|
if ((p != null || a != null) && buf != null && w > 0 && h > 0) {
|
||||||
|
if (p != null) p.updateFrame(buf, w, h);
|
||||||
|
else a.updateFrame(buf, w, h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1552,6 +1849,102 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void applySecondaryImmersive(android.view.Window w) {
|
||||||
|
if (w == null) return;
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
||||||
|
w.setDecorFitsSystemWindows(false);
|
||||||
|
android.view.WindowInsetsController c = w.getInsetsController();
|
||||||
|
if (c != null) {
|
||||||
|
c.hide(android.view.WindowInsets.Type.systemBars());
|
||||||
|
c.setSystemBarsBehavior(
|
||||||
|
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
w.getDecorView().setSystemUiVisibility(
|
||||||
|
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||||
|
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SecondaryActivity extends android.app.Activity {
|
||||||
|
private FrameView frameView;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
Display display = getActivityDisplay(this);
|
||||||
|
if (!secondaryEnabled || display == null
|
||||||
|
|| display.getDisplayId() != secondaryActivityTarget) {
|
||||||
|
secondaryActivityPending = false;
|
||||||
|
secondaryActivityTarget = Display.INVALID_DISPLAY;
|
||||||
|
secondaryRetryAfter = android.os.SystemClock.elapsedRealtime() + 1000;
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
frameView = new FrameView(this);
|
||||||
|
android.view.Window w = getWindow();
|
||||||
|
w.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||||
|
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
|
||||||
|
WindowManager.LayoutParams.FLAG_FULLSCREEN
|
||||||
|
| WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS);
|
||||||
|
setContentView(frameView);
|
||||||
|
applySecondaryImmersive(w);
|
||||||
|
secondaryActivity = this;
|
||||||
|
secondaryActivityPending = false;
|
||||||
|
secondaryRetryAfter = 0;
|
||||||
|
synchronized (secondaryFrameLock) {
|
||||||
|
if (secondaryFrame != null) {
|
||||||
|
setBackground(secondaryBackground);
|
||||||
|
updateFrame(java.nio.ByteBuffer.wrap(secondaryFrame),
|
||||||
|
secondaryFrameWidth, secondaryFrameHeight, secondaryFrameCover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
if (secondaryActivity == this) secondaryActivity = null;
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onWindowFocusChanged(boolean hasFocus) {
|
||||||
|
super.onWindowFocusChanged(hasFocus);
|
||||||
|
if (hasFocus) applySecondaryImmersive(getWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||||
|
GameActivity activity = (GameActivity) mSingleton;
|
||||||
|
return activity != null
|
||||||
|
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||||
|
GameActivity activity = (GameActivity) mSingleton;
|
||||||
|
return activity != null
|
||||||
|
? activity.dispatchGenericMotionEvent(event)
|
||||||
|
: super.dispatchGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||||
|
frameView.updateFrame(buf, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||||
|
frameView.updateFrame(buf, w, h, cover);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setBackground(int color) {
|
||||||
|
frameView.setFrameBackground(color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static class SecondaryPresentation extends android.app.Presentation {
|
private static class SecondaryPresentation extends android.app.Presentation {
|
||||||
private final FrameView frameView;
|
private final FrameView frameView;
|
||||||
|
|
||||||
@@ -1585,31 +1978,36 @@ public class GameActivity extends SDLActivity {
|
|||||||
if (hasFocus) applyImmersive();
|
if (hasFocus) applyImmersive();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchKeyEvent(android.view.KeyEvent event) {
|
||||||
|
GameActivity activity = (GameActivity) mSingleton;
|
||||||
|
return activity != null
|
||||||
|
? activity.dispatchKeyEvent(event) : super.dispatchKeyEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean dispatchGenericMotionEvent(android.view.MotionEvent event) {
|
||||||
|
GameActivity activity = (GameActivity) mSingleton;
|
||||||
|
return activity != null
|
||||||
|
? activity.dispatchGenericMotionEvent(event)
|
||||||
|
: super.dispatchGenericMotionEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
private void applyImmersive() {
|
private void applyImmersive() {
|
||||||
android.view.Window w = getWindow();
|
applySecondaryImmersive(getWindow());
|
||||||
if (w == null) return;
|
|
||||||
if (android.os.Build.VERSION.SDK_INT >= 30) {
|
|
||||||
w.setDecorFitsSystemWindows(false);
|
|
||||||
android.view.WindowInsetsController c = w.getInsetsController();
|
|
||||||
if (c != null) {
|
|
||||||
c.hide(android.view.WindowInsets.Type.systemBars());
|
|
||||||
c.setSystemBarsBehavior(
|
|
||||||
android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
w.getDecorView().setSystemUiVisibility(
|
|
||||||
android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
|
||||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
||||||
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
|
||||||
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
|
||||||
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN
|
|
||||||
| android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||||
frameView.updateFrame(buf, w, h);
|
frameView.updateFrame(buf, w, h);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||||
|
frameView.updateFrame(buf, w, h, cover);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setBackground(int color) {
|
||||||
|
frameView.setFrameBackground(color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class FrameView extends View {
|
private static class FrameView extends View {
|
||||||
@@ -1618,7 +2016,9 @@ public class GameActivity extends SDLActivity {
|
|||||||
private final android.graphics.Paint paint = new android.graphics.Paint();
|
private final android.graphics.Paint paint = new android.graphics.Paint();
|
||||||
private final Object lock = new Object();
|
private final Object lock = new Object();
|
||||||
private int fw, fh;
|
private int fw, fh;
|
||||||
|
private int backgroundColor = 0xFF000000;
|
||||||
private int activePointer = -1;
|
private int activePointer = -1;
|
||||||
|
private boolean cover;
|
||||||
|
|
||||||
FrameView(Context context) {
|
FrameView(Context context) {
|
||||||
super(context);
|
super(context);
|
||||||
@@ -1628,7 +2028,12 @@ public class GameActivity extends SDLActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h) {
|
||||||
|
updateFrame(buf, w, h, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFrame(java.nio.ByteBuffer buf, int w, int h, boolean cover) {
|
||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
|
this.cover = cover;
|
||||||
if (bitmap == null || fw != w || fh != h) {
|
if (bitmap == null || fw != w || fh != h) {
|
||||||
if (bitmap != null) bitmap.recycle();
|
if (bitmap != null) bitmap.recycle();
|
||||||
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
bitmap = android.graphics.Bitmap.createBitmap(w, h, android.graphics.Bitmap.Config.ARGB_8888);
|
||||||
@@ -1640,6 +2045,13 @@ public class GameActivity extends SDLActivity {
|
|||||||
postInvalidate();
|
postInvalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setFrameBackground(int color) {
|
||||||
|
synchronized (lock) {
|
||||||
|
backgroundColor = 0xFF000000 | (color & 0x00FFFFFF);
|
||||||
|
}
|
||||||
|
postInvalidate();
|
||||||
|
}
|
||||||
|
|
||||||
private void enqueueTouch(String event) {
|
private void enqueueTouch(String event) {
|
||||||
synchronized (secondaryTouches) {
|
synchronized (secondaryTouches) {
|
||||||
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
if (secondaryTouches.size() >= MAX_SECONDARY_TOUCHES) {
|
||||||
@@ -1691,12 +2103,15 @@ public class GameActivity extends SDLActivity {
|
|||||||
synchronized (lock) {
|
synchronized (lock) {
|
||||||
if (bitmap == null || fw == 0 || fh == 0) return;
|
if (bitmap == null || fw == 0 || fh == 0) return;
|
||||||
int vw = getWidth(), vh = getHeight();
|
int vw = getWidth(), vh = getHeight();
|
||||||
int s = Math.min(vw / fw, vh / fh);
|
float fit = Math.min((float) vw / fw, (float) vh / fh);
|
||||||
if (s < 1) s = 1;
|
if (fit <= 0) return;
|
||||||
int dw = fw * s, dh = fh * s;
|
float scale = cover
|
||||||
|
? Math.max((float) vw / fw, (float) vh / fh)
|
||||||
|
: fit >= 2f ? (float) Math.floor(fit) : fit;
|
||||||
|
int dw = Math.round(fw * scale), dh = Math.round(fh * scale);
|
||||||
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
int dx = (vw - dw) / 2, dy = (vh - dh) / 2;
|
||||||
dst.set(dx, dy, dx + dw, dy + dh);
|
dst.set(dx, dy, dx + dw, dy + dh);
|
||||||
canvas.drawColor(0xFF000000);
|
canvas.drawColor(backgroundColor);
|
||||||
canvas.drawBitmap(bitmap, null, dst, paint);
|
canvas.drawBitmap(bitmap, null, dst, paint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,48 @@
|
|||||||
"tintColor": "3b5ca8",
|
"tintColor": "3b5ca8",
|
||||||
"category": "games",
|
"category": "games",
|
||||||
"versions": [
|
"versions": [
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"date": "2026-08-16",
|
||||||
|
"size": 11380117,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.98/gen1recomp++-0.1.98-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1211 S.S. Anne Visual bug when it's sailing away\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1228 No option to nickname starter\n- #1229 Encounter rate grace period not working\n- #1230 Couple of sound effects missing\n- #1231 Using Tackle partially distorts battle sprites\n- #1232 Wild pokemon's sprite disappears early when using a pokeball\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1251 You don't have a COIN CASE\n- #1265 Major: Regression from #984 (probably?)\n- #1267 [GOLD] POKEDEX didn't show pokemon appear area\n- #1269 [Gold] shadow ball should be invert the screen\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1276 Nurse back to not bowing (and turning)\n- #1279 Rival still not looking at player when initiating first fight\n- #1282 PKMN league PC option missing\n- #1293 Dig animation is bugged in-battle\n- #1296 Opponent's moves failing\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1301 Pixels aren't square\n- #1303 Animation speed of walking NPCs too slow\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1329 Name Select Background\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1338 Rival's sister missing dialogue and roaming\n- #1340 Color palett doesn't affect attack animations\n- #1341 Pokedex entries look wrong\n- #1343 No dashes in empty attack slots during fights\n- #1344 Town Map not showing player sprite\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n- #1368 Fishing Rods behaving irregularly\n- #1385 Team Rocket Hideouts missing music\n- #1388 Safeguard targets opponent, not user\n- #1389 Gastly unobtainable\n- #1391 NPC not escorting player to museum\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.1.97",
|
||||||
|
"date": "2026-08-16",
|
||||||
|
"size": 11376813,
|
||||||
|
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.97/gen1recomp++-0.1.97-ios.ipa",
|
||||||
|
"localizedDescription": "Download the correct version for your computer below.\n\n## Issues closed\n\n- #1181 Poison seems to trigger twice during the poisoned pokemon's turn\n- #1212 the move payday does not grant money in gen 2\n- #1214 Title Screen with OG Red is the wrong color\n- #1224 Windowed and borderless toggle in Gold\n- #1230 Couple of sound effects missing\n- #1249 Cant use stat items IE HP UP PP UP PROTIEN\n- #1271 [Gold] substitute image broken/not shown\n- #1272 [Gold] swift still checks accuracy and/or evasion\n- #1273 S.S. Anne Issues\n- #1298 Gen1 sound tracks have a fade in period, if you enter a route and immediately exit it while this transition is going on it will land on the wrong music\n- #1305 Wrong Pikachu cry when getting defeated\n- #1307 Rival theme broken after initial fight in Yellow\n- #1318 Thunder Wave works on Ground-types\n- #1328 Message for turning on the PC missing\n- #1330 Message before looking at map missing\n- #1331 Messages in Oak's lab missing\n- #1333 E-mail in Oak's lab missing\n- #1334 Missing message after picking starter\n- #1335 No Money Box\n- #1340 Color palett doesn't affect attack animations\n- #1345 Wrong health color on OG palett\n- #1346 Health still black when viewing stats\n- #1360 No Surfing Music\n- #1362 Poison damage does not flash the screen\n\n## Contributors\n\n- @bryanthaboi"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.1.96",
|
"version": "0.1.96",
|
||||||
"date": "2026-08-16",
|
"date": "2026-08-16",
|
||||||
|
|||||||
@@ -218,13 +218,20 @@ function BattleAPI:submit(intent)
|
|||||||
return nil, "stale battle context"
|
return nil, "stale battle context"
|
||||||
end
|
end
|
||||||
local kind = battle:battleKind()
|
local kind = battle:battleKind()
|
||||||
if kind == "oldman" or kind == "link" or kind == "safari" then
|
if kind == "oldman" or kind == "link" then
|
||||||
return nil, "battle kind is not controllable"
|
return nil, "battle kind is not controllable"
|
||||||
end
|
end
|
||||||
if top ~= battle then return nil, "battle menu is covered" end
|
if top ~= battle then return nil, "battle menu is covered" end
|
||||||
|
|
||||||
local ok, err
|
local ok, err
|
||||||
if intent.kind == "menu" then
|
if intent.kind == "safari" then
|
||||||
|
if kind ~= "safari" then return nil, "safari menu is not active" end
|
||||||
|
ok, err = battle:chooseSafari(intent.action)
|
||||||
|
elseif kind == "safari" then
|
||||||
|
return nil, "battle kind is not controllable"
|
||||||
|
elseif intent.kind == "mimic" then
|
||||||
|
ok, err = battle:chooseMimic(intent.index)
|
||||||
|
elseif intent.kind == "menu" then
|
||||||
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
|
if battle.phase ~= "menu" then return nil, "battle menu is not active" end
|
||||||
if not MENU_CHOICES[intent.choice] then
|
if not MENU_CHOICES[intent.choice] then
|
||||||
return nil, "unknown battle menu choice"
|
return nil, "unknown battle menu choice"
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -88,6 +89,33 @@ function BattleState:wantsFillScale()
|
|||||||
return options and options.battleFit == "fill" or false
|
return options and options.battleFit == "fill" or false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- EXTENDED HUD configurations are admitted one at a time after their own
|
||||||
|
-- placement and screenshot review. FIXED supports the three authored battle
|
||||||
|
-- backgrounds; FILL uses one adaptive presentation stored as WHITE: stock
|
||||||
|
-- battles retain the paper field required by Gen 1 back sprites, while arena
|
||||||
|
-- providers may replace it with their own scene. Only the HUD moves to window
|
||||||
|
-- space.
|
||||||
|
function BattleState:extendedHUD()
|
||||||
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
|
local bg = options and options.battleBg
|
||||||
|
return self:wideLayout()
|
||||||
|
and options and options.battleHud == "extended"
|
||||||
|
and ((options.battleFit == "fixed"
|
||||||
|
and (bg == "world" or bg == "white" or bg == "black"))
|
||||||
|
or (options.battleFit == "fill" and bg == "white"))
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:extendedWorldHUD()
|
||||||
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
|
return self:extendedHUD() and options
|
||||||
|
and options.battleFit == "fixed" and options.battleBg == "world"
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:extendedBlackHUD()
|
||||||
|
local options = self.game and self.game.save and self.game.save.options
|
||||||
|
return self:extendedHUD() and options and options.battleBg == "black"
|
||||||
|
end
|
||||||
|
|
||||||
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
-- BATTLE BG: what fills the screen AROUND the battle -- the letterbox voids
|
||||||
-- that grow as the window gets bigger or the view is zoomed out. The battle
|
-- that grow as the window gets bigger or the view is zoomed out. The battle
|
||||||
-- screen itself is untouched: it keeps its white paper field in every mode.
|
-- screen itself is untouched: it keeps its white paper field in every mode.
|
||||||
@@ -583,7 +611,8 @@ end
|
|||||||
local function stampOT(save, mon)
|
local function stampOT(save, mon)
|
||||||
save.player.id = save.player.id or math.random(0, 65535)
|
save.player.id = save.player.id or math.random(0, 65535)
|
||||||
mon.ot = mon.ot or save.player.name
|
mon.ot = mon.ot or save.player.name
|
||||||
mon.otId = mon.otId or save.player.id
|
-- engine/battle/experience.asm:69
|
||||||
|
if not mon.traded then mon.otId = mon.otId or save.player.id end
|
||||||
end
|
end
|
||||||
BattleState.stampOT = stampOT
|
BattleState.stampOT = stampOT
|
||||||
|
|
||||||
@@ -955,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)
|
||||||
@@ -1102,6 +1131,15 @@ function BattleState:stepHPDrain()
|
|||||||
if not b.shownPx then b.shownPx = targetPx end
|
if not b.shownPx then b.shownPx = targetPx end
|
||||||
if (b.drainHold or 0) > 0 then
|
if (b.drainHold or 0) > 0 then
|
||||||
b.drainHold = b.drainHold - 1
|
b.drainHold = b.drainHold - 1
|
||||||
|
-- Once the count runs out with nothing left pending (bar and
|
||||||
|
-- number already on the final total), the drain is over, not just
|
||||||
|
-- between steps: leave the field at 0 and BattleSafety.inspect
|
||||||
|
-- reads it as still mid-animation for the rest of the battle,
|
||||||
|
-- since drainHold ~= nil is its settled-presentation gate.
|
||||||
|
if b.drainHold <= 0 and b.shownPx == targetPx and b.shownHP == goal
|
||||||
|
and not b.draining then
|
||||||
|
b.drainHold = nil
|
||||||
|
end
|
||||||
busy = true
|
busy = true
|
||||||
elseif b.shownPx ~= targetPx then
|
elseif b.shownPx ~= targetPx then
|
||||||
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
-- .barAnimationLoop redraws the bar one pixel at a time, `ld c, 2 /
|
||||||
@@ -1448,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
|
||||||
@@ -1806,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"
|
||||||
@@ -2018,6 +2049,38 @@ function BattleState:cancelMove()
|
|||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local SAFARI_ACTION_INDEX = { ball = 1, bait = 2, rock = 3, run = 4 }
|
||||||
|
|
||||||
|
function BattleState:chooseSafari(action)
|
||||||
|
if self.phase ~= "menu" or not self.safari then
|
||||||
|
return nil, "safari menu is not active"
|
||||||
|
end
|
||||||
|
if self.safari.balls <= 0 then return nil, "no safari balls remain" end
|
||||||
|
local index = SAFARI_ACTION_INDEX[action]
|
||||||
|
if not index then return nil, "invalid safari action" end
|
||||||
|
self.menuIndex = index
|
||||||
|
self:safariAction(action)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function BattleState:chooseMimic(index)
|
||||||
|
if self.phase ~= "mimicSelect" then
|
||||||
|
return nil, "mimic menu is not active"
|
||||||
|
end
|
||||||
|
if type(index) ~= "number" or index % 1 ~= 0 then
|
||||||
|
return nil, "invalid mimic slot"
|
||||||
|
end
|
||||||
|
local pick = self.mimicMoves and self.mimicMoves[index]
|
||||||
|
local ctx = self.mimicCtx
|
||||||
|
if not pick or not ctx then return nil, "invalid mimic slot" end
|
||||||
|
self.mimicIndex = index
|
||||||
|
self.mimicMoves, self.mimicCtx = nil, nil
|
||||||
|
self.phase = "messages"
|
||||||
|
self.nextInsert = 0 -- the copy's anim + text go to the queue head
|
||||||
|
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
function BattleState:swapMoves(i, j)
|
function BattleState:swapMoves(i, j)
|
||||||
if i == j then return end
|
if i == j then return end
|
||||||
local moves = self.player.curMoves
|
local moves = self.player.curMoves
|
||||||
@@ -2139,7 +2202,7 @@ function BattleState:update(dt)
|
|||||||
self.menuIndex = row * 2 + col + 1
|
self.menuIndex = row * 2 + col + 1
|
||||||
if input:wasPressed("a") then
|
if input:wasPressed("a") then
|
||||||
require("src.core.Sound").play(self.data, "Press_AB")
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
self:safariAction(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
self:chooseSafari(({ "ball", "bait", "rock", "run" })[self.menuIndex])
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2247,12 +2310,7 @@ function BattleState:update(dt)
|
|||||||
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
self.mimicIndex = self.mimicIndex < #moves and self.mimicIndex + 1 or 1
|
||||||
elseif input:wasPressed("a") then
|
elseif input:wasPressed("a") then
|
||||||
require("src.core.Sound").play(self.data, "Press_AB")
|
require("src.core.Sound").play(self.data, "Press_AB")
|
||||||
local pick = moves[self.mimicIndex]
|
self:chooseMimic(self.mimicIndex)
|
||||||
local ctx = self.mimicCtx
|
|
||||||
self.mimicMoves, self.mimicCtx = nil, nil
|
|
||||||
self.phase = "messages"
|
|
||||||
self.nextInsert = 0 -- the copy's anim + text go to the queue head
|
|
||||||
self:applyMimic(ctx.user, ctx.target, ctx.moveInst, pick.slot)
|
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -2616,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())
|
||||||
@@ -3167,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
|
||||||
@@ -3225,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
|
||||||
@@ -3253,10 +3350,18 @@ 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
|
||||||
if p.x < p.to then
|
local move = true
|
||||||
p.x = math.min(p.to, p.x + p.step)
|
if p.hold then
|
||||||
elseif p.x > p.to then
|
p.held = (p.held or 0) + 1
|
||||||
p.x = math.max(p.to, p.x - p.step)
|
move = p.held >= p.hold
|
||||||
|
if move then p.held = 0 end
|
||||||
|
end
|
||||||
|
if move then
|
||||||
|
if p.x < p.to then
|
||||||
|
p.x = math.min(p.to, p.x + p.step)
|
||||||
|
elseif p.x > p.to then
|
||||||
|
p.x = math.max(p.to, p.x - p.step)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -4006,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)
|
||||||
@@ -4167,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
|
||||||
@@ -4231,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
|
||||||
@@ -4427,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)
|
||||||
@@ -5685,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
|
||||||
@@ -5923,10 +6020,16 @@ function BattleState:drawTextArea()
|
|||||||
Font.drawCode(Font.BORDER.h, 32, 96)
|
Font.drawCode(Font.BORDER.h, 32, 96)
|
||||||
Font.drawCode(Font.BORDER.br, 80, 96)
|
Font.drawCode(Font.BORDER.br, 80, 96)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
for i, mv in ipairs(self.player.curMoves) do
|
-- engine/battle/misc.asm:37
|
||||||
-- unknown ids (mod-injected moves) print raw instead of crashing
|
for i = 1, 4 do
|
||||||
local def = self.data.moves[mv.id]
|
local mv = self.player.curMoves[i]
|
||||||
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
if mv then
|
||||||
|
-- unknown ids (mod-injected moves) print raw instead of crashing
|
||||||
|
local def = self.data.moves[mv.id]
|
||||||
|
Font.draw(def and def.name or tostring(mv.id), 48, 96 + i * 8)
|
||||||
|
else
|
||||||
|
Font.draw("-", 48, 96 + i * 8)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
|
-- Swap cursor: SelectMenuItem parks the hollow arrow on the marked row
|
||||||
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
|
-- (core.asm:2600-2607), then HandleMenuInput's PlaceMenuCursor writes the
|
||||||
@@ -5953,13 +6056,16 @@ function BattleState:drawTextArea()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
elseif self.phase == "mimicSelect" then
|
elseif self.phase == "mimicSelect" then
|
||||||
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:
|
-- Mimic's copy menu (MoveSelectionMenu .mimicmenu, core.asm:2506-2517):
|
||||||
-- 2506-2517): the enemy's move list in a 16x6 box at (0,7), names
|
-- 16x6 box at (0,7), names from (2,8), cursor at column 1
|
||||||
-- single-spaced from (2,8), cursor at column 1
|
|
||||||
Font.drawBox(0, 7, 16, 6)
|
Font.drawBox(0, 7, 16, 6)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
for i, m in ipairs(self.mimicMoves) do
|
-- engine/battle/misc.asm:37
|
||||||
Font.draw(self.data.moves[m.id].name, 16, (7 + i) * 8)
|
for i = 1, 4 do
|
||||||
|
local m = self.mimicMoves[i]
|
||||||
|
local def = m and self.data.moves[m.id]
|
||||||
|
Font.draw(m and (def and def.name or tostring(m.id)) or "-",
|
||||||
|
16, (7 + i) * 8)
|
||||||
end
|
end
|
||||||
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8)
|
||||||
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112)
|
||||||
|
|||||||
@@ -232,15 +232,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
|||||||
hitSfx = { sound = "Damage", pitch = 0x20 }
|
hitSfx = { sound = "Damage", pitch = 0x20 }
|
||||||
end
|
end
|
||||||
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
-- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm
|
||||||
-- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake
|
-- :3159 / :5555): 4 blinks the enemy pic, 1 shakes vertically, 5 / 2 once
|
||||||
-- the screen vertically) for a damaging move with no added effect, and
|
-- the move has an added effect (#354)
|
||||||
-- 5 / 2 (a horizontal shake) as soon as the move HAS one -- which is why
|
|
||||||
-- Bubblebeam and Confusion shake instead of blinking (#354)
|
|
||||||
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
local added = move.effect ~= nil and move.effect ~= "NO_ADDITIONAL_EFFECT"
|
||||||
|
-- PlayApplyingAttackAnimation runs on both arms of the wOptions check
|
||||||
|
-- (engine/battle/animations.asm:424-437), so the blink is not gated (#1384)
|
||||||
local hitFx = { sfx = hitSfx,
|
local hitFx = { sfx = hitSfx,
|
||||||
animType = user.isPlayer and (added and 5 or 4)
|
animType = user.isPlayer and (added and 5 or 4)
|
||||||
or (added and 2 or 1),
|
or (added and 2 or 1),
|
||||||
blink = battle:animationsOn() and target or nil }
|
blink = target }
|
||||||
|
|
||||||
local totalDealt = 0
|
local totalDealt = 0
|
||||||
local landed, brokeSub = 0, false
|
local landed, brokeSub = 0, false
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ end
|
|||||||
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
local HEAL_AMOUNT = { POTION = 20, SUPER_POTION = 50, HYPER_POTION = 200 }
|
||||||
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
local X_STAT = { X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed" }
|
||||||
|
|
||||||
|
-- Strings.source, not Strings: harvested at require time so the catalog
|
||||||
|
-- generator can see the literal, same pattern as MoveEffects.lua's
|
||||||
|
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
|
||||||
|
-- the harvester can't discover.
|
||||||
|
local STAT_LABEL = {
|
||||||
|
attack = Strings.source("ATTACK"), defense = Strings.source("DEFENSE"),
|
||||||
|
speed = Strings.source("SPEED"),
|
||||||
|
}
|
||||||
|
|
||||||
-- The trainer's ai_classes record from the merged registry; the direct
|
-- The trainer's ai_classes record from the merged registry; the direct
|
||||||
-- require covers battles built without a loader. A trainer record's
|
-- require covers battles built without a loader. A trainer record's
|
||||||
-- aiClass field picks a record other than its own id.
|
-- aiClass field picks a record other than its own id.
|
||||||
@@ -124,7 +133,7 @@ function TrainerAI.useItem(battle, item)
|
|||||||
elseif X_STAT[item] then
|
elseif X_STAT[item] then
|
||||||
local stat = X_STAT[item]
|
local stat = X_STAT[item]
|
||||||
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
enemy.stages[stat] = math.min(6, (enemy.stages[stat] or 0) + 1)
|
||||||
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), stat:upper()))
|
table.insert(msgs, Strings("%s's\n%s rose!", displayName(enemy), Strings(STAT_LABEL[stat])))
|
||||||
elseif item == "GUARD_SPEC" then
|
elseif item == "GUARD_SPEC" then
|
||||||
enemy.mist = true
|
enemy.mist = true
|
||||||
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
|
table.insert(msgs, Strings("%s's\nprotected against\nstat changes!", displayName(enemy)))
|
||||||
|
|||||||
@@ -92,6 +92,26 @@ local function levelAt(battle, battler, x, y)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function battleIsTopState(battle)
|
||||||
|
local stack = battle.game and battle.game.stack
|
||||||
|
return not (stack and stack.top) or stack:top() == battle
|
||||||
|
end
|
||||||
|
|
||||||
|
local function anchorHUD(battle, x, y, w, h, anchor)
|
||||||
|
if not battle:extendedHUD() or not battleIsTopState(battle) then return end
|
||||||
|
local renderer = battle.game and battle.game.renderer
|
||||||
|
if not (renderer and renderer.setBattleUIAnchor) then return end
|
||||||
|
x = x + (battle.extendedHUDOffsetX or 0)
|
||||||
|
y = y + (battle.extendedHUDOffsetY or 0)
|
||||||
|
local x2 = math.min(WideBattle.WIDTH, x + w)
|
||||||
|
local y2 = math.min(WideBattle.HEIGHT, y + h)
|
||||||
|
x, y = math.max(0, x), math.max(0, y)
|
||||||
|
w, h = x2 - x, y2 - y
|
||||||
|
if w > 0 and h > 0 then
|
||||||
|
renderer:setBattleUIAnchor(x, y, w, h, anchor)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
-- One side's status box: name and level on the first line, a long HP bar
|
-- One side's status box: name and level on the first line, a long HP bar
|
||||||
-- under it, and the numeric HP on the player's box only (the foe's exact
|
-- under it, and the numeric HP on the player's box only (the foe's exact
|
||||||
-- HP is never shown, like the original).
|
-- HP is never shown, like the original).
|
||||||
@@ -114,6 +134,7 @@ local function drawStatusPanel(battle, battler, x, y, player)
|
|||||||
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
Font.draw(("%3d/%3d"):format(shownHP(battler), battler.mon.stats.hp),
|
||||||
x + tw * 8 - 64, y + 24)
|
x + tw * 8 - 64, y + 24)
|
||||||
end
|
end
|
||||||
|
anchorHUD(battle, x, y, tw * 8, th * 8, player and "bottom" or "top")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
||||||
@@ -144,7 +165,6 @@ local function drawHUDs(battle, slide)
|
|||||||
and not battle.showPlayerBack and slide == 0 then
|
and not battle.showPlayerBack and slide == 0 then
|
||||||
drawStatusPanel(battle, battle.player, 184, 56, true)
|
drawStatusPanel(battle, battle.player, 184, 56, true)
|
||||||
end
|
end
|
||||||
drawIntroBalls(battle)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local function drawMessageBox(battle)
|
local function drawMessageBox(battle)
|
||||||
@@ -268,6 +288,8 @@ local function drawTextArea(battle)
|
|||||||
else
|
else
|
||||||
Font.drawBox(0, 13, 38, 5)
|
Font.drawBox(0, 13, 38, 5)
|
||||||
end
|
end
|
||||||
|
anchorHUD(battle, 0, WideBattle.FIELD_BOTTOM,
|
||||||
|
WideBattle.WIDTH, WideBattle.HEIGHT - WideBattle.FIELD_BOTTOM, "bottom")
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Battle animations are authored in the original 160px coordinate space.
|
-- Battle animations are authored in the original 160px coordinate space.
|
||||||
@@ -309,17 +331,23 @@ end
|
|||||||
-- The whole 304x144 composition for one frame.
|
-- The whole 304x144 composition for one frame.
|
||||||
function WideBattle.draw(battle)
|
function WideBattle.draw(battle)
|
||||||
local g = love.graphics
|
local g = love.graphics
|
||||||
|
local renderer = battle.game and battle.game.renderer
|
||||||
|
local extendedHUD = battle:extendedHUD() and renderer
|
||||||
|
and renderer.beginBattleHUDPass
|
||||||
|
and renderer.endBattleHUDPass
|
||||||
-- The field is the display mode's paper. Under a forced-mono mode the
|
-- The field is the display mode's paper. Under a forced-mono mode the
|
||||||
-- whole surface is remapped downstream (WideBattle.zones), so the field
|
-- whole surface is remapped downstream (WideBattle.zones), so the field
|
||||||
-- goes down as DMG white and comes out of that pass as the mode's paper;
|
-- goes down as DMG white and comes out of that pass as the mode's paper;
|
||||||
-- painting the resolved shade there would run it through the remap twice
|
-- painting the resolved shade there would run it through the remap twice
|
||||||
-- and land a shade off the letterbox the renderer fills around it.
|
-- and land a shade off the letterbox the renderer fills around it.
|
||||||
if monoMode() then
|
if not (extendedHUD and battle:extendedWorldHUD()) then
|
||||||
g.setColor(1, 1, 1, 1)
|
if monoMode() then
|
||||||
else
|
g.setColor(1, 1, 1, 1)
|
||||||
g.setColor(PaletteFX.paperShade(battle.data))
|
else
|
||||||
|
g.setColor(PaletteFX.paperShade(battle.data))
|
||||||
|
end
|
||||||
|
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||||
end
|
end
|
||||||
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
|
||||||
-- AskName clears the field the same way the classic layout does
|
-- AskName clears the field the same way the classic layout does
|
||||||
if battle.blankForAskName then return end
|
if battle.blankForAskName then return end
|
||||||
|
|
||||||
@@ -346,6 +374,7 @@ function WideBattle.draw(battle)
|
|||||||
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
inRegion(160 + sx, sy, 144, WideBattle.FIELD_BOTTOM, 136 + sx, sy,
|
||||||
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
function() battle:drawPicsLayer(slide, 0, 0, "enemy", true) end)
|
||||||
battle.wideRegion = nil
|
battle.wideRegion = nil
|
||||||
|
drawIntroBalls(battle)
|
||||||
|
|
||||||
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
|
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the
|
||||||
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
|
-- shakes move IS the whole screen: PredefShakeScreenHorizontally,
|
||||||
@@ -359,12 +388,26 @@ function WideBattle.draw(battle)
|
|||||||
if sx == 0 and sy == 0 then return fn() end
|
if sx == 0 and sy == 0 then return fn() end
|
||||||
g.push()
|
g.push()
|
||||||
g.translate(sx, sy)
|
g.translate(sx, sy)
|
||||||
|
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = sx, sy
|
||||||
fn()
|
fn()
|
||||||
|
battle.extendedHUDOffsetX, battle.extendedHUDOffsetY = nil, nil
|
||||||
g.pop()
|
g.pop()
|
||||||
end
|
end
|
||||||
shaken(function() drawHUDs(battle, slide) end)
|
|
||||||
drawAnimationLayer(battle)
|
drawAnimationLayer(battle)
|
||||||
shaken(function() drawTextArea(battle) end)
|
|
||||||
|
if extendedHUD then
|
||||||
|
local previous = renderer:beginBattleHUDPass()
|
||||||
|
shaken(function() drawHUDs(battle, slide) end)
|
||||||
|
shaken(function() drawTextArea(battle) end)
|
||||||
|
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||||
|
g.setColor(1, 1, 1, 0.85)
|
||||||
|
g.rectangle("fill", 0, 0, WideBattle.WIDTH, WideBattle.HEIGHT)
|
||||||
|
end
|
||||||
|
renderer:endBattleHUDPass(previous)
|
||||||
|
else
|
||||||
|
shaken(function() drawHUDs(battle, slide) end)
|
||||||
|
shaken(function() drawTextArea(battle) end)
|
||||||
|
end
|
||||||
|
|
||||||
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
if fx and fx.flash and fx.flash > 0 and battle.frame % 4 < 2 then
|
||||||
g.setColor(1, 1, 1, 0.85)
|
g.setColor(1, 1, 1, 0.85)
|
||||||
|
|||||||
@@ -192,14 +192,8 @@ function Runner:loadGfx(names)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleAnimCmd_BattlerGFX_1Row / _2Row. The battlers' pic tiles are
|
-- engine/battle_anims/anim_commands.asm:755. The jumptable crosses the macro
|
||||||
-- APPENDED after whatever the script already loaded rather than replacing it,
|
-- names: $d9 (anim_battlergfx_2row) dispatches to _1Row (#1401)
|
||||||
-- and they always land on the same two fixed tile ids.
|
|
||||||
--
|
|
||||||
-- (pokegold's jumptable has these two labels the other way round from the
|
|
||||||
-- macro names -- $d9 dispatches to BattleAnimCmd_BattlerGFX_1Row while
|
|
||||||
-- anim_battlergfx_2row is $d9 -- so the names below follow the MACRO, which
|
|
||||||
-- is what a script actually writes.)
|
|
||||||
function Runner:loadBattlerGfx(rows)
|
function Runner:loadBattlerGfx(rows)
|
||||||
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
|
local tiles = rows == 2 and BATTLER_TILES.twoRow or BATTLER_TILES.oneRow
|
||||||
local slot = 1
|
local slot = 1
|
||||||
@@ -210,11 +204,11 @@ function Runner:loadBattlerGfx(rows)
|
|||||||
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
|
self.tileDict[slot] = { gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player }
|
||||||
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
|
self.tileDict[slot + 1] = { gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy }
|
||||||
self.loaded[#self.loaded + 1] =
|
self.loaded[#self.loaded + 1] =
|
||||||
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 6,
|
{ gfx = "BATTLE_ANIM_GFX_PLAYERHEAD", tile = tiles.player, tiles = rows * 7,
|
||||||
battler = "player", rows = rows }
|
|
||||||
self.loaded[#self.loaded + 1] =
|
|
||||||
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 7,
|
|
||||||
battler = "enemy", rows = rows }
|
battler = "enemy", rows = rows }
|
||||||
|
self.loaded[#self.loaded + 1] =
|
||||||
|
{ gfx = "BATTLE_ANIM_GFX_ENEMYFEET", tile = tiles.enemy, tiles = rows * 6,
|
||||||
|
battler = "player", rows = rows }
|
||||||
end
|
end
|
||||||
|
|
||||||
--------------------------------------------------------------------------
|
--------------------------------------------------------------------------
|
||||||
@@ -249,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)
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ Battle.SECONDARY_EFFECTS = {
|
|||||||
EFFECT_PARALYZE_HIT = "paralyze",
|
EFFECT_PARALYZE_HIT = "paralyze",
|
||||||
EFFECT_SLEEP_HIT = "sleep",
|
EFFECT_SLEEP_HIT = "sleep",
|
||||||
EFFECT_CONFUSE_HIT = "confuse",
|
EFFECT_CONFUSE_HIT = "confuse",
|
||||||
|
EFFECT_SACRED_FIRE = "burn", -- data/moves/effects.asm:1696
|
||||||
}
|
}
|
||||||
|
|
||||||
local function rand(random, n)
|
local function rand(random, n)
|
||||||
@@ -1320,6 +1321,18 @@ function Battle:markMissed()
|
|||||||
if self.moveEvent then self.moveEvent.missed = true end
|
if self.moveEvent then self.moveEvent.missed = true end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:3615
|
||||||
|
Battle.AI_FAIL_STATUSES = {
|
||||||
|
sleep = true, poison = true, toxic = true, paralyze = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:3615
|
||||||
|
function Battle:aiRandomFail(attacker, defender)
|
||||||
|
if self:sideOf(attacker) ~= "enemy" then return false end
|
||||||
|
if self:volatile(defender).lockOn then return false end
|
||||||
|
return rand(self.random, 256) < 64
|
||||||
|
end
|
||||||
|
|
||||||
-- One attack, start to finish.
|
-- One attack, start to finish.
|
||||||
function Battle:useMove(attacker, defender, moveId)
|
function Battle:useMove(attacker, defender, moveId)
|
||||||
local move = self:findMove(attacker, moveId)
|
local move = self:findMove(attacker, moveId)
|
||||||
@@ -1478,10 +1491,10 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
if charge and not charging then
|
if charge and not charging then
|
||||||
state.chargeMove = moveId
|
state.chargeMove = moveId
|
||||||
state.vanished = charge.vanish or nil
|
state.vanished = charge.vanish or nil
|
||||||
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table
|
-- engine/battle/effect_commands.asm:5458
|
||||||
-- keyed by effect cannot tell them apart and DIG announced itself with
|
if self.moveEvent then self.moveEvent.animParam = 1 end
|
||||||
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the
|
-- BattleCommand_Charge picks the line off the MOVE, not the shared
|
||||||
-- effect: `cp DIG` and then the burrow text.
|
-- EFFECT_FLY (`cp DIG`, effect_commands.asm:5464).
|
||||||
local text = charge.text
|
local text = charge.text
|
||||||
if moveId == "DIG" then text = "%s dug a hole!" end
|
if moveId == "DIG" then text = "%s dug a hole!" end
|
||||||
self:emit({ kind = "message", text = text:format(name) })
|
self:emit({ kind = "message", text = text:format(name) })
|
||||||
@@ -1811,20 +1824,20 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
-- Defense Curl arms Rollout as well as raising Defense.
|
-- Defense Curl arms Rollout as well as raising Defense.
|
||||||
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
|
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
|
||||||
|
|
||||||
-- Stat changes: the primary ones always land, the *_HIT ones roll the
|
-- A refused primary change writes wAttackMissed (effect_commands.asm:4191,
|
||||||
-- move's effect chance after a hit that connected.
|
-- :4380-4400); the *_HIT twins animate first and must stay unmarked.
|
||||||
--
|
|
||||||
-- A refused primary change is a failure the cart detects BEFORE its anim
|
|
||||||
-- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` /
|
|
||||||
-- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400),
|
|
||||||
-- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER
|
|
||||||
-- `attackup` / `attackdown` in the effect list (data/moves/effects.asm,
|
|
||||||
-- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs
|
|
||||||
-- after `moveanim` (AttackDownHit), so the animation has already played.
|
|
||||||
local change = Effects.STAT_CHANGES[def.effect]
|
local change = Effects.STAT_CHANGES[def.effect]
|
||||||
if change then
|
if change then
|
||||||
local target = change[3] == "self" and attacker or defender
|
local target = change[3] == "self" and attacker or defender
|
||||||
if not self:changeStageAgainstMist(attacker, target, change[1], change[2])
|
-- CheckMist first (effect_commands.asm:4290), then .ComputerMiss (:4318)
|
||||||
|
local misted = target ~= attacker and (change[2] or 0) < 0
|
||||||
|
and self:volatile(target).mist
|
||||||
|
if not misted and change[3] == "foe"
|
||||||
|
and def.effect ~= "EFFECT_ACCURACY_DOWN_HIT"
|
||||||
|
and self:aiRandomFail(attacker, target) then
|
||||||
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
elseif not self:changeStageAgainstMist(attacker, target, change[1], change[2])
|
||||||
then
|
then
|
||||||
self:markMissed()
|
self:markMissed()
|
||||||
end
|
end
|
||||||
@@ -1853,19 +1866,26 @@ function Battle:useMove(attacker, defender, moveId)
|
|||||||
local record = Battle.moveEffectRecordFor(self.data, def.effect)
|
local record = Battle.moveEffectRecordFor(self.data, def.effect)
|
||||||
local status = record and record.kind == "primary" and record.status or nil
|
local status = record and record.kind == "primary" and record.status or nil
|
||||||
if status and (def.power or 0) == 0 then
|
if status and (def.power or 0) == 0 then
|
||||||
-- Every status command's already-statused / immune arm ends on
|
-- A refused primary status is a failed move (effect_commands.asm:3748,
|
||||||
-- AnimateFailedMove (BattleCommand_Poison's `.failed`,
|
-- :6656); a refused secondary already animated and stays unmarked (:3752).
|
||||||
-- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay,
|
if self:statusRefusedByType(defender, def.type, status) then
|
||||||
-- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the
|
self:markMissed()
|
||||||
-- success path (:3752), and the effect scripts carry no moveanim of their
|
self:emit({ kind = "message",
|
||||||
-- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS
|
text = "It doesn't affect " .. self:monName(defender) .. "..." })
|
||||||
-- branch below is the opposite case: that move already hit and already
|
elseif Battle.AI_FAIL_STATUSES[status]
|
||||||
-- animated, so a refused secondary must leave the event unmarked.
|
and self:aiRandomFail(attacker, defender) then
|
||||||
if not self:applyStatus(defender, status, attacker) then self:markMissed() end
|
self:markMissed()
|
||||||
|
self:emit({ kind = "message", text = "But it failed!" })
|
||||||
|
elseif not self:applyStatus(defender, status, attacker) then
|
||||||
|
self:markMissed()
|
||||||
|
end
|
||||||
else
|
else
|
||||||
local secondary = record and record.kind == "secondary"
|
local secondary = record and record.kind == "secondary"
|
||||||
and record.status or nil
|
and record.status or nil
|
||||||
if secondary and (defender.hp or 0) > 0 then
|
-- engine/battle/effect_commands.asm:6325
|
||||||
|
if secondary and (defender.hp or 0) > 0
|
||||||
|
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)
|
||||||
@@ -2414,7 +2434,7 @@ Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker)
|
|||||||
if (side.lightScreen or 0) > 0 then return fail(self) end
|
if (side.lightScreen or 0) > 0 then return fail(self) end
|
||||||
side.lightScreen = Battle.SCREEN_TURNS
|
side.lightScreen = Battle.SCREEN_TURNS
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. "'s SPCL.DEF rose!" })
|
text = Strings("%s's SPCL.DEF rose!", self:monName(attacker)) })
|
||||||
end
|
end
|
||||||
|
|
||||||
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
||||||
@@ -2422,7 +2442,16 @@ Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
|
|||||||
if (side.reflect or 0) > 0 then return fail(self) end
|
if (side.reflect or 0) > 0 then return fail(self) end
|
||||||
side.reflect = Battle.SCREEN_TURNS
|
side.reflect = Battle.SCREEN_TURNS
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
text = self:monName(attacker) .. "'s DEFENSE rose!" })
|
text = Strings("%s's DEFENSE rose!", self:monName(attacker)) })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/battle/move_effects/safeguard.asm:1
|
||||||
|
Battle.MOVE_EFFECTS.EFFECT_SAFEGUARD = function(self, attacker)
|
||||||
|
local side = self.screens[self:sideOf(attacker)]
|
||||||
|
if (side.safeguard or 0) > 0 then return fail(self) end
|
||||||
|
side.safeguard = Battle.SCREEN_TURNS
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = self:monName(attacker) .. "'s covered by a veil!" })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
|
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
|
||||||
@@ -2910,14 +2939,47 @@ function Battle.statusPenaltyFor(data, mon, stat, value)
|
|||||||
return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
|
return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- engine/battle/effect_commands.asm:6325
|
||||||
|
function Battle:safeguarded(mon)
|
||||||
|
return (self.screens[self:sideOf(mon)].safeguard or 0) > 0
|
||||||
|
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
|
||||||
-- in the volatile beside the major status, so a confused mon can still be
|
-- in the volatile beside the major status, so a confused mon can still be
|
||||||
-- burned and a switch shakes the confusion off.
|
-- burned and a switch shakes the confusion off.
|
||||||
if status == "confuse" then return self:applyConfusion(mon) end
|
if status == "confuse" then return self:applyConfusion(mon, nil, source) end
|
||||||
|
-- engine/battle/effect_commands.asm:6338
|
||||||
|
if source and self:sideOf(source) ~= self:sideOf(mon)
|
||||||
|
and self:safeguarded(mon) then
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
|
||||||
|
return false
|
||||||
|
end
|
||||||
-- One major status at a time.
|
-- One major status at a time.
|
||||||
if mon.status then
|
if mon.status then
|
||||||
self:emit({ kind = "message",
|
self:emit({ kind = "message",
|
||||||
@@ -2953,8 +3015,15 @@ end
|
|||||||
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
|
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
|
||||||
Battle.BERSERK_GENE_CONFUSE_TURNS = 256
|
Battle.BERSERK_GENE_CONFUSE_TURNS = 256
|
||||||
|
|
||||||
function Battle:applyConfusion(mon, turns)
|
function Battle:applyConfusion(mon, turns, source)
|
||||||
if (mon.hp or 0) <= 0 then return false end
|
if (mon.hp or 0) <= 0 then return false end
|
||||||
|
-- engine/battle/effect_commands.asm:6338
|
||||||
|
if source and self:sideOf(source) ~= self:sideOf(mon)
|
||||||
|
and self:safeguarded(mon) then
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = self:monName(mon) .. " is protected by SAFEGUARD!" })
|
||||||
|
return false
|
||||||
|
end
|
||||||
local state = self:volatile(mon)
|
local state = self:volatile(mon)
|
||||||
if (state.substitute or 0) > 0 then return false end
|
if (state.substitute or 0) > 0 then return false end
|
||||||
local held = self:heldEffect(mon, "confuse")
|
local held = self:heldEffect(mon, "confuse")
|
||||||
@@ -3159,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
|
||||||
@@ -3320,25 +3390,33 @@ 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]
|
||||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||||
end
|
end
|
||||||
local function applyShare(mon, split)
|
local function applyShare(mon, split)
|
||||||
for index, candidate in ipairs(self.party) do
|
for index, candidate in ipairs(self.party) do
|
||||||
if candidate == mon then
|
if candidate == mon then
|
||||||
return self:giveExperiencePass(loser, def, { index },
|
return self:giveExperiencePass(loser, def, { index },
|
||||||
math.max(1, split or 1), halved)
|
math.max(1, split or 1), halved)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
Runtime.call("battle.exp_award", vanillaAward, {
|
||||||
|
battle = self, participants = #participants, alive = alive,
|
||||||
|
applyShare = applyShare, recipients = participants, holders = holders,
|
||||||
|
halved = halved, loser = loser,
|
||||||
|
})
|
||||||
|
else
|
||||||
|
vanillaAward()
|
||||||
end
|
end
|
||||||
Runtime.call("battle.exp_award", vanillaAward, {
|
|
||||||
battle = self, participants = #participants, alive = alive,
|
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||||
applyShare = applyShare, recipients = participants, holders = holders,
|
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||||
halved = halved, loser = loser,
|
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
|
||||||
@@ -4434,14 +4512,20 @@ Battle.SCREEN_FALL_TEXT = {
|
|||||||
function Battle:tickScreens()
|
function Battle:tickScreens()
|
||||||
for _, side in ipairs({ "player", "enemy" }) do
|
for _, side in ipairs({ "player", "enemy" }) do
|
||||||
local screens = self.screens[side]
|
local screens = self.screens[side]
|
||||||
for _, field in ipairs({ "lightScreen", "reflect" }) do
|
for _, field in ipairs({ "lightScreen", "reflect", "safeguard" }) do
|
||||||
if (screens[field] or 0) > 0 then
|
if (screens[field] or 0) > 0 then
|
||||||
screens[field] = screens[field] - 1
|
screens[field] = screens[field] - 1
|
||||||
if screens[field] <= 0 then
|
if screens[field] <= 0 then
|
||||||
screens[field] = nil
|
screens[field] = nil
|
||||||
self:emit({ kind = "message",
|
if field == "safeguard" then
|
||||||
text = Battle.SCREEN_SIDE_LABEL[side]
|
-- engine/battle/core.asm:1527
|
||||||
.. Battle.SCREEN_FALL_TEXT[field] })
|
self:emit({ kind = "message",
|
||||||
|
text = self:monName(self[side]) .. "'s SAFEGUARD faded!" })
|
||||||
|
else
|
||||||
|
self:emit({ kind = "message",
|
||||||
|
text = Battle.SCREEN_SIDE_LABEL[side]
|
||||||
|
.. Battle.SCREEN_FALL_TEXT[field] })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -72,11 +72,11 @@ function Pool:reset()
|
|||||||
for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end
|
for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end
|
||||||
-- wBGP / wOBP0 / wOBP1, as DMG palette bytes.
|
-- wBGP / wOBP0 / wOBP1, as DMG palette bytes.
|
||||||
self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL
|
self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL
|
||||||
-- Per-battler state the CGB paths write instead of touching wBGP: a DMG
|
-- Per-battler state the CGB paths write instead of touching wBGP: shade
|
||||||
-- shade byte the view remaps that battler's pic through, whether the pic is
|
-- byte, hidden flag, lifted tile rows, and which BG square it is drawn at.
|
||||||
-- hidden outright, and which of the six BG squares it is drawn at.
|
|
||||||
self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL }
|
self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL }
|
||||||
self.hidden = { player = false, enemy = false }
|
self.hidden = { player = false, enemy = false }
|
||||||
|
self.liftedRows = { player = nil, enemy = nil }
|
||||||
self.picSize = { player = nil, enemy = nil }
|
self.picSize = { player = nil, enemy = nil }
|
||||||
self.slide = { player = 0, enemy = 0 }
|
self.slide = { player = 0, enemy = 0 }
|
||||||
-- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the
|
-- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the
|
||||||
@@ -488,6 +488,7 @@ local function runPicResize(self, st, script)
|
|||||||
self.picSize[side] = step
|
self.picSize[side] = step
|
||||||
self.hidden[side] = false
|
self.hidden[side] = false
|
||||||
end
|
end
|
||||||
|
self.liftedRows[side] = nil
|
||||||
incJt(st)
|
incJt(st)
|
||||||
elseif jt >= 1 and jt <= 2 then
|
elseif jt >= 1 and jt <= 2 then
|
||||||
incJt(st)
|
incJt(st)
|
||||||
@@ -545,7 +546,7 @@ end
|
|||||||
|
|
||||||
-- The two battler-pic objects: the animation borrows the mon's own tiles as
|
-- The two battler-pic objects: the animation borrows the mon's own tiles as
|
||||||
-- an OBJ so it can be moved without touching the tilemap.
|
-- an OBJ so it can be moved without touching the tilemap.
|
||||||
local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
|
local function battlerObj(self, st, objectPlayer, objectEnemy, rows)
|
||||||
local jt = st.jt
|
local jt = st.jt
|
||||||
if jt == 0 then
|
if jt == 0 then
|
||||||
if self:flyDig(st) then
|
if self:flyDig(st) then
|
||||||
@@ -562,25 +563,26 @@ local function battlerObj(self, st, objectPlayer, objectEnemy, clearRows)
|
|||||||
}
|
}
|
||||||
elseif jt == 1 then
|
elseif jt == 1 then
|
||||||
incJt(st)
|
incJt(st)
|
||||||
-- The rows the OBJ now covers are cleared out of the tilemap so the mon
|
-- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers
|
||||||
-- is not drawn twice.
|
-- come out of the tilemap, and .five never puts them back.
|
||||||
self.hidden[self:sideKey(st)] = clearRows
|
self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)]
|
||||||
elseif jt >= 2 and jt <= 4 then
|
elseif jt >= 2 and jt <= 4 then
|
||||||
incJt(st)
|
incJt(st)
|
||||||
elseif jt == 5 then
|
elseif jt == 5 then
|
||||||
self.hidden[self:sideKey(st)] = false
|
|
||||||
endEffect(st)
|
endEffect(st)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st)
|
E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st)
|
||||||
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW",
|
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW",
|
||||||
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW", true)
|
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
|
||||||
|
{ player = { 0, 1 }, enemy = { 6, 1 } })
|
||||||
end
|
end
|
||||||
|
|
||||||
E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st)
|
E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st)
|
||||||
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
|
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
|
||||||
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW", true)
|
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW",
|
||||||
|
{ player = { 0, 2 }, enemy = { 5, 2 } })
|
||||||
end
|
end
|
||||||
|
|
||||||
-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler
|
-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -112,7 +112,13 @@ function Mon.syncIdentity(mon, data)
|
|||||||
if mon.dvs then
|
if mon.dvs then
|
||||||
mon.gender = Mon.gender(def, mon.dvs,
|
mon.gender = Mon.gender(def, mon.dvs,
|
||||||
{ species = mon.species, level = mon.level })
|
{ species = mon.species, level = mon.level })
|
||||||
mon.shiny = Mon.isShiny(mon.dvs,
|
-- shiny is monotonic once true, the same as opts.shiny winning over
|
||||||
|
-- shiny.roll at Mon.new: a forced shiny (the scripted-shiny path, DVs
|
||||||
|
-- that do not themselves read as shiny) must not un-shiny the moment
|
||||||
|
-- this runs again, and it runs on every SummaryMenu open via
|
||||||
|
-- refreshStats. A mon not already shiny still promotes normally if
|
||||||
|
-- its DVs justify it, e.g. after an edit.
|
||||||
|
mon.shiny = mon.shiny or Mon.isShiny(mon.dvs,
|
||||||
{ species = mon.species, def = def, level = mon.level })
|
{ species = mon.species, def = def, level = mon.level })
|
||||||
if mon.species == Unown.SPECIES then
|
if mon.species == Unown.SPECIES then
|
||||||
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
mon.unownLetter = Unown.letterFromDVs(mon.dvs)
|
||||||
@@ -382,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep")
|
|||||||
local Input = require("src.core.Input")
|
local Input = require("src.core.Input")
|
||||||
local Logger = require("src.core.Logger")
|
local Logger = require("src.core.Logger")
|
||||||
local Renderer = require("src.render.Renderer")
|
local Renderer = require("src.render.Renderer")
|
||||||
|
local GameViewport = require("src.render.GameViewport")
|
||||||
local SaveData = require("src.core.SaveData")
|
local SaveData = require("src.core.SaveData")
|
||||||
local StateStack = require("src.core.StateStack")
|
local StateStack = require("src.core.StateStack")
|
||||||
local TouchControls = require("src.core.TouchControls")
|
local TouchControls = require("src.core.TouchControls")
|
||||||
@@ -58,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()
|
||||||
@@ -184,6 +188,40 @@ 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: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
|
||||||
@@ -305,12 +343,32 @@ function Game.worldBgBattleDim(stack)
|
|||||||
for i = #(stack and stack.states or {}), 1, -1 do
|
for i = #(stack and stack.states or {}), 1, -1 do
|
||||||
local state = stack.states[i]
|
local state = stack.states[i]
|
||||||
if state and state.bgMode and state:bgMode() == "world" then
|
if state and state.bgMode and state:bgMode() == "world" then
|
||||||
|
-- The extended fixed HUD intentionally exposes the live world across
|
||||||
|
-- the whole physical window. Keep this as a world-backed battle (zero
|
||||||
|
-- is non-nil, so scaling and overlay holds remain active), but do not
|
||||||
|
-- paint the standard dim veil around the native battle rectangle.
|
||||||
|
if state.extendedWorldHUD and state:extendedWorldHUD() then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
return state.BG_WORLD_DIM or 0.55
|
return state.BG_WORLD_DIM or 0.55
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Does the stack contain the opt-in fixed Extended WORLD battle? Renderer
|
||||||
|
-- uses this separately from battleDim: the world remains the surround, while
|
||||||
|
-- the native-width battle field receives a paper backing from top to bottom.
|
||||||
|
function Game.extendedWorldHUDInStack(stack)
|
||||||
|
for i = #(stack and stack.states or {}), 1, -1 do
|
||||||
|
local state = stack.states[i]
|
||||||
|
if state and state.extendedWorldHUD and state:extendedWorldHUD() then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
|
-- Is a BATTLE BG "world" battle composing itself over the live map right now?
|
||||||
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
|
-- Same whole-stack walk as worldBgBattleDim, asked for a different reason: the
|
||||||
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
|
-- dark-cave shade shift (wMapPalOffset) must not reach a frame a battle is
|
||||||
@@ -403,13 +461,13 @@ function Game.uiAnchorsHeldInStack(stack)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
|
-- Where Game:draw starts drawing this frame. Normally the topmost opaque
|
||||||
-- state (StateStack:visibleBase) -- but BATTLE BG "world" composes the battle
|
-- state (StateStack:visibleBase) -- but an opaque menu pushed over a WIDE
|
||||||
-- over the LIVE map, and an opaque state pushed on top of it (the party menu,
|
-- battle must not prevent that battle from drawing. Native WIDE battles own
|
||||||
-- the bag) becomes that base, cutting the overworld -- and with it the world
|
-- the 304x144 surround around a centred classic menu, while external arena
|
||||||
-- pass -- out of the frame entirely. The backdrop the battle established
|
-- providers establish their window-sized scene from BattleState:draw. If the
|
||||||
-- then collapses to endFrame's flat black clear for as long as the menu is
|
-- menu becomes the draw base, neither owner runs and the menu's white field
|
||||||
-- up. So a world-bg battle keeps the frame starting from underneath itself
|
-- replaces the whole presentation. BATTLE BG "world" additionally needs the
|
||||||
-- until it leaves the stack, the same hold uiFill and the dim already use.
|
-- overworld below the battle, as before.
|
||||||
--
|
--
|
||||||
-- Only the START of the draw moves. The clear stays keyed to the real
|
-- Only the START of the draw moves. The clear stays keyed to the real
|
||||||
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
|
-- visibleBase, so the menu still gets its opaque canvas and draws exactly as
|
||||||
@@ -420,9 +478,13 @@ function Game.drawBaseInStack(stack, visibleBase)
|
|||||||
local states = stack and stack.states or {}
|
local states = stack and stack.states or {}
|
||||||
for i = visibleBase - 1, 1, -1 do
|
for i = visibleBase - 1, 1, -1 do
|
||||||
local state = states[i]
|
local state = states[i]
|
||||||
if state and state.bgMode and state:bgMode() == "world" then
|
local worldBattle = state and state.bgMode and state:bgMode() == "world"
|
||||||
|
local wideBattle = state and state.isWideBattleLayout
|
||||||
|
and state:isWideBattleLayout()
|
||||||
|
if worldBattle or wideBattle then
|
||||||
-- restart the search from under the battle: the highest opaque state at
|
-- restart the search from under the battle: the highest opaque state at
|
||||||
-- or below it (the overworld), not the menu sitting over it
|
-- or below it (the battle itself for white/black WIDE, the overworld for
|
||||||
|
-- a non-opaque world-backed battle), not the menu sitting over it
|
||||||
for j = i, 1, -1 do
|
for j = i, 1, -1 do
|
||||||
if states[j].isOpaque then return j end
|
if states[j].isOpaque then return j end
|
||||||
end
|
end
|
||||||
@@ -432,6 +494,14 @@ function Game.drawBaseInStack(stack, visibleBase)
|
|||||||
return visibleBase
|
return visibleBase
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- A classic overlay above the approved world-backed extended HUD paints only
|
||||||
|
-- its centred area. Keep the wider owner surface transparent so its margins
|
||||||
|
-- continue to reveal the world instead of becoming an opaque white sheet.
|
||||||
|
function Game.uiCanvasTransparent(worldBelow, worldDrawn, wideBattle)
|
||||||
|
return worldBelow or (worldDrawn and wideBattle ~= nil
|
||||||
|
and wideBattle.extendedHUD and wideBattle:extendedHUD())
|
||||||
|
end
|
||||||
|
|
||||||
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
-- Shift classic SGB zones to the centred UI. A full-width base zone extends
|
||||||
-- into both margins, keeping the canvas' paper color continuous; narrower
|
-- into both margins, keeping the canvas' paper color continuous; narrower
|
||||||
-- sprite and status zones move with the classic UI content.
|
-- sprite and status zones move with the classic UI content.
|
||||||
@@ -452,6 +522,7 @@ local function centerClassicZones(zones, offset)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Game:draw()
|
function Game:draw()
|
||||||
|
GameViewport.begin(1)
|
||||||
-- the UI canvas clears transparent when the overworld's world pass
|
-- the UI canvas clears transparent when the overworld's world pass
|
||||||
-- shows through beneath it; opaque full-screen states get the classic
|
-- shows through beneath it; opaque full-screen states get the classic
|
||||||
-- white clear
|
-- white clear
|
||||||
@@ -485,6 +556,7 @@ function Game:draw()
|
|||||||
-- the stack for the same reason as uiFill above -- a prompt opened during
|
-- the stack for the same reason as uiFill above -- a prompt opened during
|
||||||
-- the battle must not drop the dim for a frame.
|
-- the battle must not drop the dim for a frame.
|
||||||
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
||||||
|
Renderer.extendedWorldBand = Game.extendedWorldHUDInStack(self.stack)
|
||||||
-- ...and for the same reason the UI's own scale has to know the world is
|
-- ...and for the same reason the UI's own scale has to know the world is
|
||||||
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
|
||||||
-- steps the UI down with the survey zoom only while a world is behind it,
|
-- steps the UI down with the survey zoom only while a world is behind it,
|
||||||
@@ -502,7 +574,8 @@ function Game:draw()
|
|||||||
-- menu to its top right, and the whole UI steps down with the zoom.
|
-- menu to its top right, and the whole UI steps down with the zoom.
|
||||||
Renderer.uiCentered = not Game.dynamicUI(self.save)
|
Renderer.uiCentered = not Game.dynamicUI(self.save)
|
||||||
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
|
Renderer.uiAnchorHold = Game.uiAnchorsHeldInStack(self.stack)
|
||||||
Renderer:beginFrame(worldBelow)
|
Renderer:beginFrame(Game.uiCanvasTransparent(
|
||||||
|
worldBelow, worldDrawn, wideBattle))
|
||||||
for i = drawFrom, #self.stack.states do
|
for i = drawFrom, #self.stack.states do
|
||||||
local state = self.stack.states[i]
|
local state = self.stack.states[i]
|
||||||
local wideState = state and state.isWideBattleLayout
|
local wideState = state and state.isWideBattleLayout
|
||||||
@@ -563,7 +636,9 @@ function Game:draw()
|
|||||||
if ModRuntime.wantsHook("render.hud") then
|
if ModRuntime.wantsHook("render.hud") then
|
||||||
ModRuntime.call("render.hud", function() end, self, viewport)
|
ModRuntime.call("render.hud", function() end, self, viewport)
|
||||||
end
|
end
|
||||||
-- on-screen mobile controls: pure screen-space, over the finished frame
|
GameViewport.finish(self)
|
||||||
|
-- OS-window chrome: keep the pad full-size and above any composed companion
|
||||||
|
-- view instead of capturing and shrinking it with the game viewport.
|
||||||
TouchControls:draw()
|
TouchControls:draw()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -939,8 +1014,10 @@ local function pointerUnclaimed() return false end
|
|||||||
-- coordinates are LOVE window units, the same space render.hud's viewport
|
-- coordinates are LOVE window units, the same space render.hud's viewport
|
||||||
-- and the touch overlay lay out in
|
-- and the touch overlay lay out in
|
||||||
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
||||||
|
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
|
||||||
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
||||||
phase = phase, source = source, id = id, x = x, y = y,
|
phase = phase, source = source, id = id, x = x, y = y,
|
||||||
|
gameX = gameX, gameY = gameY, insideGame = insideGame,
|
||||||
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -1156,6 +1233,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
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
|
|||||||
-- The mod event/hook buses. Gold reaches them through Runtime like every
|
-- The mod event/hook buses. Gold reaches them through Runtime like every
|
||||||
-- other engine file, so a call site here is the same call site Gen 1 has.
|
-- other engine file, so a call site here is the same call site Gen 1 has.
|
||||||
local ModRuntime = require("src.mods.Runtime")
|
local ModRuntime = require("src.mods.Runtime")
|
||||||
|
local GameViewport = require("src.render.GameViewport")
|
||||||
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
-- Only for the mod-supplied save migrations and the mods-changed report, which
|
||||||
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
-- are keyed off save.meta and know nothing about a generation; Gold's own save
|
||||||
-- IO is src/core/gen2/Save.lua.
|
-- IO is src/core/gen2/Save.lua.
|
||||||
@@ -73,8 +74,8 @@ end
|
|||||||
--
|
--
|
||||||
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
|
||||||
-- (the FixedStep callback in Game2:load), so none of it goes through
|
-- (the FixedStep callback in Game2:load), so none of it goes through
|
||||||
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight
|
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
|
||||||
-- hooks below never used to fire here; it is not a reason they should not. A
|
-- below never used to fire here; it is not a reason they should not. A
|
||||||
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
-- hook is a contract about a MOMENT in the frame, and Gold has every one of
|
||||||
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
|
||||||
-- PAYLOAD, at the Gen 1 point in the order:
|
-- PAYLOAD, at the Gen 1 point in the order:
|
||||||
@@ -86,6 +87,8 @@ end
|
|||||||
-- render.output* the normal composed frame (Renderer.lua:1063)
|
-- render.output* the normal composed frame (Renderer.lua:1063)
|
||||||
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
|
||||||
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
-- render.hud screen-space UI over the frame (src/core/Game.lua:521)
|
||||||
|
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
|
||||||
|
-- render.window final OS-window composition (GameViewport.lua:145)
|
||||||
--
|
--
|
||||||
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
|
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
|
||||||
-- world pass and the UI into ONE canvas, not two -- the call site says so and
|
-- world pass and the UI into ONE canvas, not two -- the call site says so and
|
||||||
@@ -680,7 +683,10 @@ end
|
|||||||
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
|
-- .SelectMon / PPRestoreItem_Cancel carry path: nothing spent.
|
||||||
function Game2:usePartyItem(itemId)
|
function Game2:usePartyItem(itemId)
|
||||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||||
local action = ItemEffects.partyAction(itemId)
|
-- without the merged dataset this can only ever see RECORDS, the
|
||||||
|
-- module's own built-ins, so a mod's field item resolves to no action
|
||||||
|
-- at all and never gets past the .Oak refusal below
|
||||||
|
local action = ItemEffects.partyAction(itemId, self.data)
|
||||||
if not action then return end
|
if not action then return end
|
||||||
local party = (self.save and self.save.party) or {}
|
local party = (self.save and self.save.party) or {}
|
||||||
if #party == 0 then
|
if #party == 0 then
|
||||||
@@ -933,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")
|
||||||
@@ -1197,9 +1205,7 @@ function Game2:frameFit(w, h)
|
|||||||
dpi = tonumber(love.window.getDPIScale()) or 1
|
dpi = tonumber(love.window.getDPIScale()) or 1
|
||||||
end
|
end
|
||||||
local pw, ph = w * dpi, h * dpi
|
local pw, ph = w * dpi, h * dpi
|
||||||
if love.graphics.getPixelDimensions then
|
pw, ph = GameViewport.pixelDimensions()
|
||||||
pw, ph = love.graphics.getPixelDimensions()
|
|
||||||
end
|
|
||||||
return scale, ox, oy, dpi, pw, ph
|
return scale, ox, oy, dpi, pw, ph
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1217,18 +1223,15 @@ function Game2:viewport(w, h)
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- The screen-space layer, in the Gen 1 order: render.hud and then the
|
-- The render.hud layer, in Gen 1's order over the finished game frame. The
|
||||||
-- on-screen pad (src/core/Game.lua:521 and :524, either side of
|
-- on-screen pad is drawn separately after GameViewport.finish, because it is
|
||||||
-- Renderer:endFrame). Both are window-space, both sit over the finished
|
-- OS-window chrome and must not be captured or scaled with this canvas.
|
||||||
-- frame -- post passes, letterbox and all -- and neither ever enters the game
|
|
||||||
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
|
|
||||||
-- true of the composed frame a mod owns as well as of the plain one.
|
|
||||||
--
|
--
|
||||||
-- render.hud: persistent tool status. The call is fenced with
|
-- render.hud: persistent tool status. The call is fenced with
|
||||||
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
|
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
|
||||||
-- mod render callback: a subscriber that returns cleanly but leaves a shader
|
-- mod render callback: a subscriber that returns cleanly but leaves a shader
|
||||||
-- bound, the canvas redirected or the colour changed must not corrupt the next
|
-- bound, the canvas redirected or the colour changed must not corrupt the next
|
||||||
-- frame -- or, now, the pad drawn immediately after it.
|
-- frame.
|
||||||
function Game2:drawHud(w, h)
|
function Game2:drawHud(w, h)
|
||||||
if ModRuntime.wantsHook("render.hud") then
|
if ModRuntime.wantsHook("render.hud") then
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
@@ -1236,10 +1239,6 @@ function Game2:drawHud(w, h)
|
|||||||
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
|
ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
|
||||||
G.pop()
|
G.pop()
|
||||||
end
|
end
|
||||||
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
|
|
||||||
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
|
|
||||||
-- forces it, and nothing ever while a controller is in use.
|
|
||||||
TouchControls:draw()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- render.letterbox: SGB borders and custom void art in the bars around the
|
-- render.letterbox: SGB borders and custom void art in the bars around the
|
||||||
@@ -1363,9 +1362,9 @@ end
|
|||||||
-- is being shown on. Mod post-processes fold in between the two, where
|
-- is being shown on. Mod post-processes fold in between the two, where
|
||||||
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
|
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
|
||||||
-- is then drawn over, rather than something that smears the grid itself.
|
-- is then drawn over, rather than something that smears the grid itself.
|
||||||
function Game2:draw()
|
function Game2:drawViewportFrame()
|
||||||
local G = love.graphics
|
local G = love.graphics
|
||||||
local w, h = G.getDimensions()
|
local w, h = GameViewport.dimensions()
|
||||||
local GBCFX = require("src.render.GBCFX")
|
local GBCFX = require("src.render.GBCFX")
|
||||||
local GbcPalette = require("src.render.GbcPalette")
|
local GbcPalette = require("src.render.GbcPalette")
|
||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
@@ -1477,6 +1476,16 @@ function Game2:draw()
|
|||||||
self:drawHud(w, h)
|
self:drawHud(w, h)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Game2:draw()
|
||||||
|
GameViewport.begin(2)
|
||||||
|
GameViewport.setTarget()
|
||||||
|
self:drawViewportFrame()
|
||||||
|
GameViewport.finish(self)
|
||||||
|
-- OS-window chrome: draw after companion composition so viewport layouts
|
||||||
|
-- neither shrink nor cover the touch pad.
|
||||||
|
TouchControls:draw()
|
||||||
|
end
|
||||||
|
|
||||||
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
|
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from
|
||||||
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
|
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
|
||||||
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
|
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
|
||||||
@@ -1803,8 +1812,10 @@ end
|
|||||||
|
|
||||||
-- coordinates are LOVE window units, the same space render.hud's viewport is in
|
-- coordinates are LOVE window units, the same space render.hud's viewport is in
|
||||||
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
|
||||||
|
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
|
||||||
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
|
||||||
phase = phase, source = source, id = id, x = x, y = y,
|
phase = phase, source = source, id = id, x = x, y = y,
|
||||||
|
gameX = gameX, gameY = gameY, insideGame = insideGame,
|
||||||
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
@@ -1956,6 +1967,7 @@ 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)
|
||||||
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
|
||||||
@@ -272,6 +284,38 @@ function HostShell.quote(s)
|
|||||||
return "'" .. s:gsub("'", "'\\''") .. "'"
|
return "'" .. s:gsub("'", "'\\''") .. "'"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Launch another instance of this packaged app without waiting for it. The
|
||||||
|
-- same path works on all process-capable desktop hosts; only the shell's
|
||||||
|
-- background spelling differs. Source checkouts include their game folder,
|
||||||
|
-- while fused releases and AppImages already carry it in the executable.
|
||||||
|
function HostShell.spawnSelfDetached(args)
|
||||||
|
if not require("src.core.Platform").canSpawnProcess() then return false end
|
||||||
|
local fs = love and love.filesystem
|
||||||
|
if not (fs and fs.getExecutablePath) then return false end
|
||||||
|
local executable = os.getenv("APPIMAGE") or fs.getExecutablePath()
|
||||||
|
if type(executable) ~= "string" or executable == "" then return false end
|
||||||
|
|
||||||
|
local argv = {}
|
||||||
|
local fused = fs.isFused and fs.isFused()
|
||||||
|
if not os.getenv("APPIMAGE") and not fused and fs.getSource then
|
||||||
|
argv[#argv + 1] = fs.getSource()
|
||||||
|
end
|
||||||
|
for _, value in ipairs(args or {}) do argv[#argv + 1] = tostring(value) end
|
||||||
|
|
||||||
|
local command = HostShell.quote(executable)
|
||||||
|
for _, value in ipairs(argv) do
|
||||||
|
command = command .. " " .. HostShell.quote(value)
|
||||||
|
end
|
||||||
|
local osName = love.system and love.system.getOS and love.system.getOS()
|
||||||
|
if osName == "Windows" then
|
||||||
|
command = 'start "" /b ' .. command .. " >NUL 2>&1"
|
||||||
|
else
|
||||||
|
command = HostShell.envPrefix() .. command .. " >/dev/null 2>&1 &"
|
||||||
|
end
|
||||||
|
local ok, _, code = os.execute(command)
|
||||||
|
return ok == true or ok == 0 or code == 0
|
||||||
|
end
|
||||||
|
|
||||||
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
|
-- MEMOISED per Lua state (so once per thread). This used to spawn a whole
|
||||||
-- `curl --version` process on every single fetch -- twice for a GET through
|
-- `curl --version` process on every single fetch -- twice for a GET through
|
||||||
-- the Android-bridge fallback -- which doubled the number of spawns the lock
|
-- the Android-bridge fallback -- which doubled the number of spawns the lock
|
||||||
@@ -420,9 +464,11 @@ end
|
|||||||
-- POST returning success/failure. Strictly one-way: the response body is
|
-- POST returning success/failure. Strictly one-way: the response body is
|
||||||
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
-- discarded, only the HTTP status class is surfaced (postLog callers never
|
||||||
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
-- trust the reply). curl --data-binary reads the payload from a pipe, so a
|
||||||
-- large body never lands in the command line; the Android bridge has no POST
|
-- large body never lands in the command line; where curl is absent (Android
|
||||||
-- transport, and httpPost reports that instead of half-working through
|
-- and the other bridge-only platforms) the POST rides the JNI bridge --
|
||||||
-- httpDownload (a GET round-trip to a POST endpoint would be a lie).
|
-- love.system.httpPost, the dedicated POST arm added beside httpDownload --
|
||||||
|
-- instead of half-working through httpDownload (a GET round-trip to a POST
|
||||||
|
-- endpoint would be a lie).
|
||||||
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
||||||
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
if type(url) ~= "string" or url == "" then return nil, "missing url" end
|
||||||
if type(body) ~= "string" then return nil, "missing body" end
|
if type(body) ~= "string" then return nil, "missing body" end
|
||||||
@@ -498,6 +544,16 @@ function HostShell.httpPost(url, body, contentType, userAgent, maxTime)
|
|||||||
if not haveBridge() then
|
if not haveBridge() then
|
||||||
return nil, "no network transport on this platform"
|
return nil, "no network transport on this platform"
|
||||||
end
|
end
|
||||||
|
-- The GET bridge has no POST; the dedicated love.system.httpPost arm
|
||||||
|
-- (GameActivity.httpPost) is the transport where curl is missing. A
|
||||||
|
-- build without it reports the same "no POST transport" a missing curl
|
||||||
|
-- would -- the old-APK skew path in the JNI bridge returns false.
|
||||||
|
if love.system and type(love.system.httpPost) == "function" then
|
||||||
|
local ok, sent = pcall(love.system.httpPost, url, body, contentType,
|
||||||
|
userAgent)
|
||||||
|
if ok and sent then return true end
|
||||||
|
return nil, "log post rejected"
|
||||||
|
end
|
||||||
return nil, "no POST transport on this platform"
|
return nil, "no POST transport on this platform"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -387,9 +387,9 @@ end
|
|||||||
|
|
||||||
-- overworld map theme; onBike/surfing override outdoor themes with the
|
-- overworld map theme; onBike/surfing override outdoor themes with the
|
||||||
-- bike/surf songs and restore the map theme when they end
|
-- bike/surf songs and restore the map theme when they end
|
||||||
function Music.playMap(data, mapId, onBike, surfing, fade)
|
function Music.playMap(data, mapId, onBike, surfing, fade, song)
|
||||||
local song = data and data.audio and data.audio.mapSongs
|
song = song or (data and data.audio and data.audio.mapSongs
|
||||||
and mapId and data.audio.mapSongs[mapId] or nil
|
and mapId and data.audio.mapSongs[mapId]) or nil
|
||||||
state.mapSong = song
|
state.mapSong = song
|
||||||
state.onBike = not not onBike
|
state.onBike = not not onBike
|
||||||
state.surfing = not not surfing
|
state.surfing = not not surfing
|
||||||
|
|||||||
@@ -7,12 +7,14 @@
|
|||||||
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
-- (touch overlay, launcher) should prefer this over getDimensions; the game
|
||||||
-- canvas may still letterbox into the full framebuffer for immersion.
|
-- canvas may still letterbox into the full framebuffer for immersion.
|
||||||
|
|
||||||
|
local GameViewport = require("src.render.GameViewport")
|
||||||
|
|
||||||
local SafeArea = {}
|
local SafeArea = {}
|
||||||
|
|
||||||
function SafeArea.rect()
|
function SafeArea.windowRect()
|
||||||
local ww, wh = 0, 0
|
local ww, wh = 0, 0
|
||||||
if love and love.graphics and love.graphics.getDimensions then
|
if love and love.graphics and love.graphics.getDimensions then
|
||||||
ww, wh = love.graphics.getDimensions()
|
ww, wh = GameViewport.fullDimensions()
|
||||||
end
|
end
|
||||||
if ww <= 0 then ww = 1 end
|
if ww <= 0 then ww = 1 end
|
||||||
if wh <= 0 then wh = 1 end
|
if wh <= 0 then wh = 1 end
|
||||||
@@ -55,4 +57,8 @@ function SafeArea.rect()
|
|||||||
return x, y, w, h
|
return x, y, w, h
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function SafeArea.rect()
|
||||||
|
return GameViewport.localSafeRect(SafeArea.windowRect())
|
||||||
|
end
|
||||||
|
|
||||||
return SafeArea
|
return SafeArea
|
||||||
|
|||||||
@@ -239,6 +239,11 @@ function SaveData.defaultOptions()
|
|||||||
-- scale the battle surface to the window so it fills vertically. See
|
-- scale the battle surface to the window so it fills vertically. See
|
||||||
-- BattleState:wantsFillScale.
|
-- BattleState:wantsFillScale.
|
||||||
battleFit = "fixed",
|
battleFit = "fixed",
|
||||||
|
-- BATTLE HUD: STANDARD keeps every wide-battle element inside the native
|
||||||
|
-- 304x144 surface. EXTENDED is opt-in window-space placement for selected
|
||||||
|
-- wide layouts; unsupported combinations deliberately fall back to the
|
||||||
|
-- standard composition.
|
||||||
|
battleHud = "standard",
|
||||||
-- BATTLE BG: what fills the screen behind and around the battle.
|
-- BATTLE BG: what fills the screen behind and around the battle.
|
||||||
-- "white" = the display mode's paper shade (the classic look),
|
-- "white" = the display mode's paper shade (the classic look),
|
||||||
-- "black" = plain black bars, "world" = the frozen overworld showing
|
-- "black" = plain black bars, "world" = the frozen overworld showing
|
||||||
@@ -661,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
|
||||||
@@ -1919,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
|
||||||
@@ -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
|
||||||
@@ -339,7 +393,7 @@ end
|
|||||||
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
|
-- demand. Mirrors it into self.orientation / self.positions / self.scale,
|
||||||
-- which layout(), the editor chrome and the tests read.
|
-- which layout(), the editor chrome and the tests read.
|
||||||
function TouchControls:currentBucket()
|
function TouchControls:currentBucket()
|
||||||
local _, _, sw, sh = SafeArea.rect()
|
local _, _, sw, sh = SafeArea.windowRect()
|
||||||
local o = orientationFor(sw, sh)
|
local o = orientationFor(sw, sh)
|
||||||
self.layouts = self.layouts or { portrait = {}, landscape = {} }
|
self.layouts = self.layouts or { portrait = {}, landscape = {} }
|
||||||
local b = self.layouts[o]
|
local b = self.layouts[o]
|
||||||
@@ -363,7 +417,7 @@ end
|
|||||||
-- while sizes stay derived from the short edge, times the orientation's
|
-- while sizes stay derived from the short edge, times the orientation's
|
||||||
-- size setting (#633).
|
-- size setting (#633).
|
||||||
function TouchControls:layout()
|
function TouchControls:layout()
|
||||||
local ox, oy, sw, sh = SafeArea.rect()
|
local ox, oy, sw, sh = SafeArea.windowRect()
|
||||||
if self.layoutW == sw and self.layoutH == sh
|
if self.layoutW == sw and self.layoutH == sh
|
||||||
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
and self.layoutOx == ox and self.layoutOy == oy and self.L then
|
||||||
return self.L
|
return self.L
|
||||||
@@ -397,7 +451,7 @@ end
|
|||||||
-- Move one control to a screen-space point and persist its normalized
|
-- Move one control to a screen-space point and persist its normalized
|
||||||
-- position within the safe rect. Used by the layout editor while dragging.
|
-- position within the safe rect. Used by the layout editor while dragging.
|
||||||
function TouchControls:setControlCenter(name, cx, cy)
|
function TouchControls:setControlCenter(name, cx, cy)
|
||||||
local ox, oy, sw, sh = SafeArea.rect()
|
local ox, oy, sw, sh = SafeArea.windowRect()
|
||||||
local L = self:layout()
|
local L = self:layout()
|
||||||
local zone = L[name]
|
local zone = L[name]
|
||||||
if not zone then return end
|
if not zone then return end
|
||||||
@@ -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
|
||||||
|
|
||||||
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by
|
local function drawStretched(img, x, y, w, h, alpha)
|
||||||
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top
|
if not img or alpha <= 0 then return end
|
||||||
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher
|
local iw, ih = img:getWidth(), img:getHeight()
|
||||||
-- layout editor under preview mode.
|
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
|
||||||
|
-- top of the game, companion composition and post-processing without being
|
||||||
|
-- captured or scaled with any game viewport. Also used by the launcher layout
|
||||||
|
-- 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,842 @@
|
|||||||
|
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
|
||||||
|
if not page.aspect or page.aspect <= 0 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
|
||||||
|
|
||||||
|
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),
|
||||||
|
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
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
function TouchSkin.page()
|
||||||
|
local skin = TouchSkin.active
|
||||||
|
if not skin then return nil end
|
||||||
|
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
|
||||||
|
if not page.fullScreen and h > 0 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 x, y = (ox or 0) + v.x * w, (oy or 0) + v.y * h
|
||||||
|
local vw, vh = v.w * w, v.h * h
|
||||||
|
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
|
||||||
|
|||||||
@@ -16,13 +16,56 @@
|
|||||||
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
|
-- it (src/ui/gen2/InitClock.lua, src/script/gen2/Specials.lua SetDayOfWeek)
|
||||||
-- and World only ever reads it.
|
-- and World only ever reads it.
|
||||||
|
|
||||||
|
local Palettes = require("src.world.gen2.Palettes")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
|
||||||
local Clock = {}
|
local Clock = {}
|
||||||
|
|
||||||
Clock.MINUTES_PER_DAY = 24 * 60
|
Clock.MINUTES_PER_DAY = 24 * 60
|
||||||
Clock.DAYS = 7
|
Clock.DAYS = 7
|
||||||
|
|
||||||
|
-- data/text/day_of_week.asm order, which is wCurDay's own: SUNDAY is 0, so
|
||||||
|
-- index 1 is SUNDAY -- matching both InitClock's `self.day + 1` and
|
||||||
|
-- `Clock.weekday(save) + 1`. Strings.source, not Strings: built at require
|
||||||
|
-- time, before Strings.load has a catalog, so Clock.weekdayName looks each
|
||||||
|
-- name up at display time instead (src/battle/MoveEffects.lua's STAT_LABEL
|
||||||
|
-- is the same pattern). One shared table and one lookup function, so
|
||||||
|
-- InitClock's screens, the main menu clock box and the Pokegear clock card
|
||||||
|
-- cannot drift apart on what a weekday is called.
|
||||||
|
Clock.DAY_NAMES = {
|
||||||
|
Strings.source("SUNDAY"), Strings.source("MONDAY"), Strings.source("TUESDAY"),
|
||||||
|
Strings.source("WEDNESDAY"), Strings.source("THURSDAY"), Strings.source("FRIDAY"),
|
||||||
|
Strings.source("SATURDAY"),
|
||||||
|
}
|
||||||
|
|
||||||
|
-- The translated name for a 1-based weekday (SUNDAY = 1), or nil if `day` is
|
||||||
|
-- out of range.
|
||||||
|
function Clock.weekdayName(day)
|
||||||
|
local name = Clock.DAY_NAMES[day]
|
||||||
|
return name and Strings(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The three words Palettes.clockDaytime can hand back, translated (never
|
||||||
|
-- DARK: that one only comes out of Palettes.daytimeFor, for a PALETTE_DARK
|
||||||
|
-- map, and is never printed as text). Strings.source, not Strings:
|
||||||
|
-- clockDaytime's return value is also an internal key every
|
||||||
|
-- FORCED_DAYTIME/palette lookup in Palettes.lua compares against, so THAT
|
||||||
|
-- stays untranslated -- only this table, and Clock.daytimeLabel below, look
|
||||||
|
-- a word up, at the UI call sites that actually print it.
|
||||||
|
local DAYTIME_LABEL = {
|
||||||
|
MORN = Strings.source("MORN"), DAY = Strings.source("DAY"),
|
||||||
|
NITE = Strings.source("NITE"),
|
||||||
|
}
|
||||||
|
|
||||||
|
-- clockDaytime's word, translated -- the one InitClock's clock-setting
|
||||||
|
-- screen and the Pokegear's clock card print (DisplayHourOClock /
|
||||||
|
-- Pokegear_UpdateClock).
|
||||||
|
function Clock.daytimeLabel(hour)
|
||||||
|
local daytime = Palettes.clockDaytime(hour)
|
||||||
|
return Strings(DAYTIME_LABEL[daytime] or daytime)
|
||||||
|
end
|
||||||
|
|
||||||
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
|
-- InitClock's own default: `ld a, 10 ; default hour = 10 AM`, with the minute
|
||||||
-- buffer left at the zero ByteFill put there.
|
-- buffer left at the zero ByteFill put there.
|
||||||
Clock.DEFAULT_HOUR = 10
|
Clock.DEFAULT_HOUR = 10
|
||||||
|
|||||||
@@ -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,7 @@ 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,
|
||||||
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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -138,15 +149,72 @@ local function coreRows(opts, hooks)
|
|||||||
ladder(opts, "battleStyle",
|
ladder(opts, "battleStyle",
|
||||||
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
|
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
|
||||||
add(Strings("BATTLE LAYOUT"),
|
add(Strings("BATTLE LAYOUT"),
|
||||||
ladder(opts, "battleLayout",
|
function()
|
||||||
{ { "og", "OG" }, { "wide", "WIDE" } }, "og"))
|
return opts.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
opts.battleLayout = opts.battleLayout == "wide" and "og" or "wide"
|
||||||
|
if opts.battleLayout ~= "wide" then
|
||||||
|
opts.battleHud = "standard"
|
||||||
|
elseif opts.battleFit == "fill" and opts.battleHud == "extended" then
|
||||||
|
opts.battleBg = "white"
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end)
|
||||||
add(Strings("BATTLE SIZE"),
|
add(Strings("BATTLE SIZE"),
|
||||||
ladder(opts, "battleFit",
|
function()
|
||||||
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
|
return opts.battleFit == "fill" and Strings("FILL") or Strings("FIXED")
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
opts.battleFit = opts.battleFit == "fill" and "fixed" or "fill"
|
||||||
|
if opts.battleFit == "fill" and opts.battleLayout == "wide"
|
||||||
|
and opts.battleHud == "extended" then
|
||||||
|
opts.battleBg = "white"
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
add(Strings("BATTLE HUD"),
|
||||||
|
function()
|
||||||
|
return opts.battleLayout == "wide" and opts.battleHud == "extended"
|
||||||
|
and Strings("EXTENDED")
|
||||||
|
or Strings("STANDARD")
|
||||||
|
end,
|
||||||
|
function()
|
||||||
|
if opts.battleLayout ~= "wide" then
|
||||||
|
opts.battleHud = "standard"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
opts.battleHud = opts.battleHud == "extended" and "standard" or "extended"
|
||||||
|
if opts.battleHud == "extended" and opts.battleFit == "fill" then
|
||||||
|
opts.battleBg = "white"
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end)
|
||||||
add(Strings("BATTLE BG"),
|
add(Strings("BATTLE BG"),
|
||||||
ladder(opts, "battleBg",
|
function()
|
||||||
{ { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } },
|
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
||||||
"white"))
|
and opts.battleHud == "extended" then
|
||||||
|
opts.battleBg = "white"
|
||||||
|
return Strings("AUTO")
|
||||||
|
end
|
||||||
|
if opts.battleBg == "black" then return Strings("BLACK") end
|
||||||
|
if opts.battleBg == "world" then return Strings("WORLD") end
|
||||||
|
return Strings("WHITE")
|
||||||
|
end,
|
||||||
|
function(dir)
|
||||||
|
if opts.battleLayout == "wide" and opts.battleFit == "fill"
|
||||||
|
and opts.battleHud == "extended" then
|
||||||
|
opts.battleBg = "white"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local order = { "white", "black", "world" }
|
||||||
|
local cur = 1
|
||||||
|
for i, mode in ipairs(order) do
|
||||||
|
if opts.battleBg == mode then cur = i break end
|
||||||
|
end
|
||||||
|
opts.battleBg = order[wrapIndex(cur - 1 + (dir or 1), #order) + 1]
|
||||||
|
return true
|
||||||
|
end)
|
||||||
add(Strings("UI LAYOUT"),
|
add(Strings("UI LAYOUT"),
|
||||||
ladder(opts, "uiLayout",
|
ladder(opts, "uiLayout",
|
||||||
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
|
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
|
||||||
@@ -570,6 +638,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
|
||||||
@@ -581,6 +659,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
|
||||||
@@ -979,7 +1080,7 @@ function LauncherView._updateControl(imp)
|
|||||||
end
|
end
|
||||||
-- idle / uptodate / error: offer a manual check, with no glow.
|
-- idle / uptodate / error: offer a manual check, with no glow.
|
||||||
return status, Strings("Check for updates"),
|
return status, Strings("Check for updates"),
|
||||||
function() pcall(imp.Check.start) end, false
|
function() pcall(imp.Check.start, true) end, false
|
||||||
end
|
end
|
||||||
|
|
||||||
-- ------------------------------------------------------------ game panel
|
-- ------------------------------------------------------------ game panel
|
||||||
@@ -1909,6 +2010,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()
|
||||||
@@ -2780,6 +3032,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)
|
||||||
@@ -3612,6 +3890,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 +4032,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 +4186,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
|
||||||
|
|||||||
@@ -988,8 +988,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 +1099,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 +1267,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 +1514,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
|
||||||
self.modNotice = { ok = false, text = text }
|
if self.pickerPendingKind == "skin" then
|
||||||
|
self.pickerPendingKind = nil
|
||||||
|
self._skinNotice = { ok = false, text = text }
|
||||||
|
else
|
||||||
|
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 +1540,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 +1557,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 +1845,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
|
||||||
self:_installMod(file)
|
-- 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)
|
||||||
|
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 +2084,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 +2103,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 +2570,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 +2597,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 +2706,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 +3072,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 +3229,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 +3732,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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ local STONES = {
|
|||||||
LEAF_STONE = true, MOON_STONE = true,
|
LEAF_STONE = true, MOON_STONE = true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Strings.source, not Strings: harvested at require time so the catalog
|
||||||
|
-- generator can see the literal, same pattern as MoveEffects.lua's
|
||||||
|
-- STAT_LABEL (#811) -- Strings(stat:upper()) alone is a dynamic argument
|
||||||
|
-- the harvester can't discover.
|
||||||
|
local STAT_LABEL = {
|
||||||
|
hp = Strings.source("HP"), attack = Strings.source("ATTACK"),
|
||||||
|
defense = Strings.source("DEFENSE"), speed = Strings.source("SPEED"),
|
||||||
|
special = Strings.source("SPECIAL"), accuracy = Strings.source("ACCURACY"),
|
||||||
|
}
|
||||||
|
|
||||||
-- vitamins: stat-exp boosters (ItemUseVitamin)
|
-- vitamins: stat-exp boosters (ItemUseVitamin)
|
||||||
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
|
local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense",
|
||||||
CARBOS = "speed", CALCIUM = "special" }
|
CARBOS = "speed", CALCIUM = "special" }
|
||||||
@@ -294,7 +304,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
"Nothing happened!") }
|
"Nothing happened!") }
|
||||||
end
|
end
|
||||||
b.stages[stat] = cur + 1
|
b.stages[stat] = cur + 1
|
||||||
return "consumed", { Strings("%s's\n%s rose!", b.name, stat:upper()) }
|
return "consumed", { Strings("%s's\n%s rose!", b.name, Strings(STAT_LABEL[stat])) }
|
||||||
end
|
end
|
||||||
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
-- ItemUseDireHit/ItemUseGuardSpec always set the bit and consume
|
||||||
-- the item, even when it is already active
|
-- the item, even when it is already active
|
||||||
@@ -493,7 +503,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
-- Spanish ROM puts the stat before the name), so the extracted line
|
-- Spanish ROM puts the stat before the name), so the extracted line
|
||||||
-- cannot be filled positionally; the engine wording stands
|
-- cannot be filled positionally; the engine wording stands
|
||||||
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
return "consumed", { Strings("%s's %s\nrose!", monName(data, target),
|
||||||
vitaminStat == "hp" and "HP" or vitaminStat:upper()) }
|
Strings(STAT_LABEL[vitaminStat])) }
|
||||||
end
|
end
|
||||||
|
|
||||||
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
-- PP UP boosts the move the player picked (ItemUsePPUp's move menu)
|
||||||
@@ -515,7 +525,10 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow)
|
|||||||
if not target then return "failed", { noEffect(data) } end
|
if not target then return "failed", { noEffect(data) } end
|
||||||
local speciesDef = data.pokemon[target.species]
|
local speciesDef = data.pokemon[target.species]
|
||||||
local ok = false
|
local ok = false
|
||||||
for _, m in ipairs(speciesDef.tmhm) do
|
-- a species record with no tmhm list at all is "teaches nothing", the
|
||||||
|
-- same as one whose list just does not name this move -- not a reason
|
||||||
|
-- to crash instead of refusing normally
|
||||||
|
for _, m in ipairs(speciesDef.tmhm or {}) do
|
||||||
if m == itemDef.machine.move then ok = true break end
|
if m == itemDef.machine.move then ok = true break end
|
||||||
end
|
end
|
||||||
if not ok then
|
if not ok then
|
||||||
|
|||||||
@@ -1297,6 +1297,7 @@ function ManagerState:drawOverlay()
|
|||||||
love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
|
love.graphics.rectangle("fill", 2 * 8, ty * 8, 16 * 8, th * 8)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
Font.drawBox(2, ty, 16, th)
|
Font.drawBox(2, ty, 16, th)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
for i, line in ipairs(lines) do
|
for i, line in ipairs(lines) do
|
||||||
drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
|
drawTruncated(line, 4 * 8, (ty + i) * 8, 14)
|
||||||
end
|
end
|
||||||
@@ -1325,6 +1326,7 @@ function ManagerState:draw()
|
|||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
Font.drawBox(0, 0, 20, 18)
|
Font.drawBox(0, 0, 20, 18)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
|
Font.draw(self.banner or Strings("MOD MANAGER"), 16, 8)
|
||||||
if self.screen == "list" then
|
if self.screen == "list" then
|
||||||
self:drawList()
|
self:drawList()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ local Manifest = {}
|
|||||||
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
Manifest.PROFILES = { content = true, overhaul = true, total_conversion = true }
|
||||||
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
Manifest.PERMISSIONS = { network = true, filesystem = true,
|
||||||
engine_internals = true, steps = true,
|
engine_internals = true, steps = true,
|
||||||
background = true }
|
background = true, compute = true }
|
||||||
|
|
||||||
-- link-relevant registries; a mod that writes into one of these while
|
-- link-relevant registries; a mod that writes into one of these while
|
||||||
-- declaring affects_link = false gets an attributed warning from the loader
|
-- declaring affects_link = false gets an attributed warning from the loader
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ local DENIED = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
-- Same idea one level up: love.filesystem is reachable by name, and
|
-- Same idea one level up: love.filesystem is reachable by name, and
|
||||||
-- love.thread starts a Lua state this sandbox has no say over.
|
-- love.thread starts a Lua state this sandbox has no say over. jit.util
|
||||||
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true }
|
-- is the LuaJIT-specific equal of the debug library above -- funcbc,
|
||||||
|
-- funck and friends read the bytecode and constants of any function a
|
||||||
|
-- chunk can reach, which is enough to walk back to upvalues (the real
|
||||||
|
-- _G, love, io) the rest of this file exists to keep out of reach. The
|
||||||
|
-- bare `jit` table stays -- env.jit above hands it over directly for
|
||||||
|
-- jit.on/off/flush -- so only the submodule require is denied.
|
||||||
|
local DENIED_PREFIX = { ["love"] = true, ["ffi"] = true, ["jit"] = true }
|
||||||
|
|
||||||
-- The wire, which is what the network permission governs.
|
-- The wire, which is what the network permission governs.
|
||||||
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
local NETWORK = { socket = true, enet = true, http = true, https = true,
|
||||||
@@ -79,13 +85,25 @@ local BLOCKED_LOVE = {
|
|||||||
|
|
||||||
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
-- Per-mod, because the compat overrides (src/mods/LegacyCompat.lua) are backed
|
||||||
-- by that mod's own overlay and must not be shared.
|
-- by that mod's own overlay and must not be shared.
|
||||||
local function loveFacade(compat)
|
local function loveFacade(compat, permissions)
|
||||||
if not _G.love then return nil end
|
if not _G.love then return nil end
|
||||||
local overrides = compat and compat.love
|
local overrides = compat and compat.love
|
||||||
return setmetatable({}, {
|
return setmetatable({}, {
|
||||||
__index = function(_, key)
|
__index = function(_, key)
|
||||||
local override = overrides and overrides[key]
|
local override = overrides and overrides[key]
|
||||||
if override ~= nil then return override end
|
if override ~= nil then return override end
|
||||||
|
if key == "thread" then
|
||||||
|
-- Threads open a fresh Lua state with a full standard library, so
|
||||||
|
-- they stay blocked unless the mod declares the `compute`
|
||||||
|
-- permission (the mod's own source runs in the worker, and the
|
||||||
|
-- worker ships source-only like every other mod file). The mod
|
||||||
|
-- must never receive arbitrary code from elsewhere: channels
|
||||||
|
-- carry data only.
|
||||||
|
if not (permissions or {}).compute then
|
||||||
|
error('love.thread needs the "compute" permission in manifest.json', 2)
|
||||||
|
end
|
||||||
|
return _G.love.thread
|
||||||
|
end
|
||||||
local hint = BLOCKED_LOVE[key]
|
local hint = BLOCKED_LOVE[key]
|
||||||
if hint then
|
if hint then
|
||||||
error(("love.%s is not available to mods%s"):format(key,
|
error(("love.%s is not available to mods%s"):format(key,
|
||||||
@@ -215,7 +233,7 @@ function Sandbox.envFor(opts)
|
|||||||
opts = opts or {}
|
opts = opts or {}
|
||||||
local compat = opts.compat
|
local compat = opts.compat
|
||||||
local env = baseGlobals()
|
local env = baseGlobals()
|
||||||
env.love = loveFacade(compat)
|
env.love = loveFacade(compat, opts.permissions)
|
||||||
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
env.require = sandboxedRequire(opts.modId, opts.permissions, compat)
|
||||||
local loader = sandboxedLoad(env)
|
local loader = sandboxedLoad(env)
|
||||||
env.load = loader
|
env.load = loader
|
||||||
|
|||||||
@@ -91,6 +91,28 @@ function f.rec(fields, opts)
|
|||||||
desc = "{" .. table.concat(parts, ", ") .. "}" }
|
desc = "{" .. table.concat(parts, ", ") .. "}" }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- An open record: the listed fields are typed (including f.id
|
||||||
|
-- cross-references) and everything else on the value passes through
|
||||||
|
-- unexamined, unlike f.rec's nested shapes, which reject any key they do
|
||||||
|
-- not name. Map objects are why this exists -- NPCs, signs, items, warps
|
||||||
|
-- and static encounters all share one array, and only a full union of
|
||||||
|
-- every kind's shape could describe it as f.rec; that is a lot of surface
|
||||||
|
-- to keep in sync with the loader for fields nothing here needs to check.
|
||||||
|
-- f.partial types just the field that actually names another registry and
|
||||||
|
-- leaves every kind-specific field around it alone.
|
||||||
|
function f.partial(fields)
|
||||||
|
local names = {}
|
||||||
|
for name in pairs(fields) do names[#names + 1] = name end
|
||||||
|
table.sort(names)
|
||||||
|
local parts = {}
|
||||||
|
for _, name in ipairs(names) do
|
||||||
|
local ft = fields[name]
|
||||||
|
parts[#parts + 1] = name .. (ft.kind == "opt" and "?" or "")
|
||||||
|
end
|
||||||
|
return { kind = "partial", fields = fields,
|
||||||
|
desc = "{" .. table.concat(parts, ", ") .. ", ...}" }
|
||||||
|
end
|
||||||
|
|
||||||
function f.union(alts)
|
function f.union(alts)
|
||||||
local parts = {}
|
local parts = {}
|
||||||
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
|
for _, alt in ipairs(alts) do parts[#parts + 1] = alt.desc end
|
||||||
@@ -197,6 +219,23 @@ checkValue = function(t, value, path, patchMode, errors, top)
|
|||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
if kind == "partial" then
|
||||||
|
-- the open counterpart of "rec": listed fields are checked exactly
|
||||||
|
-- like a rec's, and any key not listed is left alone rather than
|
||||||
|
-- flagged, so a heterogeneous blob (map objects) can have one field
|
||||||
|
-- typed without every other shape sharing the array being rejected
|
||||||
|
if type(value) ~= "table" then return fail(errors, path, t.desc, value) end
|
||||||
|
for key, ft in pairs(t.fields) do
|
||||||
|
local sub = value[key]
|
||||||
|
if sub ~= nil then
|
||||||
|
checkValue(ft, sub, path .. "." .. tostring(key), patchMode, errors)
|
||||||
|
elseif ft.kind ~= "opt" and not patchMode then
|
||||||
|
errors[#errors + 1] = ("%s.%s: missing required field (%s)")
|
||||||
|
:format(path, key, ft.desc)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
if kind == "union" then
|
if kind == "union" then
|
||||||
for _, alt in ipairs(t.alts) do
|
for _, alt in ipairs(t.alts) do
|
||||||
local scratch = {}
|
local scratch = {}
|
||||||
@@ -299,7 +338,7 @@ collectRefs = function(t, value, path, out)
|
|||||||
for k, v in pairs(value) do
|
for k, v in pairs(value) do
|
||||||
collectRefs(t.value, v, path .. "." .. tostring(k), out)
|
collectRefs(t.value, v, path .. "." .. tostring(k), out)
|
||||||
end
|
end
|
||||||
elseif kind == "rec" and type(value) == "table" then
|
elseif (kind == "rec" or kind == "partial") and type(value) == "table" then
|
||||||
for key, ft in pairs(t.fields) do
|
for key, ft in pairs(t.fields) do
|
||||||
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
|
collectRefs(ft, value[key], path .. "." .. tostring(key), out)
|
||||||
end
|
end
|
||||||
@@ -377,11 +416,19 @@ function Schemas.crossValidate(loader, data)
|
|||||||
and loader.content[ref.registry]
|
and loader.content[ref.registry]
|
||||||
-- A registry with no home in this generation has no id space to
|
-- A registry with no home in this generation has no id space to
|
||||||
-- check against: its base view resolves to nothing, so EVERY
|
-- check against: its base view resolves to nothing, so EVERY
|
||||||
-- reference into it would read as dangling. Gold's species carry a
|
-- reference into it would read as dangling. `transitions` is the
|
||||||
-- growthRate and an evolution method like Red's do; the ids are
|
-- standing example -- Gold draws its own battle intro and never
|
||||||
-- fine, it is the Gen 1 `growth_rates` / `evolution_methods`
|
-- reads the merged table, so a mod's transition id there is
|
||||||
-- namespaces that are not there to confirm them. Skipped for the
|
-- unconfirmable, not wrong. `growth_rates` and `evolution_methods`
|
||||||
-- same reason an undeclared registry is: unknown, not wrong.
|
-- used to sit in that category too, back when Gold had no id space
|
||||||
|
-- for either. Both are routed now: `growth_rates` keeps its Gen 1
|
||||||
|
-- path and is seeded from data.pokemon.growthRates (the extractor's
|
||||||
|
-- Gold curves, src/battle/gen2/Mon.lua), and `evolution_methods`
|
||||||
|
-- routes to gen2EvolutionMethods (src/core/gen2/Evolution.lua's
|
||||||
|
-- literal EVOLVE_* ids, present with or without a ROM import). A
|
||||||
|
-- Gold species' growthRate or evolution method is checked against
|
||||||
|
-- real ids exactly like a Red one's, so a genuine typo is still
|
||||||
|
-- caught here rather than waved through as "unknown, not wrong."
|
||||||
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
|
if refRegistry and Schemas.gatedFor(ref.registry, loader.generation) then
|
||||||
refRegistry = nil
|
refRegistry = nil
|
||||||
end
|
end
|
||||||
@@ -887,7 +934,15 @@ R.maps = {
|
|||||||
destMap = f.str, destWarp = f.int(0),
|
destMap = f.str, destWarp = f.int(0),
|
||||||
destGroup = f.opt(f.int(0)),
|
destGroup = f.opt(f.int(0)),
|
||||||
destMapNum = f.opt(f.int(0)) })),
|
destMapNum = f.opt(f.int(0)) })),
|
||||||
objects = f.opt(f.list(f.any)),
|
-- NPCs, signs, items, warps and static wild encounters all share this
|
||||||
|
-- one array, with no field the loader could use to tell them apart
|
||||||
|
-- ahead of time -- an f.rec strict enough to describe every kind would
|
||||||
|
-- reject the others. f.partial types only `pokemon` (the static
|
||||||
|
-- encounter's species, OverworldController.lua's `d.pokemon` ->
|
||||||
|
-- BattleState.newWild) so a bad id is a load-time error, the same as
|
||||||
|
-- an encounter slot's species, instead of the crash newWild has no
|
||||||
|
-- guard against. Every other object field passes through untouched.
|
||||||
|
objects = f.opt(f.list(f.partial{ pokemon = f.opt(f.id("pokemon")) })),
|
||||||
signs = f.opt(f.list(f.any)),
|
signs = f.opt(f.list(f.any)),
|
||||||
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
|
connections = f.opt(f.map(f.enum{ "north", "south", "east", "west" }, f.any)),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
-- Minimal second-window process for src/render/DesktopScreen.lua.
|
||||||
|
|
||||||
|
local DesktopCompanion = {}
|
||||||
|
|
||||||
|
function DesktopCompanion.install(config)
|
||||||
|
local enet = require("enet")
|
||||||
|
local host = assert(enet.host_create())
|
||||||
|
local peer = assert(host:connect(("127.0.0.1:%d"):format(config.port), 2))
|
||||||
|
local image, sourceW, sourceH, preference
|
||||||
|
local background = { 0, 0, 0, 1 }
|
||||||
|
local connected, commandedQuit = false, false
|
||||||
|
local pointerDown = false
|
||||||
|
local started = love.timer.getTime()
|
||||||
|
local lastContact = started
|
||||||
|
|
||||||
|
local function send(kind, payload)
|
||||||
|
if not connected then return end
|
||||||
|
pcall(peer.send, peer, kind .. config.token .. (payload or ""), 1, "reliable")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function receiveFrame(data)
|
||||||
|
local prefix = "F" .. config.token .. "\n"
|
||||||
|
if data:sub(1, #prefix) ~= prefix then return end
|
||||||
|
local split = data:find("\n", #prefix + 1, true)
|
||||||
|
if not split then return end
|
||||||
|
local w, h, rgb, mode = data:sub(#prefix + 1, split - 1)
|
||||||
|
:match("^(%d+),(%d+),(%d+),([%w_:.-]+)$")
|
||||||
|
w, h, rgb = tonumber(w), tonumber(h), tonumber(rgb)
|
||||||
|
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return end
|
||||||
|
local ok, raw = pcall(love.data.decompress, "string", "lz4",
|
||||||
|
data:sub(split + 1))
|
||||||
|
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return end
|
||||||
|
local made, pixels = pcall(love.image.newImageData, w, h, "rgba8", raw)
|
||||||
|
if not made then return end
|
||||||
|
if not image or sourceW ~= w or sourceH ~= h then
|
||||||
|
if image and image.release then image:release() end
|
||||||
|
image = love.graphics.newImage(pixels)
|
||||||
|
else
|
||||||
|
image:replacePixels(pixels)
|
||||||
|
end
|
||||||
|
sourceW, sourceH, preference = w, h, mode
|
||||||
|
image:setFilter(mode:find("cover", 1, true) and "linear" or "nearest",
|
||||||
|
mode:find("cover", 1, true) and "linear" or "nearest")
|
||||||
|
background = {
|
||||||
|
math.floor(rgb / 0x10000) % 0x100 / 255,
|
||||||
|
math.floor(rgb / 0x100) % 0x100 / 255,
|
||||||
|
rgb % 0x100 / 255, 1,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function service()
|
||||||
|
while true do
|
||||||
|
local event = host:service(0)
|
||||||
|
if not event then break end
|
||||||
|
if event.type == "connect" then
|
||||||
|
connected, lastContact = true, love.timer.getTime()
|
||||||
|
send("H")
|
||||||
|
elseif event.type == "receive" then
|
||||||
|
lastContact = love.timer.getTime()
|
||||||
|
if event.data == "Q" .. config.token then
|
||||||
|
commandedQuit = true
|
||||||
|
love.event.quit()
|
||||||
|
elseif event.data ~= "P" .. config.token then
|
||||||
|
receiveFrame(event.data)
|
||||||
|
end
|
||||||
|
elseif event.type == "disconnect" then
|
||||||
|
love.event.quit()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function placement()
|
||||||
|
if not image then return 0, 0, 1 end
|
||||||
|
local ww, wh = love.graphics.getDimensions()
|
||||||
|
local cover = preference and preference:find("cover", 1, true)
|
||||||
|
local scale = (cover and math.max or math.min)(ww / sourceW, wh / sourceH)
|
||||||
|
return (ww - sourceW * scale) / 2, (wh - sourceH * scale) / 2, scale
|
||||||
|
end
|
||||||
|
|
||||||
|
local function input(action, x, y)
|
||||||
|
if not image then return false end
|
||||||
|
local dx, dy, scale = placement()
|
||||||
|
local sx, sy = math.floor((x - dx) / scale), math.floor((y - dy) / scale)
|
||||||
|
if sx < 0 or sy < 0 or sx >= sourceW or sy >= sourceH then return false end
|
||||||
|
send("I", ("\n%s,%d,%d"):format(action, sx, sy))
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.update()
|
||||||
|
service()
|
||||||
|
local t = love.timer.getTime()
|
||||||
|
if (not connected and t - started > 5) or t - lastContact > 5 then
|
||||||
|
love.event.quit()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.draw()
|
||||||
|
love.graphics.clear(background[1], background[2], background[3], background[4])
|
||||||
|
if not image then return end
|
||||||
|
local x, y, scale = placement()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.draw(image, x, y, 0, scale, scale)
|
||||||
|
end
|
||||||
|
|
||||||
|
function love.mousepressed(x, y, button)
|
||||||
|
if button == 1 then pointerDown = input("down", x, y) end
|
||||||
|
end
|
||||||
|
function love.mousereleased(x, y, button)
|
||||||
|
if button == 1 and pointerDown then
|
||||||
|
if not input("up", x, y) then send("I", "\ncancel,0,0") end
|
||||||
|
pointerDown = false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
function love.touchpressed(_, x, y) input("down", x, y) end
|
||||||
|
function love.touchreleased(_, x, y) input("up", x, y) end
|
||||||
|
function love.keypressed(key)
|
||||||
|
if key == "escape" then love.event.quit() end
|
||||||
|
end
|
||||||
|
function love.quit()
|
||||||
|
if not commandedQuit then send("C") end
|
||||||
|
pcall(peer.disconnect_now, peer)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return DesktopCompanion
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
-- Cross-platform desktop secondary display. LOVE owns one window, so a
|
||||||
|
-- second minimal instance of this same app owns the companion window. ENet
|
||||||
|
-- is bundled with LOVE; binding it to loopback keeps frames and input local.
|
||||||
|
|
||||||
|
local Platform = require("src.core.Platform")
|
||||||
|
local HostShell = require("src.core.HostShell")
|
||||||
|
local okEnet, enet = pcall(require, "enet")
|
||||||
|
|
||||||
|
local DesktopScreen = {}
|
||||||
|
local state = {
|
||||||
|
enabled = false, blocked = false, host = nil, peer = nil,
|
||||||
|
token = nil, port = nil, touches = {}, retryAt = 0, heartbeatAt = 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function now()
|
||||||
|
return love and love.timer and love.timer.getTime and love.timer.getTime()
|
||||||
|
or os.clock()
|
||||||
|
end
|
||||||
|
|
||||||
|
local function destroy(sendQuit)
|
||||||
|
if state.peer and sendQuit then
|
||||||
|
pcall(state.peer.send, state.peer, "Q" .. state.token, 1, "reliable")
|
||||||
|
end
|
||||||
|
if state.peer then pcall(state.peer.disconnect_now, state.peer) end
|
||||||
|
if state.host then pcall(state.host.destroy, state.host) end
|
||||||
|
state.host, state.peer, state.token, state.port = nil, nil, nil, nil
|
||||||
|
state.touches = {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function token()
|
||||||
|
local seed = table.concat({ tostring(os.time()), tostring(now()), tostring({}) }, ":")
|
||||||
|
local digest = love.data.hash("sha256", seed)
|
||||||
|
return love.data.encode("string", "hex", digest):sub(1, 24)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function start()
|
||||||
|
if state.host or state.blocked or now() < state.retryAt then
|
||||||
|
return state.host ~= nil
|
||||||
|
end
|
||||||
|
local base = 49152 + math.floor(now() * 1000) % 12000
|
||||||
|
for attempt = 0, 31 do
|
||||||
|
local port = 49152 + (base - 49152 + attempt * 37) % 12000
|
||||||
|
local ok, host = pcall(enet.host_create,
|
||||||
|
("127.0.0.1:%d"):format(port), 1, 2)
|
||||||
|
if ok and host then
|
||||||
|
state.host, state.port, state.token = host, port, token()
|
||||||
|
local launched = HostShell.spawnSelfDetached({
|
||||||
|
("--display-companion=%d,%s"):format(port, state.token),
|
||||||
|
})
|
||||||
|
if launched then return true end
|
||||||
|
destroy(false)
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
state.retryAt = now() + 1
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function service()
|
||||||
|
if not state.enabled or state.blocked then return end
|
||||||
|
if not state.host and not start() then return end
|
||||||
|
while state.host do
|
||||||
|
local ok, event = pcall(state.host.service, state.host, 0)
|
||||||
|
if not ok then
|
||||||
|
destroy(false)
|
||||||
|
state.retryAt = now() + 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not event then break end
|
||||||
|
if event.type == "receive" then
|
||||||
|
local data = event.data or ""
|
||||||
|
if data == "H" .. state.token then
|
||||||
|
state.peer = event.peer
|
||||||
|
elseif event.peer == state.peer
|
||||||
|
and data:sub(1, #state.token + 2) == "I" .. state.token .. "\n" then
|
||||||
|
state.touches[#state.touches + 1] = data:sub(#state.token + 3)
|
||||||
|
elseif event.peer == state.peer and data == "C" .. state.token then
|
||||||
|
state.blocked = true
|
||||||
|
destroy(false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
elseif event.type == "disconnect" and event.peer == state.peer then
|
||||||
|
destroy(false)
|
||||||
|
state.retryAt = now() + 1
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if state.peer and now() >= state.heartbeatAt then
|
||||||
|
state.heartbeatAt = now() + 1
|
||||||
|
pcall(state.peer.send, state.peer, "P" .. state.token, 1, "unreliable")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function DesktopScreen.usable()
|
||||||
|
return okEnet and enet ~= nil and Platform.canSpawnProcess()
|
||||||
|
end
|
||||||
|
|
||||||
|
function DesktopScreen.available()
|
||||||
|
return DesktopScreen.detected()
|
||||||
|
end
|
||||||
|
|
||||||
|
function DesktopScreen.detected()
|
||||||
|
service()
|
||||||
|
return state.peer ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function DesktopScreen.push(imageData, w, h, background, preference)
|
||||||
|
service()
|
||||||
|
if not state.peer or not imageData or not imageData.getString then return false end
|
||||||
|
w, h = tonumber(w), tonumber(h)
|
||||||
|
if not w or not h or w < 1 or h < 1 or w > 4096 or h > 4096 then return false end
|
||||||
|
local ok, raw = pcall(imageData.getString, imageData)
|
||||||
|
if not ok or type(raw) ~= "string" or #raw ~= w * h * 4 then return false end
|
||||||
|
local packed = love.data.compress("string", "lz4", raw, 1)
|
||||||
|
local header = ("F%s\n%d,%d,%u,%s\n"):format(state.token, w, h,
|
||||||
|
tonumber(background) or 0, tostring(preference or "auto"):gsub("[^%w_:.-]", ""))
|
||||||
|
local sent = pcall(state.peer.send, state.peer, header .. packed, 0, "reliable")
|
||||||
|
return sent
|
||||||
|
end
|
||||||
|
|
||||||
|
function DesktopScreen.pollTouch()
|
||||||
|
service()
|
||||||
|
return table.remove(state.touches, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
function DesktopScreen.setEnabled(on)
|
||||||
|
on = on == true
|
||||||
|
if on == state.enabled then
|
||||||
|
if on then service() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
state.enabled = on
|
||||||
|
state.blocked = false
|
||||||
|
if on then start(); service() else destroy(true) end
|
||||||
|
end
|
||||||
|
|
||||||
|
return DesktopScreen
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
-- Optional game viewport inside the OS window. A layout mod may reserve any
|
||||||
|
-- window-space rectangle through render.viewport; the game then renders as if
|
||||||
|
-- that rectangle were its whole display. With no subscriber this module is a
|
||||||
|
-- pass-through and allocates no canvas.
|
||||||
|
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
|
||||||
|
local Viewport = {
|
||||||
|
rect = nil,
|
||||||
|
full = nil,
|
||||||
|
canvas = nil,
|
||||||
|
generation = nil,
|
||||||
|
frameActive = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function finite(value)
|
||||||
|
return type(value) == "number" and value == value
|
||||||
|
and value > -math.huge and value < math.huge
|
||||||
|
end
|
||||||
|
|
||||||
|
local function realMetrics()
|
||||||
|
local G = love.graphics
|
||||||
|
local w, h = G.getDimensions()
|
||||||
|
local pw, ph = w, h
|
||||||
|
if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
|
||||||
|
local dpiX = w > 0 and pw / w or 1
|
||||||
|
local dpiY = h > 0 and ph / h or 1
|
||||||
|
if dpiX < 1e-6 then dpiX = 1 end
|
||||||
|
if dpiY < 1e-6 then dpiY = 1 end
|
||||||
|
return math.max(1, w), math.max(1, h),
|
||||||
|
math.max(1, pw), math.max(1, ph), dpiX, dpiY
|
||||||
|
end
|
||||||
|
|
||||||
|
local function clampRect(value, w, h)
|
||||||
|
if type(value) ~= "table" then
|
||||||
|
return { x = 0, y = 0, width = w, height = h }
|
||||||
|
end
|
||||||
|
local x = finite(value.x) and math.floor(value.x) or 0
|
||||||
|
local y = finite(value.y) and math.floor(value.y) or 0
|
||||||
|
local rw = finite(value.width) and math.floor(value.width) or w
|
||||||
|
local rh = finite(value.height) and math.floor(value.height) or h
|
||||||
|
x = math.max(0, math.min(x, w - 1))
|
||||||
|
y = math.max(0, math.min(y, h - 1))
|
||||||
|
rw = math.max(1, math.min(rw, w - x))
|
||||||
|
rh = math.max(1, math.min(rh, h - y))
|
||||||
|
return { x = x, y = y, width = rw, height = rh }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function sameSize(canvas, w, h)
|
||||||
|
return canvas and canvas:getWidth() == w and canvas:getHeight() == h
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.begin(generation)
|
||||||
|
local w, h, pw, ph, dpiX, dpiY = realMetrics()
|
||||||
|
local context = {
|
||||||
|
width = w, height = h, pixelWidth = pw, pixelHeight = ph,
|
||||||
|
dpiX = dpiX, dpiY = dpiY, generation = generation,
|
||||||
|
}
|
||||||
|
local requested
|
||||||
|
if Runtime.wantsHook("render.viewport") then
|
||||||
|
requested = Runtime.call("render.viewport", function(ctx)
|
||||||
|
return { x = 0, y = 0, width = ctx.width, height = ctx.height }
|
||||||
|
end, context)
|
||||||
|
end
|
||||||
|
local rect = clampRect(requested, w, h)
|
||||||
|
Viewport.full = context
|
||||||
|
Viewport.rect = rect
|
||||||
|
Viewport.generation = generation
|
||||||
|
local active = type(requested) == "table" and requested.capture == true
|
||||||
|
or rect.x ~= 0 or rect.y ~= 0
|
||||||
|
or rect.width ~= w or rect.height ~= h
|
||||||
|
Viewport.frameActive = active
|
||||||
|
if active then
|
||||||
|
if not sameSize(Viewport.canvas, rect.width, rect.height) then
|
||||||
|
if Viewport.canvas and Viewport.canvas.release then
|
||||||
|
Viewport.canvas:release()
|
||||||
|
end
|
||||||
|
Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
|
||||||
|
Viewport.canvas:setFilter("nearest", "nearest")
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if Viewport.canvas and Viewport.canvas.release then
|
||||||
|
Viewport.canvas:release()
|
||||||
|
end
|
||||||
|
Viewport.canvas = nil
|
||||||
|
end
|
||||||
|
return rect
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.active()
|
||||||
|
return Viewport.frameActive == true and Viewport.canvas ~= nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.dimensions()
|
||||||
|
if Viewport.active() then
|
||||||
|
return Viewport.rect.width, Viewport.rect.height
|
||||||
|
end
|
||||||
|
return love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.pixelDimensions()
|
||||||
|
if Viewport.active() then
|
||||||
|
if Viewport.canvas.getPixelDimensions then
|
||||||
|
local w, h = Viewport.canvas:getPixelDimensions()
|
||||||
|
return math.max(1, w), math.max(1, h)
|
||||||
|
end
|
||||||
|
return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
|
||||||
|
math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
|
||||||
|
end
|
||||||
|
if love.graphics.getPixelDimensions then
|
||||||
|
return love.graphics.getPixelDimensions()
|
||||||
|
end
|
||||||
|
return love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.fullDimensions()
|
||||||
|
if Viewport.full then return Viewport.full.width, Viewport.full.height end
|
||||||
|
return love.graphics.getDimensions()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.target()
|
||||||
|
return Viewport.canvas
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.setTarget()
|
||||||
|
love.graphics.setCanvas(Viewport.canvas)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.toLocal(x, y)
|
||||||
|
local rect = Viewport.rect
|
||||||
|
if not rect then return x, y, true end
|
||||||
|
local lx, ly = x - rect.x, y - rect.y
|
||||||
|
return lx, ly,
|
||||||
|
lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.localSafeRect(x, y, w, h)
|
||||||
|
local rect = Viewport.rect
|
||||||
|
if not Viewport.active() or not rect then return x, y, w, h end
|
||||||
|
local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
|
||||||
|
local x2 = math.min(x + w, rect.x + rect.width)
|
||||||
|
local y2 = math.min(y + h, rect.y + rect.height)
|
||||||
|
if x2 <= x1 or y2 <= y1 then
|
||||||
|
return 0, 0, rect.width, rect.height
|
||||||
|
end
|
||||||
|
return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.finish(game)
|
||||||
|
if not Viewport.active() then return end
|
||||||
|
local G = love.graphics
|
||||||
|
local rect, full = Viewport.rect, Viewport.full
|
||||||
|
G.setCanvas()
|
||||||
|
G.push("all")
|
||||||
|
G.origin()
|
||||||
|
G.setScissor()
|
||||||
|
G.setShader()
|
||||||
|
G.setBlendMode("alpha")
|
||||||
|
G.clear(0, 0, 0, 1)
|
||||||
|
local context = {
|
||||||
|
canvas = Viewport.canvas,
|
||||||
|
x = rect.x, y = rect.y, width = rect.width, height = rect.height,
|
||||||
|
windowWidth = full.width, windowHeight = full.height,
|
||||||
|
dpiX = full.dpiX, dpiY = full.dpiY,
|
||||||
|
generation = Viewport.generation,
|
||||||
|
}
|
||||||
|
Runtime.call("render.window", function(_, ctx)
|
||||||
|
G.setColor(1, 1, 1, 1)
|
||||||
|
G.draw(ctx.canvas, ctx.x, ctx.y)
|
||||||
|
end, game, context)
|
||||||
|
G.pop()
|
||||||
|
end
|
||||||
|
|
||||||
|
function Viewport.reset()
|
||||||
|
Viewport.frameActive = false
|
||||||
|
Viewport.rect = nil
|
||||||
|
Viewport.full = nil
|
||||||
|
Viewport.generation = nil
|
||||||
|
if Viewport.canvas and Viewport.canvas.release then
|
||||||
|
Viewport.canvas:release()
|
||||||
|
end
|
||||||
|
Viewport.canvas = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return Viewport
|
||||||
@@ -12,8 +12,10 @@ local PaletteFX = require("src.render.PaletteFX")
|
|||||||
local Pipelines = require("src.render.Pipelines")
|
local Pipelines = require("src.render.Pipelines")
|
||||||
local PixelCanvas = require("src.render.PixelCanvas")
|
local PixelCanvas = require("src.render.PixelCanvas")
|
||||||
local Runtime = require("src.mods.Runtime")
|
local Runtime = require("src.mods.Runtime")
|
||||||
|
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 = {}
|
||||||
|
|
||||||
@@ -69,11 +71,9 @@ Renderer.UPRIGHT_MARGIN = 160
|
|||||||
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
||||||
-- on BOTH axes (square).
|
-- on BOTH axes (square).
|
||||||
local function displayMetrics()
|
local function displayMetrics()
|
||||||
local ww, wh = love.graphics.getDimensions()
|
local ww, wh = GameViewport.dimensions()
|
||||||
local pw, ph = ww, wh
|
local pw, ph = ww, wh
|
||||||
if love.graphics.getPixelDimensions then
|
pw, ph = GameViewport.pixelDimensions()
|
||||||
pw, ph = love.graphics.getPixelDimensions()
|
|
||||||
end
|
|
||||||
local dpiX, dpiY = 1, 1
|
local dpiX, dpiY = 1, 1
|
||||||
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
||||||
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
||||||
@@ -85,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()
|
||||||
@@ -94,6 +100,7 @@ function Renderer:init()
|
|||||||
-- reason -- worldViewSize() already works in drawable pixels.
|
-- reason -- worldViewSize() already works in drawable pixels.
|
||||||
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
||||||
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
||||||
|
self.battleHUDCanvas = nil
|
||||||
self.worldCanvas = nil
|
self.worldCanvas = nil
|
||||||
self.worldActive = false
|
self.worldActive = false
|
||||||
-- tilt mode only: a transparent overlay canvas the size of the world
|
-- tilt mode only: a transparent overlay canvas the size of the world
|
||||||
@@ -202,10 +209,38 @@ function Renderer:setUISize(w, h)
|
|||||||
w, h = math.floor(w), math.floor(h)
|
w, h = math.floor(w), math.floor(h)
|
||||||
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
||||||
if self.canvas and self.canvas.release then self.canvas:release() end
|
if self.canvas and self.canvas.release then self.canvas:release() end
|
||||||
|
if self.battleHUDCanvas and self.battleHUDCanvas.release then
|
||||||
|
self.battleHUDCanvas:release()
|
||||||
|
end
|
||||||
|
self.battleHUDCanvas = nil
|
||||||
self.uiWidth, self.uiHeight = w, h
|
self.uiWidth, self.uiHeight = w, h
|
||||||
self.canvas = PixelCanvas.new(w, h, "nearest")
|
self.canvas = PixelCanvas.new(w, h, "nearest")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Transparent native-pixel surface for an extended WIDE battle HUD. The
|
||||||
|
-- battle scene remains in `canvas`; endFrame places registered HUD regions
|
||||||
|
-- afterward in physical-window space.
|
||||||
|
function Renderer:beginBattleHUDPass()
|
||||||
|
local w, h = self:uiSize()
|
||||||
|
if not self.battleHUDCanvas
|
||||||
|
or self.battleHUDCanvas:getWidth() ~= w
|
||||||
|
or self.battleHUDCanvas:getHeight() ~= h then
|
||||||
|
if self.battleHUDCanvas and self.battleHUDCanvas.release then
|
||||||
|
self.battleHUDCanvas:release()
|
||||||
|
end
|
||||||
|
self.battleHUDCanvas = PixelCanvas.new(w, h, "nearest")
|
||||||
|
end
|
||||||
|
local previous = love.graphics.getCanvas and love.graphics.getCanvas()
|
||||||
|
or self.canvas
|
||||||
|
love.graphics.setCanvas(self.battleHUDCanvas)
|
||||||
|
love.graphics.clear(0, 0, 0, 0)
|
||||||
|
return previous
|
||||||
|
end
|
||||||
|
|
||||||
|
function Renderer:endBattleHUDPass(previous)
|
||||||
|
love.graphics.setCanvas(previous or self.canvas)
|
||||||
|
end
|
||||||
|
|
||||||
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
||||||
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
||||||
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
||||||
@@ -246,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
|
||||||
@@ -671,6 +710,17 @@ end
|
|||||||
-- centred letterbox. Declared during the element's own draw, in UI-canvas
|
-- centred letterbox. Declared during the element's own draw, in UI-canvas
|
||||||
-- pixels, and consumed by endFrame this frame only.
|
-- pixels, and consumed by endFrame this frame only.
|
||||||
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
|
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
|
||||||
|
local function addUIAnchor(renderer, x, y, w, h, anchor, windowClamped,
|
||||||
|
canvas, extract)
|
||||||
|
renderer.uiAnchors = renderer.uiAnchors or {}
|
||||||
|
renderer.uiAnchors[#renderer.uiAnchors + 1] = {
|
||||||
|
x = x, y = y, w = w, h = h, anchor = anchor,
|
||||||
|
windowClamped = windowClamped and true or false,
|
||||||
|
canvas = canvas,
|
||||||
|
extract = extract ~= false,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
function Renderer:setUIAnchor(x, y, w, h, anchor)
|
function Renderer:setUIAnchor(x, y, w, h, anchor)
|
||||||
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
|
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
|
||||||
-- save.options.uiLayout): every element stays where it was drawn in the
|
-- save.options.uiLayout): every element stays where it was drawn in the
|
||||||
@@ -684,9 +734,16 @@ function Renderer:setUIAnchor(x, y, w, h, anchor)
|
|||||||
-- battle -- keeps every element inside it, so the box blits where it was
|
-- battle -- keeps every element inside it, so the box blits where it was
|
||||||
-- drawn in the canvas instead of being pulled to the window edge.
|
-- drawn in the canvas instead of being pulled to the window edge.
|
||||||
if self.uiAnchorHold then return end
|
if self.uiAnchorHold then return end
|
||||||
self.uiAnchors = self.uiAnchors or {}
|
addUIAnchor(self, x, y, w, h, anchor, false, self.canvas, true)
|
||||||
self.uiAnchors[#self.uiAnchors + 1] =
|
end
|
||||||
{ x = x, y = y, w = w, h = h, anchor = anchor }
|
|
||||||
|
-- Battle-owned window-space placement. Unlike ordinary UI anchors this is
|
||||||
|
-- intentionally allowed while BattleState holds general dialogue/menu
|
||||||
|
-- anchors inside the battle surface. Callers must gate it to an explicit
|
||||||
|
-- battle HUD mode.
|
||||||
|
function Renderer:setBattleUIAnchor(x, y, w, h, anchor)
|
||||||
|
addUIAnchor(self, x, y, w, h, anchor, true,
|
||||||
|
self.battleHUDCanvas or self.canvas, false)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||||
@@ -698,8 +755,10 @@ end
|
|||||||
-- When GBC FX is active the composite is drawn into presentCanvas and
|
-- When GBC FX is active the composite is drawn into presentCanvas and
|
||||||
-- 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)
|
||||||
love.graphics.setCanvas()
|
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()
|
||||||
@@ -707,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.
|
||||||
@@ -724,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.
|
||||||
@@ -799,6 +858,8 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
||||||
-- a visibly brighter border.
|
-- a visibly brighter border.
|
||||||
local clearR, clearG, clearB = 0, 0, 0
|
local clearR, clearG, clearB = 0, 0, 0
|
||||||
|
local extendedBlackBand = false
|
||||||
|
local bandR, bandG, bandB = 1, 1, 1
|
||||||
if not self.worldActive then
|
if not self.worldActive then
|
||||||
local ok, Game = pcall(require, "src.core.Game")
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
local stack = ok and Game and Game.stack
|
local stack = ok and Game and Game.stack
|
||||||
@@ -825,7 +886,15 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
|
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
|
||||||
-- painted the whole phone white on New Game and in battle (#864), so
|
-- painted the whole phone white on New Game and in battle (#864), so
|
||||||
-- the lock keeps the default black bars.
|
-- the lock keeps the default black bars.
|
||||||
if state and state.letterboxWhite
|
if state and state.extendedBlackHUD and state:extendedBlackHUD()
|
||||||
|
and not FaithfulRes.scaleCap() then
|
||||||
|
-- Extended/Black keeps the author's black surround, but extends the
|
||||||
|
-- fixed battle's paper field vertically through the physical window.
|
||||||
|
-- The band uses the exact centred fixed-width composition bounds, so
|
||||||
|
-- only vertical black bars remain at the sides.
|
||||||
|
extendedBlackBand = true
|
||||||
|
bandR, bandG, bandB = PaletteFX.paperShade(Game and Game.data)
|
||||||
|
elseif state and state.letterboxWhite
|
||||||
and not (state.bgMode and state:bgMode() == "black")
|
and not (state.bgMode and state:bgMode() == "black")
|
||||||
and not FaithfulRes.scaleCap() then
|
and not FaithfulRes.scaleCap() then
|
||||||
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
||||||
@@ -833,6 +902,10 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
end
|
end
|
||||||
love.graphics.setColor(clearR, clearG, clearB, 1)
|
love.graphics.setColor(clearR, clearG, clearB, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||||
|
if extendedBlackBand then
|
||||||
|
love.graphics.setColor(bandR, bandG, bandB, 1)
|
||||||
|
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||||
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
-- render.letterbox: SGB borders / custom void art in the bars around the
|
-- render.letterbox: SGB borders / custom void art in the bars around the
|
||||||
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
||||||
@@ -861,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)
|
||||||
@@ -873,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
|
||||||
@@ -881,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
|
||||||
@@ -893,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
|
||||||
@@ -904,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
|
||||||
@@ -936,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
|
||||||
@@ -947,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
|
||||||
@@ -969,12 +1042,28 @@ 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)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Extended/WORLD keeps the frozen world as the physical surround, but stock
|
||||||
|
-- Gen 1 back sprites rely on the battle's paper shade for visible highlights.
|
||||||
|
-- Back the exact fixed-width composition from physical top to bottom so only
|
||||||
|
-- the left and right sides expose the world. The battle canvas and detached
|
||||||
|
-- HUD remain transparent layers composited afterward.
|
||||||
|
-- A worldOverride is an arena provider's completed scene (for example,
|
||||||
|
-- StadiumBattleFX/Dramaless). It replaces the stock paper-backed battle
|
||||||
|
-- field, so never cover it with the native back-sprite fallback.
|
||||||
|
if self.extendedWorldBand and not self.worldOverride
|
||||||
|
and not FaithfulRes.scaleCap() then
|
||||||
|
local ok, Game = pcall(require, "src.core.Game")
|
||||||
|
love.graphics.setColor(PaletteFX.paperShade(ok and Game and Game.data))
|
||||||
|
love.graphics.rectangle("fill", uox, 0, uvpw, wh)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
-- UI: anchored regions against their screen edges, the rest in the classic
|
-- UI: anchored regions against their screen edges, the rest in the classic
|
||||||
-- centred letterbox. With nothing anchored this is the single blit it has
|
-- centred letterbox. With nothing anchored this is the single blit it has
|
||||||
-- always been.
|
-- always been.
|
||||||
@@ -997,14 +1086,23 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
if a.anchor == "bottom" then
|
if a.anchor == "bottom" then
|
||||||
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||||
dy = wh - gapB - dh
|
dy = wh - gapB - dh
|
||||||
|
elseif a.anchor == "top" then
|
||||||
|
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
||||||
|
dy = a.y * Uy
|
||||||
elseif a.anchor == "topright" then
|
elseif a.anchor == "topright" then
|
||||||
dx = ww - gapR - dw
|
dx = ww - gapR - dw
|
||||||
dy = a.y * Uy
|
dy = a.y * Uy
|
||||||
else -- unknown anchor: leave it where it is
|
else -- unknown anchor: leave it where it is
|
||||||
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
||||||
end
|
end
|
||||||
|
if a.windowClamped then
|
||||||
|
dx = math.max(0, math.min(math.max(0, ww - dw), dx))
|
||||||
|
dy = math.max(0, math.min(math.max(0, wh - dh), dy))
|
||||||
|
end
|
||||||
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
||||||
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
if a.extract then
|
||||||
|
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
for _, r in ipairs(rest) do
|
for _, r in ipairs(rest) do
|
||||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
|
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
|
||||||
@@ -1013,7 +1111,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
|
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
|
||||||
-- The zone scissors are computed from the same origin, so an SGB
|
-- The zone scissors are computed from the same origin, so an SGB
|
||||||
-- region travels with the element instead of staying in the letterbox.
|
-- region travels with the element instead of staying in the letterbox.
|
||||||
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
|
blit(p.a.canvas or self.canvas, Ux, Uy, zones, Ux, Uy,
|
||||||
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1064,7 +1162,7 @@ function Renderer:endFrame(zones, worldZones)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if present then
|
if present then
|
||||||
love.graphics.setCanvas()
|
GameViewport.setTarget()
|
||||||
-- Post-process pipelines run over the finished composite -- world, UI
|
-- Post-process pipelines run over the finished composite -- world, UI
|
||||||
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||||
-- LCD grid is then drawn over rather than something that smears the
|
-- LCD grid is then drawn over rather than something that smears the
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
-- Bridge to native secondary-display output (Android Presentation). The C
|
-- Shared secondary-display facade. Android uses its native Presentation
|
||||||
-- functions live in mobile/android/love/src/jni/love/src/common/android.cpp.
|
-- bridge; process-capable desktop hosts fall back to a companion window.
|
||||||
-- Everything is guarded: off Android, or if the symbols cannot be resolved,
|
-- Everything is guarded, so unsupported hosts keep the in-window layout.
|
||||||
-- this stays inert and the renderer keeps the in-window stacked layout.
|
|
||||||
|
|
||||||
local SecondScreen = {}
|
local SecondScreen = {}
|
||||||
local C = nil
|
local C = nil
|
||||||
local ffi = nil
|
local ffi = nil
|
||||||
|
local desktop = nil
|
||||||
|
local nativePresent = false
|
||||||
|
local nativeTarget = false
|
||||||
|
|
||||||
local function log(msg)
|
local function log(msg)
|
||||||
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
pcall(function() require("src.core.Logger").info("SecondScreen: %s", msg) end)
|
||||||
@@ -21,6 +23,10 @@ do
|
|||||||
int love_android_secondary_ready();
|
int love_android_secondary_ready();
|
||||||
void love_android_push_secondary(const void *rgba, int w, int h);
|
void love_android_push_secondary(const void *rgba, int w, int h);
|
||||||
void love_android_secondary_enable(int on);
|
void love_android_secondary_enable(int on);
|
||||||
|
int love_android_secondary_detected();
|
||||||
|
int love_android_present_secondary(const void *rgba, int w, int h,
|
||||||
|
unsigned int background, int cover);
|
||||||
|
void love_android_secondary_target(int target);
|
||||||
const char *love_android_poll_secondary_touch();
|
const char *love_android_poll_secondary_touch();
|
||||||
]])
|
]])
|
||||||
local okLib, lib = pcall(ffi.load, "love")
|
local okLib, lib = pcall(ffi.load, "love")
|
||||||
@@ -34,21 +40,74 @@ do
|
|||||||
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
|
log(("bridge symbols not found (ffi.load ok=%s); second display disabled")
|
||||||
:format(tostring(okLib)))
|
:format(tostring(okLib)))
|
||||||
end
|
end
|
||||||
|
if C then
|
||||||
|
local okDetected, detected = pcall(function()
|
||||||
|
return C.love_android_secondary_detected
|
||||||
|
end)
|
||||||
|
local okPresent, present = pcall(function()
|
||||||
|
return C.love_android_present_secondary
|
||||||
|
end)
|
||||||
|
local okTarget, target = pcall(function()
|
||||||
|
return C.love_android_secondary_target
|
||||||
|
end)
|
||||||
|
nativePresent = okDetected and detected ~= nil
|
||||||
|
and okPresent and present ~= nil
|
||||||
|
nativeTarget = okTarget and target ~= nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if not C then
|
||||||
|
local ok, backend = pcall(require, "src.render.DesktopScreen")
|
||||||
|
if ok and backend and backend.usable and backend.usable() then
|
||||||
|
desktop = backend
|
||||||
|
log("desktop companion backend ready")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function SecondScreen.usable()
|
function SecondScreen.usable()
|
||||||
return C ~= nil
|
return C ~= nil or desktop ~= nil
|
||||||
end
|
end
|
||||||
|
|
||||||
function SecondScreen.available()
|
function SecondScreen.available()
|
||||||
|
if desktop then return desktop.available() end
|
||||||
if not C then return false end
|
if not C then return false end
|
||||||
local ok, r = pcall(C.love_android_secondary_ready)
|
local ok, r = pcall(C.love_android_secondary_ready)
|
||||||
return ok and r ~= 0
|
return ok and r ~= 0
|
||||||
end
|
end
|
||||||
|
|
||||||
function SecondScreen.push(imageData, w, h)
|
-- A connected display is not necessarily the current Presentation yet. This
|
||||||
|
-- distinction lets a companion retry its first frame after hotplug/re-target.
|
||||||
|
function SecondScreen.detected()
|
||||||
|
if desktop then return desktop.detected() end
|
||||||
|
if nativePresent then
|
||||||
|
local ok, r = pcall(C.love_android_secondary_detected)
|
||||||
|
return ok and r ~= 0
|
||||||
|
end
|
||||||
|
return SecondScreen.available()
|
||||||
|
end
|
||||||
|
|
||||||
|
function SecondScreen.push(imageData, w, h, background, preference)
|
||||||
|
if desktop then
|
||||||
|
return desktop.push(imageData, w, h, background, preference)
|
||||||
|
end
|
||||||
if not C or not imageData then return false end
|
if not C or not imageData then return false end
|
||||||
|
if nativePresent and (background ~= nil or preference ~= nil) then
|
||||||
|
if nativeTarget then
|
||||||
|
local target = 0
|
||||||
|
if preference == "handheld" or preference == "handheld:cover" then
|
||||||
|
target = 1
|
||||||
|
elseif preference == "secondary" or preference == "secondary:cover" then
|
||||||
|
target = 2
|
||||||
|
end
|
||||||
|
pcall(C.love_android_secondary_target, target)
|
||||||
|
end
|
||||||
|
local cover = type(preference) == "string"
|
||||||
|
and preference:sub(-6) == ":cover"
|
||||||
|
local ok, shown = pcall(C.love_android_present_secondary,
|
||||||
|
imageData:getFFIPointer(), w, h, background or 0, cover and 1 or 0)
|
||||||
|
return ok and shown ~= 0
|
||||||
|
end
|
||||||
return pcall(function()
|
return pcall(function()
|
||||||
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
|
C.love_android_push_secondary(imageData:getFFIPointer(), w, h)
|
||||||
end)
|
end)
|
||||||
@@ -57,6 +116,7 @@ end
|
|||||||
-- Returns the oldest queued secondary-display event as "action,x,y", where
|
-- Returns the oldest queued secondary-display event as "action,x,y", where
|
||||||
-- coordinates are in the submitted frame's pixel space.
|
-- coordinates are in the submitted frame's pixel space.
|
||||||
function SecondScreen.pollTouch()
|
function SecondScreen.pollTouch()
|
||||||
|
if desktop then return desktop.pollTouch() end
|
||||||
if not C then return nil end
|
if not C then return nil end
|
||||||
local ok, event = pcall(function()
|
local ok, event = pcall(function()
|
||||||
return C.love_android_poll_secondary_touch()
|
return C.love_android_poll_secondary_touch()
|
||||||
@@ -66,6 +126,7 @@ function SecondScreen.pollTouch()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function SecondScreen.setEnabled(on)
|
function SecondScreen.setEnabled(on)
|
||||||
|
if desktop then return desktop.setEnabled(on) end
|
||||||
if not C then return end
|
if not C then return end
|
||||||
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
pcall(function() C.love_android_secondary_enable(on and 1 or 0) end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -358,42 +358,33 @@ end
|
|||||||
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
|
-- is the three-way answer BugContestResults_DidNotLeaveMons branches on:
|
||||||
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
|
-- BUGCONTEST_CAUGHT_MON 0, BUGCONTEST_BOXED_MON 1, BUGCONTEST_NO_CATCH 2
|
||||||
-- (constants/script_constants.asm).
|
-- (constants/script_constants.asm).
|
||||||
-- _CaughtAskNicknameText (data/text/common_2.asm:717). Not extracted: the
|
-- _CaughtAskNicknameText (data/text/common_2.asm:717), engine-printed so
|
||||||
-- routine that prints it is engine code and no script bytecode points at the
|
-- the extractor never reaches it.
|
||||||
-- string, so the extractor never reaches it.
|
|
||||||
local CONTEST_NICKNAME_PROMPT =
|
local CONTEST_NICKNAME_PROMPT =
|
||||||
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
|
Strings.source("Give a nickname to\nthe {STRBUF} you\nreceived?")
|
||||||
|
|
||||||
|
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123)
|
||||||
|
function Specials.askNickname(vm, mon)
|
||||||
|
nameMon(vm, mon.species)
|
||||||
|
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
|
||||||
|
if coroutine.yield({ kind = "yesorno" }) then
|
||||||
|
-- InitNickname (engine/pokemon/move_mon.asm:1787)
|
||||||
|
local h = hooks(vm)
|
||||||
|
local name = h.renameMon and Specials.block(vm, function(done)
|
||||||
|
h.renameMon(mon, done, { blank = true })
|
||||||
|
end)
|
||||||
|
-- _InitString's blank test (home/string.asm:6-30)
|
||||||
|
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
H.CheckPartyFullAfterContest = function(vm)
|
H.CheckPartyFullAfterContest = function(vm)
|
||||||
local Breeding = require("src.core.gen2.Breeding")
|
local Breeding = require("src.core.gen2.Breeding")
|
||||||
local result, mon =
|
local result, mon =
|
||||||
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
|
BugContest.collectCaughtMon(contestSave(vm), Breeding.PARTY_SIZE)
|
||||||
-- GiveANickname_YesNo sits on BOTH arms of CheckPartyFullAfterContest -- the
|
-- GiveANickname_YesNo runs on both contest arms, party and box
|
||||||
-- mon that joined the party and the one that went to the box -- and nowhere
|
|
||||||
-- else in the contest: BugContest_SetCaughtContestMon merely holds the catch
|
|
||||||
-- in wContestMon, so this is the only place the player is ever asked.
|
|
||||||
-- GetPokemonName runs first, which is what {STRBUF} reads.
|
|
||||||
if mon and result ~= BugContest.NO_CATCH then
|
if mon and result ~= BugContest.NO_CATCH then
|
||||||
nameMon(vm, mon.species)
|
Specials.askNickname(vm, mon)
|
||||||
-- GiveANickname_YesNo (engine/pokemon/caught_nickname.asm:123) is
|
|
||||||
-- `PrintText / jp YesNoBox`, so the prompt goes up over the box this page
|
|
||||||
-- left standing.
|
|
||||||
showRawHeld(vm, Strings(CONTEST_NICKNAME_PROMPT))
|
|
||||||
if coroutine.yield({ kind = "yesorno" }) then
|
|
||||||
-- `ld b, NAME_MON / callfar InitNickname`: the keyboard opens EMPTY on a
|
|
||||||
-- fresh catch (the Name Rater is the one that pre-fills), and InitNickname
|
|
||||||
-- copies the species name back over an empty entry -- so a cancelled
|
|
||||||
-- keyboard is the same as answering NO.
|
|
||||||
local h = hooks(vm)
|
|
||||||
local name = h.renameMon and Specials.block(vm, function(done)
|
|
||||||
h.renameMon(mon, done, { blank = true })
|
|
||||||
end)
|
|
||||||
-- _InitString's own blank test (home/string.asm:6-30): "zero or more
|
|
||||||
-- spaces followed by a null". The keyboard's blank cells are real
|
|
||||||
-- typeable characters, so an all-space entry has to be discarded the
|
|
||||||
-- same way an empty one is, not stored as a name of spaces.
|
|
||||||
if name and name:gsub(" ", "") ~= "" then mon.nickname = name end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
answer(vm, result)
|
answer(vm, result)
|
||||||
end
|
end
|
||||||
@@ -1005,7 +996,7 @@ end
|
|||||||
-- check is transcribed here rather than left to the screen, because its two
|
-- check is transcribed here rather than left to the screen, because its two
|
||||||
-- refusals are TEXT and the script has to see them before the machine opens:
|
-- refusals are TEXT and the script has to see them before the machine opens:
|
||||||
-- no coins at all, or no COIN_CASE to hold them.
|
-- no coins at all, or no COIN_CASE to hold them.
|
||||||
local COIN_CASE = 0x47 -- constants/item_constants.asm
|
local COIN_CASE = 0x36 -- constants/item_constants.asm:62
|
||||||
|
|
||||||
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
|
-- _NoCoinsText / _NoCoinCaseText, data/text/common_1.asm.
|
||||||
local NO_COINS_TEXT = "You have no coins."
|
local NO_COINS_TEXT = "You have no coins."
|
||||||
@@ -2409,7 +2400,9 @@ local STUB_ROWS = {
|
|||||||
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
|
{ "WaitForOtherPlayerToExit", nil, "link cable: nobody to wait for" },
|
||||||
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
|
{ "SetBitsForBattleRequest", nil, "link cable: no Gen 2 cable club" },
|
||||||
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
|
{ "SetBitsForTimeCapsuleRequest", nil, "link cable: no Time Capsule" },
|
||||||
{ "CheckTimeCapsuleCompatibility", 2, "link cable: no Gen 1 partner" },
|
-- maps/PokeCenter2F.asm:200-203: 2 is .MonMoveTooNew; 0 falls through to
|
||||||
|
-- WaitForLinkedFriend and lands on .FriendNotReady
|
||||||
|
{ "CheckTimeCapsuleCompatibility", 0, "link cable: no Gen 1 partner" },
|
||||||
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
|
{ "EnterTimeCapsule", nil, "link cable: no Time Capsule" },
|
||||||
{ "TradeCenter", nil, "link cable: no trade room" },
|
{ "TradeCenter", nil, "link cable: no trade room" },
|
||||||
{ "Colosseum", nil, "link cable: no battle room" },
|
{ "Colosseum", nil, "link cable: no battle room" },
|
||||||
|
|||||||
@@ -544,7 +544,12 @@ local function runCmd(self, cmd, op)
|
|||||||
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
||||||
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
||||||
if self.givePokeFn then
|
if self.givePokeFn then
|
||||||
self.givePokeFn(species, level, item)
|
local mon = self.givePokeFn(species, level, item)
|
||||||
|
-- engine/pokemon/move_mon.asm:1753-1757
|
||||||
|
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
||||||
|
if mon and trainer == 0 then
|
||||||
|
Specials.askNickname(self, mon)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
elseif op == "checkpoke" then
|
elseif op == "checkpoke" then
|
||||||
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
|
-- Script_checkpoke: IsInArray over wPartySpecies. Party only, so a boxed
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
local ItemEffects = require("src.inventory.ItemEffects")
|
local ItemEffects = require("src.inventory.ItemEffects")
|
||||||
local ListMenu = require("src.ui.ListMenu")
|
local ListMenu = require("src.ui.ListMenu")
|
||||||
|
local Runtime = require("src.mods.Runtime")
|
||||||
local TextBox = require("src.render.TextBox")
|
local TextBox = require("src.render.TextBox")
|
||||||
|
|
||||||
local BagMenu = {}
|
local BagMenu = {}
|
||||||
@@ -46,7 +47,16 @@ end
|
|||||||
-- the stack, so every exit that prints has to close it afterwards. For
|
-- the stack, so every exit that prints has to close it afterwards. For
|
||||||
-- every other item the picker popped itself first and closePicker's identity
|
-- every other item the picker popped itself first and closePicker's identity
|
||||||
-- check makes it a no-op (#252).
|
-- check makes it a no-op (#252).
|
||||||
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
--
|
||||||
|
-- Every result string used to fall through to this one unconditional
|
||||||
|
-- function with no seam around it: a mod could not suppress a message,
|
||||||
|
-- delay it behind a screen of its own, or replace the outcome for one item
|
||||||
|
-- id. The "item.use" hook wraps the whole dispatch (not a name per
|
||||||
|
-- result -- a mod deciding what a Poké Doll or a stone does needs the
|
||||||
|
-- SAME reach a vanilla `if result == ...` branch has, not a narrower one),
|
||||||
|
-- the way "battle.overlay" and "ui.party.submenu" already wrap a
|
||||||
|
-- screen's own default behavior elsewhere in src/ui.
|
||||||
|
local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||||
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
|
local result, payload, extra = ItemEffects.use(game.data, game.save, id, target,
|
||||||
battle, moveIndex, game.overworld)
|
battle, moveIndex, game.overworld)
|
||||||
local function closePicker()
|
local function closePicker()
|
||||||
@@ -290,7 +300,7 @@ local function useOn(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
|
||||||
@@ -375,6 +385,11 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
|||||||
showMessages(game, payload, closePicker) -- failed
|
showMessages(game, payload, closePicker) -- failed
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||||
|
return Runtime.call("item.use", vanillaUseOn,
|
||||||
|
game, battle, id, target, list, moveIndex, picker)
|
||||||
|
end
|
||||||
|
|
||||||
local function pickTargetAndUse(game, battle, id, list)
|
local function pickTargetAndUse(game, battle, id, list)
|
||||||
-- pick a target from the party
|
-- pick a target from the party
|
||||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||||
@@ -413,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
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
local Font = require("src.render.Font")
|
local Font = require("src.render.Font")
|
||||||
local Strings = require("src.core.Strings")
|
local Strings = require("src.core.Strings")
|
||||||
|
local Theme = require("src.ui.Theme")
|
||||||
|
|
||||||
local DexEntryMenu = {}
|
local DexEntryMenu = {}
|
||||||
DexEntryMenu.__index = DexEntryMenu
|
DexEntryMenu.__index = DexEntryMenu
|
||||||
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
|
|||||||
return speciesOrOpts, false
|
return speciesOrOpts, false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function ownedFor(game, def, forceOwned)
|
||||||
|
return forceOwned
|
||||||
|
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
|
||||||
|
local function descPages(game, def, forceOwned)
|
||||||
|
local e = def.dexEntry or {}
|
||||||
|
local owned = ownedFor(game, def, forceOwned)
|
||||||
|
local text = owned and e.text and game.data.text[e.text] or nil
|
||||||
|
if not text then return nil end
|
||||||
|
local pages = {}
|
||||||
|
for chunk in (text .. "\f"):gmatch("(.-)\f") do
|
||||||
|
local lines = {}
|
||||||
|
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
|
||||||
|
lines[#lines + 1] = line
|
||||||
|
end
|
||||||
|
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
|
||||||
|
if #lines > 0 then pages[#pages + 1] = lines end
|
||||||
|
end
|
||||||
|
if #pages == 0 then return nil end
|
||||||
|
local last = pages[#pages]
|
||||||
|
last[#last] = last[#last] .. "."
|
||||||
|
return pages
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
|
||||||
|
local frameCache = {}
|
||||||
|
local function frameSheet(game)
|
||||||
|
local fx = game.data.field and game.data.field.overworldFx
|
||||||
|
local def = fx and fx.pokedexFrame
|
||||||
|
local path = def and def.path
|
||||||
|
if not path then return nil end
|
||||||
|
local hit = frameCache[path]
|
||||||
|
if hit ~= nil then return hit or nil end
|
||||||
|
local ok, img = pcall(love.graphics.newImage, path)
|
||||||
|
if not ok or not img then
|
||||||
|
frameCache[path] = false
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local iw, ih = img:getDimensions()
|
||||||
|
local quads = {}
|
||||||
|
for i = 0, 17 do
|
||||||
|
quads[i] = love.graphics.newQuad((i % 3) * 8,
|
||||||
|
math.floor(i / 3) * 8, 8, 8, iw, ih)
|
||||||
|
end
|
||||||
|
frameCache[path] = { img = img, quads = quads }
|
||||||
|
return frameCache[path]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- engine/menus/pokedex.asm:601
|
||||||
|
local DIVIDER = {
|
||||||
|
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
|
||||||
|
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
|
||||||
|
}
|
||||||
|
|
||||||
function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
||||||
local species, forceOwned = resolveArgs(speciesOrOpts)
|
local species, forceOwned = resolveArgs(speciesOrOpts)
|
||||||
local self = setmetatable({ game = game, forceOwned = forceOwned,
|
local self = setmetatable({ game = game, forceOwned = forceOwned,
|
||||||
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
|
|||||||
self.def = game.data.pokemon[species]
|
self.def = game.data.pokemon[species]
|
||||||
local path, trueColor = require("src.pokemon.Sprites").path(
|
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||||
game.data, species, "front", { kind = "dex" })
|
game.data, species, "front", { kind = "dex" })
|
||||||
-- `path and pcall(...)` truncates to one value, so img was always nil and
|
-- pcall's second return has to survive the guard (#307)
|
||||||
-- every dex page drew without its pic (#307); the guard has to be a
|
|
||||||
-- statement for pcall's second return to survive.
|
|
||||||
local ok, img = false, nil
|
local ok, img = false, nil
|
||||||
if path then ok, img = pcall(love.graphics.newImage, path) end
|
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||||
self.sprite = ok and img or nil
|
self.sprite = ok and img or nil
|
||||||
self.spriteTrueColor = self.sprite and trueColor or false
|
self.spriteTrueColor = self.sprite and trueColor or false
|
||||||
|
self.page = 1
|
||||||
|
local pages = descPages(game, self.def, forceOwned)
|
||||||
|
self.pageCount = pages and #pages or 1
|
||||||
require("src.core.Sound").playCry(game.data, species)
|
require("src.core.Sound").playCry(game.data, species)
|
||||||
return self
|
return self
|
||||||
end
|
end
|
||||||
@@ -57,6 +115,11 @@ end
|
|||||||
function DexEntryMenu:update(dt)
|
function DexEntryMenu:update(dt)
|
||||||
local input = self.game.input
|
local input = self.game.input
|
||||||
if input:wasPressed("a") or input:wasPressed("b") then
|
if input:wasPressed("a") or input:wasPressed("b") then
|
||||||
|
-- home/text.asm:245
|
||||||
|
if self.page < (self.pageCount or 1) then
|
||||||
|
self.page = self.page + 1
|
||||||
|
return
|
||||||
|
end
|
||||||
self.game.stack:pop()
|
self.game.stack:pop()
|
||||||
if self.onDone then self.onDone() end
|
if self.onDone then self.onDone() end
|
||||||
end
|
end
|
||||||
@@ -64,64 +127,87 @@ end
|
|||||||
|
|
||||||
function DexEntryMenu:draw()
|
function DexEntryMenu:draw()
|
||||||
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
|
||||||
self.spriteTrueColor)
|
self.spriteTrueColor, self.page)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Static entry-page renderer, shared with the printer stand-in
|
-- Static entry-page renderer, shared with the printer stand-in
|
||||||
-- (src/core/Printer.lua renders the same page into a PNG the way
|
-- (src/core/Printer.lua renders the same page into a PNG the way
|
||||||
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
-- PrintPokedexEntry rendered it to the Game Boy Printer).
|
||||||
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
|
-- engine/menus/pokedex.asm:399
|
||||||
|
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
|
||||||
|
page = page or 1
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
local frame = frameSheet(game)
|
||||||
|
if frame then
|
||||||
|
local function tile(code, tx, ty)
|
||||||
|
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
|
||||||
|
end
|
||||||
|
-- engine/menus/pokedex.asm:418
|
||||||
|
for tx = 1, 18 do
|
||||||
|
tile(0x64, tx, 0)
|
||||||
|
tile(0x6f, tx, 17)
|
||||||
|
end
|
||||||
|
for ty = 1, 16 do
|
||||||
|
tile(0x66, 0, ty)
|
||||||
|
tile(0x67, 19, ty)
|
||||||
|
end
|
||||||
|
tile(0x63, 0, 0)
|
||||||
|
tile(0x65, 19, 0)
|
||||||
|
tile(0x6c, 0, 17)
|
||||||
|
tile(0x6e, 19, 17)
|
||||||
|
-- engine/menus/pokedex.asm:445
|
||||||
|
for tx = 0, 19 do
|
||||||
|
tile(DIVIDER[tx + 1], tx, 9)
|
||||||
|
end
|
||||||
|
end
|
||||||
if sprite then
|
if sprite then
|
||||||
local y = math.max(0, 60 - sprite:getHeight())
|
-- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
|
||||||
love.graphics.draw(sprite, 8, y)
|
local w, h = sprite:getDimensions()
|
||||||
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
|
local x = 8 + math.floor((8 - w / 8) / 2) * 8
|
||||||
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
|
local y = 8 + (7 - h / 8) * 8
|
||||||
-- it renders to its own PNG canvas, and a mark left behind there would
|
love.graphics.draw(sprite, x + w, y, 0, -1, 1)
|
||||||
-- bleed into the next real frame.
|
-- the unshaded pass needs the pic bounds (#350)
|
||||||
if trueColor then
|
if trueColor then
|
||||||
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
|
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
Font.draw(def.name, 72, 8)
|
-- engine/menus/pokedex.asm:454
|
||||||
|
Font.draw(def.name, 72, 16)
|
||||||
local e = def.dexEntry or {}
|
local e = def.dexEntry or {}
|
||||||
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
|
-- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
|
||||||
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
|
Font.draw(e.kind or "?", 72, 32)
|
||||||
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
|
|
||||||
Font.draw(e.kind or "?", 72, 20)
|
|
||||||
-- same number width as the list (constants.dexDigits), so a dex past 999
|
-- same number width as the list (constants.dexDigits), so a dex past 999
|
||||||
-- prints the extra digit everywhere at once
|
-- prints the extra digit everywhere at once
|
||||||
local digits = (game.data.constants or {}).dexDigits or 3
|
local digits = (game.data.constants or {}).dexDigits or 3
|
||||||
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
|
-- engine/menus/pokedex.asm:478
|
||||||
local owned = forceOwned
|
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
|
||||||
or (game.save.pokedex and game.save.pokedex.owned[def.id])
|
16, 64)
|
||||||
-- height/weight print only once owned, like the description
|
local owned = ownedFor(game, def, forceOwned)
|
||||||
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
|
-- engine/menus/pokedex.asm:449, numbers only once owned
|
||||||
-- height, weight, or description")
|
|
||||||
if owned and e.heightFt then
|
if owned and e.heightFt then
|
||||||
-- feet/inches use the dex screen's ′/″ glyphs ("HT ?′??″" in
|
|
||||||
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
|
|
||||||
-- engine/gfx/load_pokedex_tiles.asm)
|
|
||||||
if e.heightM then
|
if e.heightM then
|
||||||
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
|
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
|
||||||
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
|
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
|
||||||
else
|
else
|
||||||
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 44)
|
Font.draw(Strings("HT %d′%02d″", e.heightFt, e.heightIn or 0), 72, 48)
|
||||||
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
|
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
local text = owned and e.text and game.data.text[e.text] or nil
|
local pages = descPages(game, def, forceOwned)
|
||||||
local y = 72
|
if pages then
|
||||||
if text then
|
-- engine/menus/pokedex.asm:568
|
||||||
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
|
local lines = pages[page] or pages[#pages]
|
||||||
if y > 132 then break end
|
for i, line in ipairs(lines) do
|
||||||
Font.draw(line, 8, y)
|
Font.draw(line, 8, 72 + i * 16)
|
||||||
y = y + 10
|
end
|
||||||
|
-- home/text.asm:245
|
||||||
|
if page < #pages then
|
||||||
|
Font.drawCode(Theme.moreArrow, 144, 128)
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Font.draw(Strings("Data unknown."), 8, y)
|
Font.draw(Strings("Data unknown."), 8, 88)
|
||||||
end
|
end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
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.
|
local skip = input:wasPressed("a") or input:wasPressed("start")
|
||||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
|
||||||
-- 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
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
|
||||||
|
|
||||||
|
local Font = require("src.render.Font")
|
||||||
|
local Strings = require("src.core.Strings")
|
||||||
|
local HallOfFame = require("src.ui.HallOfFame")
|
||||||
|
|
||||||
|
local LeaguePC = {}
|
||||||
|
LeaguePC.__index = LeaguePC
|
||||||
|
LeaguePC.isOpaque = true
|
||||||
|
|
||||||
|
-- constants/pokemon_data_constants.asm:65
|
||||||
|
local CAPACITY = 50
|
||||||
|
|
||||||
|
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
|
||||||
|
function LeaguePC:sgbPalettes(game)
|
||||||
|
local P = require("src.render.PaletteFX")
|
||||||
|
local mon = self:currentMon()
|
||||||
|
local c = mon and P.monPal(game.data, mon.species)
|
||||||
|
if c then return { P.whole(c) } end
|
||||||
|
return P.wholeNamed(game.data, "MEWMON")
|
||||||
|
end
|
||||||
|
|
||||||
|
function LeaguePC.new(game, onDone)
|
||||||
|
local self = setmetatable({}, LeaguePC)
|
||||||
|
self.game = game
|
||||||
|
self.onDone = onDone
|
||||||
|
self.teams = (game.save and game.save.hallOfFame) or {}
|
||||||
|
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
|
||||||
|
self.monIndex = 1
|
||||||
|
self.sprites = {}
|
||||||
|
self.spriteTrueColor = {}
|
||||||
|
self:loadMon()
|
||||||
|
return self
|
||||||
|
end
|
||||||
|
|
||||||
|
function LeaguePC:currentMon()
|
||||||
|
local team = self.teams[self.teamIndex]
|
||||||
|
return team and team[self.monIndex] or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function LeaguePC:loadMon()
|
||||||
|
local mon = self:currentMon()
|
||||||
|
if not mon then return end
|
||||||
|
local species = mon.species
|
||||||
|
if self.sprites[species] == nil then
|
||||||
|
local path, trueColor = require("src.pokemon.Sprites").path(
|
||||||
|
self.game.data, species, "front", { kind = "hof" })
|
||||||
|
local ok, img = false, nil
|
||||||
|
if path then ok, img = pcall(love.graphics.newImage, path) end
|
||||||
|
self.sprites[species] = ok and img or false
|
||||||
|
self.spriteTrueColor[species] = (ok and img and trueColor) or false
|
||||||
|
end
|
||||||
|
require("src.core.Sound").playCry(self.game.data, species)
|
||||||
|
end
|
||||||
|
|
||||||
|
function LeaguePC:close()
|
||||||
|
self.game.stack:pop()
|
||||||
|
if self.onDone then self.onDone() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function LeaguePC:update(dt)
|
||||||
|
local input = self.game.input
|
||||||
|
if input:wasPressed("b") then
|
||||||
|
self:close()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if input:wasPressed("a") then
|
||||||
|
if not self:currentMon() then
|
||||||
|
self:close()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local team = self.teams[self.teamIndex]
|
||||||
|
if self.monIndex < #team then
|
||||||
|
self.monIndex = self.monIndex + 1
|
||||||
|
elseif self.teamIndex < #self.teams then
|
||||||
|
self.teamIndex = self.teamIndex + 1
|
||||||
|
self.monIndex = 1
|
||||||
|
else
|
||||||
|
self:close()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
self:loadMon()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function LeaguePC:draw()
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
|
local mon = self:currentMon()
|
||||||
|
if not mon then return end
|
||||||
|
local img = self.sprites[mon.species]
|
||||||
|
if img then
|
||||||
|
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
|
||||||
|
local w, h = img:getDimensions()
|
||||||
|
local x = 96 + math.floor((8 - w / 8) / 2) * 8
|
||||||
|
local y = 40 + (7 - h / 8) * 8
|
||||||
|
love.graphics.draw(img, x, y)
|
||||||
|
if self.spriteTrueColor[mon.species] then
|
||||||
|
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- engine/movie/hall_of_fame.asm:159
|
||||||
|
HallOfFame.drawMonInfo(self, mon)
|
||||||
|
-- engine/menus/league_pc.asm:102
|
||||||
|
Font.drawBox(0, 13, 20, 4)
|
||||||
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
|
||||||
|
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
|
||||||
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
return LeaguePC
|
||||||
@@ -79,7 +79,16 @@ end
|
|||||||
function NamingScreen:enter()
|
function NamingScreen:enter()
|
||||||
if self.presets and #self.presets > 0 then
|
if self.presets and #self.presets > 0 then
|
||||||
local Menu = require("src.ui.Menu")
|
local Menu = require("src.ui.Menu")
|
||||||
local items = { { label = Strings("NEW NAME") } }
|
-- engine/movie/oak_speech/oak_speech2.asm:1
|
||||||
|
self.choosing = true
|
||||||
|
self.isOpaque = false
|
||||||
|
local items = { {
|
||||||
|
label = Strings("NEW NAME"),
|
||||||
|
onSelect = function()
|
||||||
|
self.choosing = nil
|
||||||
|
self.isOpaque = nil
|
||||||
|
end,
|
||||||
|
} }
|
||||||
for _, preset in ipairs(self.presets) do
|
for _, preset in ipairs(self.presets) do
|
||||||
table.insert(items, {
|
table.insert(items, {
|
||||||
label = preset,
|
label = preset,
|
||||||
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function NamingScreen:draw()
|
function NamingScreen:draw()
|
||||||
|
if self.choosing then return end
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
love.graphics.setColor(1, 1, 1, 1)
|
||||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||||
love.graphics.setColor(0, 0, 0, 1)
|
love.graphics.setColor(0, 0, 0, 1)
|
||||||
|
|||||||
@@ -180,6 +180,11 @@ local function buildRows(game)
|
|||||||
step = function(g)
|
step = function(g)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
||||||
|
if o.battleLayout ~= "wide" then
|
||||||
|
o.battleHud = "standard"
|
||||||
|
elseif o.battleFit == "fill" and o.battleHud == "extended" then
|
||||||
|
o.battleBg = "white"
|
||||||
|
end
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
|
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
|
||||||
@@ -195,6 +200,31 @@ local function buildRows(game)
|
|||||||
step = function(g)
|
step = function(g)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
|
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
|
||||||
|
if o.battleFit == "fill" and o.battleLayout == "wide"
|
||||||
|
and o.battleHud == "extended" then
|
||||||
|
o.battleBg = "white"
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end },
|
||||||
|
{ id = "battleHud", label = Strings("BATTLE HUD"),
|
||||||
|
value = function(g)
|
||||||
|
local o = g.save.options
|
||||||
|
return o.battleLayout == "wide" and o.battleHud == "extended"
|
||||||
|
and Strings("EXTENDED")
|
||||||
|
or Strings("STANDARD")
|
||||||
|
end,
|
||||||
|
step = function(g)
|
||||||
|
local o = g.save.options
|
||||||
|
-- The extended HUD is a widescreen-only composition. Keep OG locked
|
||||||
|
-- to the author's standard HUD even if an older save says otherwise.
|
||||||
|
if o.battleLayout ~= "wide" then
|
||||||
|
o.battleHud = "standard"
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
|
||||||
|
if o.battleHud == "extended" and o.battleFit == "fill" then
|
||||||
|
o.battleBg = "white"
|
||||||
|
end
|
||||||
return true
|
return true
|
||||||
end },
|
end },
|
||||||
-- What sits behind and around the battle. WHITE is the classic paper
|
-- What sits behind and around the battle. WHITE is the classic paper
|
||||||
@@ -203,13 +233,24 @@ local function buildRows(game)
|
|||||||
-- shows through everywhere the battle does not paint).
|
-- shows through everywhere the battle does not paint).
|
||||||
{ id = "battleBg", label = Strings("BATTLE BG"),
|
{ id = "battleBg", label = Strings("BATTLE BG"),
|
||||||
value = function(g)
|
value = function(g)
|
||||||
local m = g.save.options.battleBg
|
local o = g.save.options
|
||||||
|
if o.battleLayout == "wide" and o.battleFit == "fill"
|
||||||
|
and o.battleHud == "extended" then
|
||||||
|
o.battleBg = "white"
|
||||||
|
return Strings("AUTO")
|
||||||
|
end
|
||||||
|
local m = o.battleBg
|
||||||
if m == "black" then return Strings("BLACK") end
|
if m == "black" then return Strings("BLACK") end
|
||||||
if m == "world" then return Strings("WORLD") end
|
if m == "world" then return Strings("WORLD") end
|
||||||
return Strings("WHITE")
|
return Strings("WHITE")
|
||||||
end,
|
end,
|
||||||
step = function(g, dir)
|
step = function(g, dir)
|
||||||
local o = g.save.options
|
local o = g.save.options
|
||||||
|
if o.battleLayout == "wide" and o.battleFit == "fill"
|
||||||
|
and o.battleHud == "extended" then
|
||||||
|
o.battleBg = "white"
|
||||||
|
return false
|
||||||
|
end
|
||||||
local order = { "white", "black", "world" }
|
local order = { "white", "black", "world" }
|
||||||
local cur = 1
|
local cur = 1
|
||||||
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
|
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -628,22 +631,8 @@ function PartyMenu:update(dt)
|
|||||||
end
|
end
|
||||||
if self.softboiledFrom then
|
if self.softboiledFrom then
|
||||||
local user = party[self.softboiledFrom]
|
local user = party[self.softboiledFrom]
|
||||||
local heal = math.floor(user.stats.hp / 5)
|
self.softboiledFrom = nil
|
||||||
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
|
self.game.overworld:useSoftboiledFieldMove(user, mon)
|
||||||
or user.hp <= heal then
|
|
||||||
self.softboiledFrom = nil
|
|
||||||
local TextBox = require("src.render.TextBox")
|
|
||||||
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
|
|
||||||
else
|
|
||||||
user.hp = user.hp - heal
|
|
||||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
|
||||||
self.softboiledFrom = nil
|
|
||||||
require("src.core.Sound").play(self.game.data, "Heal_HP")
|
|
||||||
local def = self.game.data.pokemon[mon.species]
|
|
||||||
local TextBox = require("src.render.TextBox")
|
|
||||||
self.game.stack:push(TextBox.new(self.game,
|
|
||||||
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
|
|
||||||
end
|
|
||||||
elseif self.swapFrom then
|
elseif self.swapFrom then
|
||||||
if self.swapFrom ~= self.index then
|
if self.swapFrom ~= self.index then
|
||||||
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
|
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
|
||||||
@@ -814,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)
|
||||||
|
|||||||