diff --git a/data/gb_palettes.lua b/data/gb_palettes.lua new file mode 100644 index 00000000..9534ba5e --- /dev/null +++ b/data/gb_palettes.lua @@ -0,0 +1,765 @@ +return { + { name = "BGB", palettes = { + { "BGB_v0.3", "142c3c548c74acd494ecfccc" }, + { "Blue", "0000600000c05f5fffc0c0ff" }, + { "GB_Kiosk", "1434046c6c04bcbc1cececb4" }, + { "GB_Light", "004f3b00694a009a7100b581" }, + { "Grey", "1414145c5c5ca4a4a4ececec" }, + { "Greys_High_Contrast", "000000686868b0b0b0ffffff" }, + { "KIG_Green", "20402030703040a04050d050" }, + { "LCD_Green", "0c1c24346c548cc474e4fcd4" }, + { "Red", "600000c00000ff6060ffc0c0" }, + { "Super_GB", "311852ad2921de944affefce" }, + { "Yellow", "202010686830b0a848f8f078" }, + { "Yellow_BGB", "556f3593a73bd2cf4df4ebbf" }, + { "NGM_Brown", "483818987838d8b058f8e088" }, + } }, + { name = "BGB/Inverted", palettes = { + { "BGB_v0.3 - Inverted", "ecfcccacd494548c74142c3c" }, + { "Blue - Inverted", "c0c0ff5f5fff0000c0000060" }, + { "GB_Kiosk - Inverted", "ececb4bcbc1c6c6c04143404" }, + { "GB_Light - Inverted", "00b581009a7100694a004f3b" }, + { "Grey - Inverted", "ecececa4a4a45c5c5c141414" }, + { "Greys_High_Contrast - Inverted", "ffffffb0b0b0686868000000" }, + { "KIG_Green - Inverted", "50d05040a040307030204020" }, + { "LCD_Green - Inverted", "e4fcd48cc474346c540c1c24" }, + { "Red - Inverted", "ffc0c0ff6060c00000600000" }, + { "Super_GB - Inverted", "ffefcede944aad2921311852" }, + { "Yellow - Inverted", "f8f078b0a848686830202010" }, + { "Yellow_BGB- Inverted", "f4ebbfd2cf4d93a73b556f35" }, + { "NGM_Brown - Inverted", "f8e088d8b058987838483818" }, + } }, + { name = "GalleryWebApp Palettes", palettes = { + { "Artistic Caffeinated Lactose", "2416069e754fdea963fdfef5" }, + { "AQP Pikes Peak", "2a201efa2b25868779ebeee7" }, + { "Azure Clouds", "00000012412732b66d47ff99" }, + { "BGB Emulator", "08182034685688c070e0f8d0" }, + { "Black & White", "000000555555aaaaaaffffff" }, + { "Black Zero", "2e463d385d49577b467e8416" }, + { "CGA Palette Crush 1", "000000ff55ff55ffffffffff" }, + { "CGA Palette Crush 2", "000000ff555555ffffffffff" }, + { "CMYKeystone", "373737fb00fa0be8fdffff00" }, + { "Candy Cotton Tower Raid", "3800168f0039e65790e6aec4" }, + { "Caramel Fudge Paranoia", "3f1711b01553cf7163cf9255" }, + { "Childhood in Greenland", "405010708028a0a840d0d058" }, + { "Cyanide Blues", "0e1e3d1e479321aff59efbe3" }, + { "Deep Haze Green", "00000027421f467818a1d909" }, + { "Dies ist meine Wassermelone", "222903558429f27d7affdbcb" }, + { "Drowning at Night", "030c2220293f586164a9b0b3" }, + { "DUN 2000 Remastered", "000000725441c09e7dfbf1cd" }, + { "Flowerfeldstrasse", "171f6275868fc5c5cee9d9cc" }, + { "Floyd Steinberg in Love", "4c1c2dd23c4e5fb1f5eaf5fa" }, + { "GB Light", "0b7a6d16a59619c7b31ddece" }, + { "GB Pocket", "1f1f1f4d533c8b956dc4cfa1" }, + { "Glowing Mountains", "2f1c35514f6ca1a8b8ffbf98" }, + { "Golden Elephant Curry", "0000004f3000c27600ff9c00" }, + { "Grafixkid Gray", "2b2b26706b66a89f94e0dbcd" }, + { "Grafixkid Green", "4c625a7b9278abc396dbf4b4" }, + { "Knee-Deep in the Wood", "2c24103c3ca9d5690ffffe6e" }, + { "LNK late Awakening", "5a39216b8c427bc67bffffb5" }, + { "MTR Aran Remixed", "2c1700b62558047e60aedf1e" }, + { "My Friend from Bavaria", "382977b60077df7925feda1b" }, + { "Nortorious Comadante", "0402ac04aaac54fefcfcfe54" }, + { "Original GB", "0f380f30623077a1129bbc0f" }, + { "Purple Rain", "68006aff00848570b2adfffc" }, + { "Romeros Garden", "323727574431649a57ebc4ab" }, + { "Rusted City Rain", "2c2137764462a96868edb4a1" }, + { "Space Haze Overload", "0b06306b1fb1cc3495f8e3c4" }, + { "Starlit Memories", "4f133f6f20966d53bd869ad9" }, + { "Sunflower Holidays", "000000881400ff5555ffff55" }, + { "Super Hyper Mega GB", "331e50a63725d68e49f7e7c6" }, + { "The death of Yung Columbus", "1d1414462917ff2261b5ff32" }, + { "The starry knight", "123f770f86b637cae5f5db37" }, + { "There is Always Money", "221a09977b25fed638fdfe0a" }, + { "Tramonto al Parco degli Acquedotti", "0c0a3e912978e64a4ef3c677" }, + { "VTB 1985", "000000520000db0000ff0000" }, + { "Waterfront Plaza", "10253342678e6f9edfcecece" }, + { "Youth Ikarus reloaded", "1e00009e0000f78e50cef7f7" }, + } }, + { name = "GB Color (Combo Palettes)", palettes = { + { "GBC - Down + A", "000000ff0000ffff00ffffff" }, + { "GBC - Down + B", "0000007b4a00ffff00ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC - Down", "0000009494ffff9494ffffa5" }, + { "GBC - Left + A", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" }, + { "GBC - Left + B", "000000525252a5a5a5ffffff" }, + { "GBC - Left", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC - Right + A", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC - Right + B", "ffffffffde00008484000000" }, + { "GBC - Right", "000000ff420052ff00ffffff" }, + { "GBC - Up + A", "000000943a3aff8484ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" }, + { "GBC - Up + B", "5a3108846b29ce9c84ffe6c5", "000000843100ffad63ffffff", "000000843100ffad63ffffff" }, + { "GBC - Up", "000000843100ffad63ffffff" }, + } }, + { name = "GB Color (Unique Palettes)", palettes = { + { "GBC Palette 001 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 002 (World)", "000000006300ffff00a59cff" }, + { "GBC Palette 003 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 004 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 005 (USA)", "ffffffffde00008484000000" }, + { "GBC Palette 006 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 007 (USA, Europe)", "000000ff0000ff9c00ffffff" }, + { "GBC Palette 008 (World)", "ffffffffff00ff840052de00", "0000ff63a5ffffffffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 009 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 010 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 011 (Europe)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 012 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 013 (World) (Rev A)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 014 (World)", "000000ff0000ff9c00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 015 (Japan)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" }, + { "GBC Palette 016 (USA, Europe)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" }, + { "GBC Palette 017 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" }, + { "GBC Palette 018 (USA, Europe) (Rev A)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" }, + { "GBC Palette 019 (USA, Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "0000ffff00005abdffffffff" }, + { "GBC Palette 020 (World) (Rev A)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 021 (World)", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 022 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 023 (World) (Rev A)", "000000843100ffad63ffffff" }, + { "GBC Palette 024 (World)", "000000843100ffad63ffffff" }, + { "GBC Palette 025 (USA)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 026 (Europe)", "000000843100ffad63ffffff" }, + { "GBC Palette 027 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 028 (Australia)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 029 (Japan)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 030 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 031 (Europe)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 032 (USA) (Rev A)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 033 (USA)", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 034 (World)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 035 (Japan) (Rev A)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" }, + { "GBC Palette 036 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" }, + { "GBC Palette 037 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" }, + { "GBC Palette 038 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 039 (Japan)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 040 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 041 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 042 (USA, Europe)", "00000052528c8c8cdeffffff", "000000943a3aff8484ffffff", "000000843100ffad63ffffff" }, + { "GBC Palette 043 (USA)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 044 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 045 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" }, + { "GBC Palette 046 (Japan)", "000000843100ffad63ffffff" }, + { "GBC Palette 047 (Japan)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" }, + { "GBC Palette 048 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" }, + { "GBC Palette 049 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" }, + { "GBC Palette 050 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "0084ffffff7bffffff0000ff" }, + { "GBC Palette 051 (USA, Europe)", "000000006300ffff00a59cff", "000000630000d60000ff6352", "000000630000d60000ff6352" }, + { "GBC Palette 052 (USA, Europe)", "000000843100ffad63ffffff" }, + { "GBC Palette 053 (France)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 054 (Germany)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 055 (USA, Europe) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 056 (USA, Europe) (Rev B)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 057 (USA, Europe)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 058 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 059 (Europe)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 060 (Japan)", "000000843100ffad63ffffff" }, + { "GBC Palette 061 (USA, Europe)", "000000843100ffad63ffffff" }, + { "GBC Palette 062 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 063 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 064 (Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 065 (World)", "0000000000ff63a5ffffffff", "000000630000ff0000ffff00", "0000000084007bff31ffffff" }, + { "GBC Palette 066 (Japan)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" }, + { "GBC Palette 067 (USA, Europe)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "000000944200ff7300ffffff" }, + { "GBC Palette 068 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 069 (France)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 070 (Germany)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 071 (Europe)", "000000843100ffad63ffffff" }, + { "GBC Palette 072 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 073 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 074 (USA)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 075 (Japan)", "000000843100ffad63ffffff" }, + { "GBC Palette 076 (Europe)", "00000052528c8c8cdeffffff", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542" }, + { "GBC Palette 077 (USA)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 078 (Europe)", "00000052528c8c8cdeffffff", "4a0000943a00ffd600ffc542", "4a0000943a00ffd600ffc542" }, + { "GBC Palette 079 (Japan) (Rev A)", "0000009c6300ffce00ffffff" }, + { "GBC Palette 080 (Japan) (Rev 0A)", "000000ff0000ffff00ffffff" }, + { "GBC Palette 081 (Japan) (Rev B)", "000000ff0000ffff00ffffff" }, + { "GBC Palette 082 (Japan) (Rev C)", "000000ff0000ffff00ffffff" }, + { "GBC Palette 083 (Japan) (Rev D)", "000000ff0000ffff00ffffff" }, + { "GBC Palette 084 (Japan) (Rev A)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 085 (Japan)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 086 (Japan)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 087 (Japan) (Rev A)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 088 (Japan)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 089 (Germany)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 090 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 091 (Spain)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 092 (Spain)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 093 (USA, Europe)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 094 (Germany)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 095 (France)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 096 (France)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 097 (Italy)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 098 (Italy)", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 099 (World)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 100 (Japan)", "003a3a63947394b5ffffff9c" }, + { "GBC Palette 101 (USA, Europe)", "003a3a63947394b5ffffff9c" }, + { "GBC Palette 102 (Europe) (En,Fr,De)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" }, + { "GBC Palette 103 (World)", "ffffffffde00008484000000" }, + { "GBC Palette 104 (Europe)", "ffffffffde00008484000000" }, + { "GBC Palette 105 (USA)", "ffffffffde00008484000000" }, + { "GBC Palette 106 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000084007bff31ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 107 (USA, Europe) (Rev A)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 108 (Japan)", "003a3a63947394b5ffffff9c", "4a0000943a00ffd600ffc542", "000000943a3aff8484ffffff" }, + { "GBC Palette 109 (World) (Rev A)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" }, + { "GBC Palette 110 (World)", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" }, + { "GBC Palette 111 (USA, Europe) (Rev A)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 112 (USA, Europe) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 113 (USA, Europe)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 114 (Japan) (Rev B)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 115 (Japan)", "5a5a5a9c843163efefffffce", "000000944200ff7300ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 116 (USA, Europe)", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff", "0000000084007bff31ffffff" }, + { "GBC Palette 117 (World)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" }, + { "GBC Palette 118 (World) (Rev A)", "000000ff0000ffff00ffffff" }, + { "GBC Palette 119 (World)", "000000ff0000ffff00ffffff" }, + { "GBC Palette 120 (Europe)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 121 (USA)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 122 (USA)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 123 (USA, Europe) (Rev A)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 124 (USA, Europe)", "000000ff0000ff9c00ffffff" }, + { "GBC Palette 125 (Japan)", "000000ff0000ffff00ffffff", "000000ff0000ffff00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 126 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 127 (USA)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" }, + { "GBC Palette 128 (Europe)", "000000ff524affffff6bff00", "0000ff63a5ffffffffffffff", "000000843100ffad63ffffff" }, + { "GBC Palette 129 (Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 130 (USA, Europe)", "0000000084007bff31ffffff", "000000943a3aff8484ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 131 (USA, Europe)", "0000000063c57bff31ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 132 (World)", "00000042737badad84ffffff", "000000944200ff7300ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 133 (USA, Europe)", "00000042737badad84ffffff", "000000843100ffad63ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 134 (USA, Europe)", "0000000000ff63a5ffffffff", "000000943a3aff8484ffffff", "ff00000084ffffff7bffffff" }, + { "GBC Palette 135 (Japan)", "000000525252a5a5a5ffffff" }, + { "GBC Palette 136 (Japan) (Rev A)", "000000843100ffad63ffffff" }, + { "GBC Palette 137 (Japan)", "000000843100ffad63ffffff" }, + { "GBC Palette 138 (USA)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 139 (Japan)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 140 (Japan)", "000000ff420052ff00ffffff", "000000ff420052ff00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 141 (Japan)", "000000ff420052ff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "GBC Palette 142 (USA, Europe)", "000000ff0000ff9c00ffffff", "000000ff0000ff9c00ffffff", "0000ffff00005abdffffffff" }, + { "GBC Palette 143 (Japan) (Rev A)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" }, + { "GBC Palette 144 (Japan)", "000000943a3aff8484ffffff", "004a0031840000ff00ffffff", "0000000000ff63a5ffffffff" }, + } }, + { name = "GB Color (Unused Palettes)", palettes = { + { "Unused0", "000000b573007bff00ffffff", "000000943a3aff8484ffffff", "000000943a3aff8484ffffff" }, + { "Unused1", "000000ff0000ffff00ffffff", "0000000000ff63a5ffffffff", "0000ffff00005abdffffffff" }, + { "Unused2", "000000ad5a42ffff94b5b5ff", "943a3aff8484ffffff000000", "943a3aff8484ffffff000000" }, + { "Unused3", "ffffffffde00008484000000", "000000943a3aff8484ffffff", "0000000084007bff31ffffff" }, + { "Unused4", "000000525252a5a5a5ffffff" }, + { "Unused5", "0000009c6300ffce00ffffff" }, + } }, + { name = "R.A.Helllord/Limited Edition Pockets", palettes = { + { "Classic Blue LE", "0000000232741b559e5775af" }, + { "Classic Green LE", "00000001633f0b8a68549d80" }, + { "Classic Indigo LE", "0000002631725457a47b80b4" }, + { "Classic Pink LE", "000000d8648beb8cb3efa1bf" }, + { "Classic Red LE", "00000095030dbc2b2bcb5d5b" }, + { "Classic Silver LE", "000000908a85cececee0dfde" }, + { "Classic Spice Orange LE", "000000c96722e38f48e8a36c" }, + { "Classic Yellow LE", "000000cf8b08d2921de8be63" }, + { "Glow in the Dark LE", "00000005a56d4dcc808cf68a" }, + { "Transparent Blue LE", "0000001e124f361aa6646dcb", "1e124f361aa6646dcb7f8cff", "1e124f361aa6646dcb7f8cff" }, + { "Transparent Clear LE", "0000003e3b395959599c9a99", "3e3b395959599c9a99d7d7d7", "3e3b395959599c9a99d7d7d7" }, + { "Transparent Green LE", "000000003c0600b10003ce09", "003c0600b10003ce09afe4ab", "003c0600b10003ce09afe4ab" }, + { "Transparent Orange LE", "0000003c1405ad2d01e56400", "3c1405ad2d01e56400ebbf8f", "3c1405ad2d01e56400ebbf8f" }, + { "Transparent Purple LE", "0000003c2e498d6ea7a986bb", "3c2e498d6ea7a986bbe0d6e5", "3c2e498d6ea7a986bbe0d6e5" }, + { "Transparent Red LE", "0000006d1a1a991019c51d21", "6d1a1a991019c51d21eab5b1", "6d1a1a991019c51d21eab5b1" }, + { "Transparent Smoke LE", "000000282421363333686464", "282421363333686464bfbfbe", "282421363333686464bfbfbe" }, + } }, + { name = "R.A.Helllord/Limited Edition Pockets Inverted", palettes = { + { "Classic Blue LE - inverted", "5775af1b559e023274000000" }, + { "Classic Green LE - inverted", "549d800b8a6801633f000000" }, + { "Classic Indigo LE - inverted", "7b80b45457a4263172000000" }, + { "Classic Pink LE - inverted", "efa1bfeb8cb3d8648b000000" }, + { "Classic Red LE - inverted", "cb5d5bbc2b2b95030d000000" }, + { "Classic Silver LE - inverted", "e0dfdececece908a85000000" }, + { "Classic Spice Orange LE - inverted", "e8a36ce38f48c96722000000" }, + { "Classic Yellow LE - inverted", "e8be63d2921dcf8b08000000" }, + { "Glow in the Dark LE - inverted", "8cf68a4dcc8005a56d000000" }, + { "Transparent Blue LE - inverted", "646dcb361aa61e124f000000", "7f8cff646dcb361aa61e124f", "7f8cff646dcb361aa61e124f" }, + { "Transparent Clear LE - inverted", "9c9a995959593e3b39000000", "d7d7d79c9a995959593e3b39", "d7d7d79c9a995959593e3b39" }, + { "Transparent Green LE - inverted", "03ce0900b100003c06000000", "afe4ab03ce0900b100003c06", "afe4ab03ce0900b100003c06" }, + { "Transparent Orange LE - inverted", "e56400ad2d013c1405000000", "ebbf8fe56400ad2d013c1405", "ebbf8fe56400ad2d013c1405" }, + { "Transparent Purple LE - inverted", "a986bb8d6ea73c2e49000000", "e0d6e5a986bb8d6ea73c2e49", "e0d6e5a986bb8d6ea73c2e49" }, + { "Transparent Red LE - inverted", "c51d219910196d1a1a000000", "eab5b1c51d219910196d1a1a", "eab5b1c51d219910196d1a1a" }, + { "Transparent Smoke LE - inverted", "686464363333282421000000", "bfbfbe686464363333282421", "bfbfbe686464363333282421" }, + } }, + { name = "R.A.Helllord/PPB", palettes = { + { "PPB - Amber", "ffb13b8c61216043172d1f0b" }, + { "PPB - Blue", "28ccff16708c0f4b5e07242d" }, + { "PPB - Green", "00ee00008e00005f00002f00" }, + { "PPB - White", "c1ffff6a8c8c496060202b2b" }, + { "PPB inverted - Amber", "2d1f0b6043178c6121ffb13b" }, + { "PPB inverted - Blue", "07242d0f4b5e16708c28ccff" }, + { "PPB inverted - Green", "002f00005f00008e0000ee00" }, + { "PPB inverted - White", "202b2b4960606a8c8cc1ffff" }, + } }, + { name = "SMY", palettes = { + { "Desert", "3e2f30746657a49b83d2d0b1" }, + { "Evening", "012636185569539889aee4a6" }, + { "Fake Crystal", "21293129317b4a73bd9ce7f7" }, + { "Fog", "343c3756727386a39dbfd2c3" }, + { "Magic Eggplant", "36213c842e949d69c7b0e4f1" }, + { "Radioactive Pea", "00522106731f349e16b8ce03" }, + { "Seaweed", "15003f32654278a758dfe095" }, + { "Twilight", "15003f861246bd5462e9d397" }, + } }, + { name = "Super GB", palettes = { + { "1-A (Default)", "311852ad2921de944affefce" }, + { "1-B", "000000b55210ceb573dedec6" }, + { "1-C", "39399c9c3963ef9c52ffc6ff" }, + { "1-D", "521800ff0000c6844affffad" }, + { "1-E", "5a39216b8c427bc67bffdeb5" }, + { "1-F", "004210ad0000e78c52deefff" }, + { "1-G", "ffff5a7b7b0000a5ef000052" }, + { "1-H", "311800844200ffbd8cffefe7" }, + { "2-A", "000000297b00c68c4af7cea5" }, + { "2-B", "52005aff3100ffef52ffffff" }, + { "2-C", "29299c7b31efef8c8cffc6ff" }, + { "2-D", "000052ff310000ff00ffffa5" }, + { "2-E", "10081029106394b5e7ffce84" }, + { "2-F", "180000a50000ff9452d6ffff" }, + { "2-G", "001800e7bd84e752426bbd39" }, + { "2-H", "000000737373bdbdbdffffff" }, + { "3-A", "314a63ff632973c6c6ffd69c" }, + { "3-B", "001010005200e78421dedec6" }, + { "3-C", "21215a00bdffffff7be7adce" }, + { "3-D", "00000008ce00e7ad7bf7ffbd" }, + { "3-E", "524a73b57b21e7b56bffffc6" }, + { "3-F", "424242ffd600ff6bff7b7bce" }, + { "3-G", "390000ce3139ffffff63de52" }, + { "3-H", "0818004a8c187bce39e7ffa5" }, + { "4-A", "00007bd600d67badfff7ad6b" }, + { "4-B", "180808427b39efa563f7eff7" }, + { "4-C", "0800009ca5e7dea5d6ffe7e7" }, + { "4-D", "08214a4a6b7b94ceceffffbd" }, + { "4-E", "0021317b5a8ce7ad7bffdead" }, + { "4-F", "3900008400a5de84debdd6d6" }, + { "4-G", "008463291000bd215ab5e718" }, + { "4-H", "425229848c42bdc65affffce" }, + } }, + { name = "Super GB 2 - Vaporwave Edition", palettes = { + { "SGB2VE 1-A Sunset", "1039adef2994f7a552f7f794" }, + { "SGB2VE 1-B Kimochi WRO", "4a3973bd63a5f7a56bf7efbd" }, + { "SGB2VE 1-C Pachinko", "42427b9c42eff7947bf7deef" }, + { "SGB2VE 1-D Cotton Candy", "1039adffa5ff84e7eff7f7f7" }, + { "SGB2VE 1-E Absynthe", "7b4aada5b5ffadff5affff8c" }, + { "SGB2VE 1-F Arizona", "52529cff4273ffcecedefff7" }, + { "SGB2VE 1-G Hot Vibes", "8c18f7f72973f78c18f7ce18" }, + { "SGB2VE 1-H Sorbet Whip", "63184aff6b8cffc684f7ffc6" }, + { "SGB2VE 2-A Bufotoxin", "104a63009c84ef8c84f7de9c" }, + { "SGB2VE 2-B Dank", "4a184263528463b5bdf7f794" }, + { "SGB2VE 2-C Creampuff", "632963a573f7f79cadffdef7" }, + { "SGB2VE 2-D Modern Computing", "101018ff849c6bffbdf7f7f7" }, + { "SGB2VE 2-E Uranium Glass", "4a08ef9c42ef00deded6ff9c" }, + { "SGB2VE 2-F. Tenshi", "2142e7de5aceefadceadf7c6" }, + { "SGB2VE 2-G 87 Ninjas", "42427bd63994bdb5f7c6f7de" }, + { "SGB2VE 2-H Floral", "08101852b59cff849cefe7e7" }, + { "SGB2VE 3-A Mind Game", "4200f700b5f7f784f7f7f7f7" }, + { "SGB2VE 3-B Sewer Magic", "5a00848c7be784f7c6f7f794" }, + { "SGB2VE 3-C Galactic Rabbit Trip", "4a527352a5cebdceeff7f7ef" }, + { "SGB2VE 3-D Frenemy", "1039ad42d6d6ffd6b5bdffe7" }, + { "SGB2VE 3-E Agent Orange", "632963e76b84f7a56bffe742" }, + { "SGB2VE 3-F Backstreet", "294a8cff8cbdffceb5dedeff" }, + { "SGB2VE 3-G Eccojam", "8c42a5ad7be7946cff9cefff" }, + { "SGB2VE 3-H Sakura", "4a39b5ef73d6efb5bdf7efb5" }, + { "SGB2VE 4-A Vector", "1039ad9400f700b5f7f7d684" }, + { "SGB2VE 4-B Slumber", "944ab58c8cf794ceffd6ffff" }, + { "SGB2VE 4-C Lingonberry Butter", "21529c7b84e7ffadcefff7de" }, + { "SGB2VE 4-D Zangetsu", "104a6331a5c684e7eff7efbd" }, + { "SGB2VE 4-E Icy Hot", "8c00ffff39ced6c6ffbdfff7" }, + { "SGB2VE 4-F Night Driver", "214aff6b00c6ff31ef10006b" }, + { "SGB2VE 4-G Bug Hunt", "0839633952bdf7738cf7e7ad" }, + { "SGB2VE 4-H MGB-101", "004a84107ba500bdc621ffde" }, + } }, + { name = "TheWolfBunny64/American Pop Culture Pack", palettes = { + { "Absorbent and Yellow", "343b00687600aec600fff752" }, + { "BMO Ver.", "394c3d607f6699cca3c0ffcc" }, + { "BRB Pink", "480030790050c10080f200a1" }, + { "Bedrock Caveman Vision", "002f37005e6e009eb8ff7f00" }, + { "BKB Ver.", "6060002098f048f8e0f8f880" }, + { "BLS Pink", "492e357a4d59c47c8ef59bb2" }, + { "BBS Blue", "1e3a45326274509cba64c4e9" }, + { "BTC Green", "39422a5f6e4698b070bedc8d" }, + { "DNP Silver", "33383d555d668895a3abbbcc" }, + { "Fairly OddPalette", "3d1b2d7b365bce5a997bb850" }, + { "GRF Vision", "2d1309964220e59436f5ea8b" }, + { "HGW Goldius", "3631215b523891845ab6a571" }, + { "Invincible Yellow and Blue", "113c4622788d39c9ebfee566" }, + { "NCK Orange", "4c1e007f3300cc5200ff6700" }, + { "NJT Green", "28380b435e126b961d86bc25" }, + { "OPP Palette", "0015330047abd92121d3d3d3" }, + { "PTS Pink", "4c26297f4045cc666fff808b" }, + { "Retro Bogeda", "0000006408ffff6cfffbfd1b" }, + { "Rocket Portable Power", "09122d213f99bdd539fee998" }, + { "RGR Playtime Palette", "662d91ed1c24ffde17ffffff" }, + { "Sailor Spinach Green", "2434123d581e628c307bb03c" }, + { "SCD Mystery Ver.", "2a1a318f59a5f79321c6de31" }, + { "Smurfy Blue", "0d3747165c772394bf2cb9ef" }, + { "SPB Yellow", "4a45157b7424c5ba39f7e948" }, + { "SQW Sea Foam Green", "37403d5c6b6694aca4b9d7cd" }, + { "Swampy Ogre Green", "39400d606b179aab24c1d62e" }, + { "TMT Pink", "3815205e2436963957bc486d" }, + { "UNS Blue", "0020430136710256b4036ce2" }, + } }, + { name = "TheWolfBunny64/Anime and Manga Pack", palettes = { + { "ANX BLUE", "0f2d451a4c74297ab93499e8" }, + { "ALM Hero Palette", "212f79d12021f3db43eff0f0" }, + { "Anime DGV Ver.", "1b241d2d3d3148624f5b7b63" }, + { "Anime Expo Red", "47110f771d19be2f28ee3b33" }, + { "Anime Expo Ver.", "242a2d656e729ba3a6e5eaeb" }, + { "BRS Blood", "3806065d0a0a951010bb1414" }, + { "Blue Stripes Ver.", "2d2e2e5b5d5d999b9c8bd3e1" }, + { "CDC Pink", "430427e10e82eac3d6f2f4f7" }, + { "Colorful Horizons", "373939d1525260bdc7f6cf72" }, + { "CRR Orange", "49230a7a3a11c35d1bf47522" }, + { "DKU Alpha Emerald", "11332f1c564f2d8a7e39ad9e" }, + { "DKU Gamma Palette", "24262b166668b6bec8f0e8d6" }, + { "DKU Vigilante Palette", "13131538534e878b92ada89a" }, + { "DMS Gold", "3734195d572b948c44baaf56" }, + { "DRM Tricolor", "450000e6000000a8f4ffe800" }, + { "DGB Orange", "48270878410ec06817f0831d" }, + { "DYN Hero Palette", "16161629463dc84a31d1d0cc" }, + { "EV-01", "303345765898f99b2254cf54" }, + { "EDN Academy Uniform", "483e39cc4c47dcbc76f7f8f5" }, + { "FRG Pretense Palette", "0000008d0000697d738ea99b" }, + { "GDS VISION", "4723008e4700ee77009fa0a0" }, + { "GR5 Fever", "311d3da662cdc5cedff7f7f7" }, + { "GKU GT Gi", "1221313d6ea5f0ac18dbe3e6" }, + { "GKU Gi", "061222173f72e7612cf9f0e1" }, + { "Grand Ivory", "4140396c6b5fadab98d9d6be" }, + { "GRZ Coat", "3d0b32ce26a9fce72dfbfbfa" }, + { "HKG Orange", "462718754129bb6841ea8352" }, + { "KNS Sherbet", "440021890042e5006ef08200" }, + { "KWB Blue", "00283b004462006c9d0088c5" }, + { "Legendary SSJ", "31411b536d2d84ae48a6da5b" }, + { "LSA Orange!", "461c00752f00bc4b00eb5e01" }, + { "Perfect MJN Emperor", "2718328550a9a3b453fdc1bf" }, + { "Perfected ULI", "393c4060646c99a0acc0c8d8" }, + { "PRC Marble Raspberry", "400a1b6b112eab1b49d6225c" }, + { "PTG Gold", "3633275a5541908868b4aa82" }, + { "SYN Beast Silver", "2d2e344b4d57787c8c969baf" }, + { "SHN Green", "1b3a162d6125489c3b5ac34a" }, + { "STH Red", "4a18127c291ec64130f8523c" }, + { "SSJ 3", "4a3c107c641cc6a02cf8c838" }, + { "SSJ Blue Evolved", "082d3e0d4b681578a71b97d1" }, + { "SSJ Blue", "01383d025d660496a305bccc" }, + { "SSJ God", "40001d6b0131ac024ed70362" }, + { "SSJ Rose", "4a34357b5759c58c8ff7afb3" }, + { "SSJ", "4c4b397f7e60cbc99afefcc1" }, + { "SVC Uniform", "321d1a593d34ac7c59acaba9" }, + { "SWA Cyan", "1a3a432c6171479cb459c3e2" }, + { "TMR Uniform", "474f4d755e88e94e60eeefeb" }, + { "Tri DGV Ver.", "272a2442473c697260848f79" }, + { "UAH Uniform", "0c4856a02929a4aaafededed" }, + { "UTV VISION", "262c48e24465f1a7b5f4f4f4" }, + { "ULI Sign", "1b1f212d34374853585a686f" }, + { "URM Green", "1734282657443d8b6c4dae88" }, + { "VGT Armor", "0619321554a9e3c56cefefef" }, + { "ANM vision", "231815385eaaf9be00ffffff" }, + { "HLO blue", "063f5c3368d349c4f2b0edfa" }, + } }, + { name = "TheWolfBunny64/Bandai Namco Pack", palettes = { + { "1st Vision Pastel", "101c33385ead7faf5aea7cb1" }, + { "765P Ver.", "383a445d6272959cb6bbc4e4" }, + { "765P Pink", "481720792736c23f57f34f6d" }, + { "765P TRICOLOR", "0b143f2743d2e22b30b4e04b" }, + { "ARS BLUE", "0e192e182a4e26447c30559c" }, + { "AQR Blue", "0030450050740080ba00a0e9" }, + { "BDN Tricolor", "001f350069b1df4f61f6b700" }, + { "CND Blue", "0b263c1340641e67a02681c8" }, + { "IDOL WORLD TRICOLOR!!!", "0b263c2681c8f34f6dffc30b" }, + { "LLA Purple!", "31152e52234d84387ca5469b" }, + { "MLV GOLD!", "3d351d665930a48e4dcdb261" }, + { "MLN Yellow!", "4c3a037f6105cc9c08ffc30b" }, + { "Moonlight Vision", "1010103050783890e8f8d868" }, + { "MUS Pink", "44002672003fb60065e4007f" }, + { "NMC Idol Pink", "4c22377f3a5ccc5c93ff74b8" }, + { "NJG Orange", "482d00794c00c27900f39800" }, + { "PCM-PALETTE", "420000dc0000ff8c00ffd800" }, + { "PCM Vision", "0000003732ffffb897ffff00" }, + { "PCM Yellow", "4c44007f7100ccb500ffe300" }, + { "RADIANT SMILE RAMP", "1b1c81e6016b01b3c4fff89b" }, + { "RX-78-2 GDM Mode", "333f3fa73736dbad40dbdcd5" }, + { "RYG Sunset", "2b10399238befd99e1ffe43f" }, + { "SHINY Sky Blue", "2a384c465d7f7095cc8dbbff" }, + { "SNS Red", "3911105f1c1b982d2bbf3936" }, + { "School Idol Blue", "2830663960d787adf5f9f9f8" }, + { "School Idol Mix", "31152ea5469b00a0e9f39800" }, + { "SDM Green", "04392c075f4a0c98760fbe94" }, + { "Sunny Passion Paradise", "0920341e6caee06846f0ba40" }, + { "SYL Rose Pink", "452728744243b9696be88486" }, + { "Tarnished Gold", "392f18604e299a7d42c19d53" }, + { "Vulnerable Blue", "ffb897cc93787f5c4b3732ff" }, + { "Wind Ring Emerald", "0c3627145a4220906a28b585" }, + } }, + { name = "TheWolfBunny64/Company Pack", palettes = { + { "AMZ Vision", "252f3e008296ff9900ffffff" }, + { "AND Green", "1242271e6e4230b0693ddc84" }, + { "APL Silver", "30333351555681888aa2aaad" }, + { "DUO Green", "1a3d002c660146a30158cc02" }, + { "DRC Copper", "3c291b64442ea06d4ac8895d" }, + { "GGL Blue", "13274921427a346ac34285f4" }, + { "GGL Green", "0f32181a542929864234a853" }, + { "GGL Red", "46140f75211abb352aea4335" }, + { "GGL Yellow", "4b38017d5e02c89604fbbc05" }, + { "SPT Green", "09401c0f6b3018ac4c1ed760" }, + } }, + { name = "TheWolfBunny64/Food and Drink Pack", palettes = { + { "7EL Color Combo", "062218147350eb0f2aff6c00" }, + { "BJB Beach", "273d3b4e7a7683ccc5dbe441" }, + { "BJB Storm", "1f3a31346152539b8368c2a4" }, + { "BKR Sundae", "40202178284bc04078f05097" }, + { "BGK Color Combo", "502314d62300ff8732f5ebdc" }, + { "CHT Orange", "442300723b00b75e00e57600" }, + { "CRK Color Combo", "460d0a8d1b15ec2e24f99b2a" }, + { "CCL Vision", "000000f40009d7d7d7ffffff" }, + { "Dew Green", "2d40004b6b0078ac0097d700" }, + { "DMP Vision", "000000006491e31837ffffff" }, + { "DRP Red", "290a0e4511186e1b278a2231" }, + { "DNK Vision", "48032b910756f20c90ff6e0c" }, + { "KFR Red", "33070e550c17881325ab182f" }, + { "KKR Vision", "000000166938cf152dffffff" }, + { "Lemon-Lime Green", "18310e30631d51a631f1c545" }, + { "MTD Ver.", "29673cd82a34a1d23fffffff" }, + { "PCS BLUE", "001b35002e59004a8e015db2" }, + { "PPS Vision", "0000001414c8ff1400ffffff" }, + { "PZH Red", "441012711c1fb52c31e3383e" }, + { "SPR Green", "002e17004d27007c3e009b4e" }, + } }, + { name = "TheWolfBunny64/Gaming Pack", palettes = { + { "AVG Raging Volcano", "1c0000a81000f83800f8b800" }, + { "Blue Bomber Vision", "0000000d4dc4639afce2cda7" }, + { "BBN Blue", "093e4b0f687e18a7ca1fd1fd" }, + { "BBG Green", "1f420e356e1854b0276adc31" }, + { "CHE Green", "20800040980080c800a0e000" }, + { "Classic Blurple", "22294138446d5b6dae7289da" }, + { "DMG Pea Green", "204631527f39aec440d7e894" }, + { "EBT Prolouge", "2c17084a290b854a1dc08226" }, + { "GGR Orange", "45230e743b17ba5e25e9762f" }, + { "Giga Kiwi DMG", "384828607028a0a830d0e040" }, + { "Investigation Yellow", "4c4a077f7c0cccc714fff919" }, + { "Phantom Red", "4b0b117e131cca1e2dfd2639" }, + { "PYO Green", "15431024771b39b42b48e236" }, + { "SES Blue", "073e4c0c687f14a7cc19d1ff" }, + { "SGA Tokyo Blue", "00273f00416a0068a90082d4" }, + { "Scarlett Green", "2e48034d78057cc0089bf00b" }, + { "Slime Blue", "0e2a3d1746662570a32f8ccc" }, + { "SNC Mega Blue", "13274120426c3369ad4084d9" }, + { "STM Gray", "3b3a396261609d9c99c5c3c0" }, + { "TYV Brown", "372c1f5c4a34937654b89469" }, + { "Tropical Cyber Space", "1414394344c1ef58f763fdfb" }, + { "XBX Green", "2b3c1249641f74a03192c83e" }, + } }, + { name = "TheWolfBunny64/Holiday Pack", palettes = { + { "Christmas Gold", "39321660542599873cc0a94b" }, + { "Christmas Silver", "2c32354a545977868e95a8b2" }, + { "Christmas Ver.", "300f0fa0323220a465cbb96a" }, + { "Classy Christmas", "534d579e5c5e8bab95e8e7df" }, + { "Clover Green", "1136161c5a252d903b39b54a" }, + { "Cupid's Love Palette", "5e081eb51a3ae48397e4cdd3" }, + { "Festive Christmas", "0a1609224b21cc1902bb9b60" }, + { "Glacial Winter Blue", "2839434360716c9ab487c1e2" }, + { "Grinchy Green", "3639085b5f0e929816b7be1c" }, + { "Halloween Ver.", "2c13319540a5f68c00ffcc00" }, + { "Haunted Halloween", "2f094c9e1fffffa52ecaff37" }, + { "Independence Boy", "0000000a3161b31942ffffff" }, + { "Irish Green", "143923225f3b37985e45be76" }, + { "Spooky Purple", "2f253f4f3e697e63a89e7cd2" }, + { "Winter Christmas", "341113ae3b4065b08fdddddd" }, + } }, + { name = "TheWolfBunny64/J-Pop Pack", palettes = { + { "A48 Pink", "4923317b3b53c45e84f676a6" }, + { "ATG PARETTO", "030a5b7a5375c375bcf4a7eb" }, + { "BBM KITSUNE RED", "2c00034a000677000995000c" }, + { "H46 Blue", "1b39442d5f724898b75bbee5" }, + { "J-Pop Idol Sherbet", "260c2b8129905bbee5f19db5" }, + { "MKU Blue", "05333f08566a0d8aaa11add5" }, + { "NZU PrePlay Palette", "47120fef3f3500b8b0fff200" }, + { "N46 Purple", "260c2b401448672073812990" }, + { "OOR RED", "370f0c5c1a14942920b93429" }, + { "YSB AMARANTH", "480c1b79142dc12048f2285a" }, + } }, + { name = "TheWolfBunny64/Japanese Company Pack", palettes = { + { "ANA Flight Blue", "00146e223f9a3b8bc000b3f0" }, + { "FMM Vision", "00301300602600a040008cd6" }, + { "LWS BLUE", "001f3600345b0053920068b7" }, + { "NHK Silver Gray", "262626404040666666808080" }, + { "SIT SUPER BLUE", "0b2438133d5e1f6196277abc" }, + { "SIT SUPER GREEN", "0634270b5742118b6a16ae85" }, + { "SKO Timely Vision", "2021210050a54393e6ffbf00" }, + { "SB109 PASTEL", "5503a63dacb8fd87b2f2d53f" }, + { "SNR Pink", "83534de74b5af485a1f9c2d0" }, + { "TKS CLOUDY BLUE", "27363b415a6368909f82b5c7" }, + { "NCO sea green", "073a310c6152149c8319c3a4" }, + } }, + { name = "TheWolfBunny64/K-Pop Pack", palettes = { + { "BGT ARMY PURPLE", "26154040246c6639ac8048d8" }, + { "BKP BLINK PINK", "4932377a535dc38594f4a7ba" }, + { "EER COSMIC LATTE", "4c4a457f7c73ccc6b8fff8e7" }, + { "LSS FEARLESS BLUE", "26314a40527c6784c781a5f9" }, + { "NCZ YELLOW", "43451e717333b4b851e2e766" }, + { "SVT COOL CARAT", "2b323e57647d92a8d1f7cac9" }, + { "SHW GREEN", "22413e396d685cafa673dbd0" }, + { "SWN PINK", "48172579273ec13f63f24f7c" }, + { "SKD STAY PINK", "4c142d7f224ccc367aff4499" }, + { "TROPICAL TWC APRICOT", "4c1c30993961ff5fa2fcc89b" }, + } }, + { name = "TheWolfBunny64/Legacy Palette Pack", palettes = { + { "TDS VC Green", "4a4a4a5a8c429cef29bdff21" }, + { "TDS VC Ver.", "2929186b6b52a5a58ccecead" }, + { "BLL PC Screen", "000000783800b86000f87800" }, + { "DMG Ver.", "2a453b365d48577c447f860f" }, + { "DGV Ver.", "2a2a2246463970705c8c8c73" }, + { "GMT Ver.", "12424c255955437a636ba64a" }, + { "GNW LCD", "3b3c336365569ea28ac6cbad" }, + { "GMK Ver.", "184421405d3b6b9c638cce94" }, + { "GBU Classic Ver.", "26302540503f67806481a17e" }, + { "HGM Ver.", "2d2d2b414f53687f84829fa6" }, + { "Light Ver.", "004f3b00694a009a7100b581" }, + { "LKA DX Ver.", "58382068884078c078f8f8b0" }, + { "MBM Ver.", "303030505050808080a0a0a0" }, + { "MGM V Ver.", "08203040689070a0e0d0d0d0" }, + { "NGP Ver.", "101010707070b0b0b0f0f0f0" }, + { "NKA 3310 Ver.", "2231273953425c846973a684" }, + { "Pocket Tales Ver.", "00000038500088a000d0d860" }, + { "Pocket Ver.", "1f1f1f4d533c8b956dc4cfa1" }, + { "PKS Ver.", "2d2d284b4b4378786c969687" }, + { "PKM Pinball Ver.", "181820786030a0b050e8f8b8" }, + { "PKM Ver.", "181010807098f0b088f8e8f8" }, + { "PKM mini Ver.", "1b2d1b525c52849484a5b9a5" }, + { "PKT Ver.", "10281838503050805070b070" }, + { "Rocky-Valley Holiday", "204008805850d89078c0f0f8" }, + { "T83 Ver.", "2e332a4e55467c88709caa8c" }, + { "TMG Ver.", "3c383878787cc0c0c7f1f0f9" }, + { "TGC Ver.", "0000000f4f2f6f8f4fdfff8f" }, + { "Timing Hero Ver.", "202e004c67188c994ccccc99" }, + { "Travel Wood", "482810705030a08058f8d8b0" }, + { "VMU Ver.", "0814804466546ca38688cca8" }, + { "VTB Ver.", "ff0000aa0000550000000000" }, + { "WTS Ver.", "0c322c2c626454a68c7cc67c" }, + { "WDS Ver.", "2c2c2c686868c0c0c0fefefe" }, + } }, + { name = "TheWolfBunny64/Misc. Palette Pack", palettes = { + { "Camouflage Ver.", "37353879533dac7e54bcab90" }, + { "Cherry Blossom Pink", "482534783f58c0648cf07eb0" }, + { "Emerald Green", "183c2428643c40a06050c878" }, + { "Fool's Gold and Silver", "2d30345a606997a1b0c5c66d" }, + { "Glitchy Blue", "0f254b193f7d2864c8337efb" }, + { "Gold, Silver, and Bronze", "2d1f1499684386949abeb049" }, + { "Golden Trophy", "453e0774680cb9a613e8d018" }, + { "Greenscale Ver.", "0c360c2c62346e870a9cbe0c" }, + { "Leprechaun Green", "10281d1b44302c6c4d378861" }, + { "Nightvision Green", "66bf2f519825335f171e390e" }, + { "Rising Sun Red", "38000d5d0016960024bc002d" }, + { "Treasure Gold", "3c360a655a12a2901ccbb524" }, + { "Wild West Vision", "3a160e924a36c3976ad9d7c7" }, + } }, + { name = "TheWolfBunny64/Console Pack", palettes = { + { "Advanced Indigo", "241f373c355d605494796aba" }, + { "Aegis Cherry", "42111e6e1d32b02f50dd3b64" }, + { "Ancient HSU Brown", "352f2b594f488f7f73b39f90" }, + { "ANC Green", "002e25004d3f007c64009b7e" }, + { "Brilliant Diamond Blue", "2638433f5d706595b47fbbe1" }, + { "COLLECTION of SAG Ver.", "041820345d51769a67b2c0a8" }, + { "Champion's Tunic", "281b13875b40009edde2dcb1" }, + { "CHZ Blue", "1735432759703e8fb44eb3e1" }, + { "DKG Arcade Blue", "15304223516f3881b147a2de" }, + { "DKG Barrel Brown", "3a220e613a179c5c25c3742f" }, + { "DMG-099", "3132313f642f6bad1984b510" }, + { "DMG-GOLD", "30361c505a3080904ca1b560" }, + { "DMG-SWITCH", "214231426b296c94218cad28" }, + { "DRL GB Ver.", "48530e788b1db9d03af6ff70" }, + { "EVE Brown", "3c2a0f644619a07028c88d32" }, + { "FMC Disk Yellow", "483a00796100c29b00f3c200" }, + { "FMC Frenzy", "231916a32135d9be72efecda" }, + { "Frog Coin Green", "00390039840000ef00fff7de" }, + { "Fury Blue", "0c1c2d152f4c224c792b5f98" }, + { "GMC Glimmer", "3e1318cf415111a396b6bed3" }, + { "Golden Wild", "372f1e5c4f32947f50b99f65" }, + { "GMB Brown", "331a11552c1d88472faa593b" }, + { "Green Awakening", "000b1636705898db75f1ffdd" }, + { "Green Banana", "2149003969394a9e0063df08" }, + { "INK Tricolor", "1c114c603bffff505eeaff3d" }, + { "LCD Clock Green", "183626285a4040906650b580" }, + { "LBO Fawn", "4033226b5539ac885cd7aa73" }, + { "MRO Red", "430400700700b40c00e10f00" }, + { "Metallic PLD Brass", "302d0f514c1a817929a29834" }, + { "Neon Blue", "033745055c730894b80ab9e6" }, + { "Neon Green", "0942000f6e0018b0001edc00" }, + { "Neon Orange", "4b30017d5002c88004faa005" }, + { "Neon Pink", "4c0f247f193ccc2860ff3278" }, + { "Neon Purple", "3600455a00739000b8b400e6" }, + { "Neon Red", "4c120c7f1e14cc3020ff3c28" }, + { "Neon Yellow", "454c00737f00b8cc00e6ff00" }, + { "Odyssey Boy", "2224215054457e8e67acbe8c" }, + { "Odyssey Gold", "3a30006150009b8000c2a000" }, + { "POCKET SWT", "3038206372518d9c7bb5c69c" }, + { "PKC Yellow", "4c42007f6e00ccb000ffdc00" }, + { "PKD Red", "461918752a28bb4340ea5450" }, + { "Royal Blue", "151949232a7a3844c44655f5" }, + { "Shining Pearl Pink", "3f2a30694750a87180d28ea0" }, + { "Sky Pop Ivory", "52502586825cbebb95e5e0b8" }, + { "Super FMC Supreme", "0846bad9404044ac71feda5a" }, + { "Super Mushroom Vision", "000000923404cc9e22f7cec3" }, + { "Super Star Pink", "440d2372163bb7235fe52c77" }, + { "Superball Ivory", "646432828250bcbc8aeef0bc" }, + { "Timeless Gold and Red", "37090a6f1215b91e23c8aa50" }, + { "Ultra Black", "17181d2629313d414f4d5263" }, + { "WRW MicroBlue", "05293c0844650d6da11189ca" }, + { "Wonder Purple", "451a42732c6fb846b2e658df" }, + { "Yellow Banana", "734900ad6939de9e00ffdf08" }, + { "YSH Egg Green", "1e3a0e336218519c2666c430" }, + } }, + { name = "TheWolfBunny64/Sports Pack", palettes = { + { "MLB Vision", "041e42bf0d3e057affffffff" }, + { "NASCAR Ver.", "000000007ac2e4002bffd659" }, + { "NBA Vision", "000000253b73c8102effffff" }, + { "NCAA Blue", "002e42004e6f007cb1009cde" }, + { "NFL Vision", "000000013369d50a0affffff" }, + { "OLY Bronze", "3d2618664029a46741cd8152" }, + { "OLY Gold", "3f360a6a5b12aa911cd5b624" }, + { "OLY Silver", "2f312e4f524e7e847c9ea59c" }, + { "PGA Tour Vision", "000000003c80f1373dffffff" }, + { "WWE White and Red", "40070c810e19d7182affffff" }, + } }, + { name = "TheWolfBunny64/Traditional JPN Colors Pack", palettes = { + { "Aquatic Iro", "192f603e62ad2ca9e1a0d8ef" }, + { "Fruity Orange", "241a089f563af08300f3bf88" }, + { "Ghostly Aoi", "0f235019448e5a79ba84a2d4" }, + { "Golden Kiiro", "6a5d21a69425dccb18f8e58c" }, + { "Lime Midori", "4759507b8d42aacf53e0ebaf" }, + { "Oni Aka", "640125a22041d9333fec6d71" }, + { "Sakura Pink", "a25768e7609eeebbcbfdeff2" }, + { "Silver Shiro", "383c3c727171afafb0dcdddd" }, + { "Tea Midori", "33363102876088cb7fd6e9ca" }, + { "Wisteria Murasaki", "2e29307058a3a59acadbd0e6" }, + } }, + { name = "Trashuncle/Artistic", palettes = { + { "TU PSL Park", "9200aeb722d4d442f0ec89ff" }, + { "TU Quicksilver", "555555717286a4a5c3bbbce1" }, + } }, + { name = "Trashuncle/Crush", palettes = { + { "TU Blueberry Crush", "0000002811ff4dffffffffff" }, + { "TU Grape Crush", "0000003d43cb0083ebffffff" }, + { "TU Kiwi Crush", "000000059e322eef7affffff" }, + { "TU Lavender Crush", "000000812482ab6a9bffffff" }, + { "TU Orange Crush", "000000c23615ff632cffffff" }, + { "TU Pineapple Crush", "000000ffff00ffff80ffffff" }, + { "TU Strawberry Crush", "000000ea1211ff5250ffffff" }, + } }, + { name = "Trashuncle/DMG", palettes = { + { "TU DMG Bright", "0c3a1d2a61414c7b2b919707" }, + { "TU DMG Clean", "1749022b5402496503788603" }, + { "TU DMG Weak Alt", "2c4d2054733c597730677f31" }, + { "TU DMG Weak", "364e1a5772405e7a406d833d" }, + } }, + { name = "Trashuncle/GBP", palettes = { + { "TU GBP Bright", "5b5d5a879481abb8a7ffffff" }, + { "TU GBP Clean", "3d493b98a291adbfacedfff4" }, + { "TU GBP Weak Alt", "4c4b3170734e757952848459" }, + { "TU GBP Weak", "434738535a42535f49667159" }, + } }, + { name = "Trashuncle/Light", palettes = { + { "TU Light Bright", "00637400a1b100daea00e3f3" }, + { "TU Light Clean", "007577019fa000aeaf01b4b9" }, + { "TU Light Dark", "0076a80092d10098c500a5f2" }, + { "TU Light Idealized", "006e9d0093c500a6cc00ccd5" }, + } }, +} diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 2889573c..c6b41847 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -34,6 +34,12 @@ local RomText = require("src.core.RomText") local Strings = require("src.core.Strings") local WideBattle = require("src.battle.WideBattle") +local Chrome2 +do + local ok, v = pcall(require, "src.ui.gen2.Chrome") + Chrome2 = ok and v or nil +end + local romText = RomText local BattleState = {} @@ -461,7 +467,7 @@ function BattleState:picImage(img) -- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode -- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" - or PaletteFX.mode == "classic" + or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays() if self.grayPics or mono then return grayImage(img) end -- SET_PAL_BATTLE_BLACK covers every battle palette slot, so the pics go -- dark with the HP bars while the blackout text is up (#292). The intro @@ -5989,7 +5995,7 @@ function BattleState:drawZonePass(src, sx, sy) -- only the other two showed it. Keep this mode set in sync with picImage / -- PaletteFX.ensureZones / WideBattle.monoMode. local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" - or PaletteFX.mode == "classic" + or PaletteFX.mode == "classic" or PaletteFX.forcesRawGrays() love.graphics.setColor(1, 1, 1, 1) love.graphics.setShader(shader) local shaking = sx ~= 0 or sy ~= 0 @@ -6399,8 +6405,35 @@ end function BattleState:drawTextArea() if not self:bottomUIVisible() then return end - Font.drawBox(0, 12, 20, 6) - love.graphics.setColor(0, 0, 0, 1) + -- Gold only: routes the command box and its labels through GbcPalette, + -- the same fix TextBox.lua got for dialogue. moveSelect/mimicSelect below + -- aren't migrated yet since they need their own interior-patch handling + -- (see #240). + local gold = self.game and self.game.save + and (self.game.save.generation == 2 or self.game.save.version == "gold") + local Chrome = gold and Chrome2 or nil + local function box(tx, ty, tw, th) + if Chrome then + Chrome.paletteBox(tx, ty, tw, th) + else + Font.drawBox(tx, ty, tw, th) + love.graphics.setColor(0, 0, 0, 1) + end + end + local drawGlyph, finishGlyph = Font.drawCode, nil + if Chrome then + local _, dg, fg = Chrome.paletteGlyphs(Chrome.DEFAULT_BOX_PALETTE) + drawGlyph, finishGlyph = dg, fg + end + local function text(str, x, y) + local pen = x + for _, code in ipairs(Font.encode(str)) do + drawGlyph(code, pen, y) + pen = pen + Font.advanceOf(code) + end + end + + box(0, 12, 20, 6) if self.phase == "messages" and (self.current or self.animPlaying or self.msgHold) then -- during the move animation self.current is nil but shown still holds @@ -6419,48 +6452,47 @@ function BattleState:drawTextArea() for li, line in ipairs(self.shown or {}) do local y = (ys[li] or 128) + off for i = 1, #line do - Font.drawCode(line[i], 8 + (i - 1) * 8, y) + drawGlyph(line[i], 8 + (i - 1) * 8, y) end end -- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait -- (_ContText) or a typed-out page (PromptText) holds the box; both write -- it at (18,16), bottom-right, like TextBox / home/text.asm (#317) if (self.msgWaiting or self.msgPrompt) and self.frame % 60 < 30 then - Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) + drawGlyph(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4) end elseif self.phase == "menu" and self.demo then -- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the -- standard menu, with the '▶' hand drawn by the scripted keystrokes -- -- next to FIGHT (9,14) for the first 80 frames, then ITEM (9,16) - Font.drawBox(8, 12, 12, 6) - love.graphics.setColor(0, 0, 0, 1) - Font.draw(Strings("FIGHT", "battle"), 80, 112) - Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) - Font.drawCode(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) + box(8, 12, 12, 6) + text(Strings("FIGHT", "battle"), 80, 112) + drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112) + text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128) + drawGlyph(0xED, 72, (self.demoTimer or 0) <= 80 and 112 or 128) elseif self.phase == "menu" then local col = (self.menuIndex - 1) % 2 local row = math.floor((self.menuIndex - 1) / 2) if self.safari then -- SAFARI_BATTLE_MENU_TEMPLATE: full-width box, "BALLx BAIT / -- THROW ROCK RUN" from (2,14) - Font.drawBox(0, 12, 20, 6) - Font.draw(Strings("BALLx"), 16, 112); Font.draw(Strings("BAIT"), 112, 112) - Font.draw(Strings("THROW ROCK"), 16, 128); Font.draw(Strings("RUN", "battle"), 112, 128) + box(0, 12, 20, 6) + text(Strings("BALLx"), 16, 112); text(Strings("BAIT"), 112, 112) + text(Strings("THROW ROCK"), 16, 128); text(Strings("RUN", "battle"), 112, 128) -- DisplayBattleMenu .safariLeftColumn / .safariRightColumn print -- wNumSafariBalls at hlcoord 7,14 with `lb bc, 1, 2` -- one byte, two -- digits, space padded -- right after the "BALLx" label at columns -- 2..6 (engine/battle/core.asm:2074-2079, 2107-2112) (#540) - Font.draw(("%2d"):format(self.safari.balls), 56, 112) - Font.drawCode(0xED, (col == 0 and 8 or 104), 112 + row * 16) + text(("%2d"):format(self.safari.balls), 56, 112) + drawGlyph(0xED, (col == 0 and 8 or 104), 112 + row * 16) else -- BATTLE_MENU_TEMPLATE: box (8,12)-(19,17), "FIGHT / -- ITEM RUN" from (10,14); cursor columns 9 / 15 - Font.drawBox(8, 12, 12, 6) - Font.draw(Strings("FIGHT", "battle"), 80, 112) - Font.drawCode(0xE1, 128, 112); Font.drawCode(0xE2, 136, 112) - Font.draw(Strings("ITEM", "battle"), 80, 128); Font.draw(Strings("RUN", "battle"), 128, 128) - Font.drawCode(0xED, (col == 0 and 72 or 120), 112 + row * 16) + box(8, 12, 12, 6) + text(Strings("FIGHT", "battle"), 80, 112) + drawGlyph(0xE1, 128, 112); drawGlyph(0xE2, 136, 112) + text(Strings("ITEM", "battle"), 80, 128); text(Strings("RUN", "battle"), 128, 128) + drawGlyph(0xED, (col == 0 and 72 or 120), 112 + row * 16) end elseif self.phase == "moveSelect" then -- pokered MoveSelectionMenu: move list in a box at (4,12) 16x6, @@ -6535,6 +6567,7 @@ function BattleState:drawTextArea() Font.drawCode(0xED, 8, (7 + self.mimicIndex) * 8) Font.draw(Strings("WHICH TECHNIQUE?"), 8, 112) end + if finishGlyph then finishGlyph() end end function BattleState:draw() diff --git a/src/battle/WideBattle.lua b/src/battle/WideBattle.lua index 32508356..b66045fb 100644 --- a/src/battle/WideBattle.lua +++ b/src/battle/WideBattle.lua @@ -26,14 +26,10 @@ local WideBattle = { FIELD_BOTTOM = 104, } --- The forced-mono display modes re-threshold the whole finished frame --- through the shade shader, and picImage hands them raw DMG grays for that --- (#207). The wide layout has to know: it exposes a matching whole-surface --- zone and leaves the HP bar fill gray, exactly like the zone pass does in --- the classic layout (#229). Keep in sync with picImage / ensureZones. local function monoMode() local m = PaletteFX.mode return m == "og" or m == "og_inv" or m == "classic" + or PaletteFX.forcesRawGrays() end local function shownHP(battler) diff --git a/src/core/Game.lua b/src/core/Game.lua index 72a13157..c505f456 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1258,6 +1258,7 @@ function Game:applyOptions(opts) if Sound.applyOptions then Sound.applyOptions(opts) end require("src.render.PaletteFX").applyOptions(opts) require("src.render.Tilt").applyOptions(opts) + require("src.render.Letterbox").applyOptions(opts) -- after Tilt, so a persisted world pipeline can switch the tilt level it -- just restored back off (the two are mutually exclusive) require("src.render.Pipelines").applyOptions(opts) diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 44390f1a..dbc6a0fc 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -1787,6 +1787,7 @@ function Game2:hotkey(key) local GbcPalette = require("src.render.GbcPalette") GbcPalette.setMode(options.color or "gbc") options.color = GbcPalette.cycle(1) + options.palette = "" persist() return true elseif key == "3" then @@ -2040,6 +2041,7 @@ function Game2:applyOptions() Zoom.allowSurvey = caps.survey if not caps.survey and Zoom.offset < 0 then Zoom.offset = 0 end require("src.render.Tilt").applyOptions(options) + require("src.render.Letterbox").applyOptions(options) require("src.render.GbcPalette").applyOptions(options) -- engine/gfx/load_font.asm:29 LoadFrame, off options.lua's wTextboxFrame. Font.setFrame(options.frame or 1) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 56a84b7f..bc198403 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -313,6 +313,7 @@ function SaveData.defaultOptions() zoom = 0, -- OVERWORLD beyond-edge fill: trees | water | black voidFill = "trees", + uiLetterbox = "auto", -- windowed | borderless (desktop fullscreen); ignored on mobile videoMode = "windowed", -- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see diff --git a/src/core/gen2/Save.lua b/src/core/gen2/Save.lua index ad84293a..6bee7106 100644 --- a/src/core/gen2/Save.lua +++ b/src/core/gen2/Save.lua @@ -309,6 +309,7 @@ Save.DEFAULT_OPTIONS = { -- Game Boy. The Gen 1 save's equivalent key is `colors` (SGB packs), which -- means something different, hence the different name. color = "gbc", + palette = "", videoMode = "windowed", fpsCap = 60, -- BATTLE BG (#1709): white | black, the surround around the battle screen. @@ -316,6 +317,7 @@ Save.DEFAULT_OPTIONS = { -- VOID FILL: fade | water | trees | black. fade is each map header's own -- border block with the dissolve across a boundary (#1418). voidFill = "fade", + uiLetterbox = "auto", musicVol = 7, -- 0-7, like the GB's NR50 master volume sfxVol = 7, -- 0-7 musicFilter = 0, -- low-pass steps, 0 = off diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index ecba935c..071b60fa 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -605,6 +605,7 @@ local function gen2Rows(opts, hooks, shared) end opts.color = GbcPalette.MODES[wrapIndex(idx - 1 + dir, #GbcPalette.MODES) + 1] + opts.palette = "" return true end) end diff --git a/src/render/GbcPalette.lua b/src/render/GbcPalette.lua index 0ee1d212..9a5e7c67 100644 --- a/src/render/GbcPalette.lua +++ b/src/render/GbcPalette.lua @@ -18,8 +18,6 @@ -- colour IS its palettes -- so one substitution at this seam recolours the -- whole game without a single screen knowing about it: -- --- GBC the cart's own palettes. The default: this is a Game Boy --- Color game and its colour is the point. -- DMG the original grey Game Boy. Every palette collapses to the -- four hardware shades, and the sheets that are drawn straight -- (chrome, text) are already those shades, so the screen is @@ -32,9 +30,12 @@ local GbcPalette = {} GbcPalette.MODES = { "gbc", "dmg", "classic" } -GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" } +GbcPalette.MODE_LABELS = { gbc = "GEN 2", dmg = "DMG", classic = "CLASSIC", + custom = "GBC" } GbcPalette.mode = "gbc" +GbcPalette.CUSTOM_MODE = "custom" + -- rBGP's four shades as the hardware shows them. local DMG_SHADES = { { 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }, @@ -162,11 +163,22 @@ local function channel(colors, index) return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255 end +GbcPalette.customRamp = nil + +function GbcPalette.setCustomRamp(ramp) + local prev = GbcPalette.customRamp + GbcPalette.customRamp = ramp + if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then + pcall(function() require("src.render.SpriteRenderer").invalidate() end) + end +end + -- What a palette actually draws as under the current COLOR mode. Anything -- that reads a colour out of a palette directly -- a canvas cleared to BG -- colour 0, say -- has to go through this too, or the backdrop would keep its -- cart colour while everything on top of it went grey. function GbcPalette.resolve(colors) + if GbcPalette.customRamp then return GbcPalette.customRamp end if GbcPalette.mode == "gbc" then return colors end return DMG_SHADES end @@ -249,6 +261,10 @@ function GbcPalette.presentColors() end function GbcPalette.setMode(mode) + if mode == GbcPalette.CUSTOM_MODE then + GbcPalette.mode = mode + return mode + end for _, name in ipairs(GbcPalette.MODES) do if name == mode then GbcPalette.mode = mode @@ -260,7 +276,7 @@ function GbcPalette.setMode(mode) end function GbcPalette.modeLabel(mode) - return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC" + return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GEN 2" end -- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so @@ -273,11 +289,20 @@ function GbcPalette.cycle(delta) local count = #GbcPalette.MODES at = (at - 1 + (delta or 1)) % count + 1 GbcPalette.mode = GbcPalette.MODES[at] + GbcPalette.setCustomRamp(nil) return GbcPalette.mode end function GbcPalette.applyOptions(opts) - return GbcPalette.setMode(opts and opts.color or "gbc") + local mode = GbcPalette.setMode(opts and opts.color or "gbc") + local id = opts and opts.palette + if id and id ~= "" then + local ok, Palette = pcall(require, "src.render.Palette") + GbcPalette.setCustomRamp(ok and Palette.ramp(id) or nil) + else + GbcPalette.setCustomRamp(nil) + end + return mode end -- Point the shader at one 4-color palette. Colors are 0-255 triples, matching @@ -431,4 +456,14 @@ function GbcPalette.with(colors, body) return applied end +function GbcPalette.withRaw(colors, body) + local previous = love and love.graphics and love.graphics.getShader + and love.graphics.getShader() or nil + local applied = GbcPalette.useRaw(colors) + local ok, err = pcall(body) + if love and love.graphics then love.graphics.setShader(previous) end + if not ok then error(err, 0) end + return applied +end + return GbcPalette diff --git a/src/render/Letterbox.lua b/src/render/Letterbox.lua new file mode 100644 index 00000000..1b428c31 --- /dev/null +++ b/src/render/Letterbox.lua @@ -0,0 +1,72 @@ +-- UI LETTERBOX: what fills the window around a 160x144 screen. +-- +-- AUTO keeps what each screen was authored with (Gen 1 black, Gen 2 mostly +-- white, the Pokedex and credits black); the other three override every +-- screen uniformly. PALETTE takes the active COLORS ramp's paper shade, so +-- the bars match the panel they frame instead of a fixed grey. + +local Letterbox = {} + +-- Set from applyOptions on both generations, the same shape GbcPalette.setMode +-- and Tilt.setLevel use, so a draw call needs no options table in hand. +Letterbox.mode = "auto" + +Letterbox.MODES = { "auto", "black", "white", "palette" } + +local LABELS = { + auto = "AUTO", black = "BLACK", white = "WHITE", palette = "PALETTE", +} + +function Letterbox.normalize(mode) + for _, id in ipairs(Letterbox.MODES) do + if mode == id then return id end + end + return "auto" +end + +function Letterbox.label(mode) + return LABELS[Letterbox.normalize(mode)] +end + +function Letterbox.cycle(mode, dir) + local at = 1 + for i, id in ipairs(Letterbox.MODES) do + if id == Letterbox.normalize(mode) then at = i break end + end + local n = #Letterbox.MODES + return Letterbox.MODES[(at - 1 + (dir or 1)) % n + 1] +end + +-- r, g, b for `mode`, falling back to the screen's authored colour on AUTO. +-- +-- `paper` is supplied by the caller, not looked up here: the two generations +-- keep their picked palette in different modules (Gen 1 in PaletteFX, Gen 2 in +-- GbcPalette) and GbcPalette.available() is true on both, so anything that +-- sniffed for it answered with Gen 2's white default on a Gen 1 game. +function Letterbox.color(mode, r, g, b, paper) + mode = Letterbox.normalize(mode) + if mode == "black" then return 0, 0, 0 end + if mode == "white" then return 1, 1, 1 end + if mode == "palette" and paper then + local pr, pg, pb = paper() + if pr then return pr, pg, pb end + end + return r or 0, g or 0, b or 0 +end + +function Letterbox.setMode(mode) + Letterbox.mode = Letterbox.normalize(mode) + return Letterbox.mode +end + +function Letterbox.applyOptions(options) + return Letterbox.setMode(options and options.uiLetterbox) +end + +-- What a drawWidescreen should paint the window with, given the colour that +-- screen was authored to use and its generation's paper reader. +function Letterbox.fill(r, g, b, paper) + return Letterbox.color(Letterbox.mode, r, g, b, paper) +end + +return Letterbox diff --git a/src/render/Palette.lua b/src/render/Palette.lua new file mode 100644 index 00000000..21fbd3a5 --- /dev/null +++ b/src/render/Palette.lua @@ -0,0 +1,249 @@ + +local Palette = {} + +local pack = nil + +function Palette.packs() + if pack == nil then + local ok, data = pcall(require, "data.gb_palettes") + pack = ok and data or false + end + return pack or {} +end + + +local MONO_STEPS = { 1.00, 0.72, 0.38, 0.12 } + +Palette.MONO_COLOURS = { + { "GREEN", 0x9b, 0xbc, 0x0f }, -- DMG + { "LIME", 0xa8, 0xd8, 0x38 }, + { "OLIVE", 0xa6, 0xac, 0x84 }, -- Pocket + { "AMBER", 0xff, 0xb0, 0x00 }, -- plasma terminal + { "ORANGE", 0xff, 0x80, 0x30 }, + { "RED", 0xff, 0x50, 0x50 }, + { "ROSE", 0xff, 0x90, 0xb0 }, + { "MAGENTA", 0xf0, 0x60, 0xd0 }, + { "PURPLE", 0xb0, 0x70, 0xf0 }, + { "INDIGO", 0x80, 0x80, 0xf0 }, + { "BLUE", 0x50, 0xa0, 0xff }, + { "CYAN", 0x40, 0xe0, 0xe0 }, -- VFD + { "TEAL", 0x40, 0xc0, 0xa0 }, + { "MINT", 0x90, 0xe0, 0xb0 }, + { "SAND", 0xe0, 0xc8, 0x90 }, + { "BROWN", 0xc0, 0x90, 0x50 }, + { "WHITE", 0xff, 0xff, 0xff }, -- plain DMG greys + { "SILVER", 0xd0, 0xd8, 0xe0 }, +} + +local function monoRamp(r, g, b) + local out = {} + for i = 1, 4 do + local f = MONO_STEPS[i] + out[i] = { math.floor(r * f + 0.5), + math.floor(g * f + 0.5), + math.floor(b * f + 0.5) } + end + return out +end + +Palette.monoRamp = monoRamp -- named for the suite + + +function Palette.packId(group, name) + return "p:" .. group .. "/" .. name +end + +function Palette.monoId(r, g, b) + return ("m:%02x%02x%02x"):format(r, g, b) +end + +local function unhex(text) + if type(text) ~= "string" or #text ~= 24 then return nil end + local out = {} + for i = 1, 4 do + local at = (i - 1) * 6 + local r = tonumber(text:sub(at + 1, at + 2), 16) + local g = tonumber(text:sub(at + 3, at + 4), 16) + local b = tonumber(text:sub(at + 5, at + 6), 16) + if not (r and g and b) then return nil end + out[i] = { r, g, b } + end + return out +end + +Palette.unhex = unhex -- named for the suite + +local RAMP_INDEX = 2 + +local GREY_CHROMA = 12 +local HUE_ARC = 50 + +Palette.GREY_CHROMA = GREY_CHROMA -- named for the suite +Palette.HUE_ARC = HUE_ARC + +local function hueChroma(c) + local r, g, b = c[1] or 0, c[2] or 0, c[3] or 0 + local mx = math.max(r, g, b) + local mn = math.min(r, g, b) + local chroma = mx - mn + if chroma <= 0 then return 0, 0 end + local h + if mx == r then h = ((g - b) / chroma) % 6 + elseif mx == g then h = (b - r) / chroma + 2 + else h = (r - g) / chroma + 4 end + return h * 60, chroma +end + +Palette.hueChroma = hueChroma -- named for the suite + +local function hueArc(hues) + local best = 360 + for i = 1, #hues do + local widest = 0 + for j = 1, #hues do + local d = (hues[j] - hues[i]) % 360 + if d > widest then widest = d end + end + if widest < best then best = widest end + end + return best +end + +Palette.hueArc = hueArc -- named for the suite + +function Palette.categoryOf(ramp) + if type(ramp) ~= "table" or not ramp[4] then return nil end + local hues = {} + for i = 1, 4 do + local h, chroma = hueChroma(ramp[i]) + if chroma > GREY_CHROMA then hues[#hues + 1] = h end + end + if #hues == 0 then return "grey" end + return hueArc(hues) <= HUE_ARC and "single" or "full" +end + +Palette.CATEGORIES = { + { key = "full", label = "FULL COLOUR" }, + { key = "single", label = "SINGLE COLOUR" }, + { key = "grey", label = "GREYSCALE" }, +} + +local byId, buckets = nil, nil + +local function buildIndex() + byId, buckets = {}, {} + for _, category in ipairs(Palette.CATEGORIES) do buckets[category.key] = {} end + for _, p in ipairs(Palette.packs()) do + for _, entry in ipairs(p.palettes or {}) do + local name = entry[1] + if type(name) == "string" then + local id = Palette.packId(p.name, name) + byId[id] = entry + local raw = unhex(entry[RAMP_INDEX] or entry[2]) + local bucket = raw and buckets[Palette.categoryOf(raw) or ""] or nil + if bucket then + bucket[#bucket + 1] = { name = name, pack = p.name, id = id } + end + end + end + end +end + +local function ensureIndex() + if not byId then buildIndex() end +end + +function Palette.categories() + ensureIndex() + local out = {} + for i, category in ipairs(Palette.CATEGORIES) do + out[i] = { key = category.key, name = category.label, + palettes = buckets[category.key] or {} } + end + return out +end + +function Palette.category(key) + ensureIndex() + if not buckets[key] then return nil end + for _, category in ipairs(Palette.CATEGORIES) do + if category.key == key then + return { key = key, name = category.label, palettes = buckets[key] } + end + end + return nil +end + +function Palette.locate(id) + if type(id) ~= "string" or id == "" then return nil end + ensureIndex() + for _, category in ipairs(Palette.CATEGORIES) do + for i, item in ipairs(buckets[category.key] or {}) do + if item.id == id then return category.key, i end + end + end + return nil +end + +local function findPack(group, name) + ensureIndex() + return byId[Palette.packId(group, name)] +end + +Palette.findPack = findPack -- named for the suite + +function Palette.label(id) + if type(id) ~= "string" or id == "" then return nil end + local hex = id:match("^m:(%x+)$") + if hex then + local r = tonumber(hex:sub(1, 2), 16) + local g = tonumber(hex:sub(3, 4), 16) + local b = tonumber(hex:sub(5, 6), 16) + for _, c in ipairs(Palette.MONO_COLOURS) do + if c[2] == r and c[3] == g and c[4] == b then return c[1] end + end + return "MONO" + end + local name = id:match("^p:.*/([^/]+)$") + return name or id +end + +local cacheId, cacheRamp = nil, nil + +function Palette.ramp(id) + if type(id) ~= "string" or id == "" then return nil end + if id == cacheId then return cacheRamp end + + local out = nil + local hex = id:match("^m:(%x+)$") + if hex and #hex == 6 then + out = monoRamp(tonumber(hex:sub(1, 2), 16) or 0, + tonumber(hex:sub(3, 4), 16) or 0, + tonumber(hex:sub(5, 6), 16) or 0) + else + local group, name = id:match("^p:(.*)/([^/]+)$") + if group then + local entry = findPack(group, name) + local ramp = entry and unhex(entry[RAMP_INDEX] or entry[2]) + if ramp then + out = { ramp[4], ramp[3], ramp[2], ramp[1] } + end + end + end + + cacheId, cacheRamp = id, out + return out +end + +function Palette.invalidate() + cacheId, cacheRamp = nil, nil + byId, buckets = nil, nil +end + +function Palette.swatch(id) + local ramp = Palette.ramp(id) + if not ramp then return nil end + return { ramp[4], ramp[3], ramp[2], ramp[1] } +end + +return Palette diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index f94ff2ca..7cf4c752 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -797,6 +797,18 @@ function PaletteFX.shadeMap() return shadeMap end +local function invalidateColorCaches() + pcall(function() require("src.battle.BattleState").invalidate() end) + pcall(function() require("src.render.SpriteRenderer").invalidate() end) + pcall(function() + require("src.world.MapLoader").invalidateAll() + local Game = require("src.core.Game") + if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then + Game.overworld:reloadMap(Game.overworld.map.id, "colors") + end + end) +end + function PaletteFX.setMode(mode) local prev = PaletteFX.mode local ok = false @@ -808,27 +820,35 @@ function PaletteFX.setMode(mode) end end if not ok then PaletteFX.mode = "gbc" end - -- battle pics and overworld sprites bake the active pack into ImageData; - -- drop those caches when the pack (or any COLORS mode) changes so the - -- next draw re-tints - if prev ~= PaletteFX.mode then - pcall(function() require("src.battle.BattleState").invalidate() end) - pcall(function() require("src.render.SpriteRenderer").invalidate() end) - -- RED++'s baked tileset atlas (TileRenderer.getGbcAtlas) is built once - -- per loaded map, so a mode toggle needs every cached Map/TileRenderer - -- dropped and the currently-visible one rebuilt in place -- otherwise - -- the on-screen map keeps its stale (wrong-mode) atlas until the next - -- map transition happens to reload it. - pcall(function() - require("src.world.MapLoader").invalidateAll() - local Game = require("src.core.Game") - if Game.overworld and Game.overworld.map and Game.overworld.reloadMap then - Game.overworld:reloadMap(Game.overworld.map.id, "colors") - end - end) + if prev ~= PaletteFX.mode then invalidateColorCaches() end +end + +PaletteFX.customRamp = nil + +function PaletteFX.setCustomRamp(ramp) + local prev = PaletteFX.customRamp + PaletteFX.customRamp = ramp + if (prev ~= nil) ~= (ramp ~= nil) or prev ~= ramp then + invalidateColorCaches() end end +function PaletteFX.pickerActive() + local ok, Game = pcall(require, "src.core.Game") + local states = ok and Game.stack and Game.stack.states + local top = states and states[#states] + return top ~= nil and top.screenId == "PaletteScreen" +end + +-- Whether the active COLORS state needs a battle pic rendered as raw DMG +-- grays instead of real colour, the same "forced-mono" contract OG/OG +-- INV/CLASSIC already use (issue #207). A custom palette needs this too, +-- since ensureZones re-thresholds the already-drawn frame. Keep this in +-- sync with ensureZones and the mode checks in BattleState and WideBattle. +function PaletteFX.forcesRawGrays() + return PaletteFX.customRamp ~= nil +end + function PaletteFX.cycleMode() local cur = PaletteFX.mode or "gbc" local idx = 1 @@ -841,6 +861,13 @@ end function PaletteFX.applyOptions(opts) PaletteFX.setMode(opts and opts.colors or "gbc") + local id = opts and opts.palette + if id and id ~= "" then + local ok, Palette = pcall(require, "src.render.Palette") + PaletteFX.setCustomRamp(ok and Palette.ramp(id) or nil) + else + PaletteFX.setCustomRamp(nil) + end end function PaletteFX.modeLabel(mode) @@ -853,12 +880,11 @@ function PaletteFX.modeLabel(mode) end -- When a state exposes no SGB zones but COLORS needs a forced palette --- (OG / OG INV / CLASSIC), invent a whole-screen zone so the shade-remap --- shader still runs. GBC / RED++ / GBC INV leave nil alone (raw DMG canvas). function PaletteFX.ensureZones(zones) if zones and zones[1] then return zones end local mode = PaletteFX.mode or "gbc" - if mode == "og" or mode == "og_inv" or mode == "classic" then + if mode == "og" or mode == "og_inv" or mode == "classic" + or (PaletteFX.forcesRawGrays() and not PaletteFX.pickerActive()) then return { PaletteFX.whole(PaletteFX.GRAYS) } end return zones @@ -888,7 +914,9 @@ function PaletteFX.effectiveColors(c) if not c then return nil end local mode = PaletteFX.mode or "gbc" local out = c - if mode == "og" then + if PaletteFX.customRamp and not PaletteFX.pickerActive() then + out = PaletteFX.customRamp + elseif mode == "og" then out = PaletteFX.GRAYS elseif mode == "og_inv" then out = PaletteFX.permute(PaletteFX.GRAYS, INV_MAP) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 4c795875..7dc88eb4 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -972,6 +972,13 @@ function Renderer:endFrame(zones, worldZones) and not FaithfulRes.scaleCap() then clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) end + -- UI LETTERBOX overrides whatever the rules above settled on, except + -- under FAITHFUL RATIO's mobile lock, which promises black bars. + if not FaithfulRes.scaleCap() then + local Letterbox = require("src.render.Letterbox") + clearR, clearG, clearB = Letterbox.fill(clearR, clearG, clearB, + function() return PaletteFX.paperShade(Game and Game.data) end) + end end if cut then love.graphics.setColor(0, 0, 0, 1) diff --git a/src/render/TextBox.lua b/src/render/TextBox.lua index fc324825..c8ce982a 100644 --- a/src/render/TextBox.lua +++ b/src/render/TextBox.lua @@ -11,6 +11,12 @@ local UIVisibility = require("src.battle.UIVisibility") local Theme = require("src.ui.Theme") local Timing = require("src.core.Timing") +local Chrome2 +do + local ok, v = pcall(require, "src.ui.gen2.Chrome") + Chrome2 = ok and v or nil +end + local TextBox = {} TextBox.__index = TextBox TextBox.isTextBox = true @@ -470,8 +476,21 @@ function TextBox:draw() -- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile -- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil. local paper = self.game and self.game.textboxPaper and self.game:textboxPaper() - Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper) - love.graphics.setColor(0, 0, 0, 1) + + local gold = self.game and self.game.save + and (self.game.save.generation == 2 or self.game.save.version == "gold") + local Chrome = gold and Chrome2 or nil + local drawGlyph, finishGlyph = Font.drawCode, nil + if Chrome then + local base = paper and { paper, paper, paper, { 0, 0, 0 } } + or Chrome.DEFAULT_BOX_PALETTE + Chrome.paletteBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, base) + local _, dg, fg = Chrome.paletteGlyphs(base) + drawGlyph, finishGlyph = dg, fg + else + Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper) + love.graphics.setColor(0, 0, 0, 1) + end if self.scrollPx and self.scrollPx > 0 then self.scrollPx = self.scrollPx - 2 if self.scrollPx <= 0 then self.scrollPx = nil end @@ -491,17 +510,25 @@ function TextBox:draw() -- measured with; every fixed-width page still lands on the 8px grid local pen = self.textX for _, code in ipairs(line) do - Font.drawCode(code, pen, y) + drawGlyph(code, pen, y) pen = pen + Font.advanceOf(code) end end if self.money then -- money box (engine/menus/text_box.asm:130): DisplayMoneyBox at -- hlcoord 11,0, the amount right-aligned on its middle row - Font.drawBox(11, 0, 9, 3) - love.graphics.setColor(0, 0, 0, 1) + if Chrome then + Chrome.paletteBox(11, 0, 9, 3, Chrome.DEFAULT_BOX_PALETTE) + else + Font.drawBox(11, 0, 9, 3) + love.graphics.setColor(0, 0, 0, 1) + end local money = ("¥%d"):format(self.money() or 0) - Font.draw(money, 152 - Font.width(money), 8) + local pen = 152 - Font.width(money) + for _, code in ipairs(Font.encode(money)) do + drawGlyph(code, pen, 8) + pen = pen + Font.advanceOf(code) + end end if (self.waiting or (self.done and not self.choice and not self.auto and (not self.stay @@ -509,10 +536,11 @@ function TextBox:draw() and self.blink < 30 then -- page-advance cursor: glyph $EE by default, the blinking down arrow -- the original prints via `ld a, "▼"` (home/text.asm) - Font.drawCode(Theme.moreArrow or 0xEE, - (self.boxTx + self.boxTw - 2) * 8, - (self.boxTy + self.boxTh - 1) * 8 - 4) + drawGlyph(Theme.moreArrow or 0xEE, + (self.boxTx + self.boxTw - 2) * 8, + (self.boxTy + self.boxTh - 1) * 8 - 4) end + if finishGlyph then finishGlyph() end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/sync/SyncEngine.lua b/src/sync/SyncEngine.lua index 83be5644..d2d4313a 100644 --- a/src/sync/SyncEngine.lua +++ b/src/sync/SyncEngine.lua @@ -116,6 +116,36 @@ function SyncEngine.overlaps(a, b) return aStart <= bEnd and bStart <= aEnd end +-- Inline, under .meta, or under .remoteMeta, depending on the endpoint. +function SyncEngine.metaOf(row) + if type(row) ~= "table" then return nil end + if type(row.meta) == "table" then return row.meta end + if type(row.remoteMeta) == "table" then return row.remoteMeta end + return row +end + +-- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold save +-- and summary.timeText is the only field that survives. +local function playedMinutes(meta) + if type(meta) ~= "table" then return nil end + local summary = type(meta.summary) == "table" and meta.summary or nil + local text = summary and summary.timeText + if type(text) == "string" then + local hours, minutes = text:match("^(%d+):(%d%d)$") + if hours then return tonumber(hours) * 60 + tonumber(minutes) end + end + local seconds = tonumber(meta.playTime) + if seconds then return math.floor(seconds / 60) end + return nil +end + +-- Same minute of playtime means the same point in the playthrough, so there +-- is no fork. Unknown playtime on either side is NOT a match. +function SyncEngine.samePlaytime(a, b) + local left, right = playedMinutes(a), playedMinutes(b) + return left ~= nil and left == right +end + function SyncEngine.new(opts) opts = opts or {} local eng = setmetatable({}, SyncEngine) @@ -457,7 +487,11 @@ function SyncEngine:_planFrom(remoteState) if not row then self:_queueUpload(entry, key, false) elseif localChanged and remoteChanged then - self:_addConflict(entry, key, row) + if SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + self:_queueUpload(entry, key, true) + else + self:_addConflict(entry, key, row) + end elseif localChanged then self:_queueUpload(entry, key, false) elseif remoteChanged and key ~= self.protectedKey then @@ -477,12 +511,7 @@ function SyncEngine:_planFrom(remoteState) end function SyncEngine:_addConflict(entry, key, row) - local remoteMeta = row - if type(row.meta) == "table" then - remoteMeta = row.meta - elseif type(row.remoteMeta) == "table" then - remoteMeta = row.remoteMeta - end + local remoteMeta = SyncEngine.metaOf(row) self.conflicts[#self.conflicts + 1] = { key = key, version = entry.version, @@ -528,7 +557,14 @@ function SyncEngine:_queueUpload(entry, key, force) end, function(e, res) if res.code == 409 then local row = res.data or {} - e:_addConflict(entry, key, row) + -- Retried with force only once: a forced write that still 409s is a + -- real refusal, and retrying it would spin. + if not force + and SyncEngine.samePlaytime(entry.meta, SyncEngine.metaOf(row)) then + e:_queueUpload(entry, key, true) + else + e:_addConflict(entry, key, row) + end if not e:busy() then e:_finish() end return true end diff --git a/src/ui/Marquee.lua b/src/ui/Marquee.lua new file mode 100644 index 00000000..521cb46d --- /dev/null +++ b/src/ui/Marquee.lua @@ -0,0 +1,49 @@ +-- Scrolling for a menu line too long for its box: hold, step one character +-- at a time, hold on the tail, snap back. Shared by the Gen 1 four-box +-- viewport (src/ui/OptionRows.lua) and the Gen 2 textbox +-- (src/ui/gen2/OptionsMenu.lua) -- different widths, same problem. + +local Marquee = {} + +Marquee.HOLD = 1.0 +Marquee.STEP = 0.25 + +-- Only ever one row scrolls (the cursor's), so one shared phase keeps that +-- row's label and value stepping together. +local phase = { key = nil, start = 0 } + +local function clock() + local timer = love and love.timer and love.timer.getTime + return timer and timer() or 0 +end + +-- Pure, so a test can step it without a clock. +function Marquee.at(text, maxChars, elapsed) + text = text or "" + local over = #text - maxChars + if over <= 0 then return text end + local span = Marquee.HOLD * 2 + over * Marquee.STEP + local now = elapsed % span + local offset + if now < Marquee.HOLD then + offset = 0 + elseif now < Marquee.HOLD + over * Marquee.STEP then + offset = math.floor((now - Marquee.HOLD) / Marquee.STEP) + else + offset = over + end + return text:sub(offset + 1, offset + maxChars) +end + +-- `key` identifies the highlighted row; changing it restarts the cycle. +function Marquee.scroll(text, maxChars, key) + if not text or #text <= maxChars then return text or "" end + if phase.key ~= key then phase.key, phase.start = key, clock() end + return Marquee.at(text, maxChars, clock() - phase.start) +end + +function Marquee.clip(text, maxChars) + return (text or ""):sub(1, maxChars) +end + +return Marquee diff --git a/src/ui/OptionRows.lua b/src/ui/OptionRows.lua index 1a39b71c..f4e1a68e 100644 --- a/src/ui/OptionRows.lua +++ b/src/ui/OptionRows.lua @@ -7,12 +7,18 @@ -- rows that open something instead (MODS, CANCEL stays the caller's). local Font = require("src.render.Font") +local Marquee = require("src.ui.Marquee") local Theme = require("src.ui.Theme") local OptionRows = {} OptionRows.VISIBLE = 4 -- option boxes on screen at once (4 tiles each) +-- Font.drawBox(0, y, 20, 4) spends column 19 on the frame, so a line drawn +-- to the 160px screen edge prints over its own border. +local CONTENT_RIGHT = 152 +local function fits(x) return math.floor((CONTENT_RIGHT - x) / 8) end + -- keep the cursor's box inside the viewport; the fixed bottom row shows -- the tail of the list function OptionRows.clampScroll(index, scroll, total, bottomRow) @@ -27,7 +33,7 @@ function OptionRows.clampScroll(index, scroll, total, bottomRow) end -- one bordered box per row, label line + value line, with the fixed --- bottom line below (CANCEL in the options menu, the manager's footer) +-- bottom line below (BACK in the options menu, the manager's footer) function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow) love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) @@ -37,8 +43,18 @@ function OptionRows.draw(game, rows, index, scroll, bottomLabel, bottomRow) if not row then break end Font.drawBox(0, (slot - 1) * 4, 20, 4) love.graphics.setColor(0, 0, 0, 1) - Font.draw(row.label, 16, ((slot - 1) * 4 + 1) * 8) - Font.draw(row.value and row.value(game) or "", 24, ((slot - 1) * 4 + 2) * 8) + local label = row.label or "" + local value = row.value and row.value(game) or "" + if i == index then + local key = tostring(row.id or row.label) .. "\0" .. tostring(value) + label = Marquee.scroll(label, fits(16), key) + value = Marquee.scroll(value, fits(24), key) + else + label = Marquee.clip(label, fits(16)) + value = Marquee.clip(value, fits(24)) + end + Font.draw(label, 16, ((slot - 1) * 4 + 1) * 8) + Font.draw(value, 24, ((slot - 1) * 4 + 2) * 8) if i == index then Font.drawCode(Theme.cursor, 8, ((slot - 1) * 4 + 1) * 8) end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 7e2fdc3d..b095d866 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -11,10 +11,12 @@ -- bottom line like pokered's. local PaletteFX = require("src.render.PaletteFX") +local Palette = require("src.render.Palette") local Pipelines = require("src.render.Pipelines") local Tilt = require("src.render.Tilt") local ShaderFX = require("src.render.ShaderFX") local Zoom = require("src.render.Zoom") +local Letterbox = require("src.render.Letterbox") local TileRenderer = require("src.render.TileRenderer") local GameSpeed = require("src.core.GameSpeed") local GameVersion = require("src.core.GameVersion") @@ -127,14 +129,6 @@ local function stepVolume(v, dir) return math.max(0, math.min(7, (v or 7) + dir)) end -local function colorIndex(opts) - local cur = opts.colors or "gbc" - for i, m in ipairs(PaletteFX.MODES) do - if m == cur then return i end - end - return 1 -end - local function wrapIndex(i, n) i = i % n if i < 0 then i = i + n end @@ -341,15 +335,14 @@ local function buildRows(game) end }, { id = "colors", label = Strings("COLORS"), value = function(g) + local id = g.save.options.palette + if id and id ~= "" then + return Palette.label(id) or PaletteFX.modeLabel(g.save.options.colors or "gbc") + end return PaletteFX.modeLabel(g.save.options.colors or "gbc") end, - step = function(g, dir) - local o = g.save.options - local i = colorIndex(o) - i = wrapIndex(i - 1 + dir, #PaletteFX.MODES) + 1 - o.colors = PaletteFX.MODES[i] - PaletteFX.setMode(o.colors) - return true + activate = function(g) + require("src.ui.Screens").push(g, "PaletteScreen") end }, { id = "tilt", label = Strings("TILT"), value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end, @@ -376,6 +369,16 @@ local function buildRows(game) -- cycling in place on this row. -- ShaderFXScreen.lua does the actual list/activate; this row only opens -- it and shows what is currently active. + { id = "uiLetterbox", label = Strings("UI LETTERBOX"), + value = function(g) + return Strings(Letterbox.label(g.save.options.uiLetterbox)) + end, + step = function(g, dir) + local o = g.save.options + o.uiLetterbox = Letterbox.cycle(o.uiLetterbox, dir) + Letterbox.setMode(o.uiLetterbox) + return true + end }, { id = "shaderfx", label = Strings("SHADER FX"), value = function(g) return shaderfxLabel(ShaderFX.activeEntry("main")) @@ -643,8 +646,90 @@ local function buildRows(game) return rows end +-- Grouping runs AFTER the ui.options.rows hook and never touches self.rows, +-- so a mod still sees and edits the flat list it always did; a row in no +-- group stays where it is, which is where a mod's own additions land. +local GROUPS = { + { id = "group.battle", label = "BATTLE OPTIONS", + members = { "animations", "battleStyle", "battleLayout", "battleFit", + "battleHud", "battleBg" } }, + { id = "group.audio", label = "AUDIO", + members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } }, + { id = "group.video", label = "VIDEO", + members = { "uiLayout", "videoMode", "orientation", "faithfulRes", + "screenPos", "fpsCap" } }, + { id = "group.speed", label = "SPEED", + members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } }, + { id = "group.graphics", label = "GRAPHICS", + members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } }, + -- A mod's Pipelines row splices in after TILT and is in no group, so it + -- stays on the top level rather than being swallowed into this page. + { id = "group.extras", label = "EXTRAS", + members = { "tilt", "zoom", "voidFill" } }, +} + +-- The top level's order, groups and singles alike. Anything not named here +-- (a mod's row, the touch rows) keeps its flat position after these. +local ORDER = { + "group.speed", "group.video", "group.graphics", "group.audio", + "performance", "ruleset", "group.battle", "group.extras", "mods", +} + +-- Each group replaced by one opener. +local function groupRows(game, rows) + local owner, picked = {}, {} + for _, group in ipairs(GROUPS) do + for _, id in ipairs(group.members) do owner[id] = group end + picked[group.id] = {} + end + for _, row in ipairs(rows) do + local group = row.id and owner[row.id] + if group then picked[group.id][#picked[group.id] + 1] = row end + end + local made = {} + for _, group in ipairs(GROUPS) do + local members = picked[group.id] + if #members > 0 then + made[group.id] = { + id = group.id, label = Strings(group.label), group = true, + value = function() return Strings("%d OPTIONS", #members) end, + -- No onCancel: BACK out of a page returns here, it does not close + -- OPTIONS, so the caller's close callback must not fire. + activate = function(g) + g.stack:push(OptionsMenu.new(g, { rows = members })) + end, + } + end + end + local byId, view, taken = {}, {}, {} + for _, row in ipairs(rows) do + if row.id and not owner[row.id] then byId[row.id] = row end + end + for _, id in ipairs(ORDER) do + local row = made[id] or byId[id] + if row then + view[#view + 1] = row + taken[id] = true + end + end + -- Whatever ORDER does not name, in the order the flat list had it: a mod's + -- own rows, and the platform rows that come and go. + for _, row in ipairs(rows) do + local id = row.id + if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end + end + return view +end + +-- opts.rows makes a submenu: a fixed row list, no hook and no regrouping, so +-- a group's page is this same screen driving the rows it was handed. function OptionsMenu.new(game, opts) opts = opts or {} + if opts.rows then + return setmetatable({ game = game, rows = opts.rows, view = opts.rows, + index = 1, scroll = 0, sub = true, + onCancel = opts.onCancel }, OptionsMenu) + end local rows = buildRows(game) local hooked = Runtime.call("ui.options.rows", sameRows, game, rows) if type(hooked) == "table" then @@ -653,14 +738,44 @@ function OptionsMenu.new(game, opts) Logger.error("ui.options.rows returned %s; keeping the vanilla rows", type(hooked)) end - return setmetatable({ game = game, rows = rows, index = 1, scroll = 0, - onCancel = opts.onCancel }, OptionsMenu) + local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0, + onCancel = opts.onCancel }, OptionsMenu) + self.view = groupRows(game, rows) + return self +end + +-- Cursor onto a row by id, opening its group page first if it lives in one. +-- Returns the screen the row ended up on, so a caller can keep driving it. +function OptionsMenu:focusRow(id) + local rows = self.view or self.rows + for i, row in ipairs(rows) do + if row.id == id then + self.index = i + self.scroll = OptionRows.clampScroll(i, self.scroll or 0, #rows, #rows + 1) + return self + end + end + for i, row in ipairs(rows) do + if row.group and row.activate then + local group + for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end + for _, member in ipairs(group and group.members or {}) do + if member == id then + self.index = i + row.activate(self.game) + local sub = self.game.stack:top() + return sub.focusRow and sub:focusRow(id) or sub + end + end + end + end + return nil end function OptionsMenu:update(dt) local input = self.game.input - local rows = self.rows - -- CANCEL sits below the hook-built rows so a mod cannot orphan the exit + local rows = self.view or self.rows + -- BACK sits below the hook-built rows so a mod cannot orphan the exit local cancelRow = #rows + 1 local changed = false if input:wasPressed("up") then @@ -702,14 +817,15 @@ function OptionsMenu:update(dt) end function OptionsMenu:draw() - -- Through Strings, like every other label on this menu. CANCEL is + -- Through Strings, like every other label on this menu. BACK is -- appended AFTER the rows hook (see the header), which is what keeps a mod -- from orphaning the exit -- but it also means a translation mod never sees -- this string, and cannot: there is no row for it to rewrite. So the one -- word a Spanish player could not read on a fully translated OPTIONS menu -- was the way out of it. - OptionRows.draw(self.game, self.rows, self.index, self.scroll or 0, - Strings("CANCEL"), #self.rows + 1) + local rows = self.view or self.rows + OptionRows.draw(self.game, rows, self.index, self.scroll or 0, + Strings("BACK"), #rows + 1) end return OptionsMenu diff --git a/src/ui/PaletteScreen.lua b/src/ui/PaletteScreen.lua new file mode 100644 index 00000000..a6d8f983 --- /dev/null +++ b/src/ui/PaletteScreen.lua @@ -0,0 +1,578 @@ + +local Font = require("src.render.Font") +local Theme = require("src.ui.Theme") +local Sound = require("src.core.Sound") +local Strings = require("src.core.Strings") +local Palette = require("src.render.Palette") +local PaletteFX = require("src.render.PaletteFX") + +local PaletteScreen = {} +PaletteScreen.__index = PaletteScreen + +PaletteScreen.isOpaque = true + +local COLS, ROWS = 5, 4 +local TILE_W, TILE_H = 28, 20 +local GAP = 4 +local GRID_X, GRID_Y = 2, 14 +local PER_PAGE = COLS * ROWS + +local SCREEN_W, SCREEN_H = 160, 144 + +local BACK = 0 + +local function sanitize(text) + text = tostring(text or ""):upper():gsub("_", "'") + text = text:gsub("[^A-Z0-9 %.'%-]", " ") + text = text:gsub("%s+", " "):gsub("^ ", ""):gsub(" $", "") + return text +end + +PaletteScreen.sanitize = sanitize -- named for the suite + +local function wrap(text, width, lines) + local out = {} + local rest = text + for i = 1, lines do + if rest == "" then break end + if #rest <= width then + out[i] = rest + rest = "" + else + local cut = rest:sub(1, width + 1):match("^.*() ") + if not cut or cut < 2 then cut = width + 1 end + out[i] = rest:sub(1, cut - 1) + rest = rest:sub(cut):gsub("^ +", "") + end + end + if rest ~= "" and #out > 0 then + local last = out[#out] + out[#out] = last:sub(1, math.max(0, width - 1)) .. "." + end + return out +end + +PaletteScreen.wrap = wrap -- named for the suite + +local function packLabel(name, width) + local text = sanitize(name) + if #text > width then + local leaf = tostring(name):match("([^/]+)$") + if leaf then text = sanitize(leaf) end + end + return wrap(text, width, 1)[1] or "" +end + +PaletteScreen.packLabel = packLabel -- named for the suite + + +local function fill(x, y, w, h, c) + love.graphics.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) + love.graphics.rectangle("fill", x, y, w, h) +end + +local function frame(x, y, w, h, shade) + love.graphics.setColor(shade, shade, shade, 1) + love.graphics.rectangle("line", x + 0.5, y + 0.5, w - 1, h - 1) +end + +local function drawSwatch(x, y, w, h, swatch) + if not swatch then + frame(x, y, w, h, 0.5) + return + end + local band = w / 4 + for i = 1, 4 do + fill(x + math.floor((i - 1) * band), y, + math.ceil(band), h, swatch[i]) + end +end + +PaletteScreen.drawSwatch = drawSwatch -- named for the suite + +local function drawBack(selected) + if selected then + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle("fill", 0, 0, 10, 9) + love.graphics.setColor(1, 1, 1, 1) + else + love.graphics.setColor(0, 0, 0, 1) + end + for i = 0, 3 do + love.graphics.rectangle("fill", 2 + i, 4 - i, 1, 1 + i * 2) + end +end + +PaletteScreen.drawBack = drawBack -- named for the suite + + +local function engineModes() + local labels = PaletteFX.MODE_LABELS or {} + local out = {} + for i, id in ipairs(PaletteFX.MODES) do + out[i] = { PaletteFX.modeLabel(id) or labels[id] or id:upper(), id } + end + return out +end + +local function dropConflictingMode(game) + local mode = PaletteFX.mode + local bakes = PaletteFX.usesGbcPack(mode) or PaletteFX.usesSpriteObp(mode) + if not bakes then return end + local o = game.save.options + o.colors = "gbc" + PaletteFX.setMode("gbc") +end + +local function liveOpts(game) + dropConflictingMode(game) + return { + palette = Palette, + get = function() return game.save.options.palette or "" end, + set = function(v) + local o = game.save.options + o.palette = v + if v ~= "" then dropConflictingMode(game) end + PaletteFX.setCustomRamp(v ~= "" and Palette.ramp(v) or nil) + if game.writeOptions then pcall(game.writeOptions, game) end + end, + modes = engineModes(), + getMode = function() return game.save.options.colors or "gbc" end, + setMode = function(v) + local o = game.save.options + o.colors = v + PaletteFX.setMode(v) + if game.writeOptions then pcall(game.writeOptions, game) end + end, + } +end + + +function PaletteScreen.new(game, opts) + opts = opts or liveOpts(game) + local self = setmetatable({ + game = game, + Palette = opts.palette, + get = opts.get, set = opts.set, + modes = opts.modes or {}, + getMode = opts.getMode, setMode = opts.setMode, + onChange = opts.onChange, + view = "root", + index = 1, + scroll = 0, + group = nil, + stackTrail = {}, + }, PaletteScreen) + self.rows = self:buildRoot() + self:enterFromSelection() + return self +end + + +function PaletteScreen:buildRoot() + local rows = {} + for _, category in ipairs(self.Palette and self.Palette.categories() or {}) do + if #(category.palettes or {}) > 0 then + rows[#rows + 1] = { label = category.name, folder = true, + view = "tiles", group = category } + end + end + if self.Palette and self.Palette.MONO_COLOURS then + rows[#rows + 1] = { label = "MONOCHROME", folder = true, view = "mono" } + end + for _, mode in ipairs(self.modes) do + rows[#rows + 1] = { label = mode[1], mode = mode[2] } + end + return rows +end + +function PaletteScreen:rootIndexFor(view, group) + for i, row in ipairs(self.rows or {}) do + if row.view == view then + if view ~= "tiles" then return i end + if row.group and group and row.group.key == group.key then return i end + end + end + return 1 +end + +function PaletteScreen:enterFromSelection() + local Palette = self.Palette + if not Palette then return end + local id = self.get and self.get() or "" + if type(id) ~= "string" or id == "" then return end + + if id:match("^m:") then + for i, c in ipairs(Palette.MONO_COLOURS) do + if Palette.monoId(c[2], c[3], c[4]) == id then + self:openAt("mono", nil, i) + return + end + end + return + end + + local key, at = Palette.locate(id) + if key then self:openAt("tiles", Palette.category(key), at) end +end + +function PaletteScreen:openAt(view, group, index) + self.stackTrail = { { view = "root", index = self:rootIndexFor(view, group), + scroll = 0, group = nil } } + self.view, self.group, self.index, self.scroll = view, group, index or 1, 0 + local items = self:items() + self:clampScroll(#items, #items + 1, PER_PAGE) +end + + +function PaletteScreen:items() + local Palette = self.Palette + if self.view == "root" then return self.rows or {} end + if self.view == "tiles" then + return self.group and self.group.palettes or {} + end + if self.view == "mono" then + return Palette and Palette.MONO_COLOURS or {} + end + return {} +end + +function PaletteScreen:isGrid() + return self.view == "tiles" or self.view == "mono" +end + +function PaletteScreen:idAt(i) + local Palette = self.Palette + if not Palette then return nil end + if self.view == "tiles" then + local entry = self.group and (self.group.palettes or {})[i] + return entry and entry.id or nil + elseif self.view == "mono" then + local c = Palette.MONO_COLOURS[i] + return c and Palette.monoId(c[2], c[3], c[4]) or nil + end + return nil +end + +function PaletteScreen:title() + if self.view == "root" then return Strings("COLOUR") end + if self.view == "mono" then return Strings("MONOCHROME") end + return sanitize(self.group and self.group.name or "") +end + + +function PaletteScreen:update() + local input = self.game.input + local items = self:items() + local total = #items + local cancel = total + 1 + + if input:wasPressed("b") then + self:back() + return + end + + if self:isGrid() then + self:moveGrid(input, total, cancel) + else + self:moveList(input, total, cancel) + end + + if input:wasPressed("a") then + self:activate(cancel) + end +end + +function PaletteScreen:moveList(input, total, cancel) + if self.index == BACK then + if input:wasPressed("down") or input:wasPressed("right") then + self.index = 1 + end + return + end + if input:wasPressed("left") then + self.index = BACK + return + end + if input:wasPressed("up") then + self.index = self.index > 1 and self.index - 1 or BACK + elseif input:wasPressed("down") then + self.index = self.index < cancel and self.index + 1 or 1 + end + self:clampScroll(total, cancel, 12) +end + +function PaletteScreen:moveGrid(input, total, cancel) + local i = self.index + + if i == BACK then + if input:wasPressed("down") or input:wasPressed("right") then + self.index = self.scroll + 1 + if self.index > total then self.index = 1 end + end + return + end + + if i > total then + if input:wasPressed("up") then + self.index = total + elseif input:wasPressed("down") then + self.index = 1 + end + self:clampScroll(total, cancel, PER_PAGE) + return + end + + if input:wasPressed("left") then + self.index = ((i - 1) % COLS == 0) and BACK or i - 1 + elseif input:wasPressed("right") then + self.index = i < total and i + 1 or 1 + elseif input:wasPressed("up") then + self.index = (i - COLS >= 1) and i - COLS or BACK + elseif input:wasPressed("down") then + if i + COLS <= total then + self.index = i + COLS + else + self.index = cancel + end + end + self:clampScroll(total, cancel, PER_PAGE) +end + +function PaletteScreen:clampScroll(total, cancel, visible) + if self.index == BACK then return end + if self.index >= cancel then + self.scroll = math.max(0, total - visible) + if self:isGrid() then + self.scroll = math.floor(self.scroll / COLS) * COLS + end + return + end + local step = self:isGrid() and COLS or 1 + if self.index <= self.scroll then + self.scroll = math.floor((self.index - 1) / step) * step + elseif self.index > self.scroll + visible then + self.scroll = math.floor((self.index - 1) / step) * step - visible + step + end + if self.scroll < 0 then self.scroll = 0 end +end + +function PaletteScreen:activate(cancel) + if self.index == BACK or self.index >= cancel then + self:back() + return + end + + if self.view == "root" then + local row = (self.rows or {})[self.index] + if not row then return end + if row.folder then + self:push(row.view, row.group) + elseif row.mode then + if self.set then self.set("") end + if self.setMode then self.setMode(row.mode) end + self:changed() + end + + elseif self:isGrid() then + local id = self:idAt(self.index) + if id then + if self.set then self.set(id) end + self:changed() + end + end +end + +function PaletteScreen:changed() + if self.game.data and Sound and Sound.play then + pcall(Sound.play, self.game.data, "Press_AB") + end + if self.onChange then self.onChange() end +end + +function PaletteScreen:push(view, group) + self.stackTrail[#self.stackTrail + 1] = + { view = self.view, index = self.index, scroll = self.scroll, + group = self.group } + self.view, self.index, self.scroll = view, 1, 0 + self.group = group + self:cursorToSelection() +end + +function PaletteScreen:cursorToSelection() + local id = self.get and self.get() or "" + if type(id) ~= "string" or id == "" then return end + local items = self:items() + for i = 1, #items do + if self:idAt(i) == id then + self.index = i + self:clampScroll(#items, #items + 1, + self:isGrid() and PER_PAGE or 12) + return + end + end +end + +function PaletteScreen:back() + local prev = table.remove(self.stackTrail) + if prev then + self.view, self.index = prev.view, prev.index + self.scroll, self.group = prev.scroll, prev.group + return + end + self:close() +end + +function PaletteScreen:close() + if self.game.data and Sound and Sound.play then + pcall(Sound.play, self.game.data, "Press_AB") + end + self.game.stack:pop() +end + + +function PaletteScreen:sgbPalettes() + return nil +end + + +function PaletteScreen:draw() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + + drawBack(self.index == BACK) + + love.graphics.setColor(0, 0, 0, 1) + Font.draw(self:title(), 12, 0) + + if self:isGrid() then + self:drawGrid() + else + self:drawList() + end + + love.graphics.setColor(0, 0, 0, 1) + local items = self:items() + if self.index >= #items + 1 then + Font.draw(Strings("CANCEL"), 16, SCREEN_H - 8) + Font.drawCode(Theme.cursor, 8, SCREEN_H - 8) + end + love.graphics.setColor(1, 1, 1, 1) +end + +function PaletteScreen:drawList() + local items = self:items() + local mode = self.getMode and self.getMode() or nil + local visible = 12 + for slot = 1, visible do + local i = self.scroll + slot + local row = items[i] + if not row then break end + local y = 16 + (slot - 1) * 8 + + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings(row.label or ""), 16, y) + if i == self.index then + Font.drawCode(Theme.cursor, 8, y) + end + if row.folder then + Font.drawCode(Theme.cursor, 8 + 8 * 17, y) + elseif row.mode and mode and row.mode == mode then + love.graphics.rectangle("fill", 8 + 8 * 17 + 2, y + 2, 4, 4) + end + end +end + +function PaletteScreen:drawGrid() + local Palette = self.Palette + local items = self:items() + local total = #items + + for slot = 1, PER_PAGE do + local i = self.scroll + slot + if i > total then break end + local col = (slot - 1) % COLS + local row = math.floor((slot - 1) / COLS) + local x = GRID_X + col * (TILE_W + GAP) + local y = GRID_Y + row * (TILE_H + GAP) + + local swatch + if self.view == "mono" then + local c = Palette and Palette.MONO_COLOURS[i] + if c then + local ramp = Palette.monoRamp(c[2], c[3], c[4]) + swatch = { ramp[4], ramp[3], ramp[2], ramp[1] } + end + else + swatch = Palette and Palette.swatch(self:idAt(i)) or nil + end + drawSwatch(x, y, TILE_W, TILE_H, swatch) + + if i == self.index then + frame(x - 2, y - 2, TILE_W + 4, TILE_H + 4, 0) + frame(x - 1, y - 1, TILE_W + 2, TILE_H + 2, 1) + end + end + + self:drawFooter(total) +end + +function PaletteScreen:drawFooter(total) + local Palette = self.Palette + love.graphics.setColor(0, 0, 0, 1) + + local name, pack = "", nil + if self.index >= 1 and self.index <= total then + if self.view == "mono" then + local c = Palette and Palette.MONO_COLOURS[self.index] + name = c and c[1] or "" + else + local entry = self.group and (self.group.palettes or {})[self.index] + if entry then + name = sanitize(entry.name) + pack = entry.pack + end + end + end + + local lines = wrap(name, 20, 2) + for i = 1, #lines do + Font.draw(lines[i], 0, 110 + (i - 1) * 8) + end + + local at = 0 + if total > PER_PAGE then + local page = math.floor(self.scroll / PER_PAGE) + 1 + local pages = math.ceil(total / PER_PAGE) + Font.draw(("%d/%d"):format(page, pages), 0, 128) + at = 40 + end + if pack then + Font.draw(packLabel(pack, (SCREEN_W - at) / 8), at, 128) + end + + local stored = self.get and self.get() or "" + if type(stored) == "string" and stored ~= "" then + for slot = 1, PER_PAGE do + local i = self.scroll + slot + if i > total then break end + if self:idAt(i) == stored then + local col = (slot - 1) % COLS + local row = math.floor((slot - 1) / COLS) + love.graphics.setColor(0, 0, 0, 1) + love.graphics.rectangle( + "fill", + GRID_X + col * (TILE_W + GAP) + TILE_W - 5, + GRID_Y + row * (TILE_H + GAP) + TILE_H - 5, 4, 4) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle( + "fill", + GRID_X + col * (TILE_W + GAP) + TILE_W - 4, + GRID_Y + row * (TILE_H + GAP) + TILE_H - 4, 2, 2) + break + end + end + end +end + +return PaletteScreen diff --git a/src/ui/gen2/BattleAnimView.lua b/src/ui/gen2/BattleAnimView.lua index 13991c53..7a34938b 100644 --- a/src/ui/gen2/BattleAnimView.lua +++ b/src/ui/gen2/BattleAnimView.lua @@ -18,6 +18,7 @@ local bit = require("bit") local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") @@ -164,12 +165,8 @@ local function needsCanvas(runner) end -- The battle background is BG colour 0 everywhere the two pic boxes and the --- text box are not, which Chrome.clear draws as plain white; anything the --- scanline blit exposes has to be that colour and not a hole. function BattleAnimView:fillBackground() - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) + Chrome.paletteFill(0, 0, SCREEN_W, SCREEN_H) end -- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index cabda30f..4cb11ee5 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -3520,7 +3520,7 @@ function BattleState:drawHud() -- And nothing at all before UpdateEnemyHUD has ever run: the intro bands -- slide in over a blanked tilemap (core.asm:8554/8564). if showStatus and self.showEnemyHud and not self:hudCleared("enemy") then - Chrome.print(self:name(enemy), 1, 0) + Chrome.printThrough(self:name(enemy), 1, 0, Chrome.DEFAULT_BOX_PALETTE) -- PrintLevel writes at the coordinate it is given and then LEFT-aligns -- the digits after it, so the glyph is pinned to column 6 whether the level -- is 5 or 100; only a three-digit level moves, and it does so by eating the @@ -3528,10 +3528,12 @@ function BattleState:drawHud() -- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag -- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops -- the level entirely while one is up. - Chrome.print(self:statusTag(enemy, "enemy") - or ("" .. tostring(enemy.level or 1)), 6, 1) + Chrome.printThrough(self:statusTag(enemy, "enemy") + or ("" .. tostring(enemy.level or 1)), 6, 1, Chrome.DEFAULT_BOX_PALETTE) local enemyGender = self:genderSymbol(enemy) - if enemyGender then Chrome.print(enemyGender, 9, 1) end + if enemyGender then + Chrome.printThrough(enemyGender, 9, 1, Chrome.DEFAULT_BOX_PALETTE) + end -- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at -- (1,1) (engine/battle/trainer_huds.asm:140-152). if self.battle and self.battle.wild and self.caughtMark then @@ -3580,17 +3582,20 @@ function BattleState:drawHud() Font.useBattleExtra(wasBattle) return end - Chrome.print(self:name(player), 10, 7) + Chrome.printThrough(self:name(player), 10, 7, Chrome.DEFAULT_BOX_PALETTE) -- PrintPlayerHUD places the same status tag at (14,8) and skips the level -- while it is up. - Chrome.print(self:statusTag(player, "player") - or ("" .. tostring(self.shownLevel or player.level or 1)), 14, 8) + Chrome.printThrough(self:statusTag(player, "player") + or ("" .. tostring(self.shownLevel or player.level or 1)), 14, 8, + Chrome.DEFAULT_BOX_PALETTE) local playerGender = self:genderSymbol(player) - if playerGender then Chrome.print(playerGender, 17, 8) end + if playerGender then + Chrome.printThrough(playerGender, 17, 8, Chrome.DEFAULT_BOX_PALETTE) + end self:drawHpBar(player, "player", 10, 9) local maxHp = player.maxHp or (player.stats and player.stats.hp) or 0 - Chrome.printRight(("%d/%d"):format(self:hudHp(player, "player"), maxHp), - 18, 10) + Chrome.printRightThrough(("%d/%d"):format(self:hudHp(player, "player"), maxHp), + 18, 10, Chrome.DEFAULT_BOX_PALETTE) -- Stub on the RIGHT (tile $73), border from (18,10) laid leftward, exp bar -- at (10,11). -- The chased fill, not the mon's: AnimateExpBar crawls it, and reading the @@ -3659,8 +3664,8 @@ end function BattleState:printMessage() local lines = Chrome.wrap(self.message or "", TEXT_WIDTH) for i = 1, math.min(#lines, TEXT_ROWS) do - Chrome.print(lines[i], TEXT_INNER_X, - TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP) + Chrome.printThrough(lines[i], TEXT_INNER_X, + TEXT_INNER_Y + (i - 1) * TEXT_ROW_STEP, Chrome.DEFAULT_BOX_PALETTE) end end @@ -3670,15 +3675,17 @@ function BattleState:drawMoveInfoBox(move) if not move then return end local fighter = self.battle and self.battle.player if fighter and self.battle:moveDisabled(fighter, move.id) then - Chrome.print("Disabled!", 1, 10) + Chrome.printThrough("Disabled!", 1, 10, Chrome.DEFAULT_BOX_PALETTE) return end local def = self.game and self.game.data and self.game.data.moves and self.game.data.moves[move.id] - Chrome.print("TYPE/", 1, 9) + Chrome.printThrough("TYPE/", 1, 9, Chrome.DEFAULT_BOX_PALETTE) local moveType = def and def.type - Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10) - Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11) + Chrome.printThrough(moveType and (TYPE_NAMES[moveType] or moveType) or "", + 2, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), + 5, 11, Chrome.DEFAULT_BOX_PALETTE) end function BattleState:drawPanel() @@ -3687,7 +3694,7 @@ function BattleState:drawPanel() -- required there; everywhere else a missing side is a caller bug. local hasPlayer = self.battle and (self.battle.player or self.tutorial) if not (self.battle and hasPlayer and self.battle.enemy) then - Chrome.print("NO BATTLE", 1, 1) + Chrome.printThrough("NO BATTLE", 1, 1, Chrome.DEFAULT_BOX_PALETTE) return end self:drawHud() @@ -3719,8 +3726,10 @@ function BattleState:drawPanel() local col = ((i - 1) % 2) * spacing local row = math.floor((i - 1) / 2) * 2 local tx, ty = boxX + 2 + col, 14 + row - if i == self.menuIndex then Chrome.cursor(tx - 1, ty) end - Chrome.print(label, tx, ty) + if i == self.menuIndex then + Chrome.cursorThrough(tx - 1, ty, Chrome.DEFAULT_BOX_PALETTE) + end + Chrome.printThrough(label, tx, ty, Chrome.DEFAULT_BOX_PALETTE) end elseif self.phase == "moves" or (self.phase == "choose-forget" and (self.messageTimer or 0) <= 0) then @@ -3740,21 +3749,24 @@ function BattleState:drawPanel() for i, move in ipairs(moves) do local ty = 13 + (i - 1) -- Cursor in the box's own gutter, not clipped against the border. - if i == cursorRow then Chrome.cursor(cursorCol, ty) end + if i == cursorRow then + Chrome.cursorThrough(cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE) + end -- The held slot's marker. `.battle_player_moves` writes '▷' into the -- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move -- picked up for a swap is visible while the cursor moves off it. It -- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers -- the marker on the cursor's row. if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then - Chrome.print("\u{25B7}", cursorCol, ty) + Chrome.printThrough("\u{25B7}", cursorCol, ty, Chrome.DEFAULT_BOX_PALETTE) end local def = self.game and self.game.data and self.game.data.moves and self.game.data.moves[move.id] - Chrome.print((def and def.name) or move.id, nameCol, ty) + Chrome.printThrough((def and def.name) or move.id, nameCol, ty, + Chrome.DEFAULT_BOX_PALETTE) if not moveMenu then - Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), - 19, ty) + Chrome.printRightThrough(("%d/%d"):format(move.pp or 0, move.maxPp or 0), + 19, ty, Chrome.DEFAULT_BOX_PALETTE) end end if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end @@ -3773,13 +3785,14 @@ function BattleState:drawPanel() local left = (self.phase == "ask-shift" or self.phase == "ask-next-mon") and 1 or 14 Chrome.box(left, 7, 6, 5) - Chrome.print("YES", left + 2, 8) - Chrome.print("NO", left + 2, 10) + Chrome.printThrough("YES", left + 2, 8, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("NO", left + 2, 10, Chrome.DEFAULT_BOX_PALETTE) local index = self.phase == "ask-nickname" and self.nicknameIndex or self.phase == "ask-shift" and self.shiftIndex or self.phase == "ask-next-mon" and self.nextMonIndex or self.forgetChoice - Chrome.cursor(left + 1, index == 1 and 8 or 10) + Chrome.cursorThrough(left + 1, index == 1 and 8 or 10, + Chrome.DEFAULT_BOX_PALETTE) end end if self.phase == "stats-box" and self.statsBoxMon then @@ -3808,8 +3821,9 @@ function BattleState:drawStatsBox(mon) Chrome.textbox(9, 0, 9, 10) for i, row in ipairs(STATS_BOX_ROWS) do local ty = 1 + (i - 1) * 2 - Chrome.print(Strings(row[1]), 11, ty) - Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1) + Chrome.printThrough(Strings(row[1]), 11, ty, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printRightThrough(("%d"):format(stats[row[2]] or 0), 19, ty + 1, + Chrome.DEFAULT_BOX_PALETTE) end end @@ -3893,8 +3907,7 @@ end function BattleState:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/BoxMenu.lua b/src/ui/gen2/BoxMenu.lua index 027ccf42..9847ca13 100644 --- a/src/ui/gen2/BoxMenu.lua +++ b/src/ui/gen2/BoxMenu.lua @@ -945,8 +945,7 @@ end function BoxMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CardFlip.lua b/src/ui/gen2/CardFlip.lua index 88e7a170..3b777f0f 100644 --- a/src/ui/gen2/CardFlip.lua +++ b/src/ui/gen2/CardFlip.lua @@ -968,8 +968,7 @@ end function CardFlip:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CenterPcMenu.lua b/src/ui/gen2/CenterPcMenu.lua index 222f7213..3007170b 100644 --- a/src/ui/gen2/CenterPcMenu.lua +++ b/src/ui/gen2/CenterPcMenu.lua @@ -309,8 +309,7 @@ end function CenterPcMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Chrome.lua b/src/ui/gen2/Chrome.lua index 6a147077..58141124 100644 --- a/src/ui/gen2/Chrome.lua +++ b/src/ui/gen2/Chrome.lua @@ -27,13 +27,39 @@ Chrome.SCREEN_H = 18 -- charmap.asm "¥", the money field's own prefix tile. local YEN = "\xc2\xa5" -function Chrome.clear() +function Chrome.paletteFill(px, py, pw, ph, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE local G = love.graphics G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) + if GbcPalette.available() then + GbcPalette.with(palette, function() G.rectangle("fill", px, py, pw, ph) end) + else + G.rectangle("fill", px, py, pw, ph) + end G.setColor(0, 0, 0, 1) end +-- Every drawWidescreen paints the window around its 160x144 panel through +-- here, so UI LETTERBOX has exactly one place to override. r/g/b is what the +-- screen was authored with and is what AUTO keeps. +local function letterboxPaper() + local c = GbcPalette.resolve(Chrome.DEFAULT_BOX_PALETTE) + c = c and c[1] + if not c then return nil end + return c[1] / 255, c[2] / 255, c[3] / 255 +end + +function Chrome.letterbox(winW, winH, r, g, b) + local Letterbox = require("src.render.Letterbox") + local G = love.graphics + G.setColor(Letterbox.fill(r or 1, g or 1, b or 1, letterboxPaper)) + G.rectangle("fill", 0, 0, winW, winH) +end + +function Chrome.clear() + Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8) +end + -- The blit scale every `drawWidescreen` paints its 160x144 panel at. -- -- One GB pixel has to cover a WHOLE number of window pixels or the 8x8 grid @@ -77,10 +103,24 @@ function Chrome.positionLift(winW, winH, scale) or Chrome.fitScale(winW, winH)), ScreenPosition.safeTop()) end +Chrome.DEFAULT_BOX_PALETTE = { + { 255, 255, 255 }, { 255, 255, 255 }, { 255, 255, 255 }, { 0, 0, 0 }, +} + +function Chrome.paletteBox(tx, ty, tw, th, palette) + palette = palette or Chrome.DEFAULT_BOX_PALETTE + if GbcPalette.available() then + love.graphics.setColor(1, 1, 1, 1) + GbcPalette.with(palette, function() Font.drawBox(tx, ty, tw, th) end) + else + Font.drawBox(tx, ty, tw, th, palette[1]) + end + love.graphics.setColor(0, 0, 0, 1) +end + -- A bordered box, tile coords. Leaves the draw color black for text. function Chrome.box(tx, ty, tw, th) - Font.drawBox(tx, ty, tw, th) - love.graphics.setColor(0, 0, 0, 1) + Chrome.paletteBox(tx, ty, tw, th) end -- Gold's Textbox helper takes an interior width/height and draws the border @@ -90,11 +130,16 @@ function Chrome.textbox(tx, ty, interiorW, interiorH) Chrome.box(tx, ty, interiorW + 2, interiorH + 2) end -function Chrome.print(text, tx, ty) +local function flatPrint(text, tx, ty) love.graphics.setColor(0, 0, 0, 1) return Font.draw(text, tx * 8, ty * 8) end +function Chrome.print(text, tx, ty) + if not GbcPalette.available() then return flatPrint(text, tx, ty) end + return Chrome.printThrough(text, tx, ty, Chrome.DEFAULT_BOX_PALETTE) +end + -- Prints a string the way a tilemap screen does: the glyph tiles replace the -- cells they land on, and both their ink and their blank pixels go through -- that screen's own BG palette. @@ -124,32 +169,39 @@ function Chrome.throughPalette(palette, invert) return GbcPalette.remap(pal, GbcPalette.bgp) end --- `palette` is a 4-colour table of 0-255 triples. `invert` is what --- Chrome.printInverted passes; see there. -function Chrome.printThrough(text, tx, ty, palette, invert) +function Chrome.rawPalette(palette, invert) + if invert then return { palette[4], palette[3], palette[2], palette[1] } end + return palette +end + +-- The per-glyph shader/tint switch printThrough needs, factored out so a +-- caller that positions its own glyphs (TextBox's typewriter/scroll +-- animation) gets the same shaded ink without re-deriving the TTF-vs-tile +-- split. +-- +-- Returns (pal, drawGlyph, finish): +-- pal the resolved ramp, or nil if there's no shader to draw +-- through; a caller that also draws a paper rect should skip +-- it and take the plain Chrome.print degrade instead. +-- drawGlyph (code, x, y): draws one glyph, shaded through `pal` for a +-- tile glyph or tinted with pal[4] for a TTF one (see the +-- inline comment below, gen1recomp#1642). +-- finish call once after the last glyph to restore the previous +-- shader and draw colour. +function Chrome.paletteGlyphs(palette, invert, raw) if not (palette and GbcPalette.available()) then - return Chrome.print(text, tx, ty) + return nil, Font.drawCode, function() end end -- `pal` is used below and never touches the caller's palette again -- -- including useRaw for the draw, since useRaw skips the fold GbcPalette.use -- would otherwise apply and folding it a second time would undo this. - local pal = Chrome.throughPalette(palette, invert) - local width = Font.width(text) - local paper = pal[1] or { 255, 255, 255 } - love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8) - -- Per glyph, not per string: a TTF-mod build still keeps the multi-byte - -- charmap sequences (the naming screen's /, the 'd/'l/'s ligatures) - -- and anything the mod names in ttf.tiles (Font.lua's own note on keeping - -- digits tile-based for column alignment) on their ROM tiles, so a string - -- can freely mix the two kinds of glyph. + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) local ink = pal[4] or { 0, 0, 0 } local previous = love.graphics.getShader() local shaded = false - local pen = tx * 8 - for _, code in ipairs(Font.encode(text)) do + local function drawGlyph(code, x, y) if code >= Font.TTF_BASE then - -- The shader below recovers a shade from the RED CHANNEL of an already -- flat-shaded 2bpp tile sheet -- exactly what a TTF glyph is not. -- LÖVE's font rasterizer stores glyph coverage as alpha over a plain -- white texture, which this shader reads back as shade 0 no matter how @@ -167,11 +219,28 @@ function Chrome.printThrough(text, tx, ty, palette, invert) GbcPalette.useRaw(pal) shaded = true end - Font.drawCode(code, pen, ty * 8) + Font.drawCode(code, x, y) + end + local function finish() + if shaded then love.graphics.setShader(previous) end + love.graphics.setColor(0, 0, 0, 1) + end + return pal, drawGlyph, finish +end + +function Chrome.printThrough(text, tx, ty, palette, invert, raw) + local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw) + if not pal then return Chrome.print(text, tx, ty) end + local width = Font.width(text) + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx * 8, ty * 8, width, 8) + local pen = tx * 8 + for _, code in ipairs(Font.encode(text)) do + drawGlyph(code, pen, ty * 8) pen = pen + Font.advanceOf(code) end - if shaded then love.graphics.setShader(previous) end - love.graphics.setColor(0, 0, 0, 1) + finish() return width end @@ -186,12 +255,34 @@ end -- Right-aligned within a field that ends at tile `txEnd` (exclusive), which is -- how Gold prints numbers (PrintNum fills from the right). -function Chrome.printRight(text, txEnd, ty) +local function flatPrintRight(text, txEnd, ty) local width = Font.width(text) love.graphics.setColor(0, 0, 0, 1) return Font.draw(text, txEnd * 8 - width, ty * 8) end +function Chrome.printRight(text, txEnd, ty) + if not GbcPalette.available() then return flatPrintRight(text, txEnd, ty) end + return Chrome.printRightThrough(text, txEnd, ty, Chrome.DEFAULT_BOX_PALETTE) +end + +function Chrome.printRightThrough(text, txEnd, ty, palette, invert, raw) + local pal, drawGlyph, finish = Chrome.paletteGlyphs(palette, invert, raw) + if not pal then return Chrome.printRight(text, txEnd, ty) end + local width = Font.width(text) + local tx = txEnd * 8 - width + local paper = pal[1] or { 255, 255, 255 } + love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) + love.graphics.rectangle("fill", tx, ty * 8, width, 8) + local pen = tx + for _, code in ipairs(Font.encode(text)) do + drawGlyph(code, pen, ty * 8) + pen = pen + Font.advanceOf(code) + end + finish() + return width +end + -- Wrap text to `width` tiles, measuring with the real font so a proportional -- page wraps where it actually overflows. Nothing on a 160px screen may print -- past tile 20: text that does is drawn outside the GB frame entirely. @@ -227,21 +318,27 @@ function Chrome.printWrapped(text, tx, ty, width, rows) return #lines end -function Chrome.cursor(tx, ty, hollow) +local function flatCursor(tx, ty, hollow) love.graphics.setColor(0, 0, 0, 1) Font.drawCode(hollow and Chrome.CURSOR_HOLLOW or Chrome.CURSOR, tx * 8, ty * 8) end +function Chrome.cursor(tx, ty, hollow) + if not GbcPalette.available() then return flatCursor(tx, ty, hollow) end + return Chrome.cursorThrough(tx, ty, Chrome.DEFAULT_BOX_PALETTE, nil, hollow) +end + -- The cursor glyph through a palette, the way Chrome.printThrough draws text. -- A screen whose font page is inverted (the #DEX: Pokedex_LoadInvertedFont -- xors both bitplanes, so it runs white on black) needs its cursor inverted -- with everything else -- Chrome.cursor's flat black is invisible against that -- ground, which is what made the dex's action arrow look absent. -function Chrome.cursorThrough(tx, ty, palette, invert, hollow) +function Chrome.cursorThrough(tx, ty, palette, invert, hollow, raw) if not (palette and GbcPalette.available()) then return Chrome.cursor(tx, ty, hollow) end - local pal = Chrome.throughPalette(palette, invert) + local pal = raw and Chrome.rawPalette(palette, invert) + or Chrome.throughPalette(palette, invert) local paper = pal[1] or { 255, 255, 255 } love.graphics.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) love.graphics.rectangle("fill", tx * 8, ty * 8, 8, 8) @@ -286,8 +383,8 @@ end -- PRINTNUM_LEADINGZEROS. function Chrome.coinBalanceBox(coins) Chrome.textbox(11, 0, 7, 1) - Chrome.print("COIN", 12, 0) - Chrome.print(Chrome.number(coins, 4, true), 13, 1) + Chrome.printThrough("COIN", 12, 0, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 13, 1, Chrome.DEFAULT_BOX_PALETTE) end -- DisplayMoneyAndCoinBalance: one Textbox at (5,0) with a 13x3 interior @@ -295,10 +392,10 @@ end -- "COIN" at (6,3) with the four-digit count at (15,3). function Chrome.moneyAndCoinBalanceBox(money, coins) Chrome.textbox(5, 0, 13, 3) - Chrome.print("MONEY", 6, 1) - Chrome.print(Chrome.money(money), 12, 1) - Chrome.print("COIN", 6, 3) - Chrome.print(Chrome.number(coins, 4, true), 15, 3) + Chrome.printThrough("MONEY", 6, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("COIN", 6, 3, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(coins, 4, true), 15, 3, Chrome.DEFAULT_BOX_PALETTE) end -- PlaceMoneyTopRight: MoneyTopRightMenuHeader is `menu_coords 11, 0, @@ -306,7 +403,7 @@ end -- prints the number at MenuBoxCoord2Tile + SCREEN_WIDTH + 1, i.e. (12,1). function Chrome.moneyBalanceBox(money) Chrome.box(11, 0, 9, 3) - Chrome.print(Chrome.money(money), 12, 1) + Chrome.printThrough(Chrome.money(money), 12, 1, Chrome.DEFAULT_BOX_PALETTE) end -- A vertical cursor list. @@ -344,6 +441,7 @@ function List.new(opts) self.onChoose = opts.onChoose self.onCancel = opts.onCancel self.onMove = opts.onMove + self.palette = opts.palette == nil and Chrome.DEFAULT_BOX_PALETTE or opts.palette self.index = math.max(1, math.min(opts.index or 1, math.max(1, #self.items))) self.scroll = 0 self:ensureVisible() @@ -409,15 +507,32 @@ function List:draw() local item = self.items[i] if item then local ty = self.y + (row - 1) * self.spacing - if i == self.index then Chrome.cursor(self.x - 1, ty) end - Chrome.print(item.label, self.x, ty) + if i == self.index then + if self.palette then + Chrome.cursorThrough(self.x - 1, ty, self.palette) + else + flatCursor(self.x - 1, ty) + end + end + if self.palette then + Chrome.printThrough(item.label, self.x, ty, self.palette) + else + flatPrint(item.label, self.x, ty) + end end end -- Scrolling lists get the ▼ hint Gold shows when there is more below. if self.rows < #self.items and self.scroll + self.rows < #self.items then - love.graphics.setColor(0, 0, 0, 1) - Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, - (self.y + self.rows * self.spacing - 1) * 8) + if self.palette then + local _, drawGlyph, finish = Chrome.paletteGlyphs(self.palette) + drawGlyph(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + finish() + else + love.graphics.setColor(0, 0, 0, 1) + Font.drawCode(Chrome.DOWN_ARROW, (self.x - 1) * 8, + (self.y + self.rows * self.spacing - 1) * 8) + end end end diff --git a/src/ui/gen2/ContestMenu.lua b/src/ui/gen2/ContestMenu.lua index 52f5a52d..d644b7d4 100644 --- a/src/ui/gen2/ContestMenu.lua +++ b/src/ui/gen2/ContestMenu.lua @@ -176,8 +176,7 @@ end function ContestMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Credits.lua b/src/ui/gen2/Credits.lua index 8ab716e0..11405ca9 100644 --- a/src/ui/gen2/Credits.lua +++ b/src/ui/gen2/Credits.lua @@ -841,8 +841,7 @@ end function Credits:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CrystalIntro.lua b/src/ui/gen2/CrystalIntro.lua index 9611403b..7a22cf0b 100644 --- a/src/ui/gen2/CrystalIntro.lua +++ b/src/ui/gen2/CrystalIntro.lua @@ -1275,8 +1275,7 @@ end function CrystalIntro:drawWidescreen(winW, winH) local G = love.graphics local fill = self:surroundColor() - G.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, fill[1] / 255, fill[2] / 255, fill[3] / 255) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/CrystalSplash.lua b/src/ui/gen2/CrystalSplash.lua index 6436a6fa..acd03cd9 100644 --- a/src/ui/gen2/CrystalSplash.lua +++ b/src/ui/gen2/CrystalSplash.lua @@ -410,8 +410,7 @@ end function CrystalSplash:drawWidescreen(winW, winH) local G = love.graphics local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK - G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/DecorationMenu.lua b/src/ui/gen2/DecorationMenu.lua index f8c100f6..6cd77472 100644 --- a/src/ui/gen2/DecorationMenu.lua +++ b/src/ui/gen2/DecorationMenu.lua @@ -263,8 +263,7 @@ end function DecorationMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Diploma.lua b/src/ui/gen2/Diploma.lua index a8dd55ce..73ea6ba3 100644 --- a/src/ui/gen2/Diploma.lua +++ b/src/ui/gen2/Diploma.lua @@ -180,8 +180,7 @@ function Diploma:drawWidescreen(winW, winH) -- Nil palette degrades to the DMG ramp's colour 1, i.e. white, matching the -- Chrome.clear() the panel falls back to when the cache has no gfx/diploma. local paper = GbcPalette.color(self:palette(), 1) - G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) diff --git a/src/ui/gen2/EggHatchAnim.lua b/src/ui/gen2/EggHatchAnim.lua index 931fd3b4..af8cde8a 100644 --- a/src/ui/gen2/EggHatchAnim.lua +++ b/src/ui/gen2/EggHatchAnim.lua @@ -440,8 +440,7 @@ end function EggHatchAnim:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/EvolutionAnim.lua b/src/ui/gen2/EvolutionAnim.lua index 753d2880..80ad94be 100644 --- a/src/ui/gen2/EvolutionAnim.lua +++ b/src/ui/gen2/EvolutionAnim.lua @@ -567,8 +567,7 @@ end function EvolutionAnim:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/GameFreakPresents.lua b/src/ui/gen2/GameFreakPresents.lua index c7a4650f..9d7ec584 100644 --- a/src/ui/gen2/GameFreakPresents.lua +++ b/src/ui/gen2/GameFreakPresents.lua @@ -369,8 +369,7 @@ end function GameFreakPresents:drawWidescreen(winW, winH) local G = love.graphics local backdrop = GbcPalette.color(self.bgColors, 1) or BLACK - G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/GenderSelect.lua b/src/ui/gen2/GenderSelect.lua index 07aca9b9..97ed3bdf 100644 --- a/src/ui/gen2/GenderSelect.lua +++ b/src/ui/gen2/GenderSelect.lua @@ -125,8 +125,7 @@ end function GenderSelect:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/GoldSilverIntro.lua b/src/ui/gen2/GoldSilverIntro.lua index 1d6aabb3..65ba1310 100644 --- a/src/ui/gen2/GoldSilverIntro.lua +++ b/src/ui/gen2/GoldSilverIntro.lua @@ -1029,8 +1029,7 @@ end function GoldSilverIntro:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/HallOfFame.lua b/src/ui/gen2/HallOfFame.lua index d1940f67..761e9dea 100644 --- a/src/ui/gen2/HallOfFame.lua +++ b/src/ui/gen2/HallOfFame.lua @@ -668,8 +668,7 @@ end function HallOfFame:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/InitClock.lua b/src/ui/gen2/InitClock.lua index b957c956..c86e36ce 100644 --- a/src/ui/gen2/InitClock.lua +++ b/src/ui/gen2/InitClock.lua @@ -358,8 +358,7 @@ end function InitClock:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/ItemPcMenu.lua b/src/ui/gen2/ItemPcMenu.lua index 0bdfaec8..9498f11a 100644 --- a/src/ui/gen2/ItemPcMenu.lua +++ b/src/ui/gen2/ItemPcMenu.lua @@ -598,8 +598,7 @@ end function ItemPcMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/MagnetTrainRide.lua b/src/ui/gen2/MagnetTrainRide.lua index f9f40830..52d3d5e1 100644 --- a/src/ui/gen2/MagnetTrainRide.lua +++ b/src/ui/gen2/MagnetTrainRide.lua @@ -444,8 +444,7 @@ function MagnetTrainRide:drawsWidescreen() return true end function MagnetTrainRide:drawWidescreen(winW, winH) local G = love.graphics local backdrop = self:backdrop() - G.setColor(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) diff --git a/src/ui/gen2/MailCompose.lua b/src/ui/gen2/MailCompose.lua index 4101c4c9..5ad8c25c 100644 --- a/src/ui/gen2/MailCompose.lua +++ b/src/ui/gen2/MailCompose.lua @@ -336,8 +336,7 @@ end function MailCompose:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0.62, 0.62, 0.62, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0.62, 0.62, 0.62) G.setColor(1, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() diff --git a/src/ui/gen2/MailRead.lua b/src/ui/gen2/MailRead.lua index b96c857a..a0ff3237 100644 --- a/src/ui/gen2/MailRead.lua +++ b/src/ui/gen2/MailRead.lua @@ -90,8 +90,7 @@ end function MailRead:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/MainMenu.lua b/src/ui/gen2/MainMenu.lua index da0d42e9..0092de39 100644 --- a/src/ui/gen2/MainMenu.lua +++ b/src/ui/gen2/MainMenu.lua @@ -215,8 +215,7 @@ end function MainMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/MoveTutor.lua b/src/ui/gen2/MoveTutor.lua index ad6f0dc4..a7f9bfc3 100644 --- a/src/ui/gen2/MoveTutor.lua +++ b/src/ui/gen2/MoveTutor.lua @@ -150,8 +150,7 @@ end -- edge, at the one integer scale Chrome.fitScale picks. function MoveTutor:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/NamePick.lua b/src/ui/gen2/NamePick.lua index dec13836..ca6a8ee1 100644 --- a/src/ui/gen2/NamePick.lua +++ b/src/ui/gen2/NamePick.lua @@ -239,8 +239,7 @@ end function NamePick:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/NamingScreen.lua b/src/ui/gen2/NamingScreen.lua index 6cca1deb..f5cc0087 100644 --- a/src/ui/gen2/NamingScreen.lua +++ b/src/ui/gen2/NamingScreen.lua @@ -566,8 +566,7 @@ function NamingScreen:drawWidescreen(winW, winH) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) local paper = self:paperColor() - G.setColor(paper[1] / 255, paper[2] / 255, paper[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, paper[1] / 255, paper[2] / 255, paper[3] / 255) G.setColor(1, 1, 1, 1) if self.tiles.border then local tilesX = math.ceil(winW / (8 * scale)) diff --git a/src/ui/gen2/OakSpeech.lua b/src/ui/gen2/OakSpeech.lua index 230693a1..15d5883c 100644 --- a/src/ui/gen2/OakSpeech.lua +++ b/src/ui/gen2/OakSpeech.lua @@ -719,8 +719,7 @@ end function OakSpeech:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/OptionsMenu.lua b/src/ui/gen2/OptionsMenu.lua index 42218d21..c2a9b269 100644 --- a/src/ui/gen2/OptionsMenu.lua +++ b/src/ui/gen2/OptionsMenu.lua @@ -6,7 +6,7 @@ -- literal "TYPE". The cursor is a ▶ in column 1 at row 2 + index * 2. -- -- Up/down move between rows, left/right change the value under the cursor, and --- START or B leaves. CANCEL is a row like any other that simply exits. +-- START or B leaves. BACK is a row like any other that simply exits. -- -- Values are stored on the save's options table by name (see -- src/core/gen2/Save.lua DEFAULT_OPTIONS) rather than as the packed wOptions @@ -20,6 +20,8 @@ local Chrome = require("src.ui.gen2.Chrome") local Logger = require("src.core.Logger") +local Marquee = require("src.ui.Marquee") +local Palette = require("src.render.Palette") local Performance = require("src.core.Performance") local Runtime = require("src.mods.Runtime") local Save = require("src.core.gen2.Save") @@ -41,6 +43,45 @@ local function stepVolume(v, delta) return math.max(0, math.min(7, (v or 7) + delta)) end +local function dropClassic(options) + local GbcPalette = require("src.render.GbcPalette") + if GbcPalette.mode ~= "classic" then return end + local target = (options.palette and options.palette ~= "") + and GbcPalette.CUSTOM_MODE or "gbc" + options.color = target + GbcPalette.setMode(target) +end + +local function colorPickerOpts(game) + local GbcPalette = require("src.render.GbcPalette") + local options = game.options + dropClassic(options) + local labels = GbcPalette.MODE_LABELS or {} + local modes = {} + for i, id in ipairs(GbcPalette.MODES) do + modes[i] = { labels[id] or id:upper(), id } + end + return { + palette = Palette, + get = function() return options.palette or "" end, + set = function(v) + options.palette = v + local target = (v ~= "") and GbcPalette.CUSTOM_MODE or "gbc" + options.color = target + GbcPalette.setMode(target) + GbcPalette.setCustomRamp(v ~= "" and Palette.ramp(v) or nil) + if game.persistOptions then pcall(game.persistOptions, game) end + end, + modes = modes, + getMode = function() return options.color or "gbc" end, + setMode = function(v) + options.color = v + GbcPalette.setMode(v) + if game.persistOptions then pcall(game.persistOptions, game) end + end, + } +end + -- Each row: the label, the option key it edits, and the cycle of values with -- the exact strings the cart prints (trailing spaces included -- they are what -- blank the longer previous value, e.g. "MID " over "SLOW"). @@ -191,24 +232,30 @@ local ROWS = { text = function(options) return require("src.render.Tilt").levelLabel(options.tilt or 0) end }, - -- COLOR is the Gen 2 answer to the Gen 1 screen's COLORS row. Gold is a - -- CGB game whose colour comes from its own palettes, so there are no packs - -- to swap -- what there is instead is the choice to turn that colour OFF, - -- down to the grey Game Boy or the green one. GBC is the default. { label = Strings.source("COLOR"), key = "color", port = true, - cycle = function(options, delta) - local GbcPalette = require("src.render.GbcPalette") - GbcPalette.setMode(options.color or "gbc") - options.color = GbcPalette.cycle(delta) - end, text = function(options) + local name = Palette.label(options.palette) + if name then return name end return require("src.render.GbcPalette").modeLabel(options.color or "gbc") + end, + activate = function(game) + require("src.ui.Screens").push(game, "PaletteScreen", colorPickerOpts(game)) end }, -- SHADER FX reaches Gen 2 too, not just Gen 1. Same "activate" shape as -- CONTROLS/TOUCH LAYOUT below (a pushed screen, not a `cycle` ladder) -- -- ShaderFXScreen is the shared list screen both generations push, `id` -- matching the Gen 1 row's so a mod filtering "shaderfx" on Red also -- reaches Gold. + { id = "uiLetterbox", label = Strings.source("UI LETTERBOX"), port = true, + cycle = function(options, delta) + local Letterbox = require("src.render.Letterbox") + options.uiLetterbox = Letterbox.cycle(options.uiLetterbox, delta) + Letterbox.setMode(options.uiLetterbox) + end, + text = function(options) + local Letterbox = require("src.render.Letterbox") + return Strings(Letterbox.label(options.uiLetterbox)) + end }, { id = "shaderfx", label = Strings.source("SHADER FX"), port = true, text = function(options) local ShaderFX = require("src.render.ShaderFX") @@ -294,7 +341,7 @@ local ROWS = { { label = Strings.source("BATTLE BG"), key = "battleBg", port = true, values = { "white", "black" }, display = { white = "WHITE", black = "BLACK" } }, - { label = Strings.source("CANCEL"), cancel = true }, + { label = Strings.source("BACK"), cancel = true }, } -- The cart's screen is one full-height textbox with every row on it. This one @@ -303,6 +350,69 @@ local ROWS = { -- only when the cursor would leave it. local VISIBLE_ROWS = 7 +-- The same grouping the Gen 1 screen uses (src/ui/OptionsMenu.lua GROUPS), +-- with Gold's own rows in it: FRAME is a graphics setting here and SOUND is +-- an audio one. Runs after the ui.options.rows hook and only builds +-- self.view, so self.rows stays the flat list a mod reads and edits. +local GROUPS = { + { id = "group.speed", label = Strings.source("SPEED"), + members = { "textSpeed", "speed" } }, + { id = "group.video", label = Strings.source("VIDEO"), + members = { "videoMode", "screenPos", "fpsCap" } }, + { id = "group.graphics", label = Strings.source("GRAPHICS"), + members = { "color", "uiLetterbox", "shaderfx", "shaderfx2", "frame" } }, + { id = "group.audio", label = Strings.source("AUDIO"), + members = { "sound", "musicVol", "sfxVol", "musicFilter" } }, + { id = "group.battle", label = Strings.source("BATTLE OPTIONS"), + members = { "battleScene", "battleStyle", "battleBg" } }, + { id = "group.extras", label = Strings.source("EXTRAS"), + members = { "zoom", "voidFill", "tilt" } }, +} + +local ORDER = { + "group.speed", "group.video", "group.graphics", "group.audio", + "performance", "group.battle", "group.extras", +} + +-- A group's page is this same screen driving the rows it was handed, with +-- its own BACK on the bottom. +local function groupView(rows, open) + local owner, picked = {}, {} + for _, group in ipairs(GROUPS) do + for _, id in ipairs(group.members) do owner[id] = group end + picked[group.id] = {} + end + for _, row in ipairs(rows) do + if row.id and owner[row.id] then + local into = picked[owner[row.id].id] + into[#into + 1] = row + end + end + local made, byId = {}, {} + for _, group in ipairs(GROUPS) do + local members = picked[group.id] + if #members > 0 then + -- No value line: the value column is eight characters wide here, and a + -- group has no setting of its own to show in it anyway. + made[group.id] = { id = group.id, label = group.label, group = true, + activate = function(game) open(game, members) end } + end + end + for _, row in ipairs(rows) do + if row.id and not owner[row.id] then byId[row.id] = row end + end + local view, taken = {}, {} + for _, id in ipairs(ORDER) do + local row = made[id] or byId[id] + if row then view[#view + 1] = row; taken[id] = true end + end + for _, row in ipairs(rows) do + local id = row.id + if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end + end + return view +end + -- ui.options.rows identity: an unhooked build hands its own rows back. local function sameRows(_, rows) return rows end @@ -323,8 +433,17 @@ local function buildRows() local showTouch = env == "1" or (env ~= "0" and (osName == "Android" or osName == "iOS")) for i, row in ipairs(ROWS) do - if showTouch or (row.id ~= "touchControls" and row.id ~= "touchLayout" - and row.id ~= "haptics") then + -- PRINT is wGBPrinterBrightness, how dark the Game Boy Printer prints + -- (GBPRINTER_LIGHTEST..DARKEST, constants/ram_constants.asm:67-70). + -- There is no printer here (src/script/gen2/Specials.lua answers the + -- print specials with "no Game Boy Printer"), so nothing reads the + -- value and the row is hidden rather than offering a dead setting. + -- The descriptor and the save key stay, so a build that grows a printer + -- only has to drop this test. + local hidden = row.key == "print" + or (not showTouch and (row.id == "touchControls" + or row.id == "touchLayout" or row.id == "haptics")) + if not hidden then local copy = {} for key, value in pairs(row) do copy[key] = value end copy.id = copy.id or copy.key or (copy.cancel and "cancel") or nil @@ -338,6 +457,22 @@ function OptionsMenu:wantsFillScale() return true end function OptionsMenu:drawsWidescreen() return true end -- opts: options (the table to edit in place), onDone(options) +-- The page edits the SAME options table the parent screen does, so a value +-- changed on it lands where the caller's onDone will read it. +local function pushGroup(parent, members) + local page = setmetatable({}, OptionsMenu) + page.game = parent.game + page.rows = members + page.options = parent.options + page.index, page.scroll, page.sub = 1, 0, true + page.view = {} + for i, row in ipairs(members) do page.view[i] = row end + page.view[#page.view + 1] = { label = Strings.source("BACK"), cancel = true, + id = "cancel" } + parent.game.stack:push(page) + return page +end + function OptionsMenu.new(game, opts) opts = opts or {} local self = setmetatable({}, OptionsMenu) @@ -363,21 +498,57 @@ function OptionsMenu.new(game, opts) self.onDone = opts.onDone self.index = 1 self.scroll = 0 + self.view = groupView(rows, function(_, members) + return pushGroup(self, members) + end) return self end +function OptionsMenu:visible() + return self.view or self.rows +end + function OptionsMenu:ensureVisible() + local rows = self:visible() if self.index <= self.scroll then self.scroll = self.index - 1 elseif self.index > self.scroll + VISIBLE_ROWS then self.scroll = self.index - VISIBLE_ROWS end self.scroll = math.max(0, - math.min(self.scroll, math.max(0, #self.rows - VISIBLE_ROWS))) + math.min(self.scroll, math.max(0, #rows - VISIBLE_ROWS))) end function OptionsMenu:row() - return self.rows[self.index] + return self:visible()[self.index] +end + +-- Cursor onto a row by id, opening its group page first if it lives in one. +-- Returns the screen the row ended up on, so a caller can keep driving it. +function OptionsMenu:focusRow(id) + local rows = self:visible() + for i, row in ipairs(rows) do + if row.id == id then + self.index = i + self:ensureVisible() + return self + end + end + for i, row in ipairs(rows) do + if row.group and row.activate then + local group + for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end + for _, member in ipairs(group and group.members or {}) do + if member == id then + self.index = i + row.activate(self.game) + local page = self.game.stack:top() + return page.focusRow and page:focusRow(id) or page + end + end + end + end + return nil end function OptionsMenu:cycle(row, delta) @@ -437,12 +608,13 @@ function OptionsMenu:update(_dt) self:leave_() return end + local rows = self:visible() if input:wasPressed("up") then - self.index = self.index > 1 and self.index - 1 or #self.rows + self.index = self.index > 1 and self.index - 1 or #rows self:ensureVisible() return elseif input:wasPressed("down") then - self.index = self.index < #self.rows and self.index + 1 or 1 + self.index = self.index < #rows and self.index + 1 or 1 self:ensureVisible() return end @@ -473,35 +645,46 @@ function OptionsMenu:drawPanel() Chrome.clear() -- hlcoord 0,0 with b = SCREEN_HEIGHT - 2, c = SCREEN_WIDTH - 2. Chrome.textbox(0, 0, Chrome.SCREEN_W - 2, Chrome.SCREEN_H - 2) - for slot = 1, math.min(VISIBLE_ROWS, #self.rows) do + -- The textbox spends column 19 on its frame, so a line drawn past column + -- 18 prints over the border: 17 characters from the label's column 2, + -- 8 from the value's column 11. Anything longer scrolls under the cursor. + local rows = self:visible() + for slot = 1, math.min(VISIBLE_ROWS, #rows) do local i = slot + self.scroll - local row = self.rows[i] + local row = rows[i] if row then local labelY = 2 + (slot - 1) * 2 - Chrome.print(Strings(row.label), 2, labelY) + local hot = i == self.index + local key = tostring(row.id or row.label) + local function fit(text, col) + local room = 19 - col + if hot then return Marquee.scroll(text, room, key) end + return Marquee.clip(text, room) + end + Chrome.print(fit(Strings(row.label), 2), 2, labelY) if row.frame then Chrome.print(Strings(":TYPE"), 10, labelY + 1) Chrome.print(tostring(self.options.frame or 1), 16, labelY + 1) elseif row.text then Chrome.print(":", 10, labelY + 1) - Chrome.print(row.text(self.options), 11, labelY + 1) + Chrome.print(fit(row.text(self.options), 11), 11, labelY + 1) elseif row.values then Chrome.print(":", 10, labelY + 1) local value = self.options[row.key] local text = row.display and Strings(row.display[value]) or tostring(value) - Chrome.print(text, 11, labelY + 1) + Chrome.print(fit(text, 11), 11, labelY + 1) elseif type(row.value) == "function" then -- the Gen 1 row's value reader (src/ui/OptionRows.lua:4), so a mod row -- shows its setting here instead of drawing a bare label local ok, text = pcall(row.value, self.game) Chrome.print(":", 10, labelY + 1) - Chrome.print(ok and tostring(text) or "?", 11, labelY + 1) + Chrome.print(fit(ok and tostring(text) or "?", 11), 11, labelY + 1) end end end Chrome.cursor(1, 2 + (self.index - self.scroll - 1) * 2) -- The ▼ hint every scrolling Gen 2 list shows when there is more below. - if self.scroll + VISIBLE_ROWS < #self.rows then + if self.scroll + VISIBLE_ROWS < #rows then local Font = require("src.render.Font") love.graphics.setColor(0, 0, 0, 1) Font.drawCode(Chrome.DOWN_ARROW, 1 * 8, (2 + VISIBLE_ROWS * 2 - 1) * 8) @@ -514,8 +697,7 @@ end function OptionsMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PackGfx.lua b/src/ui/gen2/PackGfx.lua index 68103dc8..2dde75d8 100644 --- a/src/ui/gen2/PackGfx.lua +++ b/src/ui/gen2/PackGfx.lua @@ -21,6 +21,7 @@ local Assets = require("src.render.Assets") local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") local PackGfx = {} PackGfx.__index = PackGfx @@ -111,9 +112,7 @@ end -- Everything behind the item list: the header strip, the background column, -- the bag picture for this pocket, and the pocket plaque. function PackGfx:draw(pocketId) - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE) -- ◀▶ POCKET ▼▲ ITEMS: 20 running tiles from $28. local header = self.gfx.headerFirstTile or 0x28 diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index 3a5d970c..4c17968c 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -973,22 +973,22 @@ function PackMenu:drawList(listX, listY) local entry = self.rows[i] -- engine/items/tmhm.asm:355-385 (#1695) if entry.tmhmLabel then - Chrome.print(entry.tmhmLabel, listX - 3, ty) + Chrome.printThrough(entry.tmhmLabel, listX - 3, ty, Chrome.DEFAULT_BOX_PALETTE) end if i == self.index then self:cursorAt(listX - 1, ty) elseif i == self.switching then self:cursorAt(listX - 1, ty, true) end - Chrome.print((entry.tmhmLabel and entry.teaches) or entry.name, - listX, ty) + Chrome.printThrough((entry.tmhmLabel and entry.teaches) or entry.name, + listX, ty, Chrome.DEFAULT_BOX_PALETTE) if entry.showCount then - Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2), - listX + 9, ty + 1) + Chrome.printThrough("\xc3\x97" .. Chrome.number(entry.count, 2), + listX + 9, ty + 1, Chrome.DEFAULT_BOX_PALETTE) end elseif i == self:total() then if i == self.index then self:cursorAt(listX - 1, ty) end - Chrome.print("CANCEL", listX, ty) + Chrome.printThrough("CANCEL", listX, ty, Chrome.DEFAULT_BOX_PALETTE) end end end @@ -1006,7 +1006,8 @@ function PackMenu:drawDescription(ty) local top, step = ty, 2 if #lines > 2 then top, step = ty - 1, 1 end for i, line in ipairs(lines) do - Chrome.print((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step) + Chrome.printThrough((line:gsub("{PLAYER}", name)), 1, top + (i - 1) * step, + Chrome.DEFAULT_BOX_PALETTE) end return end @@ -1019,8 +1020,8 @@ function PackMenu:drawDescription(ty) -- the second line is row 16. '\n' covers hand-written data. local first, second = description:match("^(.-)(.*)$") if not first then first, second = description:match("^(.-)\n(.*)$") end - Chrome.print(first or description, 1, ty) - if second then Chrome.print(second, 1, ty + 2) end + Chrome.printThrough(first or description, 1, ty, Chrome.DEFAULT_BOX_PALETTE) + if second then Chrome.printThrough(second, 1, ty + 2, Chrome.DEFAULT_BOX_PALETTE) end end -- The submenu box. Every one of the seven headers is `menu_coords 0, top, @@ -1037,23 +1038,24 @@ function PackMenu:drawSubmenu() Chrome.box(0, top, 7, bottom - top + 1) for i, id in ipairs(menu.rows) do local ty = top + 1 + (i - 1) * 2 - if i == menu.index then Chrome.cursor(1, ty) end - Chrome.print(SUBMENU_LABEL[id] or id, 2, ty) + if i == menu.index then Chrome.cursorThrough(1, ty, Chrome.DEFAULT_BOX_PALETTE) end + Chrome.printThrough(SUBMENU_LABEL[id] or id, 2, ty, Chrome.DEFAULT_BOX_PALETTE) end end -- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay function PackMenu:drawQuantity() Chrome.box(15, 9, 5, 3) - Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10) + Chrome.printThrough("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10, + Chrome.DEFAULT_BOX_PALETTE) end -- YesNoBox's own coords, the same box every other Gen 2 screen here draws. function PackMenu:drawYesNo() Chrome.box(14, 7, 6, 5) - Chrome.print("YES", 16, 8) - Chrome.print("NO", 16, 10) - Chrome.cursor(15, self.confirm.choice == 1 and 8 or 10) + Chrome.printThrough("YES", 16, 8, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("NO", 16, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.cursorThrough(15, self.confirm.choice == 1 and 8 or 10, Chrome.DEFAULT_BOX_PALETTE) end function PackMenu:drawOverlays() @@ -1080,7 +1082,7 @@ function PackMenu:drawPanel() -- boxes, the layout this screen shipped with. Chrome.clear() Chrome.box(0, 0, 20, 3) - Chrome.print(self:pocket().label, 2, 1) + Chrome.printThrough(self:pocket().label, 2, 1, Chrome.DEFAULT_BOX_PALETTE) Chrome.box(0, 3, 20, 12) self:drawList(5, 4) Chrome.box(0, 12, 20, 6) @@ -1095,8 +1097,7 @@ end function PackMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PartyMenu.lua b/src/ui/gen2/PartyMenu.lua index cefa59aa..1caa3151 100644 --- a/src/ui/gen2/PartyMenu.lua +++ b/src/ui/gen2/PartyMenu.lua @@ -845,8 +845,7 @@ end function PartyMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PcMenu.lua b/src/ui/gen2/PcMenu.lua index a7fdd744..05717c55 100644 --- a/src/ui/gen2/PcMenu.lua +++ b/src/ui/gen2/PcMenu.lua @@ -456,8 +456,7 @@ end function PcMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/PhotoStudio.lua b/src/ui/gen2/PhotoStudio.lua index d719e709..dd4dee58 100644 --- a/src/ui/gen2/PhotoStudio.lua +++ b/src/ui/gen2/PhotoStudio.lua @@ -202,8 +202,7 @@ function PhotoStudio:drawsWidescreen() return true end function PhotoStudio:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index a0c4a685..3b023465 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -1458,8 +1458,7 @@ end function PokedexMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index 242a3515..e5ac4eca 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -22,6 +22,7 @@ local Chrome = require("src.ui.gen2.Chrome") local FieldMoves = require("src.world.gen2.FieldMoves") +local GbcPalette = require("src.render.GbcPalette") local Gen2Save = require("src.core.gen2.Save") local Clock = require("src.core.gen2.Clock") local Font = require("src.render.Font") @@ -971,6 +972,7 @@ function Pokegear.new(game, opts) if gfx then self.sheet = TileSheet.new({ path = gfx.tiles, wide = gfx.tilesWide or 16, firstTile = 0, + raw = true, paletteFor = function(tile) return self:colorsFor(tile) end, }) end @@ -997,10 +999,9 @@ end -- Every string on a Pokegear card is a run of font tiles laid straight into -- the tilemap, so it wears BG palette 0 (PokegearPals' first entry) rather --- than drawing as black ink over whatever was underneath. function Pokegear:text(str, tx, ty) local pals = self:pals() - return Chrome.printThrough(str, tx, ty, pals and pals[1]) + return Chrome.printThrough(str, tx, ty, pals and pals[1], false, true) end -- wPokegearFlags' four card bits are ENGINE flags: EngineFlags rows 0-3 are @@ -2411,6 +2412,8 @@ function Pokegear:drawPlain() end end +Pokegear.CUSTOM_RAMP_FILM = true + function Pokegear:drawPanel() if not self:styled() then self:drawPlain() @@ -2418,29 +2421,54 @@ function Pokegear:drawPanel() return end local G = love.graphics - -- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the - -- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap), - -- and every cell the card leaves blank is a font-page tile on palette 0, so - -- the ground under a card is the gear's paper, not white. See - -- Pokegear:paperColor. - local ground = self:groundColor() - G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) - G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) - local id = self:card() and self:card().id - if id == "map" then - self:drawMap() - elseif id == "radio" then - self:drawRadio() - elseif id == "phone" then - self:drawPhone() - else - self:drawClock() + local function paint() + -- InitPokegearTilemap ByteFills the whole SCREEN_AREA with $4f before the + -- card's tilemap goes down (engine/pokegear/pokegear.asm InitPokegearTilemap), + -- and every cell the card leaves blank is a font-page tile on palette 0, so + -- the ground under a card is the gear's paper, not white. See + -- Pokegear:paperColor. + local ground = self:groundColor() + G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) + G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + local id = self:card() and self:card().id + if id == "map" then + self:drawMap() + elseif id == "radio" then + self:drawRadio() + elseif id == "phone" then + self:drawPhone() + else + self:drawClock() + end + -- Last: the arrow is an OBJ and composites over whatever the card drew. + -- _FlyMap has no card strip and never animates it + -- (engine/pokegear/pokegear.asm:1999); neither does _TownMap + -- (../pokecrystal/engine/pokegear/pokegear.asm:1757). + if not (self.fly or self.townMap) then self:drawModeArrow() end + end + + if Pokegear.CUSTOM_RAMP_FILM and GbcPalette.customRamp + and GbcPalette.available() then + if not self.filmCanvas then + self.filmCanvas = G.newCanvas(SCREEN_W * 8, SCREEN_H * 8) + self.filmCanvas:setFilter("nearest", "nearest") + end + local previousCanvas = G.getCanvas() + G.setCanvas(self.filmCanvas) + G.clear(0, 0, 0, 0) + G.push() + G.origin() + local ok, err = pcall(paint) + G.pop() + G.setCanvas(previousCanvas) + if not ok then error(err, 0) end + G.setColor(1, 1, 1, 1) + GbcPalette.with(GbcPalette.customRamp, function() + G.draw(self.filmCanvas, 0, 0) + end) + else + paint() end - -- Last: the arrow is an OBJ and composites over whatever the card drew. - -- _FlyMap has no card strip and never animates it - -- (engine/pokegear/pokegear.asm:1999); neither does _TownMap - -- (../pokecrystal/engine/pokegear/pokegear.asm:1757). - if not (self.fly or self.townMap) then self:drawModeArrow() end G.setColor(1, 1, 1, 1) end @@ -2454,8 +2482,7 @@ function Pokegear:drawWidescreen(winW, winH) -- black card sitting inside a cream frame. It follows groundColor for the -- same reason drawPanel does: $4f is what the screen is filled with. local ground = self:groundColor() - G.setColor(ground[1] / 255, ground[2] / 255, ground[3] / 255, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, ground[1] / 255, ground[2] / 255, ground[3] / 255) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/PrizeMenu.lua b/src/ui/gen2/PrizeMenu.lua index 41f43756..168b4473 100644 --- a/src/ui/gen2/PrizeMenu.lua +++ b/src/ui/gen2/PrizeMenu.lua @@ -556,8 +556,7 @@ end function PrizeMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/SaveMenu.lua b/src/ui/gen2/SaveMenu.lua index bbcfd70e..8b2ff4b8 100644 --- a/src/ui/gen2/SaveMenu.lua +++ b/src/ui/gen2/SaveMenu.lua @@ -260,8 +260,7 @@ end function SaveMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/SlotMachine.lua b/src/ui/gen2/SlotMachine.lua index fe0bded1..5eb81920 100644 --- a/src/ui/gen2/SlotMachine.lua +++ b/src/ui/gen2/SlotMachine.lua @@ -1531,8 +1531,7 @@ end function SlotMachine:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/StartMenu.lua b/src/ui/gen2/StartMenu.lua index b3d90d8c..bb203096 100644 --- a/src/ui/gen2/StartMenu.lua +++ b/src/ui/gen2/StartMenu.lua @@ -295,9 +295,7 @@ function StartMenu:draw() if not desc then return end -- ._DrawMenuAccount ClearBox (0,13) 5 rows by 10, .PrintMenuAccount decoord -- 0, 14 and the desc's `next` steps two rows (start_menu.asm:366-382). - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 13 * 8, 10 * 8, 5 * 8) + Chrome.paletteFill(0, 13 * 8, 10 * 8, 5 * 8) Chrome.print(desc[1] or "", 0, 14) Chrome.print(desc[2] or "", 0, 16) end diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 431b778f..7ce631d8 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -1266,8 +1266,7 @@ end function SummaryMenu:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/TileSheet.lua b/src/ui/gen2/TileSheet.lua index d2907d68..d264e258 100644 --- a/src/ui/gen2/TileSheet.lua +++ b/src/ui/gen2/TileSheet.lua @@ -18,7 +18,6 @@ local TileSheet = {} TileSheet.__index = TileSheet -- opts: path, wide, firstTile, palette (a 4-colour table) or palettes + a --- paletteFor(tile, tx, ty) hook, transparent function TileSheet.new(opts) local self = setmetatable({}, TileSheet) self.path = opts and opts.path @@ -26,6 +25,7 @@ function TileSheet.new(opts) self.firstTile = (opts and opts.firstTile) or 0 self.palette = opts and opts.palette self.paletteFor = opts and opts.paletteFor + self.raw = opts and opts.raw self.quads = {} return self end @@ -79,7 +79,11 @@ function TileSheet:draw(tile, tx, ty) if self.paletteFor then colors = self.paletteFor(tile, tx, ty) or colors end local function body() G.draw(image, quad, tx * 8, ty * 8) end if colors and GbcPalette.available() then - GbcPalette.with(colors, body) + if self.raw then + GbcPalette.withRaw(colors, body) + else + GbcPalette.with(colors, body) + end else body() end diff --git a/src/ui/gen2/TitleState.lua b/src/ui/gen2/TitleState.lua index 1dfdbe75..cb7db333 100644 --- a/src/ui/gen2/TitleState.lua +++ b/src/ui/gen2/TitleState.lua @@ -316,11 +316,8 @@ function TitleState:update(_dt) end end --- Which of the two baked sets is showing. GBC is the cart's colour; every --- other COLOR mode wants the grey source, and CLASSIC's green comes from the --- present pass over the finished frame rather than from a third set of art. function TitleState:gray() - return GbcPalette.mode ~= "gbc" + return GbcPalette.mode == "dmg" or GbcPalette.mode == "classic" end function TitleState:art() diff --git a/src/ui/gen2/TradeAnim.lua b/src/ui/gen2/TradeAnim.lua index bd404038..76dc62d1 100644 --- a/src/ui/gen2/TradeAnim.lua +++ b/src/ui/gen2/TradeAnim.lua @@ -870,8 +870,7 @@ end function TradeAnimView:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/TrainerCard.lua b/src/ui/gen2/TrainerCard.lua index 859fc424..c05a27a7 100644 --- a/src/ui/gen2/TrainerCard.lua +++ b/src/ui/gen2/TrainerCard.lua @@ -271,16 +271,24 @@ end -- TrainerCard_PrintTopHalfOfCard runs once, in .InitRAM, and no page redraws -- it -- so the name, ID, money and portrait stay on screen behind the badge -- pages too. +function TrainerCard:print(text, tx, ty) + Chrome.printThrough(text, tx, ty, self:colorsAt(tx, ty)) +end + +function TrainerCard:cursor(tx, ty, hollow) + Chrome.cursorThrough(tx, ty, self:colorsAt(tx, ty), false, hollow) +end + function TrainerCard:drawTopHalf() local player = (self.save or {}).player or {} self:frame(0, 5) - Chrome.print("NAME/", 2, 2) - Chrome.print(player.name or "GOLD", 7, 2) + self:print("NAME/", 2, 2) + self:print(player.name or "GOLD", 7, 2) self:tile(self.card, TILE_ID_NO[1], 2, 4) self:tile(self.card, TILE_ID_NO[2], 3, 4) - Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) - Chrome.print("MONEY", 2, 6) - Chrome.print(moneyText(player.money), 7, 6) + self:print(Chrome.number(player.id or 0, 5, true), 5, 4) + self:print("MONEY", 2, 6) + self:print(moneyText(player.money), 7, 6) for x = 1, 12 do self:tile(self.card, TILE_DIVIDER, x, 3) end self:tile(self.card, TILE_DIVIDER_END, 13, 3) self:drawPortrait() @@ -298,22 +306,22 @@ function TrainerCard:drawCard() -- `#` is the compression byte for POKé, four tiles, so spelling it out is -- what the cart actually draws. - Chrome.print("POKéDEX", 2, 10) - Chrome.print("PLAY TIME", 2, 12) - Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) + self:print("POKéDEX", 2, 10) + self:print("PLAY TIME", 2, 12) + self:print(Chrome.number(self:caughtCount(), 3), 15, 10) local time = save.playTime or {} - Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12) + self:print(Chrome.number(time.hours or 0, 4), 11, 12) -- The colon is $2e, which belongs to CardStatusGFX rather than the card -- sheet, and TrainerCard_Page1_PrintGameTime xors it with ' ' every 32 -- frames -- which is what makes the clock look like it is running. if math.floor(self.frames / 32) % 2 == 0 then self:tile(self.status, TILE_COLON, 15, 12) end - Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12) + self:print(Chrome.number(time.minutes or 0, 2, true), 16, 12) - Chrome.print("BADGES", 12, 15) - Chrome.cursor(18, 15) + self:print("BADGES", 12, 15) + self:cursor(18, 15) end -- TrainerCard_Page2_3_PlaceLeadersFaces: four tiles across the top row, then @@ -397,22 +405,22 @@ function TrainerCard:drawPlain() Chrome.clear() if self.page == 1 then Chrome.box(0, 0, 20, 9) - Chrome.print("NAME/", 2, 2) - Chrome.print(player.name or "GOLD", 7, 2) - Chrome.print("ID No", 2, 4) - Chrome.print(Chrome.number(player.id or 0, 5, true), 5, 4) - Chrome.print("MONEY", 2, 6) - Chrome.print(moneyText(player.money), 7, 6) + Chrome.printThrough("NAME/", 2, 2, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(player.name or "GOLD", 7, 2, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("ID No", 2, 4, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(player.id or 0, 5, true), 5, 4, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("MONEY", 2, 6, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(moneyText(player.money), 7, 6, Chrome.DEFAULT_BOX_PALETTE) Chrome.box(0, 8, 20, 10) - Chrome.print("POKéDEX", 2, 10) - Chrome.print(Chrome.number(self:caughtCount(), 3), 15, 10) - Chrome.print("PLAY TIME", 2, 12) + Chrome.printThrough("POKéDEX", 2, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(self:caughtCount(), 3), 15, 10, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("PLAY TIME", 2, 12, Chrome.DEFAULT_BOX_PALETTE) local time = save.playTime or {} - Chrome.print(Chrome.number(time.hours or 0, 4), 11, 12) - Chrome.print(":", 15, 12) - Chrome.print(Chrome.number(time.minutes or 0, 2, true), 16, 12) - Chrome.print("BADGES", 12, 15) - Chrome.cursor(18, 15) + Chrome.printThrough(Chrome.number(time.hours or 0, 4), 11, 12, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(":", 15, 12, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough(Chrome.number(time.minutes or 0, 2, true), 16, 12, Chrome.DEFAULT_BOX_PALETTE) + Chrome.printThrough("BADGES", 12, 15, Chrome.DEFAULT_BOX_PALETTE) + Chrome.cursorThrough(18, 15, Chrome.DEFAULT_BOX_PALETTE) return end local names = self.page == 2 and JOHTO_BADGES or KANTO_BADGES @@ -423,12 +431,14 @@ function TrainerCard:drawPlain() -- player.badges on both pages to match. local held = player.badges or {} Chrome.box(0, 0, 20, 9) - Chrome.print(self.page == 2 and "JOHTO BADGES" or "KANTO BADGES", 2, 2) + Chrome.printThrough(self.page == 2 and "JOHTO BADGES" or "KANTO BADGES", 2, 2, + Chrome.DEFAULT_BOX_PALETTE) Chrome.box(0, 8, 20, 10) for i, name in ipairs(names) do local tx = 2 + ((i - 1) % 4) * 4 local ty = 10 + math.floor((i - 1) / 4) * 3 - Chrome.print((held[i] or held[name]) and name:sub(1, 4) or "----", tx, ty) + Chrome.printThrough((held[i] or held[name]) and name:sub(1, 4) or "----", tx, ty, + Chrome.DEFAULT_BOX_PALETTE) end end @@ -438,9 +448,7 @@ function TrainerCard:drawPanel() love.graphics.setColor(1, 1, 1, 1) return end - local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, SCREEN_W * 8, SCREEN_H * 8) + Chrome.paletteFill(0, 0, SCREEN_W * 8, SCREEN_H * 8, Chrome.DEFAULT_BOX_PALETTE) local player = (self.save and self.save.player) or {} if self.page == 1 then self:drawCard() @@ -453,7 +461,7 @@ function TrainerCard:drawPanel() -- the same table page 2 reads. self:drawBadges(JOHTO_BADGES, player.badges or {}) end - G.setColor(1, 1, 1, 1) + love.graphics.setColor(1, 1, 1, 1) end function TrainerCard:draw() @@ -462,8 +470,7 @@ end function TrainerCard:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/ui/gen2/UnownPrinter.lua b/src/ui/gen2/UnownPrinter.lua index 332b4ab2..b3f0ed32 100644 --- a/src/ui/gen2/UnownPrinter.lua +++ b/src/ui/gen2/UnownPrinter.lua @@ -191,8 +191,7 @@ function UnownPrinter:drawsWidescreen() return true end function UnownPrinter:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(1, 1, 1, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 1, 1, 1) local scale = Chrome.fitScale(winW, winH) local ox, oy = Chrome.fitOrigin(winW, winH, scale) G.push() diff --git a/src/ui/gen2/UnownPuzzle.lua b/src/ui/gen2/UnownPuzzle.lua index ff8e7d8a..262adbd2 100644 --- a/src/ui/gen2/UnownPuzzle.lua +++ b/src/ui/gen2/UnownPuzzle.lua @@ -576,8 +576,7 @@ function UnownPuzzle:drawsWidescreen() return true end function UnownPuzzle:drawWidescreen(winW, winH) local G = love.graphics - G.setColor(0, 0, 0, 1) - G.rectangle("fill", 0, 0, winW, winH) + Chrome.letterbox(winW, winH, 0, 0, 0) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) diff --git a/src/world/gen2/UnownWords.lua b/src/world/gen2/UnownWords.lua index 852679eb..08900fdb 100644 --- a/src/world/gen2/UnownWords.lua +++ b/src/world/gen2/UnownWords.lua @@ -4,6 +4,7 @@ -- SpecialKabutoChamber, the four routines that open the chambers' walls. local Assets = require("src.render.Assets") +local Chrome = require("src.ui.gen2.Chrome") local Font = require("src.render.Font") local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") @@ -189,7 +190,7 @@ function UnownWords:draw() local wall = self.wall if not wall then return end local bx, by, bw, bh = UnownWords.boxRect(wall) - Font.drawBox(bx, by, bw, bh) + Chrome.paletteBox(bx, by, bw, bh) local tileset, atlas = self:tileset() if not tileset then return end local perRow = tileset.tilesPerRow or 16 diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index ce3b82ec..56bbdba9 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -8217,7 +8217,8 @@ function World:mapCacheKey(mapId) local daytime = self.daytime local flicker = (daytime == "DARK") and self.flickerPhase or 1 return mapId .. "|" .. tostring(daytime) - .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) + .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp) + .. "|" .. tostring(flicker) end function World:imageFor(mapId) @@ -8440,7 +8441,8 @@ function World:borderImageFor(mapId) -- stale bake (#1418). local waterFrame = self:borderWaterFrame(def, tileset) local cacheKey = BorderFill.cacheKey(mapId .. "|" .. tostring(daytime) - .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(flicker) + .. "|" .. tostring(GbcPalette.mode) .. "|" .. tostring(GbcPalette.customRamp) + .. "|" .. tostring(flicker) .. "|" .. tostring(BorderFill.voidFill or "fade") .. "|" .. tostring(blockId) .. "|" .. tostring(waterFrame and waterFrame.row or 0)) @@ -10500,12 +10502,12 @@ end -- The COLOR option can change under a standing world (the hotkey, or the -- OPTION screen closing), and the map is a baked canvas rather than a live -- draw -- so the cached references have to be re-fetched when it does. The --- bakes themselves are keyed by mode in imageFor, so this is a pointer swap --- after the first time each mode is seen, not a re-bake. function World:refreshColorMode() local mode = GbcPalette.mode - if self.colorMode == mode then return end + local ramp = GbcPalette.customRamp + if self.colorMode == mode and self.colorRamp == ramp then return end self.colorMode = mode + self.colorRamp = ramp if not self.map then return end self.mapImage = self:imageFor(self.map.id) self:rebuildNeighbors() diff --git a/tests/drivers/battle_hud_layout_lock_test.lua b/tests/drivers/battle_hud_layout_lock_test.lua index 24b44732..f0c85767 100644 --- a/tests/drivers/battle_hud_layout_lock_test.lua +++ b/tests/drivers/battle_hud_layout_lock_test.lua @@ -20,8 +20,8 @@ return function(game) end assert(layoutRow and hudRow, "battle layout/HUD rows are present") - menu.index = 6 - menu.scroll = 3 + -- BATTLE HUD lives on the BATTLE OPTIONS page now, so focus it there. + assert(menu:focusRow("battleHud"), "BATTLE HUD row is reachable") assert(hudRow.value(game) == "EXTENDED", "WIDE displays EXTENDED") assert(U.shot(game, BEFORE), "WIDE/EXTENDED screenshot was written") diff --git a/tests/drivers/custom_ramp_probe.lua b/tests/drivers/custom_ramp_probe.lua new file mode 100644 index 00000000..87662fe3 --- /dev/null +++ b/tests/drivers/custom_ramp_probe.lua @@ -0,0 +1,46 @@ +-- Diagnostic probe: forces a loud custom COLORS ramp (hot pink paper, blue +-- ink) and shoots StartMenu, PartyMenu, and PackMenu, so a gap between +-- those screens and the rest of the UI can be checked by eye. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/custom_ramp_probe.lua love . +local U = require("tests.drivers.util") + +local GbcPalette = require("src.render.GbcPalette") +local StartMenu = require("src.ui.gen2.StartMenu") +local PartyMenu = require("src.ui.gen2.PartyMenu") +local PackMenu = require("src.ui.gen2.PackMenu") + +return function(game) + local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/custom-ramp-probe" + + local function shot(name) + U.wait(3) + U.shot(game, ("%s/%s.png"):format(out, name)) + end + + local function show(name, state) + game.stack:push(state) + shot(name) + game.stack:pop() + end + + U.wait(45) + assert(game.world and game.world.map, "gold world did not boot") + + print("[driver] GbcPalette.available() = " .. tostring(GbcPalette.available())) + + GbcPalette.setCustomRamp({ + { 255, 100, 220 }, { 220, 60, 180 }, { 140, 20, 120 }, { 20, 0, 60 }, + }) + print("[driver] customRamp set, mode=" .. tostring(GbcPalette.mode)) + + local save = game.save + save.party = save.party or {} + save.inventory = { POTION = 5, SUPER_POTION = 2, ANTIDOTE = 1 } + + show("01-startmenu-custom-ramp", StartMenu.new(game, { save = save })) + show("02-party-custom-ramp", PartyMenu.new(game, { prompt = "choose" })) + show("03-pack-items-custom-ramp", PackMenu.new(game, { pocket = "ITEM" })) + + print("[driver] PASS custom ramp probe shots in " .. out) +end diff --git a/tests/drivers/fill_extended_auto_menu_test.lua b/tests/drivers/fill_extended_auto_menu_test.lua index c9745fc1..acf2d28a 100644 --- a/tests/drivers/fill_extended_auto_menu_test.lua +++ b/tests/drivers/fill_extended_auto_menu_test.lua @@ -28,8 +28,8 @@ return function(game) assert(bgRow.step(game, 1) == false, "AUTO background row is locked") assert(options.battleBg == "black", "locked AUTO does not overwrite the stored value") - menu.index = bgIndex - menu.scroll = math.max(0, bgIndex - 5) + -- BATTLE BG lives on the BATTLE OPTIONS page now, so focus it there. + assert(menu:focusRow("battleBg"), "BATTLE BG row is reachable") U.wait(2) local autoPath = DIR .. "/fill_extended_auto_menu.png" os.remove(autoPath) diff --git a/tests/drivers/gold_options_groups_shots.lua b/tests/drivers/gold_options_groups_shots.lua new file mode 100644 index 00000000..1d90d08d --- /dev/null +++ b/tests/drivers/gold_options_groups_shots.lua @@ -0,0 +1,30 @@ +-- The grouped Gen 2 OPTION screen: the top level, one page, and a long +-- COLOR value scrolling under the cursor. +-- +-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_options_groups_shots.lua love . +local U = require("tests.drivers.util") + +return function(game) + local OptionsMenu = require("src.ui.gen2.OptionsMenu") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.wait(5) + local menu = OptionsMenu.new(game, { options = game.options }) + game.stack:push(menu) + U.wait(3) + U.shot(game, DIR .. "/g2_0_top.png") + + local page = menu:focusRow("color") + U.wait(2) + U.shot(game, DIR .. "/g2_1_graphics.png") + game.options.palette = "1-A (Default) (BGB)" + U.wait(2) + U.shot(game, DIR .. "/g2_2_color_start.png") + U.wait(85) + U.shot(game, DIR .. "/g2_3_color_scrolled.png") + + page.index = #page.view + U.wait(3) + U.shot(game, DIR .. "/g2_4_back.png") + U.log("GOLD_OPTIONS_GROUPS_DONE") + game.driverDone = true +end diff --git a/tests/drivers/options_groups_shots.lua b/tests/drivers/options_groups_shots.lua new file mode 100644 index 00000000..b09c10f0 --- /dev/null +++ b/tests/drivers/options_groups_shots.lua @@ -0,0 +1,33 @@ +-- Driver: the grouped OPTION screen -- the top level with its group openers, +-- one group's page, and the COLORS row scrolling its overlong value. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + local OptionsMenu = require("src.ui.OptionsMenu") + local menu = OptionsMenu.new(game) + game.stack:push(menu) + U.wait(5) + U.shot(game, DIR .. "/opt_0_top.png") + + menu:focusRow("group.battle") + U.wait(2) + U.shot(game, DIR .. "/opt_1_battle_opener.png") + U.tap(game, "a"); U.wait(3) + U.shot(game, DIR .. "/opt_2_battle_page.png") + U.tap(game, "b"); U.wait(3) + + game.save.options.palette = "1-A (Default) (BGB)" + -- COLORS is on the GRAPHICS page, so focusRow hands back that page. + local graphics = menu:focusRow("colors") + U.wait(2) + U.shot(game, DIR .. "/opt_3_colors_start.png") + U.wait(85) + U.shot(game, DIR .. "/opt_4_colors_scrolled.png") + + graphics.index = #graphics.rows + 1 -- the page's own BACK + U.wait(3) + U.shot(game, DIR .. "/opt_5_back.png") + U.log("OPTIONS_GROUPS_SHOTS_DONE") + game.driverDone = true +end diff --git a/tests/drivers/options_test.lua b/tests/drivers/options_test.lua index 02c2f532..318759b3 100644 --- a/tests/drivers/options_test.lua +++ b/tests/drivers/options_test.lua @@ -1,18 +1,21 @@ -- Driver: the options screen with the port's audio rows (MUSIC VOL / -- SFX VOL / MUSIC FILTER) to prove the 4-box viewport, the ▼ scroll --- marker, and CANCEL fixed on the bottom line. The menu is pushed --- directly (title-menu row order shifts when a save file exists, so --- blind taps are unreliable). +-- marker, and BACK fixed on the bottom line. The audio rows live on the +-- AUDIO page now, so the menu focuses its way there rather than tapping +-- down a fixed number of times. The menu is pushed directly (title-menu +-- row order shifts when a save file exists, so blind taps are unreliable). return function(game) local U = dofile("tests/drivers/util.lua") local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" U.teleport(game, "PALLET_TOWN", 10, 8, "down") local OptionsMenu = require("src.ui.OptionsMenu") - game.stack:push(OptionsMenu.new(game)) + local menu = OptionsMenu.new(game) + game.stack:push(menu) U.wait(5) U.shot(game, DIR .. "/options_0_top.png") - for _ = 1, 4 do U.tap(game, "down"); U.wait(2) end - U.shot(game, DIR .. "/options_1_musicvol.png") -- scrolled, ▼ visible + local audio = menu:focusRow("musicVol") + U.wait(2) + U.shot(game, DIR .. "/options_1_musicvol.png") -- the AUDIO page U.tap(game, "left"); U.wait(2) U.tap(game, "left"); U.wait(2) U.shot(game, DIR .. "/options_2_musicvol_5.png") @@ -20,6 +23,7 @@ return function(game) U.tap(game, "down"); U.wait(2) U.tap(game, "right"); U.wait(2) -- MUSIC FILTER -> 1X U.shot(game, DIR .. "/options_3_filter_1x.png") - U.tap(game, "down"); U.wait(2) -- CANCEL, tail rows behind it - U.shot(game, DIR .. "/options_4_cancel.png") + audio.index = #audio.view + 1 -- BACK, the page's rows behind it + U.wait(2) + U.shot(game, DIR .. "/options_4_back.png") end diff --git a/tests/drivers/palette_picker_shots.lua b/tests/drivers/palette_picker_shots.lua new file mode 100644 index 00000000..aab07b2e --- /dev/null +++ b/tests/drivers/palette_picker_shots.lua @@ -0,0 +1,17 @@ +-- The COLORS picker after the palette rename: the root folder list and one +-- pack's contents. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + local Screens = require("src.ui.Screens") + Screens.push(game, "PaletteScreen") + U.wait(5) + U.shot(game, DIR .. "/pal_0_root.png") + for _ = 1, 6 do U.tap(game, "down"); U.wait(1) end + U.shot(game, DIR .. "/pal_1_scrolled.png") + U.tap(game, "a"); U.wait(4) + U.shot(game, DIR .. "/pal_2_inside.png") + U.log("PALETTE_PICKER_SHOTS_DONE") + game.driverDone = true +end diff --git a/tests/engine/gen2_battle_anim_view_fill_palette_test.lua b/tests/engine/gen2_battle_anim_view_fill_palette_test.lua new file mode 100644 index 00000000..950797e4 --- /dev/null +++ b/tests/engine/gen2_battle_anim_view_fill_palette_test.lua @@ -0,0 +1,52 @@ +-- BattleAnimView:fillBackground() used to draw a flat literal white +-- rectangle with no GbcPalette involvement, leaving a stark white gap +-- during the battle intro's slide-in under a custom COLORS ramp. +-- +-- No real shader runs headless, so this asserts on which seam gets called +-- rather than on rendered pixels, same as every palette-seam test here. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local BattleAnimView = require("src.ui.gen2.BattleAnimView") + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +do + GbcPalette.available = function() return true end + GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("with") + local view = BattleAnimView.new({}, {}) + view:fillBackground() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "BattleAnimView:fillBackground() reaches the GbcPalette seam") +end + +-- Unthemed / no-shader boot must still degrade to the plain literal white +-- fill, byte-identical to before this fix. +do + GbcPalette.available = function() return false end + calls = {} + spy("resolve"); spy("use"); spy("with") + local view = BattleAnimView.new({}, {}) + local ok = pcall(function() view:fillBackground() end) + T.check(ok, "BattleAnimView:fillBackground() does not error with no shader") + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "BattleAnimView:fillBackground() does not touch GbcPalette with no shader") +end + +T.finish() diff --git a/tests/engine/gen2_battle_menu_palette_test.lua b/tests/engine/gen2_battle_menu_palette_test.lua new file mode 100644 index 00000000..b9bb10ad --- /dev/null +++ b/tests/engine/gen2_battle_menu_palette_test.lua @@ -0,0 +1,58 @@ +-- BattleState:drawTextArea drew the FIGHT/ITEM/RUN command box with a flat +-- setColor, the same gap TextBox.lua had. Gen 1 gets colour from +-- PaletteFX's whole-frame remap, but Gen 2 has no such pass outside +-- CLASSIC mode, so the command box needed its own fix. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local BattleState = require("src.battle.BattleState") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function fakeState() + return { + phase = "menu", menuIndex = 1, safari = nil, demo = false, + bottomUIVisible = function() return true end, + game = { save = { generation = 2, version = "gold" } }, + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawTextArea(fakeState()) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Gen 2 battle command box reaches the GbcPalette seam") +end + +-- Gen 1 recolours through PaletteFX's whole-frame zone remap, not per draw +-- call, so this must stay untouched. Same guard gen2_textbox_palette_test +-- checks for TextBox. +do + calls = {} + spy("resolve"); spy("use"); spy("with") + local state = fakeState() + state.game = { save = { generation = 1, version = "red" } } + BattleState.drawTextArea(state) + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Gen 1 battle command box is untouched, it never calls GbcPalette") +end + +T.finish() diff --git a/tests/engine/gen2_battle_state_text_palette_test.lua b/tests/engine/gen2_battle_state_text_palette_test.lua new file mode 100644 index 00000000..77b2d3bc --- /dev/null +++ b/tests/engine/gen2_battle_state_text_palette_test.lua @@ -0,0 +1,69 @@ +-- src/ui/gen2/BattleState.lua is the real Gen 2 battle screen (wired in +-- through Gen2Compat.lua's facade); it drew every label through flat +-- Chrome.print/cursor/printRight, never touching GbcPalette. Added +-- Chrome.printRightThrough alongside this fix since printRight had no +-- palette-aware counterpart. +-- +-- No real shader runs headless, so this asserts on which seam gets called +-- rather than on rendered pixels. Exercises drawMoveInfoBox, drawStatsBox, +-- and drawPanel's no-battle fallback as representative cases. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local BattleState = require("src.ui.gen2.BattleState") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function hits() + return (calls.resolve or 0) + (calls.use or 0) + (calls.with or 0) +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawMoveInfoBox({}, { id = "TACKLE", pp = 10, maxPp = 35 }) + T.check(hits() > 0, "drawMoveInfoBox's TYPE/PP labels reach the GbcPalette seam") +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawMoveInfoBox({}, nil) + T.eq(hits(), 0, "drawMoveInfoBox with no move draws nothing") +end + +do + local mon = { species = "RATTATA", + stats = { attack = 10, defense = 9, specialAttack = 8, specialDefense = 8, + speed = 12 } } + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawStatsBox({}, mon) + T.check(hits() > 0, "drawStatsBox's stat names/numbers reach the GbcPalette seam") +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + BattleState.drawPanel({ battle = nil, tutorial = false }) + T.check(hits() > 0, "drawPanel's NO BATTLE fallback reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/gen2_chrome_clear_palette_test.lua b/tests/engine/gen2_chrome_clear_palette_test.lua new file mode 100644 index 00000000..b270b624 --- /dev/null +++ b/tests/engine/gen2_chrome_clear_palette_test.lua @@ -0,0 +1,56 @@ +-- Chrome.clear() is the base background wipe ~30 opaque Gen 2 menu screens +-- start their frame with. It used to draw a flat literal white rectangle, +-- never touching GbcPalette, so every screen's background stayed white +-- regardless of a picked COLORS palette even though its boxes and text +-- already recoloured correctly. +-- +-- No real shader runs headless, so this can't check a rendered pixel, same +-- limitation gen2_textbox_palette_test notes: force GbcPalette.available() +-- true and assert on which seam gets called, not on pixels. +-- tests/drivers/gold_menu_shots.lua is where a real rendered screen gets +-- checked by eye. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +do + GbcPalette.available = function() return true end + GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("with") + Chrome.clear() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Chrome.clear() reaches the GbcPalette seam when a shader is available") +end + +-- Unthemed / no-shader boot must still degrade to the plain literal white +-- wipe, byte-identical to before this fix, same guard Chrome.paletteBox +-- already takes. +do + GbcPalette.available = function() return false end + calls = {} + spy("resolve"); spy("use"); spy("with") + local ok = pcall(Chrome.clear) + T.check(ok, "Chrome.clear() does not error with no shader available") + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Chrome.clear() does not touch GbcPalette when no shader is available") +end + +T.finish() diff --git a/tests/engine/gen2_chrome_flat_primitives_palette_test.lua b/tests/engine/gen2_chrome_flat_primitives_palette_test.lua new file mode 100644 index 00000000..48dbc998 --- /dev/null +++ b/tests/engine/gen2_chrome_flat_primitives_palette_test.lua @@ -0,0 +1,73 @@ +-- Chrome.print / Chrome.cursor / Chrome.printRight are the flat, +-- hardcoded-black primitives ~40 Gen 2 screens still call directly, so any +-- glyph through them rendered solid black regardless of COLORS. Made the +-- primitives themselves palette-aware instead of migrating 40 files. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function reset() + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") +end + +local function touched() + return (calls.resolve or 0) + (calls.use or 0) + (calls.useRaw or 0) + (calls.with or 0) +end + +do + GbcPalette.available = function() return true end + GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + + reset() + Chrome.print("HELLO", 1, 1) + T.check(touched() > 0, "Chrome.print() reaches the GbcPalette seam when a shader is available") + + reset() + Chrome.cursor(1, 1) + T.check(touched() > 0, "Chrome.cursor() reaches the GbcPalette seam when a shader is available") + + reset() + Chrome.printRight("42", 10, 1) + T.check(touched() > 0, "Chrome.printRight() reaches the GbcPalette seam when a shader is available") +end + +-- Unthemed / no-shader boot must still degrade to the plain literal black +-- glyph draw, byte-identical to before this fix. +do + GbcPalette.available = function() return false end + + reset() + local ok = pcall(Chrome.print, "HELLO", 1, 1) + T.check(ok, "Chrome.print() does not error with no shader available") + T.eq(touched(), 0, "Chrome.print() does not touch GbcPalette when no shader is available") + + reset() + ok = pcall(Chrome.cursor, 1, 1) + T.check(ok, "Chrome.cursor() does not error with no shader available") + T.eq(touched(), 0, "Chrome.cursor() does not touch GbcPalette when no shader is available") + + reset() + ok = pcall(Chrome.printRight, "42", 10, 1) + T.check(ok, "Chrome.printRight() does not error with no shader available") + T.eq(touched(), 0, "Chrome.printRight() does not touch GbcPalette when no shader is available") +end + +T.finish() diff --git a/tests/engine/gen2_chrome_list_palette_test.lua b/tests/engine/gen2_chrome_list_palette_test.lua new file mode 100644 index 00000000..948bfb73 --- /dev/null +++ b/tests/engine/gen2_chrome_list_palette_test.lua @@ -0,0 +1,64 @@ +-- Chrome.List:draw() backs nearly every Gen 2 menu's row text, but drew +-- each row through flat, hardcoded-black Chrome.print/cursor, so rows +-- stayed black-on-white even though the box behind them recoloured. Fixed +-- by giving List a `palette` option that routes rows through +-- printThrough/cursorThrough. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +do + local list = Chrome.List.new({ items = { "POKEMON", "PACK", "QUIT" }, x = 2, y = 2 }) + calls = {} + spy("resolve"); spy("use"); spy("with") + list:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Chrome.List:draw() reaches the GbcPalette seam by default") +end + +-- A caller that explicitly opts out (palette = false) keeps the old flat +-- black draw untouched by GbcPalette. +do + local list = Chrome.List.new({ items = { "A", "B" }, x = 2, y = 2, palette = false }) + calls = {} + spy("resolve"); spy("use"); spy("with") + list:draw() + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Chrome.List:draw() with palette = false does not touch GbcPalette") +end + +-- The scroll-hint down arrow, drawn only when the list is longer than its +-- visible rows, must also go through the palette rather than a bare +-- Font.drawCode. +do + local items = {} + for i = 1, 5 do items[i] = "ITEM " .. i end + local list = Chrome.List.new({ items = items, x = 2, y = 2, rows = 2 }) + calls = {} + spy("resolve"); spy("use"); spy("with") + list:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Chrome.List:draw()'s scroll-hint arrow reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/gen2_color_cache_invalidation_test.lua b/tests/engine/gen2_color_cache_invalidation_test.lua new file mode 100644 index 00000000..c108bd74 --- /dev/null +++ b/tests/engine/gen2_color_cache_invalidation_test.lua @@ -0,0 +1,109 @@ +-- World's per-map colour bake keyed its cache on GbcPalette.mode alone, so +-- two different picked packs (both mode == "custom") kept showing +-- whichever was baked first until a full reload. +-- luajit tests/engine/gen2_color_cache_invalidation_test.lua +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local GbcPalette = require("src.render.GbcPalette") +local BorderFill = require("src.world.gen2.BorderFill") +local World = require("src.world.gen2.World") + +GbcPalette.setMode("gbc") +GbcPalette.setCustomRamp(nil) + +-- ------- mapCacheKey: must depend on customRamp identity, not just mode + +do + local fakeSelf = { daytime = "DAY", flickerPhase = 1 } + local key1 = World.mapCacheKey(fakeSelf, "map1") + + GbcPalette.setCustomRamp( + { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } }) + local key2 = World.mapCacheKey(fakeSelf, "map1") + + check(key1 ~= key2, + "BUG FIX: mapCacheKey differs once a custom ramp is active, even " .. + "though GbcPalette.mode ('gbc') never changed") + + GbcPalette.setCustomRamp( + { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } }) + local key3 = World.mapCacheKey(fakeSelf, "map1") + check(key2 ~= key3, + "BUG FIX: mapCacheKey differs again for a second, different ramp " .. + "under the same mode") + + GbcPalette.setCustomRamp(nil) + eq(World.mapCacheKey(fakeSelf, "map1"), key1, + "clearing the ramp returns to the original no-ramp key") +end + +-- ------- refreshColorMode: must refetch on a customRamp-only change + +do + local fakeSelf = { daytime = "DAY", flickerPhase = 1 } + local calls = 0 + fakeSelf.imageFor = function(_, id) calls = calls + 1; return "img:" .. id end + fakeSelf.rebuildNeighbors = function() end + fakeSelf.map = { id = "map1" } + + World.refreshColorMode(fakeSelf) + eq(calls, 1, "first refreshColorMode call bakes/fetches once") + + World.refreshColorMode(fakeSelf) + eq(calls, 1, "a second call with nothing changed does not refetch") + + GbcPalette.setCustomRamp( + { { 5, 5, 5 }, { 4, 4, 4 }, { 3, 3, 3 }, { 2, 2, 2 } }) + World.refreshColorMode(fakeSelf) + eq(calls, 2, + "BUG FIX: a customRamp change alone (mode unchanged) still refetches") + + GbcPalette.setCustomRamp(nil) + World.refreshColorMode(fakeSelf) + eq(calls, 3, "clearing the ramp (mode still unchanged) refetches too") +end + +-- ------- World:borderImageFor: the void/border bake had the identical gap +-- in its own separately-inlined cache key (it doesn't call mapCacheKey). A +-- border baked while GEN 2 (no ramp) was active stayed cached forever, +-- since picking a pack never showed up in this key, only the mode did. + +do + local def = { id = "map1", tileset = "TILESET_JOHTO", borderBlock = 0 } + local fakeSelf = { + daytime = "DAY", flickerPhase = 1, + maps = { map1 = def }, + tilesets = { TILESET_JOHTO = { blocks = { [1] = {} }, tilesPerRow = 16 } }, + mapImages = {}, + borderWaterFrame = function() return nil end, + atlasFor = function() return nil end, + } + + GbcPalette.setCustomRamp(nil) + -- Seed the cache under exactly the key borderImageFor computes today for + -- "no ramp active", with a sentinel, proving the fix without touching + -- the real love.graphics bake pipeline. + local keyNoRamp = + BorderFill.cacheKey("map1|DAY|gbc|nil|1|fade|0|0") + fakeSelf.mapImages[keyNoRamp] = "SENTINEL-no-ramp" + eq(World.borderImageFor(fakeSelf, "map1"), "SENTINEL-no-ramp", + "sanity: the no-ramp key matches what borderImageFor builds today") + + GbcPalette.setCustomRamp( + { { 1, 1, 1 }, { 2, 2, 2 }, { 3, 3, 3 }, { 4, 4, 4 } }) + local got = World.borderImageFor(fakeSelf, "map1") + check(got ~= "SENTINEL-no-ramp", + "BUG FIX: borderImageFor must not reuse the no-ramp cached void " .. + "bake once a custom ramp is active") + + GbcPalette.setCustomRamp(nil) +end + +GbcPalette.setCustomRamp(nil) +GbcPalette.setMode("gbc") + +T.finish("gen2 color cache invalidation") diff --git a/tests/engine/gen2_options_menu_translation_test.lua b/tests/engine/gen2_options_menu_translation_test.lua index 2ce171c3..836e8ac9 100644 --- a/tests/engine/gen2_options_menu_translation_test.lua +++ b/tests/engine/gen2_options_menu_translation_test.lua @@ -54,13 +54,41 @@ local function rowIndex(rows, id) end end +-- TEXT SPEED and the other cart rows live on a group page now, so the +-- screen a row draws on is the page its group opens, not the top level. +local function pageFor(menu, id) + for _, row in ipairs(menu.view) do + if row.group then + row.activate(menu.game) + local page = menu.game.stack:top() + if rowIndex(page.view, id) then return page end + menu.game.stack:pop() + end + end +end + +local function fakeGame() + local stack = { items = {} } + function stack:push(s) self.items[#self.items + 1] = s; return s end + function stack:pop() self.items[#self.items] = nil end + function stack:top() return self.items[#self.items] end + return { stack = stack } +end + -- ---------------------------------------------- vanilla: no mod catalog do - local menu = OptionsMenu.new({}) + local menu = OptionsMenu.new(fakeGame()) drawn = {} menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "SPEED", + "row 1 is the SPEED group with no mod loaded") + + local page = pageFor(menu, "textSpeed") + T.check(page ~= nil, "TEXT SPEED is on a group page") + drawn = {} + page:drawPanel() T.eq(drawnAt(LABEL_X, 2 * 8), "TEXT SPEED", - "row 1's label draws in English with no mod loaded") + "the page's first label draws in English") -- Save.DEFAULT_OPTIONS.textSpeed is "MID", the cart's own default. T.eq(drawnAt(VALUE_X, 3 * 8), "MID ", "and its cart-original display value too") @@ -73,13 +101,20 @@ do ["TEXT SPEED"] = "VITESSE TEXTE", ["MID "] = "MOY ", ["CONTROLS"] = "COMMANDES", - ["CANCEL"] = "ANNULER", + ["SPEED"] = "VITESSE", + ["BACK"] = "RETOUR", }, }) - local menu = OptionsMenu.new({}) + local menu = OptionsMenu.new(fakeGame()) drawn = {} menu:drawPanel() + T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE", + "a mod catalog reaches a group opener's label too") + + local page = pageFor(menu, "textSpeed") + drawn = {} + page:drawPanel() T.eq(drawnAt(LABEL_X, 2 * 8), "VITESSE TEXTE", "a mod catalog reaches a cart-original row's label") T.eq(drawnAt(VALUE_X, 3 * 8), "MOY ", @@ -87,7 +122,7 @@ do -- CONTROLS is the first port-added row; scroll to it so it lands in the -- VISIBLE_ROWS=7 window drawPanel actually draws. - local index = rowIndex(menu.rows, "controls") + local index = rowIndex(menu.view, "controls") T.check(index ~= nil, "CONTROLS is one of the rows") menu.index = index menu:ensureVisible() @@ -97,17 +132,17 @@ do T.eq(drawnAt(LABEL_X, (2 + (slot - 1) * 2) * 8), "COMMANDES", "and a port-added row's label is translated too") - -- CANCEL is the last row, built into ROWS like any other -- there is no + -- BACK is the last row, built into ROWS like any other -- there is no -- separate hook to fall through if this one row is missed. - local cancelMenu = OptionsMenu.new({}) - local cancelIndex = #cancelMenu.rows - T.check(cancelMenu.rows[cancelIndex].cancel, "the last row is CANCEL") + local cancelMenu = OptionsMenu.new(fakeGame()) + local cancelIndex = #cancelMenu.view + T.check(cancelMenu.view[cancelIndex].cancel, "the last row is BACK") cancelMenu.index = cancelIndex cancelMenu:ensureVisible() drawn = {} cancelMenu:drawPanel() local cancelSlot = cancelIndex - cancelMenu.scroll - T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "ANNULER", + T.eq(drawnAt(LABEL_X, (2 + (cancelSlot - 1) * 2) * 8), "RETOUR", "CANCEL, the way out of the menu, is translated too") -- Module state is process-global (see tests/gen2_clock_test.lua's own diff --git a/tests/engine/gen2_pack_menu_item_list_palette_test.lua b/tests/engine/gen2_pack_menu_item_list_palette_test.lua new file mode 100644 index 00000000..fb300dab --- /dev/null +++ b/tests/engine/gen2_pack_menu_item_list_palette_test.lua @@ -0,0 +1,65 @@ +-- PackMenu:drawList and PackGfx:draw stayed flat white/black regardless of +-- COLORS, since PackGfx only repaints its left five columns and the +-- item-list columns kept their initial bare-rectangle fill. Fixed by +-- routing that fill through Chrome.paletteFill and PackMenu's prints +-- through printThrough/cursorThrough. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local PackGfx = require("src.ui.gen2.PackGfx") +local PackMenu = require("src.ui.gen2.PackMenu") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +do + -- An empty gfx.pack table (no header/background/pack-image paths): draw() + -- still runs its header/pattern loops, they just find nothing to blit, so + -- only the base fill itself is exercised. + local gfx = PackGfx.new({ pack = {} }) + calls = {} + spy("resolve"); spy("use"); spy("with") + gfx:draw("ITEMS") + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "PackGfx:draw()'s base fill reaches the GbcPalette seam") +end + +local function fakePackMenu() + return { + rows = { + { id = "POTION", name = "POTION", showCount = true, count = 1 }, + }, + scroll = 0, + index = 1, + switching = nil, + gfx = PackGfx.new(nil), + cursorAt = PackMenu.cursorAt, + total = function() return 2 end, -- 1 row + CANCEL + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + PackMenu.drawList(fakePackMenu(), 5, 4) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "PackMenu:drawList() reaches the GbcPalette seam for item labels") +end + +T.finish() diff --git a/tests/engine/gen2_palette_picker_test.lua b/tests/engine/gen2_palette_picker_test.lua new file mode 100644 index 00000000..1cdf7fac --- /dev/null +++ b/tests/engine/gen2_palette_picker_test.lua @@ -0,0 +1,275 @@ +-- Gen 2's answer to tests/engine/palette_test.lua: the same COLORS picker, +-- wired onto Gold's COLOR row and its own substitution seam +-- (src/render/GbcPalette.lua) instead of Gen 1's PaletteFX. +-- luajit tests/engine/gen2_palette_picker_test.lua +-- +-- Covers GbcPalette.customRamp/setCustomRamp (the seam every Gen 2 draw call +-- shares), that a stored palette beats CLASSIC's present pass the same way +-- it beats OG/OG INV/CLASSIC on Gen 1, and that the COLOR row opens the +-- picker while GBC/DMG/CLASSIC stay reachable inside it. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local function hex(c) + return type(c) == "table" and ("%02x%02x%02x"):format(c[1], c[2], c[3]) or "?" +end + +local Palette = require("src.render.Palette") +local GbcPalette = require("src.render.GbcPalette") + +-- ------- GbcPalette seam: a custom ramp wins outright + +do + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") + + local cartColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + eq(GbcPalette.resolve(cartColors), cartColors, + "no custom ramp: GBC mode passes the cart's own colours through") + + local id = Palette.packId("GalleryWebApp Palettes", "Original GB") + local ramp = Palette.ramp(id) + GbcPalette.setCustomRamp(ramp) + + local out = GbcPalette.resolve(cartColors) + for i = 1, 4 do + eq(hex(out[i]), hex(ramp[i]), + "resolve[" .. i .. "] is the custom ramp, not the cart's own colours") + end + + -- it should win even over DMG, since DMG's whole effect lives at this seam + GbcPalette.setMode("dmg") + local out2 = GbcPalette.resolve(cartColors) + eq(hex(out2[1]), hex(ramp[1]), "a custom ramp beats DMG too") + + GbcPalette.setMode("gbc") + GbcPalette.setCustomRamp(nil) + eq(GbcPalette.resolve(cartColors), cartColors, + "clearing the ramp restores the cart's own colours") +end + +-- ------- CLASSIC is a present pass, same as Gen 1: it has to be dropped + +do + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("classic") + check(GbcPalette.presentColors() ~= nil, + "CLASSIC has a present pass before any palette is picked") + + local id = Palette.packId("BGB", "Red") + local ramp = Palette.ramp(id) + GbcPalette.setCustomRamp(ramp) + -- resolve() alone can't save CLASSIC: presentColors bypasses resolve + -- entirely, so the picker's opts have to drop the mode itself. Covered + -- below via the real row wiring. + GbcPalette.setMode("gbc") + GbcPalette.setCustomRamp(nil) +end + +-- ------- applyOptions: a saved palette re-applies on load, like Gen 1's + +do + GbcPalette.setCustomRamp(nil) + local id = Palette.packId("BGB", "Blue") + GbcPalette.applyOptions({ color = "dmg", palette = id }) + eq(GbcPalette.mode, "dmg", "applyOptions still sets the mode") + check(GbcPalette.customRamp ~= nil, "applyOptions applied the saved palette") + eq(hex(GbcPalette.customRamp[1]), hex(Palette.ramp(id)[1]), + "and it's the right ramp") + + GbcPalette.applyOptions({ color = "gbc", palette = "" }) + check(GbcPalette.customRamp == nil, + "applyOptions with no palette clears the custom ramp") +end + +-- ------- the COLOR row's real wiring: activate opens the picker + +local OptionsMenu = require("src.ui.gen2.OptionsMenu") + +local function rowNamed(rows, label) + for _, row in ipairs(rows) do + if row.label == label then return row end + end + return nil +end + +do + local colorRow = rowNamed(OptionsMenu.ROWS, "COLOR") + check(colorRow ~= nil, "COLOR is still a row") + check(colorRow.cycle == nil, "COLOR no longer cycles in place") + check(type(colorRow.activate) == "function", + "COLOR opens the picker instead") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("classic") + + local pushed + local game = { + options = { color = "classic", palette = "" }, + stack = { states = {}, + push = function(self, inst) pushed = inst end }, + persistOptions = function() end, + } + + colorRow.activate(game) + check(pushed ~= nil, "activate pushed a screen") + check(pushed.view == "root", "the pushed screen is a real PaletteScreen") + eq(game.options.color, "gbc", + "opening the picker while CLASSIC was active dropped it to GBC") + eq(GbcPalette.mode, "gbc", "and the live mode followed") + + -- the root offers the engine's own GBC/DMG/CLASSIC ladder alongside the + -- 699-pack folders and MONOCHROME: "the original options" still work + local sawModes = {} + for _, row in ipairs(pushed.rows) do + if row.mode then sawModes[row.mode] = true end + end + check(sawModes.gbc and sawModes.dmg and sawModes.classic, + "GBC, DMG and CLASSIC are all still reachable from the picker") + + local id = Palette.packId("BGB", "Blue") + pushed.set(id) + eq(game.options.palette, id, "storing a palette writes options.palette") + check(GbcPalette.customRamp ~= nil, "and GbcPalette.setCustomRamp actually ran") + eq(hex(GbcPalette.customRamp[1]), hex(Palette.ramp(id)[1]), "with the right ramp") + + pushed.set("") + check(GbcPalette.customRamp == nil, "storing empty clears the custom ramp") + eq(game.options.color, "gbc", + "clearing a palette lands back on GEN 2, not whatever mode was active") + eq(GbcPalette.mode, "gbc", "and the live mode followed") + + -- picking an engine mode through the picker still works and clears the + -- stored palette, exactly like Gen 1's picker + pushed.set(id) + pushed.setMode("dmg") + eq(game.options.color, "dmg", "setMode still writes options.color") + eq(GbcPalette.mode, "dmg", "and drives the live module") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") +end + +-- ------- GBC/GEN 2 relabel: GbcPalette.MODES keeps its id "gbc", but the +-- label the player sees for it moved to "GEN 2", freeing "GBC" up as +-- CUSTOM_MODE's own label, the state a stored palette actually lands on. + +do + eq(GbcPalette.MODE_LABELS.gbc, "GEN 2", + "GEN 2 is the engine's own unique-colour mode's display name now") + eq(GbcPalette.MODE_LABELS[GbcPalette.CUSTOM_MODE], "GBC", + "GBC is the label for a stored custom palette's state") + check(not (function() + for _, id in ipairs(GbcPalette.MODES) do + if id == GbcPalette.CUSTOM_MODE then return true end + end + return false + end)(), "CUSTOM_MODE is deliberately not one of the root ladder's own modes") +end + +-- ------- the bug this session's relabel fixes: picking a palette from a +-- non-GEN-2 mode used to strand the player there once the palette cleared, +-- because only CLASSIC ever got dropped back. Now ANY mode lands on +-- CUSTOM_MODE the moment a palette is picked, so clearing it always finds +-- its way back to GEN 2's own unique colours. + +do + local OptionsMenu = require("src.ui.gen2.OptionsMenu") + local colorRow = nil + for _, r in ipairs(OptionsMenu.ROWS) do + if r.key == "color" then colorRow = r end + end + check(colorRow ~= nil, "found the COLOR row") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("dmg") + + local pushed + local game = { + options = { color = "dmg", palette = "" }, + stack = { states = {}, push = function(self, inst) pushed = inst end }, + persistOptions = function() end, + } + colorRow.activate(game) + + -- opening the picker from DMG (not CLASSIC) doesn't touch the mode yet: + -- dropClassic only fires for CLASSIC, exactly as before. + eq(game.options.color, "dmg", "opening the picker from DMG leaves it alone") + + local id = Palette.packId("BGB", "Blue") + pushed.set(id) + eq(game.options.color, GbcPalette.CUSTOM_MODE, + "picking a palette from DMG now lands on CUSTOM_MODE, not DMG") + eq(GbcPalette.mode, GbcPalette.CUSTOM_MODE, "and the live mode followed") + + pushed.set("") + eq(game.options.color, "gbc", + "clearing it lands on GEN 2 (the bug: this used to stay on DMG, " .. + "with no selectable row left to get back to GEN 2's unique colours)") + eq(GbcPalette.mode, "gbc", "and the live mode followed") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") +end + +-- ------- TitleState's two baked art sets: GEN 2 and a picked custom +-- palette both want the colour set (there's no live recolour of this +-- screen's art either way); only DMG/CLASSIC want the grey set. + +do + local TitleState = require("src.ui.gen2.TitleState") + GbcPalette.setMode("gbc") + check(not TitleState.gray(), "GEN 2 shows the colour art") + GbcPalette.setMode(GbcPalette.CUSTOM_MODE) + check(not TitleState.gray(), "a picked custom palette also shows the colour art") + GbcPalette.setMode("dmg") + check(TitleState.gray(), "DMG shows the grey art") + GbcPalette.setMode("classic") + check(TitleState.gray(), "CLASSIC shows the grey art (then the green present pass)") + GbcPalette.setMode("gbc") +end + +-- ------- the "2" hotkey ladder (Game2:hotkey) and GbcPalette.cycle(): the +-- bug this session fixes. Unlike the picker's own row.mode branch (tested +-- above), this ladder used to change GbcPalette.mode/options.color without +-- touching customRamp or options.palette, so resolve() stayed locked onto +-- the old pack's colours no matter how many times "2" landed back on "gbc". + +do + local Game2 = require("src.core.Game2") + local cartColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") + + local id = Palette.packId("BGB", "Blue") + local ramp = Palette.ramp(id) + local game = { + options = { color = GbcPalette.CUSTOM_MODE, palette = id }, + persistOptions = function() end, + } + GbcPalette.setMode(GbcPalette.CUSTOM_MODE) + GbcPalette.setCustomRamp(ramp) + + -- cycle() isn't in GbcPalette.MODES so a custom-mode start is treated as + -- index 1: three "2" presses walk dmg -> classic -> gbc. + for _ = 1, 3 do Game2.hotkey(game, "2") end + + eq(game.options.color, "gbc", "three presses land back on GEN 2's label") + eq(game.options.palette, "", + "BUG FIX: the hotkey ladder clears options.palette too, not just color") + check(GbcPalette.customRamp == nil, + "BUG FIX: GbcPalette.cycle() drops the live custom ramp") + eq(GbcPalette.resolve(cartColors), cartColors, + "BUG FIX: resolve() answers with the cart's own colours again, not " .. + "the stale custom ramp") + + GbcPalette.setCustomRamp(nil) + GbcPalette.setMode("gbc") +end + +T.finish("gen2 palette picker") diff --git a/tests/engine/gen2_pokegear_custom_ramp_film_test.lua b/tests/engine/gen2_pokegear_custom_ramp_film_test.lua new file mode 100644 index 00000000..b92dad7e --- /dev/null +++ b/tests/engine/gen2_pokegear_custom_ramp_film_test.lua @@ -0,0 +1,83 @@ +-- Follow-up to gen2_pokegear_native_colors_test.lua's "keep the gear +-- vanilla" fix: CLASSIC swept the native-coloured gear for free, so this +-- extends the same whole-panel tint to a picked custom COLORS ramp, with +-- Pokegear.CUSTOM_RAMP_FILM = false as an easy revert. +-- +-- No real shader/asset loads headless, so this isolates drawPanel()'s new +-- branch with a minimal fake self, same "stub the rest, assert the seam" +-- approach as the TrainerCard drawPanel() test. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Pokegear = require("src.ui.gen2.Pokegear") + +GbcPalette.available = function() return true end + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function hits() + return (calls.resolve or 0) + (calls.use or 0) + (calls.useRaw or 0) + + (calls.with or 0) +end + +local function fakeGear() + return { + styled = function() return true end, + groundColor = function() return { 0, 0, 0 } end, + card = function() return { id = "clock" } end, + drawClock = function() end, + drawMap = function() end, + drawRadio = function() end, + drawPhone = function() end, + drawModeArrow = function() end, + } +end + +do + Pokegear.CUSTOM_RAMP_FILM = true + GbcPalette.setCustomRamp({ { 227, 249, 227 }, { 180, 200, 180 }, + { 90, 100, 90 }, { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") + Pokegear.drawPanel(fakeGear()) + T.check(hits() > 0, + "with the film on and a custom ramp active, drawPanel() reaches GbcPalette") +end + +do + Pokegear.CUSTOM_RAMP_FILM = false + GbcPalette.setCustomRamp({ { 227, 249, 227 }, { 180, 200, 180 }, + { 90, 100, 90 }, { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") + Pokegear.drawPanel(fakeGear()) + T.eq(hits(), 0, + "Pokegear.CUSTOM_RAMP_FILM = false is a one-line revert to fully native") +end + +do + Pokegear.CUSTOM_RAMP_FILM = true + GbcPalette.setCustomRamp(nil) + calls = {} + spy("resolve"); spy("use"); spy("useRaw"); spy("with") + Pokegear.drawPanel(fakeGear()) + T.eq(hits(), 0, + "with no custom ramp picked, the film never engages -- native GEN 2 stays untouched") +end + +Pokegear.CUSTOM_RAMP_FILM = true + +T.finish() diff --git a/tests/engine/gen2_pokegear_native_colors_test.lua b/tests/engine/gen2_pokegear_native_colors_test.lua new file mode 100644 index 00000000..3e5cf377 --- /dev/null +++ b/tests/engine/gen2_pokegear_native_colors_test.lua @@ -0,0 +1,66 @@ +-- The Pokegear's six hardware palette banks guarantee a highlighted cell +-- contrasts with its text, a guarantee GbcPalette.resolve() breaks by +-- collapsing every bank onto the same 4 shades. Product decision: the +-- Pokegear stays on native hardware colours always, via a `raw` path +-- (GbcPalette.withRaw, Chrome.*Through's `raw` param, TileSheet's `raw` +-- option) that calls useRaw directly and skips resolve. +-- +-- No real shader runs headless, so this asserts on which entry point gets +-- called rather than on rendered pixels. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local Chrome = require("src.ui.gen2.Chrome") +local TileSheet = require("src.ui.gen2.TileSheet") +local Pokegear = require("src.ui.gen2.Pokegear") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local BANK_1 = { { 227, 249, 227 }, { 180, 200, 180 }, { 90, 100, 90 }, + { 0, 0, 0 } } + +do + calls = {} + spy("resolve"); spy("useRaw") + Pokegear.text({ pals = function() return { BANK_1 } end }, "SUNDAY", 6, 6) + T.eq(calls.resolve or 0, 0, + "Pokegear:text() never substitutes the active COLORS ramp") + T.check((calls.useRaw or 0) > 0, + "Pokegear:text() still shades its own native colours through the shader") +end + +do + local sheet = TileSheet.new({ palette = BANK_1, raw = true }) + -- No real image asset in the headless harness, so this exercises the + -- option's plumbing (raw stored, read by :draw) rather than a full + -- render; gen2_trainer_card_text_palette_test.lua and friends take the + -- same "assert the seam, not the pixel" approach one level up. + T.check(sheet.raw == true, "TileSheet:new stores the raw option") +end + +-- A sheet built without raw must still go through the normal substituting +-- seam, guarding the new option from silently becoming the default for +-- every other Gen 2 screen's tile art. +do + local sheet = TileSheet.new({ palette = BANK_1 }) + T.eq(sheet.raw, nil, "TileSheet:new defaults raw to unset") +end + +T.finish() diff --git a/tests/engine/gen2_startmenu_description_palette_test.lua b/tests/engine/gen2_startmenu_description_palette_test.lua new file mode 100644 index 00000000..023acf6e --- /dev/null +++ b/tests/engine/gen2_startmenu_description_palette_test.lua @@ -0,0 +1,66 @@ +-- StartMenu:draw drew the OPTION row's description tooltip, the "Change +-- settings" box under the list, with a raw love.graphics.rectangle at a +-- flat literal white, never touching GbcPalette. Every other box on this +-- screen already recoloured correctly with a picked COLORS palette, so +-- this one sub-region stood out as structurally white/black regardless. +-- +-- No real shader runs headless, so this can't check a rendered pixel, same +-- limitation every palette test in this directory notes: force +-- GbcPalette.available() true and assert on which seam gets called, not +-- on pixels. tests/drivers/custom_ramp_probe.lua is where a real rendered +-- screen gets checked by eye. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local StartMenu = require("src.ui.gen2.StartMenu") + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +-- Minimal fake: draw() only needs #self.items (box height), a list stub, +-- and a current item with a desc, the same shape start_menu.asm carries +-- for every row (see StartMenu.lua's ITEMS table). +local function fakeMenu() + return { + items = { { id = "option" } }, + list = { + draw = function() end, + current = function() return { desc = { "Change", "settings" } } end, + }, + phase = nil, + showDescription = true, + } +end + +do + GbcPalette.available = function() return true end + GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + calls = {} + spy("resolve"); spy("use"); spy("with") + StartMenu.draw(fakeMenu()) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "StartMenu's description tooltip reaches the GbcPalette seam") +end + +do + GbcPalette.available = function() return false end + calls = {} + spy("resolve"); spy("use"); spy("with") + local ok = pcall(StartMenu.draw, fakeMenu()) + T.check(ok, "StartMenu:draw() does not error with no shader available") +end + +T.finish() diff --git a/tests/engine/gen2_textbox_palette_test.lua b/tests/engine/gen2_textbox_palette_test.lua new file mode 100644 index 00000000..8c458bf7 --- /dev/null +++ b/tests/engine/gen2_textbox_palette_test.lua @@ -0,0 +1,63 @@ +-- TextBox drew its box border and glyphs through Font.drawBox/drawCode +-- with a flat setColor, never touching GbcPalette, so dialogue text never +-- picked up a COLORS palette even though every other Chrome.lua-based +-- screen did. +-- +-- No real shader runs headless, so this asserts on which seam gets called +-- rather than checking a rendered pixel; tests/drivers/gold_menu_shots.lua +-- is where a real box gets checked by eye. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local TextBox = require("src.render.TextBox") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function newGame(generation) + return { + save = { player = { name = "RED" }, generation = generation, + version = generation == 2 and "gold" or "red", options = {} }, + stringBuffer = "", boxNumString = "", boxMonNicks = {}, + data = {}, + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + local box = TextBox.new(newGame(2), "Hello world!", nil, {}) + box:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Gen 2 dialogue box reaches the GbcPalette seam") +end + +-- Gen 1 recolours through a whole-frame zone remap upstream of this file +-- (PaletteFX), not per draw call, so TextBox must not start touching +-- GbcPalette for a Gen 1 game. +do + calls = {} + spy("resolve"); spy("use"); spy("with") + local box = TextBox.new(newGame(1), "Hello world!", nil, {}) + box:draw() + T.eq((calls.resolve or 0) + (calls.use or 0) + (calls.with or 0), 0, + "Gen 1 dialogue box is untouched, it never calls GbcPalette") +end + +T.finish() diff --git a/tests/engine/gen2_trainer_card_text_palette_test.lua b/tests/engine/gen2_trainer_card_text_palette_test.lua new file mode 100644 index 00000000..633a4ee7 --- /dev/null +++ b/tests/engine/gen2_trainer_card_text_palette_test.lua @@ -0,0 +1,69 @@ +-- TrainerCard had two palette gaps: its own text drew through flat +-- Chrome.print/cursor, fixed with a print/:cursor pair routed through +-- printThrough/cursorThrough via self:colorsAt; and drawPanel() painted +-- the screen with a flat white rectangle before anything else, leaving the +-- NAME/ID/MONEY/STATUS/BADGES interiors white, fixed by routing that fill +-- through Chrome.paletteFill. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local TrainerCard = require("src.ui.gen2.TrainerCard") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +local function fakeCard() + return { + colorsAt = function() return { { 255, 255, 255 }, { 200, 0, 200 }, + { 100, 0, 100 }, { 0, 0, 0 } } end, + } +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + TrainerCard.print(fakeCard(), "NAME/", 2, 2) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "TrainerCard:print() reaches the GbcPalette seam via self:colorsAt") +end + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + TrainerCard.cursor(fakeCard(), 18, 15) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "TrainerCard:cursor() reaches the GbcPalette seam via self:colorsAt") +end + +-- drawPanel() painted the whole screen with a flat white rectangle before +-- drawing the card, and self:frame only draws border tiles, so panel +-- interiors stayed white regardless of the picked palette. drawCard is +-- stubbed here to isolate that base fill from the print/cursor seam +-- covered above. +do + local fake = { styled = function() return true end, page = 1, + drawCard = function() end } + calls = {} + spy("resolve"); spy("use"); spy("with") + TrainerCard.drawPanel(fake) + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "TrainerCard:drawPanel()'s base fill reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/gen2_unown_words_box_palette_test.lua b/tests/engine/gen2_unown_words_box_palette_test.lua new file mode 100644 index 00000000..5f6bb9ef --- /dev/null +++ b/tests/engine/gen2_unown_words_box_palette_test.lua @@ -0,0 +1,46 @@ +-- UnownWords:draw already routed its tile art through GbcPalette.with, but +-- drew the frame around it with a plain Font.drawBox, so the puzzle's own +-- glyphs picked up a selected COLORS palette while the box border stayed +-- white/black, the same shape of gap gen2_textbox_palette_test.lua found +-- in TextBox.lua. Fixed by routing the box through Chrome.paletteBox. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +love = require("tests.love_stub") + +require("src.core.Logger").warn = function() end + +local GbcPalette = require("src.render.GbcPalette") +local UnownWords = require("src.world.gen2.UnownWords") + +GbcPalette.available = function() return true end +GbcPalette.setCustomRamp({ { 255, 0, 255 }, { 200, 0, 200 }, { 100, 0, 100 }, + { 0, 0, 0 } }) + +local calls +local function spy(name) + local original = GbcPalette[name] + GbcPalette[name] = function(...) + calls[name] = (calls[name] or 0) + 1 + return original(...) + end +end + +-- data/events/unown_walls.asm:15 MenuHeaders_UnownWalls's ESCAPE row, the +-- same fixture tests/gen2_unown_words_test.lua uses for boxRect. +local ESCAPE_WALL = { x1 = 3, y1 = 4, x2 = 16, y2 = 9, + chars = { 0x08, 0x44, 0x04, 0x00, 0x2e, 0x08 } } + +do + calls = {} + spy("resolve"); spy("use"); spy("with") + -- no opts.world -> :tileset() returns nil -> draw() exits right after the + -- box, isolating the box's own palette seam from the tile-art body wrap + local words = UnownWords.new({}, { wall = ESCAPE_WALL }) + words:draw() + T.check((calls.resolve or 0) > 0 or (calls.use or 0) > 0 or (calls.with or 0) > 0, + "Unown wall box reaches the GbcPalette seam") +end + +T.finish() diff --git a/tests/engine/options_submenu_marquee_test.lua b/tests/engine/options_submenu_marquee_test.lua new file mode 100644 index 00000000..ce4b580e --- /dev/null +++ b/tests/engine/options_submenu_marquee_test.lua @@ -0,0 +1,152 @@ +-- The OPTION screen's grouped pages and its scrolling text. +-- +-- The flat row list outgrew the four-box viewport, so the battle, audio, +-- video and speed rows collapse into one opener each. Grouping happens after +-- the ui.options.rows hook and only builds self.view, so self.rows stays the +-- flat list a mod reads and edits. +-- +-- Text longer than its line used to run off the 160px panel with no clipping +-- (COLORS showed "1-A (Default) (B"); the highlighted row now scrolls instead. +-- luajit tests/engine/options_submenu_marquee_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local OptionsMenu = require("src.ui.OptionsMenu") +local Marquee = require("src.ui.Marquee") + +local function stubGame() + local stack = { items = {} } + function stack:push(s) self.items[#self.items + 1] = s; return s end + function stack:pop() self.items[#self.items] = nil end + function stack:top() return self.items[#self.items] end + return { + data = { rulesets = { gen1_faithful = { name = "GEN 1" } }, constants = {} }, + save = { options = {} }, + stack = stack, + modStatus = { available = {} }, + } +end + +local function ids(rows) + local out = {} + for i, row in ipairs(rows) do out[i] = row.id end + return table.concat(out, ",") +end + +local function find(rows, id) + for i, row in ipairs(rows) do if row.id == id then return row, i end end +end + +-- ------------------------------------------------------------- grouping + +local game = stubGame() +local menu = OptionsMenu.new(game) + +T.check(find(menu.rows, "battleBg"), "self.rows keeps the flat list for mods") +T.check(find(menu.rows, "musicVol"), "self.rows keeps the audio rows too") +for _, id in ipairs({ "battleBg", "speedBattle", "uiLayout", "tilt", "zoom", + "voidFill", "colors", "shaderfx", "shaderfx2", + "musicVol", "videoMode" }) do + T.check(not find(menu.view, id), id .. " moved onto a page") +end + +for _, spec in ipairs({ + { "group.battle", 6 }, { "group.audio", 3 }, { "group.video", 5 }, + { "group.speed", 4 }, { "group.graphics", 4 }, { "group.extras", 3 }, +}) do + local row = find(menu.view, spec[1]) + T.check(row, spec[1] .. " has an opener row") + T.eq(row.group, true, spec[1] .. " is marked as a group") +end + +-- Six battle rows on this build (PIKACHU VOL is Yellow-only, so AUDIO is 3). +T.eq(select(1, find(menu.view, "group.battle")).value(game), "6 OPTIONS", + "BATTLE OPTIONS counts its six rows") + +-- The top level is explicitly ordered, groups and singles interleaved. +T.eq(ids(menu.view):gsub("group%.", ""), + "speed,video,graphics,audio,performance,ruleset,battle,extras,mods," .. + "controls,dateFormat,timeFormat", + "the top level reads in the order ORDER names") +T.check(not find(menu.view, "textSpeed"), "TEXT SPEED moved onto the SPEED page") +T.check(#menu.view < #menu.rows, "the view is shorter than the flat list") + +-- A row in no group is untouched and keeps its order. +T.check(find(menu.view, "ruleset"), "RULESET stays on the top level") +T.check(find(menu.view, "performance"), "so does PERFORMANCE") +T.check(find(menu.view, "mods"), "and MODS") + +-- ------------------------------------------------------------- submenus + +local graphics = find(menu.view, "group.graphics") +graphics.activate(game) +T.eq(ids(game.stack:top().view), "colors,uiLetterbox,shaderfx,shaderfx2", + "GRAPHICS carries COLORS, UI LETTERBOX and both SHADER FX slots") +game.stack:pop() + +local extras = find(menu.view, "group.extras") +extras.activate(game) +T.eq(ids(game.stack:top().view), "tilt,zoom,voidFill", + "EXTRAS carries TILT, ZOOM and VOID FILL") +game.stack:pop() + +local video = find(menu.view, "group.video") +video.activate(game) +T.eq(ids(game.stack:top().view):match("^uiLayout"), "uiLayout", + "UI LAYOUT heads the VIDEO page") +game.stack:pop() + +local speed = find(menu.view, "group.speed") +speed.activate(game) +T.eq(ids(game.stack:top().view), + "textSpeed,speedOverworld,speedBattle,speedMenu", + "TEXT SPEED heads the SPEED page") +game.stack:pop() + +local battle, at = find(menu.view, "group.battle") +menu.index = at +battle.activate(game) +local sub = game.stack:top() +T.check(sub ~= menu, "opening a group pushes a screen") +T.eq(ids(sub.view), + "animations,battleStyle,battleLayout,battleFit,battleHud,battleBg", + "the page carries exactly its six battle rows, in order") +T.eq(sub.sub, true, "the page knows it is a submenu") +T.eq(sub.onCancel, nil, "BACK out of a page never fires the caller's close hook") + +-- focusRow reaches a grouped row by opening its page. +local game2 = stubGame() +local menu2 = OptionsMenu.new(game2) +local landed = menu2:focusRow("battleHud") +T.check(landed and landed ~= menu2, "focusRow opens the page a grouped row lives on") +T.eq(landed.view[landed.index].id, "battleHud", "and lands the cursor on it") +T.eq(menu2:focusRow("ruleset"), menu2, "an ungrouped row focuses in place") +T.eq(menu2.view[menu2.index].id, "ruleset", "on the row asked for") +T.eq(menu2:focusRow("nope"), nil, "an unknown id focuses nothing") + +-- ------------------------------------------------------------- marquee + +-- 16 characters fit beside a value's x=24 before the box frame at x=152; +-- the label's x=16 fits 17. +local long = "1-A (Default) (BGB)" +T.check(#long > 16, "the COLORS value really is too long for the line") + +-- 19 characters, 16 fit: three steps of overflow. Hold 1.0s, one char per +-- 0.25s, hold 1.0s on the tail, then repeat -- a 2.75s cycle. +T.eq(Marquee.at(long, 16, 0), long:sub(1, 16), "it holds at the start") +T.eq(Marquee.at(long, 16, 1.3), long:sub(2, 17), "then steps one char") +T.eq(Marquee.at(long, 16, 1.6), long:sub(3, 18), "then another") +T.eq(Marquee.at(long, 16, 2.4), long:sub(4, 19), "then holds on the tail") +T.eq(Marquee.at(long, 16, 2.8), long:sub(1, 16), "before wrapping back") +T.eq(Marquee.at(long, 16, 2.4):sub(-4), "BGB)", + "so the tail the panel used to clip becomes readable") +for _, at2 in ipairs({ 0, 1.3, 1.6, 2.4, 2.8 }) do + T.eq(#Marquee.at(long, 16, at2), 16, + "every frame fills the line exactly") +end + +T.eq(Marquee.at("SHORT", 16, 2.0), "SHORT", + "text that fits is never scrolled") + +T.finish("options submenu + marquee") diff --git a/tests/engine/palette_test.lua b/tests/engine/palette_test.lua new file mode 100644 index 00000000..82577ebf --- /dev/null +++ b/tests/engine/palette_test.lua @@ -0,0 +1,447 @@ +-- Tests the COLORS picker's 699-palette pack (src/render/Palette.lua) and +-- its substitution seam in src/render/PaletteFX.lua. ROM-free, since the +-- pack is committed data rather than ROM-imported. +-- luajit tests/engine/palette_test.lua +-- +-- Covers the ramp ordering flip in Palette.ramp() (the pack stores darkest +-- first, PaletteFX wants lightest first), the substitution seam itself (a +-- custom palette has to win outright and get a zone invented for it, same +-- as OG/OG INV/CLASSIC), and that BattleState/WideBattle's mono checks stay +-- in sync with that seam. + +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local function hex(c) + return type(c) == "table" and ("%02x%02x%02x"):format(c[1], c[2], c[3]) or "?" +end + +local Palette = require("src.render.Palette") +local PaletteFX = require("src.render.PaletteFX") + +-- ------- the pack + +do + eq(#Palette.packs(), 32, "32 groups") + local total = 0 + for _, group in ipairs(Palette.packs()) do + total = total + #(group.palettes or {}) + end + eq(total, 699, "699 palettes") +end + +-- ------- the folders + +do + local seen, total = {}, 0 + for _, category in ipairs(Palette.categories()) do + for _, item in ipairs(category.palettes) do + total = total + 1 + seen[item.id] = category.key + end + end + eq(total, 699, "every palette is filed") + + local counted = 0 + for _ in pairs(seen) do counted = counted + 1 end + eq(counted, 699, "no palette is filed twice") + + -- the DMG ramp is the case HUE_ARC was tuned around + eq(seen[Palette.packId("GalleryWebApp Palettes", "Original GB")], + "single", "the DMG green is one colour") + eq(seen[Palette.packId("BGB", "Grey")], "grey", "BGB Grey is grey") + eq(seen[Palette.packId("GB Color (Unique Palettes)", "GBC Palette 119 (World)")], + "full", "a yellow and red cart palette is full colour") + -- the GBC boot palette for Red is white/pink/dark red/black: a red ramp + eq(seen[Palette.packId("GB Color (Unique Palettes)", + "GBC Palette 093 (USA, Europe)")], + "single", "a tinted grey ramp is one colour") + + local first = Palette.categories()[1].palettes[1] + check(type(first.id) == "string" and Palette.ramp(first.id) ~= nil, + "a bucket entry carries a resolvable id") + check(type(first.pack) == "string" and first.pack ~= "", + "a bucket entry remembers its source pack") + + local key, at = Palette.locate("p:BGB/Blue") + eq(key, "single", "locate answers the bucket") + eq(Palette.categories()[2].palettes[at].id, "p:BGB/Blue", + "and the tile") + check(Palette.locate("p:Nope/Nothing") == nil, "an unknown id is nowhere") +end + +-- ------- classifying a ramp + +do + local function cat(a, b, c, d) return Palette.categoryOf({ a, b, c, d }) end + eq(cat({ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 }), + "grey", "four greys are greyscale") + eq(cat({ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 }), + "single", "one hue at four strengths is a single colour") + eq(cat({ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 }), + "single", "greys among one hue do not make it full colour") + eq(cat({ 255, 0, 0 }, { 0, 255, 0 }, { 0, 0, 255 }, { 0, 0, 0 }), + "full", "three hues is full colour") + eq(cat({ 255, 40, 20 }, { 255, 10, 40 }, { 128, 5, 20 }, { 40, 2, 6 }), + "single", "a ramp straddling 0 degrees is one colour") + check(Palette.categoryOf(nil) == nil, "nothing classifies as nothing") +end + +-- ------- hex decoding + +do + local c = Palette.unhex("0f380f30623077a1129bbc0f") + check(c and hex(c[1]) == "0f380f", "first colour is the string's first") + check(c and hex(c[4]) == "9bbc0f", "last colour is the string's last") + check(Palette.unhex("abc") == nil, "short string rejected") + check(Palette.unhex("zz380f30623077a1129bbc0f") == nil, "non-hex rejected") + check(Palette.unhex(nil) == nil, "nil rejected") +end + +-- ------- ordering +-- +-- #9BBC0F is the PAPER and #0F380F is the INK, so ramp()[1] must be pale. + +do + local id = Palette.packId("GalleryWebApp Palettes", "Original GB") + local ramp = Palette.ramp(id) + check(ramp ~= nil, "Original GB resolves") + eq(hex(ramp[1]), "9bbc0f", "ramp[1] is the lightest (paper)") + eq(hex(ramp[4]), "0f380f", "ramp[4] is the darkest (ink)") + + local sw = Palette.swatch(id) + eq(hex(sw[1]), "0f380f", "swatch[1] is the darkest") + eq(hex(sw[4]), "9bbc0f", "swatch[4] is the lightest") +end + +-- cross-check against PaletteFX.GBC_BG, the same palette hardcoded there +do + local ramp = Palette.ramp(Palette.packId( + "GB Color (Unique Palettes)", "GBC Palette 093 (USA, Europe)")) + for i = 1, 4 do + eq(hex(ramp[i]), hex(PaletteFX.GBC_BG[i]), + "pack Red ramp[" .. i .. "] agrees with PaletteFX.GBC_BG") + end +end + +-- ------- monochrome + +do + local id = Palette.monoId(0x9b, 0xbc, 0x0f) + eq(id, "m:9bbc0f", "id encodes as hex") + local ramp = Palette.ramp(id) + eq(hex(ramp[1]), "9bbc0f", "lightest is the chosen colour") + check(ramp[4][1] < ramp[1][1], "darkest is darker than lightest") + eq(Palette.label(id), "GREEN", "a known colour gets its name") + eq(Palette.label("m:123456"), "MONO", "an unknown one falls back") +end + +-- ------- ids + +do + check(Palette.label("") == nil, "empty id has no label") + eq(Palette.label("p:BGB/Blue"), "Blue", "pack label is the name") + check(Palette.ramp("") == nil, "empty id resolves to nothing") + check(Palette.ramp("p:Nope/Nothing") == nil, "missing palette resolves to nothing") + check(Palette.ramp("garbage") == nil, "malformed id resolves to nothing") +end + +-- ------- every palette in the pack parses + +do + local bad, checked = {}, 0 + for _, group in ipairs(Palette.packs()) do + for _, entry in ipairs(group.palettes or {}) do + local ramp = Palette.ramp(Palette.packId(group.name, entry[1])) + checked = checked + 1 + if not ramp then bad[#bad + 1] = group.name .. "/" .. tostring(entry[1]) end + end + end + eq(checked, 699, "checked every palette") + eq(#bad, 0, "all resolve (" .. table.concat(bad, ", ", 1, math.min(#bad, 3)) .. ")") +end + +-- ------- the picker's state machine +-- +-- update() touches no love API, so we can drive it with a stub game. + +local PaletteScreen = require("src.ui.PaletteScreen") + +do + local pressed = {} + + local function open(stored) + local state = { stored = stored or "", popped = 0, mode = "redpp" } + state.screen = PaletteScreen.new({ + input = { wasPressed = function(_, k) return pressed[k] == true end }, + stack = { states = {}, + pop = function() state.popped = state.popped + 1 end }, + }, { + palette = Palette, + get = function() return state.stored end, + set = function(v) state.stored = v end, + modes = { { "SGB", "gbc" }, { "ADVANCED", "redpp" } }, + getMode = function() return state.mode end, + setMode = function(v) state.mode = v end, + }) + return state + end + + local function press(state, key) + pressed = { [key] = true } + state.screen:update() + pressed = {} + end + + local fresh = open() + eq(fresh.screen.view, "root", "a fresh profile opens at the root") + eq(#fresh.screen.rows, 6, "four folders and two modes") + eq(fresh.screen.rows[4].label, "MONOCHROME") + check(fresh.screen.rows[4].folder, "MONOCHROME is the fourth folder") + eq(fresh.screen.rows[5].mode, "gbc") + check(not fresh.screen.rows[5].folder, "an engine mode is a row and not a folder") + + press(fresh, "a") + eq(fresh.screen.view, "tiles", "A on a folder opens its grid one level down") + eq(#fresh.screen.stackTrail, 1) + press(fresh, "b") + eq(fresh.screen.view, "root", "B steps back up to the root") + eq(fresh.popped, 0) + press(fresh, "b") + eq(fresh.popped, 1, "B at the root closes the screen") + + -- a stored selection should open straight into its grid + local stored = open("p:BGB/Blue") + eq(stored.screen.view, "tiles", "a stored palette opens in its folder") + eq(stored.screen.group.key, "single", "and in the RIGHT folder") + eq(stored.screen:idAt(stored.screen.index), "p:BGB/Blue", + "with the cursor on the stored tile") + eq(#stored.screen.stackTrail, 1, "the skipped level is on the trail") + press(stored, "b") + eq(stored.screen.view, "root", "B steps up instead of closing") + eq(stored.popped, 0) + eq(stored.screen.index, 2, "and lands on the folder it came from") + + -- an engine mode and a palette share the same slot + local modes = open("p:BGB/Blue") + press(modes, "b") + for _ = 1, 3 do press(modes, "down") end + eq(modes.screen.rows[modes.screen.index].mode, "gbc", "walked to the SGB row") + press(modes, "a") + eq(modes.stored, "", "picking an engine mode clears the palette") + eq(modes.mode, "gbc", "and sets the mode") + + local pick = open() + press(pick, "a") + press(pick, "right") + local wanted = pick.screen:idAt(pick.screen.index) + press(pick, "a") + eq(pick.stored, wanted, "A on a tile stores that tile's id") + check(wanted ~= nil) +end + +-- ------- the picker's REAL wiring: PaletteScreen.new(game) with no opts +-- +-- Everything above injects its own opts to keep update() engine-free, so +-- none of it would catch a bug in liveOpts/dropConflictingMode. This +-- builds the screen the way OptionsMenu actually does, with no opts at all. + +do + local game = { + input = { wasPressed = function() return false end }, + stack = { states = {}, pop = function() end }, + save = { options = { colors = "redpp" } }, + } + PaletteFX.mode = "redpp" + local screen = PaletteScreen.new(game) + check(screen.view == "root", "a live-opts screen opens at the root") + check(type(screen.get) == "function" and type(screen.set) == "function", + "liveOpts wired real get/set functions") + eq(game.save.options.colors, "gbc", + "opening the picker while ADVANCED was active dropped it to SGB " + .. "(dropConflictingMode ran)") + eq(PaletteFX.mode, "gbc", "and the live mode followed") + + local id = Palette.packId("BGB", "Blue") + screen.set(id) + eq(game.save.options.palette, id, "storing a palette writes save.options.palette") + eq(hex(PaletteFX.customRamp[1]), hex(Palette.ramp(id)[1]), + "and PaletteFX.setCustomRamp actually ran") + + screen.set("") + check(PaletteFX.customRamp == nil, "storing empty clears the custom ramp") + PaletteFX.mode = "gbc" +end + +-- ------- PaletteFX seam: a custom ramp wins outright + +do + PaletteFX.setCustomRamp(nil) + PaletteFX.mode = "gbc" + check(not PaletteFX.forcesRawGrays(), "no custom ramp: forcesRawGrays is false") + + local id = Palette.packId("GalleryWebApp Palettes", "Original GB") + local ramp = Palette.ramp(id) + PaletteFX.setCustomRamp(ramp) + check(PaletteFX.forcesRawGrays(), "a stored custom ramp: forcesRawGrays is true") + + local someZoneColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + local out = PaletteFX.effectiveColors(someZoneColors) + for i = 1, 4 do + eq(hex(out[i]), hex(ramp[i]), + "effectiveColors[" .. i .. "] is the custom ramp, not the zone's own colours") + end + + -- it should win even over a mode with its own fixed palette + PaletteFX.mode = "classic" + local out2 = PaletteFX.effectiveColors(someZoneColors) + eq(hex(out2[1]), hex(ramp[1]), "a custom ramp beats CLASSIC too") + PaletteFX.mode = "gbc" +end + +-- ------- picker immunity +-- +-- The picker's swatches are real RGB, so effectiveColors and ensureZones +-- both have to back off while it's on top or it would recolour its own +-- swatches. + +do + local Game = require("src.core.Game") + + Game.stack = nil + check(not PaletteFX.pickerActive(), "no stack: pickerActive is false") + + Game.stack = { states = { { screenId = "OptionsMenu" } } } + check(not PaletteFX.pickerActive(), "some other screen on top: pickerActive is false") + + Game.stack = { states = { { screenId = "OptionsMenu" }, + { screenId = "PaletteScreen" } } } + check(PaletteFX.pickerActive(), "PaletteScreen on top of the stack: pickerActive is true") + + local id = Palette.packId("BGB", "Red") + local ramp = Palette.ramp(id) + PaletteFX.setCustomRamp(ramp) + PaletteFX.mode = "gbc" + + local someZoneColors = { { 9, 9, 9 }, { 8, 8, 8 }, { 7, 7, 7 }, { 6, 6, 6 } } + local out = PaletteFX.effectiveColors(someZoneColors) + eq(hex(out[1]), hex(someZoneColors[1]), + "with the picker on top, effectiveColors does NOT substitute the custom ramp") + + check(PaletteFX.ensureZones(nil) == nil, + "with the picker on top, ensureZones invents nothing for the custom ramp") + + -- closing the picker should restore the substitution immediately + Game.stack = { states = { { screenId = "OptionsMenu" } } } + check(not PaletteFX.pickerActive(), "closing the picker: pickerActive is false again") + local out2 = PaletteFX.effectiveColors(someZoneColors) + eq(hex(out2[1]), hex(ramp[1]), "and the custom ramp substitutes again") + local invented = PaletteFX.ensureZones(nil) + check(invented and #invented == 1, "and ensureZones invents its zone again") + + Game.stack = nil + PaletteFX.setCustomRamp(nil) +end + +-- ------- PaletteFX seam: the invented zone, end to end +-- +-- A state with no SGB zones of its own gets one invented by ensureZones, +-- and effectiveColors then has to turn that zone's colours into the +-- chosen ramp. + +do + PaletteFX.setCustomRamp(nil) + check(PaletteFX.ensureZones(nil) == nil, + "no custom ramp, no mono mode: ensureZones invents nothing") + + local id = Palette.packId("BGB", "Red") + local ramp = Palette.ramp(id) + PaletteFX.setCustomRamp(ramp) + + local invented = PaletteFX.ensureZones(nil) + check(invented and #invented == 1, "a custom ramp invents one zone") + eq(invented[1].w, 160, "the invented zone is whole-screen (w)") + eq(invented[1].h, 144, "the invented zone is whole-screen (h)") + for i = 1, 4 do + eq(hex(invented[1].colors[i]), hex(PaletteFX.GRAYS[i]), + "the invented zone is plain DMG grays, not the ramp itself") + end + + -- run it back through effectiveColors, the way Renderer.sendColors would + local final = PaletteFX.effectiveColors(invented[1].colors) + for i = 1, 4 do + eq(hex(final[i]), hex(ramp[i]), + "the invented zone resolves to the custom ramp at send time") + end + + local realZones = { { colors = { { 1, 1, 1 } }, x = 0, y = 0, w = 40, h = 40 } } + check(PaletteFX.ensureZones(realZones) == realZones, + "a state with real zones is left alone") + + PaletteFX.setCustomRamp(nil) + check(not PaletteFX.forcesRawGrays(), "clearing the ramp clears forcesRawGrays") +end + +-- ------- the four sync points +-- +-- BattleState:picImage, BattleState:drawZonePass, WideBattle's monoMode and +-- PaletteFX.ensureZones all gate on the same "forced raw grays" condition. +-- This is a structural check (source-level, no ROM) that none of the four +-- got missed when forcesRawGrays was added. + +do + local function read(path) + local f = assert(io.open(path, "r")) + local src = f:read("*a") + f:close() + return src + end + + local battleSrc = read("src/battle/BattleState.lua") + local picImage = battleSrc:match("function BattleState:picImage%(img%)(.-)\nend") + check(picImage and picImage:find("forcesRawGrays", 1, true) ~= nil, + "picImage's mono check includes forcesRawGrays") + + local drawZonePass = battleSrc:match( + "function BattleState:drawZonePass%(src, sx, sy%)(.-)\nend") + check(drawZonePass and drawZonePass:find("forcesRawGrays", 1, true) ~= nil, + "drawZonePass's mono check includes forcesRawGrays") + + local wideSrc = read("src/battle/WideBattle.lua") + local monoMode = wideSrc:match("local function monoMode%(%)(.-)\nend") + check(monoMode and monoMode:find("forcesRawGrays", 1, true) ~= nil, + "WideBattle's monoMode includes forcesRawGrays") + + local fxSrc = read("src/render/PaletteFX.lua") + local ensureZones = fxSrc:match("function PaletteFX.ensureZones%(zones%)(.-)\nend") + check(ensureZones and ensureZones:find("forcesRawGrays", 1, true) ~= nil, + "ensureZones itself includes forcesRawGrays") +end + +-- ------- COLORS row wiring +-- +-- OptionsMenu's colors row should open the picker instead of cycling in place. + +do + local optSrc = (function() + local f = assert(io.open("src/ui/OptionsMenu.lua", "r")) + local s = f:read("*a") + f:close() + return s + end)() + -- bounded to the row itself, or the scan would run into TILT's own step + local block = optSrc:match('{ id = "colors",(.-)\n { id =') + check(block ~= nil, "colors row block found") + check(block:find("activate = function%(g%)") ~= nil, + "colors row has an activate") + check(block:find('Screens"%).push%(g, "PaletteScreen"%)', 1, false) ~= nil + or block:find('Screens").push(g, "PaletteScreen")', 1, true) ~= nil, + "colors row's activate pushes PaletteScreen") + check(block:find("step = function") == nil, + "colors row no longer has a step") +end + +T.finish("palette") diff --git a/tests/engine/sync_engine_test.lua b/tests/engine/sync_engine_test.lua index 22a79e9e..3ef6dab9 100644 --- a/tests/engine/sync_engine_test.lua +++ b/tests/engine/sync_engine_test.lua @@ -180,6 +180,58 @@ do T.eq(eng.phase, "idle", "and the sync ends idle") end +-- Identical playtime is not a fork. The Gold prompt the player saw offered +-- "GOLD - 8 badges - 3:46 - 9 seen" against "GOLD - 8 badges - 3:46 - 9 seen": +-- the same minute of the same playthrough, two saves that differ only in +-- their savedAt stamp. Nothing to choose between, so nothing to ask. +do + T.eq(SyncEngine.samePlaytime( + { summary = { timeText = "3:46" } }, + { summary = { timeText = "3:46" } }), true, + "the same H:MM is the same point in the playthrough") + T.eq(SyncEngine.samePlaytime( + { summary = { timeText = "3:46" } }, + { summary = { timeText = "3:47" } }), false, + "one minute apart is a real fork") + T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13599 }), + true, "Gen 1's seconds count compares to the minute, not the second") + T.eq(SyncEngine.samePlaytime({ playTime = 13560 }, { playTime = 13620 }), + false, "and a whole minute apart still forks") + -- Gen 2 stores playTime as a table, so meta.playTime is nil on a Gold + -- save and only timeText survives; a missing time must never read as a + -- match, or an unknowable conflict would be silently discarded. + T.eq(SyncEngine.samePlaytime({}, {}), false, + "two unknown playtimes are not a match") + T.eq(SyncEngine.samePlaytime({ summary = { timeText = "3:46" } }, {}), false, + "and neither is one unknown side") +end + +do + local state = linkedState() + SyncState.setRev(state, "red/abc", 7, 500) + local entry = saveEntry("red", "abc", 700, 650) + entry.meta.summary.timeText = "3:46" + local eng, transport = engine({ + ["GET /sync/state"] = { code = 200, + body = '{"saves":{"red/abc":{"rev":9,"meta":{"savedAt":760,' .. + '"sessionStart":600,"summary":{"name":"ASH","timeText":"3:46"}}}}}' }, + ["PUT /sync/save"] = { code = 200, body = '{"ok":true,"rev":10}' }, + }, { entry }, state) + + eng:syncNow() + pump(eng) + T.eq(#eng.conflicts, 0, "matching playtime raises no conflict") + T.eq(#eng.state.pendingConflicts, 0, "and leaves nothing pending") + T.neq(eng.phase, "conflict", "so the player is never prompted") + local put + for _, req in ipairs(transport.sent) do + if req.method == "PUT" then put = req end + end + T.check(put ~= nil, "this device's copy is pushed instead") + T.check(put and put.body:find('"force":true', 1, true) ~= nil, + "forced past the moved rev, since there is nothing to preserve") +end + local function conflictEngine() local state = linkedState() SyncState.setRev(state, "red/abc", 7, 500) diff --git a/tests/engine/ui_letterbox_test.lua b/tests/engine/ui_letterbox_test.lua new file mode 100644 index 00000000..9e813d1d --- /dev/null +++ b/tests/engine/ui_letterbox_test.lua @@ -0,0 +1,64 @@ +-- UI LETTERBOX (save.options.uiLetterbox): what fills the window around a +-- 160x144 screen. AUTO keeps what each screen was authored with, so the +-- default is byte-identical to the look before the option existed. +-- luajit tests/engine/ui_letterbox_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Letterbox = require("src.render.Letterbox") + +T.eq(require("src.core.SaveData").newGame().options.uiLetterbox, "auto", + "Gen 1 starts on AUTO") +T.eq(require("src.core.gen2.Save").DEFAULT_OPTIONS.uiLetterbox, "auto", + "so does Gen 2") + +-- AUTO hands back the colour the caller was authored with, untouched: the +-- Pokedex stays black, a menu stays white, the naming screen stays paper. +Letterbox.setMode("auto") +T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, "AUTO keeps an authored black") +T.same({ Letterbox.fill(1, 1, 1) }, { 1, 1, 1 }, "AUTO keeps an authored white") +T.same({ Letterbox.fill(0.5, 0.25, 0.75) }, { 0.5, 0.25, 0.75 }, + "AUTO keeps an authored paper colour") + +-- PALETTE reads the caller's paper, because the two generations keep a picked +-- ramp in different modules. Gen 1 used to come back white here: anything that +-- sniffed for GbcPalette found it present on Gen 1 too and answered with Gen +-- 2's white default instead of PaletteFX's actual paper. +Letterbox.setMode("palette") +T.same({ Letterbox.fill(0, 0, 0, function() return 0.25, 0.5, 1 end) }, + { 0.25, 0.5, 1 }, "PALETTE takes the caller's paper") +T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, + "with no paper reader it falls back to the authored colour, never to white") +T.same({ Letterbox.fill(1, 1, 1, function() return nil end) }, { 1, 1, 1 }, + "and a reader that cannot answer falls back too") + +Letterbox.setMode("black") +T.same({ Letterbox.fill(1, 1, 1) }, { 0, 0, 0 }, "BLACK overrides a white screen") +Letterbox.setMode("white") +T.same({ Letterbox.fill(0, 0, 0) }, { 1, 1, 1 }, "WHITE overrides a black screen") + +-- An unknown or absent value degrades to AUTO rather than to a colour, so a +-- save written before the option existed keeps its look. +T.eq(Letterbox.normalize(nil), "auto", "no stored value is AUTO") +T.eq(Letterbox.normalize("nonsense"), "auto", "and so is a value we cannot read") +Letterbox.setMode(nil) +T.same({ Letterbox.fill(0, 0, 0) }, { 0, 0, 0 }, "so an old save is unchanged") + +-- The ladder wraps both ways, like every other option row. +T.eq(Letterbox.cycle("auto", 1), "black", "right steps off AUTO") +T.eq(Letterbox.cycle("palette", 1), "auto", "and wraps at the end") +T.eq(Letterbox.cycle("auto", -1), "palette", "left wraps back") +for _, id in ipairs(Letterbox.MODES) do + T.check(Letterbox.label(id) ~= nil, id .. " has a label") +end +T.eq(Letterbox.label("uiLetterbox"), "AUTO", "an unknown id still labels") + +-- applyOptions is the seam both Game:applyOptions and Game2:applyOptions call. +Letterbox.applyOptions({ uiLetterbox = "white" }) +T.eq(Letterbox.mode, "white", "applyOptions installs the saved mode") +Letterbox.applyOptions({}) +T.eq(Letterbox.mode, "auto", "and an options table without one is AUTO") + +Letterbox.setMode("auto") +T.finish("ui letterbox") diff --git a/tests/gen2_menus_test.lua b/tests/gen2_menus_test.lua index 8843026d..f82cf496 100644 --- a/tests/gen2_menus_test.lua +++ b/tests/gen2_menus_test.lua @@ -360,33 +360,51 @@ local options = OptionsMenu.new(optionsGame, { -- speed, display, SHADER FX + SHADER FX 2 (the second slot added alongside -- the dual-shader feature), video mode, screen position, the mobile-gated -- touch three (buildRows), MAX FPS, BATTLE BG and CANCEL. -check("twenty-seven rows", #OptionsMenu.ROWS, 27) +check("twenty-eight rows", #OptionsMenu.ROWS, 28) check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame") check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls") check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol") -check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) -check("starts on TEXT SPEED", options:row().key, "textSpeed") -check("default text speed", options.options.textSpeed, "MID") +check("last row is BACK", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true) +-- PRINT is wGBPrinterBrightness and there is no Game Boy Printer here, so +-- buildRows hides it: the descriptor and the save key survive, the row does +-- not reach the screen. +local function hasRow(rows, key) + for _, row in ipairs(rows) do if row.key == key then return true end end + return false +end +check("PRINT is still a descriptor", hasRow(OptionsMenu.ROWS, "print"), true) +check("but never reaches the screen", hasRow(options.rows, "print"), false) +check("and the save keeps its value", + Save.defaultOptions().print ~= nil, true) +-- The rows are grouped into pages now, so the top level opens on the SPEED +-- group and TEXT SPEED is the first row of the page it opens. +check("starts on the SPEED group", options:row().id, "group.speed") +local speedPage = options:focusRow("textSpeed") +check("TEXT SPEED is on a page", speedPage ~= options, true) +check("and the cursor lands on it", speedPage:row().key, "textSpeed") +check("default text speed", speedPage.options.textSpeed, "MID") optionsInput:press("right") -options:update(0) +speedPage:update(0) check("right cycles forward", options.options.textSpeed, "SLOW") optionsInput:press("right") -options:update(0) +speedPage:update(0) check("right wraps", options.options.textSpeed, "FAST") optionsInput:press("left") -options:update(0) +speedPage:update(0) check("left wraps back", options.options.textSpeed, "SLOW") +check("a page edits the caller's own options table", + speedPage.options == options.options, true) -optionsInput:press("down") -options:update(0) -check("down moves a row", options:row().key, "battleScene") +local battlePage = options:focusRow("battleScene") +check("BATTLE SCENE is on the battle page", battlePage:row().key, "battleScene") optionsInput:press("right") -options:update(0) +battlePage:update(0) check("battle scene toggles off", options.options.battleScene, false) -- FRAME is 1-8 and wraps. -options.index = 7 -check("frame row", options:row().frame, true) +local framePage = options:focusRow("frame") +check("frame row", framePage:row().frame, true) +options = framePage options.options.frame = 8 optionsInput:press("right") options:update(0) @@ -406,10 +424,10 @@ local exiting = OptionsMenu.new(exitGame, { }) -- The screen's own rows, not ROWS: buildRows drops the touch three off a -- desktop, so the raw descriptor count overshoots CANCEL. -exiting.index = #exiting.rows +exiting.index = #exiting.view exitInput:press("a") exiting:update(0) -check("CANCEL leaves", savedOptions ~= nil, true) +check("BACK leaves", savedOptions ~= nil, true) -- --------------------------------------------------------- start menu @@ -934,16 +952,13 @@ local colorRow = select(2, rowNamed("COLOR")) check("COLOR is a row", colorRow ~= nil, true) check("and it defaults to the cart's own colour", Save.DEFAULT_OPTIONS.color, "gbc") -scrollOptions.options.color = "gbc" -scrollOptions:cycle(colorRow, 1) -check("right steps to DMG", scrollOptions.options.color, "dmg") -scrollOptions:cycle(colorRow, 1) -check("then CLASSIC", scrollOptions.options.color, "classic") -scrollOptions:cycle(colorRow, 1) -check("and wraps back to GBC", scrollOptions.options.color, "gbc") -scrollOptions:cycle(colorRow, -1) -check("left walks the ladder the other way", scrollOptions.options.color, - "classic") +-- COLOR no longer cycles in place (tests/engine/gen2_palette_picker_test.lua +-- covers the picker it opens instead, end to end); the ladder itself still +-- steps via the `2` hotkey (src/core/Game2.lua:hotkey), untouched here. +check("COLOR no longer cycles in place", colorRow.cycle, nil) +check("COLOR opens the picker instead", type(colorRow.activate), "function") +scrollOptions.options.color = "classic" +GbcPalette.setMode("classic") check("CLASSIC is the only mode with a present pass", GbcPalette.presentColors() ~= nil, true) GbcPalette.setMode("dmg") @@ -965,16 +980,16 @@ check("VOID FILL follows ZOOM", OptionsMenu.ROWS[zoomIndex + 1].label, "VOID FILL") check("and TILT follows VOID FILL", OptionsMenu.ROWS[zoomIndex + 2].label, "TILT") -check("SHADER FX follows COLOR follows TILT", OptionsMenu.ROWS[tiltIndex + 2].label, - "SHADER FX") -check("SHADER FX 2 follows SHADER FX", OptionsMenu.ROWS[tiltIndex + 3].label, - "SHADER FX 2") -check("VIDEO MODE follows SHADER FX 2", OptionsMenu.ROWS[tiltIndex + 4].label, - "VIDEO MODE") -check("and SCREEN POS follows it", OptionsMenu.ROWS[tiltIndex + 5].label, - "SCREEN POS") -check("and TOUCH PAD follows that", OptionsMenu.ROWS[tiltIndex + 6].label, - "TOUCH PAD") +-- By sequence rather than by offset, so inserting a row in the display block +-- moves the whole run instead of breaking six separate index assertions. +do + local run = { "TILT", "COLOR", "UI LETTERBOX", "SHADER FX", + "SHADER FX 2", "VIDEO MODE", "SCREEN POS", "TOUCH PAD" } + for at, want in ipairs(run) do + check("display block order: " .. want, + OptionsMenu.ROWS[tiltIndex + at - 1].label, want) + end +end local videoRow = select(2, rowNamed("VIDEO MODE")) check("VIDEO MODE is a row", videoRow ~= nil, true) @@ -2518,7 +2533,8 @@ local function modRowChecks() local og, oi = newGame(nil) local om = OptionsMenu.new(og, { options = Save.defaultOptions() }) check("gen2 OPTION takes the hook's row", om.rows[#om.rows].id, "modrow") - om.index = #om.rows + -- A hook row is in no group, so it stays on the top level. + check("and keeps it reachable there", om:focusRow("modrow"), om) oi:press("a") om:update(0) check("A on a mod row calls activate", fired, 1) diff --git a/tests/gen2_screen_layout_test.lua b/tests/gen2_screen_layout_test.lua index c5a42d89..c9f78ebc 100644 --- a/tests/gen2_screen_layout_test.lua +++ b/tests/gen2_screen_layout_test.lua @@ -146,8 +146,23 @@ Chrome.cursor = function(tx, ty) cursorAt = { x = tx, y = ty } end Chrome.clear = function() end Chrome.textbox = function() end -local options = OptionsMenu.new({}, { options = {} }) -options:drawPanel() +-- TEXT SPEED and FRAME sit on group pages now; the pages are the same +-- screen and draw at the same StringOptions coordinates, which is what this +-- pins. A stack, because opening a page pushes one. +local optStack = { items = {} } +function optStack:push(s) self.items[#self.items + 1] = s; return s end +function optStack:pop() self.items[#self.items] = nil end +function optStack:top() return self.items[#self.items] end + +local options = OptionsMenu.new({ stack = optStack }, { options = {} }) +options:focusRow("textSpeed"):drawPanel() +local framePage = options:focusRow("frame") +framePage.index = 1 +for i, row in ipairs(framePage:visible()) do + if row.frame then framePage.index = i end +end +framePage:ensureVisible() +framePage:drawPanel() Chrome.print, Chrome.cursor = realPrint, realCursor Chrome.clear, Chrome.textbox = realClear, realBox diff --git a/tests/mod_battle_tests.lua b/tests/mod_battle_tests.lua index 4c310756..6090ef92 100644 --- a/tests/mod_battle_tests.lua +++ b/tests/mod_battle_tests.lua @@ -293,9 +293,8 @@ do end }, stack = { pop = function() end } } local menu = OptionsMenu.new(game) - for i, row in ipairs(menu.rows) do - if row.id == "ruleset" then menu.index = i end - end + -- RULESET is in no group, so this focuses it on the top level. + check(menu:focusRow("ruleset") == menu, "RULESET focuses in place") local function press(key) pressed = { [key] = true } menu:update(1 / 60) diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 4cb7576b..2afd28d1 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -285,7 +285,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout", "battleFit", "battleHud", "battleBg", "uiLayout", "ruleset", "musicVol", "sfxVol", "musicFilter", "performance", "colors", - "tilt", "shaderfx", "shaderfx2", "zoom", "voidFill", + "tilt", "uiLetterbox", "shaderfx", "shaderfx2", "zoom", "voidFill", "videoMode", "faithfulRes", "screenPos", "fpsCap", "speedOverworld", "speedBattle", "speedMenu", "mods", "controls", "dateFormat", "timeFormat" } @@ -369,10 +369,10 @@ check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES") -- mirrors it one row down, opening the same shared screen on -- "secondary" instead. local ShaderFX = require("src.render.ShaderFX") -check(om.rows[16].id == "shaderfx", "row 16 is the SHADER FX row") -check(om.rows[16].value(om.game) == "OFF", "SHADER FX shows OFF with no presets") -check(om.rows[16].step == nil, "SHADER FX row has no step() any more") -om.rows[16].activate(om.game) +local sfx = orow(om, "shaderfx") +check(sfx.value(om.game) == "OFF", "SHADER FX shows OFF with no presets") +check(sfx.step == nil, "SHADER FX row has no step() any more") +sfx.activate(om.game) local sfxScreen = om.game.stack:top() check(sfxScreen and sfxScreen.title == "SHADER FX", "SHADER FX row.activate() pushes a ShaderFXScreen") @@ -383,9 +383,9 @@ sfxScreen.onChoose(sfxScreen.items[1]) check(ShaderFX.active("main") == false, "choosing OFF on an empty list stays a safe no-op") check(om.game.stack:top() == nil, "ShaderFXScreen pops itself after onChoose") -check(om.rows[17].id == "shaderfx2", "row 17 is the SHADER FX 2 row") -check(om.rows[17].value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets") -om.rows[17].activate(om.game) +local sfx2 = orow(om, "shaderfx2") +check(sfx2.value(om.game) == "OFF", "SHADER FX 2 shows OFF with no presets") +sfx2.activate(om.game) local sfx2Screen = om.game.stack:top() check(sfx2Screen and sfx2Screen.title == "SHADER FX 2", "SHADER FX 2 row.activate() pushes the shared ShaderFXScreen on the secondary slot")