diff --git a/package-lock.json b/package-lock.json index fd3d317738..7113dfa9d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "ip-anonymize": "^0.1.0", "jose": "^6.2.3", "js-yaml": "^4.1.1", + "lil-gui": "^0.21.0", "limiter": "^3.0.0", "nanoid": "^5.1.11", "node-html-parser": "^7.1.0", @@ -6689,6 +6690,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lil-gui": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/lil-gui/-/lil-gui-0.21.0.tgz", + "integrity": "sha512-tpvxN7v1GvE/Tv+GRopfOp0W7fVEjF4PltkuX8vOCIfim22rD1ztvfkoEMcv9lzQeuNUSeIrUmUjBwmlW/oUew==", + "license": "MIT" + }, "node_modules/limiter": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/limiter/-/limiter-3.0.0.tgz", diff --git a/package.json b/package.json index 3ed080276b..7b53252281 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "ip-anonymize": "^0.1.0", "jose": "^6.2.3", "js-yaml": "^4.1.1", + "lil-gui": "^0.21.0", "limiter": "^3.0.0", "nanoid": "^5.1.11", "node-html-parser": "^7.1.0", diff --git a/resources/atlases/emoji-atlas-meta.json b/resources/atlases/emoji-atlas-meta.json new file mode 100644 index 0000000000..ddd8fdbff0 --- /dev/null +++ b/resources/atlases/emoji-atlas-meta.json @@ -0,0 +1,68 @@ +{ + "width": 1280, + "height": 768, + "cellSize": 128, + "cols": 10, + "emojis": { + "😀": 0, + "😊": 1, + "🥰": 2, + "😇": 3, + "😎": 4, + "😞": 5, + "🥺": 6, + "😭": 7, + "😱": 8, + "😡": 9, + "😈": 10, + "🤡": 11, + "🥱": 12, + "🫡": 13, + "🖕": 14, + "👋": 15, + "👏": 16, + "✋": 17, + "🙏": 18, + "💪": 19, + "👍": 20, + "👎": 21, + "🫴": 22, + "🤌": 23, + "🤦‍♂️": 24, + "🤝": 25, + "🆘": 26, + "🕊️": 27, + "🏳️": 28, + "⌛": 29, + "🔥": 30, + "💥": 31, + "💀": 32, + "☢️": 33, + "⚠️": 34, + "↖️": 35, + "⬆️": 36, + "↗️": 37, + "👑": 38, + "🥇": 39, + "⬅️": 40, + "🎯": 41, + "➡️": 42, + "🥈": 43, + "🥉": 44, + "↙️": 45, + "⬇️": 46, + "↘️": 47, + "❤️": 48, + "💔": 49, + "💰": 50, + "⚓": 51, + "⛵": 52, + "🏡": 53, + "🛡️": 54, + "🏭": 55, + "🚂": 56, + "❓": 57, + "🐔": 58, + "🐀": 59 + } +} diff --git a/resources/atlases/emoji-atlas.png b/resources/atlases/emoji-atlas.png new file mode 100644 index 0000000000..14969caa8d Binary files /dev/null and b/resources/atlases/emoji-atlas.png differ diff --git a/resources/atlases/flag-atlas-meta.json b/resources/atlases/flag-atlas-meta.json new file mode 100644 index 0000000000..33a9501a6c --- /dev/null +++ b/resources/atlases/flag-atlas-meta.json @@ -0,0 +1,568 @@ +{ + "width": 2048, + "height": 2975, + "cellW": 128, + "cellH": 85, + "cols": 16, + "flags": { + "1_Airgialla": 0, + "1_Connacht": 1, + "1_Dalriata": 2, + "1_Dumnonia": 3, + "1_Dyfed": 4, + "1_East Anglia": 5, + "1_Essex": 6, + "1_Fortriu": 7, + "1_Franks": 8, + "1_Gwent": 9, + "1_Gwynedd": 10, + "1_Kent": 11, + "1_Laigin": 12, + "1_Mercia": 13, + "1_Munster": 14, + "1_Northern Ui Neill": 15, + "1_Northumbria": 16, + "1_Occitania": 17, + "1_Powys": 18, + "1_Southern Ui Neill": 19, + "1_Strathclyde": 20, + "1_Sussex": 21, + "1_Ulaid": 22, + "1_Wessex": 23, + "Abbasid Caliphate": 24, + "Achaemenid Empire": 25, + "African union": 26, + "Alabama": 27, + "Alaska": 28, + "Alkebulan": 29, + "Amazigh flag": 30, + "American_Samoa": 31, + "Anarchist flag": 32, + "Apartheid South Africa": 33, + "Arabia": 34, + "Aram Damascus": 35, + "Arizona": 36, + "Arkansas": 37, + "Assyria": 38, + "Athens": 39, + "Australian Aboriginal Flag": 40, + "Aztec Empire": 41, + "Babylonia": 42, + "Burma": 43, + "Burma2": 44, + "Byelorussian SSR": 45, + "Byzantine Empire": 46, + "California": 47, + "Capybara": 48, + "Carthage": 49, + "Ceara": 50, + "Chinook": 51, + "Chuvashia": 52, + "Circassia": 53, + "Colchis": 54, + "Colorado": 55, + "Communist Romania": 56, + "Communist flag": 57, + "Confederate States": 58, + "Connecticut": 59, + "Corsica": 60, + "Cthulhu Republic": 61, + "Danzig": 62, + "Delaware": 63, + "Dilmun": 64, + "District_of_Columbia": 65, + "Dutch East India Company": 66, + "Elam": 67, + "Empire of Japan": 68, + "Empire of Japan1": 69, + "Essex": 70, + "Fascist Spain": 71, + "Flag_of_the_Trucial_States_(1968–1971)": 72, + "Flanders": 73, + "Florida": 74, + "Franks": 75, + "French foreign legion": 76, + "Garamant": 77, + "Georgia_US": 78, + "Georgian SSR": 79, + "German Empire": 80, + "Guam": 81, + "Habsburg Austria": 82, + "Hawaii": 83, + "Holy Roman Empire": 84, + "Hyrcania": 85, + "Idaho": 86, + "Illinois": 87, + "Imperial Ethiopia": 88, + "Indiana": 89, + "Iowa": 90, + "Kansas": 91, + "Kazakh SSR": 92, + "Kemet": 93, + "Kent": 94, + "Kentucky": 95, + "Khemet": 96, + "Kingdom of Egypt": 97, + "Kingdom of Iraq": 98, + "Kingdom of Jerusalem": 99, + "Kingdom of Judah": 100, + "Kingdom_of_Iraq": 101, + "Kingdom_of_Judah": 102, + "Kiwi": 103, + "Kush": 104, + "Laigin": 105, + "League of Nations": 106, + "Leinster": 107, + "Liberalism_flag": 108, + "Libyan Jamahiriya": 109, + "Lihyan": 110, + "Listenbourg": 111, + "Louisiana": 112, + "Lower Silesia": 113, + "Lydia": 114, + "Macedonia": 115, + "Maine": 116, + "Maori flag": 117, + "Maryland": 118, + "Massachusetts": 119, + "Mauritania": 120, + "Median Empire": 121, + "Michigan": 122, + "Minnesota": 123, + "Mississippi": 124, + "Missouri": 125, + "Mongol Empire": 126, + "Montana": 127, + "Munster": 128, + "NATO": 129, + "Nebraska": 130, + "Nevada": 131, + "New_Hampshire": 132, + "New_Jersey": 133, + "New_Mexico": 134, + "New_York": 135, + "Newfoundland": 136, + "North karelia": 137, + "North yemen": 138, + "North_Carolina": 139, + "North_Dakota": 140, + "Northern_Mariana_Islands": 141, + "Nunavut": 142, + "OFM": 143, + "Ohio": 144, + "Oklahoma": 145, + "Oregon": 146, + "Ottoman Empire": 147, + "Pahlavi Iran": 148, + "Palekh": 149, + "Para": 150, + "Pennsylvania": 151, + "Persia": 152, + "Phrygia": 153, + "Poland Lithuania": 154, + "Polish–Lithuanian Commonwealth": 155, + "Qing Dynasty": 156, + "Quebec": 157, + "Republic of China": 158, + "Republic of Egypt": 159, + "Republic of Formosa": 160, + "Republic of Korea": 161, + "Republic of Pirates": 162, + "Rhode_Island": 163, + "Rhodesia": 164, + "Romanov Russia": 165, + "Ror Empire": 166, + "Russian SSR": 167, + "SPQR": 168, + "Saba kingdom": 169, + "Sakhalin": 170, + "Sami flag": 171, + "Santa Cruz": 172, + "Sao Paulo": 173, + "Sassanid Empire": 174, + "Second Republic of Iraq": 175, + "Second Spanish Republic": 176, + "Siam": 177, + "Siberia": 178, + "Sicily": 179, + "Socialist_flag": 180, + "South Vietnam": 181, + "South_Carolina": 182, + "South_Dakota": 183, + "Sparta": 184, + "Sultanate of Nejd": 185, + "Sweden Norway Union": 186, + "Tennessee": 187, + "Texas": 188, + "Trucial States": 189, + "Turkmen SSR": 190, + "USA 1776": 191, + "Ukrainian SSR": 192, + "Ulaid": 193, + "Umayyad Caliphate": 194, + "United Arab Republic": 195, + "United_States_Virgin_Islands": 196, + "Upper Silesia": 197, + "Urartu": 198, + "Utah": 199, + "Vermont": 200, + "Virginia": 201, + "Wallonia": 202, + "Washington": 203, + "Wassex": 204, + "West Roman Empire": 205, + "West_Virginia": 206, + "Wisconsin": 207, + "Wyoming": 208, + "Yellow_Flag": 209, + "Yukon": 210, + "Zaire": 211, + "Zheleznogorsk": 212, + "ac": 213, + "ad": 214, + "ae": 215, + "af": 216, + "ag": 217, + "ai": 218, + "al": 219, + "am": 220, + "amazonas": 221, + "an_pe": 222, + "antipope": 223, + "ao": 224, + "aq": 225, + "aquitaine": 226, + "ar": 227, + "armagnac": 228, + "as": 229, + "asturias": 230, + "at": 231, + "au": 232, + "aus_norter": 233, + "aus_nsw": 234, + "aus_quelan": 235, + "aus_souaus": 236, + "aus_tas": 237, + "aus_vic": 238, + "aus_wesaus": 239, + "austria-hungary": 240, + "aw": 241, + "ax": 242, + "az": 243, + "ba": 244, + "baguette": 245, + "bahia": 246, + "bai_bur": 247, + "bai_irk": 248, + "bb": 249, + "bd": 250, + "be": 251, + "bf": 252, + "bg": 253, + "bh": 254, + "bi": 255, + "bj": 256, + "bl": 257, + "bm": 258, + "bn": 259, + "bo": 260, + "bq": 261, + "br": 262, + "brittany": 263, + "bs": 264, + "bt": 265, + "buenos_aires": 266, + "bulgaria": 267, + "burgundy": 268, + "bv": 269, + "bw": 270, + "by": 271, + "bz": 272, + "ca": 273, + "ca_nb": 274, + "ca_ns": 275, + "ca_pe": 276, + "castille": 277, + "catalonia": 278, + "catamarca": 279, + "cc": 280, + "cd": 281, + "cf": 282, + "cg": 283, + "ch": 284, + "ci": 285, + "ck": 286, + "cl": 287, + "cm": 288, + "cn": 289, + "co": 290, + "cordoba": 291, + "cp": 292, + "cr": 293, + "cu": 294, + "cv": 295, + "cw": 296, + "cx": 297, + "cy": 298, + "cz": 299, + "de": 300, + "denmark": 301, + "dg": 302, + "dj": 303, + "dk": 304, + "dm": 305, + "do": 306, + "dz": 307, + "east_germany": 308, + "ec": 309, + "ee": 310, + "eg": 311, + "eh": 312, + "eo": 313, + "er": 314, + "es-ct": 315, + "es-ga": 316, + "es-pv": 317, + "es": 318, + "estonia": 319, + "et": 320, + "eu": 321, + "fi": 322, + "finland": 323, + "fj": 324, + "fk": 325, + "fm": 326, + "fo": 327, + "fr": 328, + "frost_giant": 329, + "ga": 330, + "galapagos": 331, + "gb-eng": 332, + "gb-sct": 333, + "gb-wls": 334, + "gb": 335, + "gd": 336, + "ge": 337, + "gf": 338, + "gg": 339, + "gh": 340, + "gi": 341, + "gl": 342, + "gm": 343, + "gn": 344, + "gp": 345, + "gq": 346, + "gr": 347, + "granada": 348, + "greece": 349, + "gs": 350, + "gt": 351, + "gu": 352, + "gw": 353, + "gy": 354, + "ha_ma": 355, + "hk": 356, + "hm": 357, + "hn": 358, + "hr": 359, + "ht": 360, + "hu": 361, + "hungary": 362, + "ic": 363, + "iceland": 364, + "id": 365, + "ie": 366, + "il": 367, + "im": 368, + "in": 369, + "io": 370, + "iq": 371, + "ir": 372, + "iraq": 373, + "ireland": 374, + "is": 375, + "it": 376, + "italy": 377, + "je": 378, + "jm": 379, + "jo": 380, + "jp": 381, + "ke": 382, + "kg": 383, + "kh": 384, + "ki": 385, + "km": 386, + "kn": 387, + "kp": 388, + "kr": 389, + "kurdistan": 390, + "kw": 391, + "ky": 392, + "kz": 393, + "la": 394, + "latvia": 395, + "lb": 396, + "lc": 397, + "leon": 398, + "li": 399, + "lithuania": 400, + "lk": 401, + "lr": 402, + "ls": 403, + "lt": 404, + "lu": 405, + "lv": 406, + "ly": 407, + "ma": 408, + "mc": 409, + "md": 410, + "me": 411, + "mf": 412, + "mg": 413, + "mh": 414, + "minas_gerais": 415, + "mk": 416, + "ml": 417, + "mm": 418, + "mn": 419, + "mo": 420, + "mp": 421, + "mq": 422, + "mr": 423, + "ms": 424, + "mt": 425, + "mu": 426, + "mv": 427, + "mw": 428, + "mx": 429, + "my": 430, + "mz": 431, + "na": 432, + "nc": 433, + "ne": 434, + "netherlands": 435, + "neuragic_empire": 436, + "nf": 437, + "ng": 438, + "ni": 439, + "nl": 440, + "no": 441, + "normandy": 442, + "northern_ireland": 443, + "norway": 444, + "np": 445, + "nr": 446, + "nu": 447, + "nz": 448, + "om": 449, + "pa": 450, + "paris": 451, + "pe": 452, + "pf": 453, + "pg": 454, + "ph": 455, + "pk": 456, + "pl": 457, + "pm": 458, + "pn": 459, + "poland": 460, + "polar_bears": 461, + "portugal": 462, + "pr": 463, + "provence": 464, + "prussia": 465, + "ps": 466, + "pt": 467, + "pw": 468, + "py": 469, + "qa": 470, + "re": 471, + "rio_de_janeiro": 472, + "ro": 473, + "rs": 474, + "ru": 475, + "rw": 476, + "sa": 477, + "santa_claus": 478, + "santa_cruz": 479, + "sardines": 480, + "sb": 481, + "sc": 482, + "sd": 483, + "se": 484, + "seville": 485, + "sg": 486, + "sh-ac": 487, + "sh-hl": 488, + "sh-ta": 489, + "sh": 490, + "sh_yugo": 491, + "si": 492, + "sj": 493, + "sk": 494, + "sl": 495, + "sm": 496, + "sn": 497, + "so": 498, + "south yemen": 499, + "spain": 500, + "spanish_empire": 501, + "sr": 502, + "ss": 503, + "st": 504, + "sv": 505, + "sweden": 506, + "sx": 507, + "sy": 508, + "sz": 509, + "ta": 510, + "tc": 511, + "td": 512, + "tf": 513, + "tg": 514, + "th": 515, + "tibet": 516, + "tj": 517, + "tk": 518, + "tl": 519, + "tm": 520, + "tn": 521, + "to": 522, + "toki_pona": 523, + "tr": 524, + "tt": 525, + "tv": 526, + "tw": 527, + "tz": 528, + "ua": 529, + "ug": 530, + "uk": 531, + "uk_us_flag": 532, + "um": 533, + "un": 534, + "us": 535, + "ussr": 536, + "uy": 537, + "uz": 538, + "va": 539, + "valencia": 540, + "vc": 541, + "ve": 542, + "venice": 543, + "vg": 544, + "vi": 545, + "vn": 546, + "vu": 547, + "west_germany": 548, + "wf": 549, + "ws": 550, + "xk": 551, + "xx": 552, + "ye": 553, + "yt": 554, + "yugoslavia": 555, + "za": 556, + "zm": 557, + "zw": 558 + } +} diff --git a/resources/atlases/flag-atlas.png b/resources/atlases/flag-atlas.png new file mode 100644 index 0000000000..288e3ac3f6 Binary files /dev/null and b/resources/atlases/flag-atlas.png differ diff --git a/resources/atlases/fx-atlas-meta.json b/resources/atlases/fx-atlas-meta.json new file mode 100644 index 0000000000..b86c81e7c2 --- /dev/null +++ b/resources/atlases/fx-atlas-meta.json @@ -0,0 +1,78 @@ +{ + "width": 540, + "height": 242, + "rows": [ + { + "yOffset": 0, + "height": 60, + "worldWidth": 60, + "worldHeight": 60 + }, + { + "yOffset": 60, + "height": 38, + "worldWidth": 48, + "worldHeight": 38 + }, + { + "yOffset": 98, + "height": 15, + "worldWidth": 17, + "worldHeight": 15 + }, + { + "yOffset": 113, + "height": 19, + "worldWidth": 19, + "worldHeight": 19 + }, + { + "yOffset": 132, + "height": 12, + "worldWidth": 13, + "worldHeight": 12 + }, + { + "yOffset": 144, + "height": 14, + "worldWidth": 16, + "worldHeight": 14 + }, + { + "yOffset": 158, + "height": 13, + "worldWidth": 7, + "worldHeight": 13 + }, + { + "yOffset": 171, + "height": 12, + "worldWidth": 11, + "worldHeight": 12 + }, + { + "yOffset": 183, + "height": 16, + "worldWidth": 24, + "worldHeight": 16 + }, + { + "yOffset": 199, + "height": 16, + "worldWidth": 24, + "worldHeight": 16 + }, + { + "yOffset": 215, + "height": 7, + "worldWidth": 9, + "worldHeight": 7 + }, + { + "yOffset": 222, + "height": 20, + "worldWidth": 21, + "worldHeight": 20 + } + ] +} diff --git a/resources/atlases/fx-atlas.png b/resources/atlases/fx-atlas.png new file mode 100644 index 0000000000..475422437a Binary files /dev/null and b/resources/atlases/fx-atlas.png differ diff --git a/resources/atlases/icon-atlas.png b/resources/atlases/icon-atlas.png new file mode 100644 index 0000000000..eda0253c13 Binary files /dev/null and b/resources/atlases/icon-atlas.png differ diff --git a/resources/atlases/msdf-atlas.json b/resources/atlases/msdf-atlas.json new file mode 100644 index 0000000000..91a1421fbf --- /dev/null +++ b/resources/atlases/msdf-atlas.json @@ -0,0 +1,29513 @@ +{ + "pages": ["overpass-bold.png"], + "chars": [ + { + "id": 124, + "index": 95, + "char": "|", + "width": 22, + "height": 64, + "xoffset": -4, + "yoffset": -11, + "xadvance": 14, + "chnl": 15, + "x": 0, + "y": 0, + "page": 0 + }, + { + "id": 199, + "index": 137, + "char": "Ç", + "width": 42, + "height": 62, + "xoffset": -5, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 23, + "y": 0, + "page": 0 + }, + { + "id": 106, + "index": 77, + "char": "j", + "width": 29, + "height": 61, + "xoffset": -11, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 66, + "y": 0, + "page": 0 + }, + { + "id": 210, + "index": 148, + "char": "Ò", + "width": 45, + "height": 61, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 0, + "y": 65, + "page": 0 + }, + { + "id": 211, + "index": 149, + "char": "Ó", + "width": 45, + "height": 61, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 46, + "y": 63, + "page": 0 + }, + { + "id": 212, + "index": 150, + "char": "Ô", + "width": 45, + "height": 61, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 92, + "y": 62, + "page": 0 + }, + { + "id": 217, + "index": 155, + "char": "Ù", + "width": 42, + "height": 61, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 96, + "y": 0, + "page": 0 + }, + { + "id": 218, + "index": 156, + "char": "Ú", + "width": 42, + "height": 61, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 138, + "y": 62, + "page": 0 + }, + { + "id": 219, + "index": 157, + "char": "Û", + "width": 42, + "height": 61, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 139, + "y": 0, + "page": 0 + }, + { + "id": 192, + "index": 130, + "char": "À", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 181, + "y": 62, + "page": 0 + }, + { + "id": 193, + "index": 131, + "char": "Á", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 182, + "y": 0, + "page": 0 + }, + { + "id": 194, + "index": 132, + "char": "Â", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 181, + "y": 123, + "page": 0 + }, + { + "id": 195, + "index": 133, + "char": "Ã", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 0, + "y": 184, + "page": 0 + }, + { + "id": 196, + "index": 134, + "char": "Ä", + "width": 47, + "height": 60, + "xoffset": -6, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 48, + "y": 184, + "page": 0 + }, + { + "id": 200, + "index": 138, + "char": "È", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 96, + "y": 184, + "page": 0 + }, + { + "id": 201, + "index": 139, + "char": "É", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 136, + "y": 124, + "page": 0 + }, + { + "id": 202, + "index": 140, + "char": "Ê", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 136, + "y": 185, + "page": 0 + }, + { + "id": 203, + "index": 141, + "char": "Ë", + "width": 39, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 29, + "chnl": 15, + "x": 176, + "y": 184, + "page": 0 + }, + { + "id": 204, + "index": 142, + "char": "Ì", + "width": 30, + "height": 60, + "xoffset": -9, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 216, + "y": 184, + "page": 0 + }, + { + "id": 205, + "index": 143, + "char": "Í", + "width": 30, + "height": 60, + "xoffset": -7, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 229, + "y": 61, + "page": 0 + }, + { + "id": 206, + "index": 144, + "char": "Î", + "width": 35, + "height": 60, + "xoffset": -10, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 230, + "y": 0, + "page": 0 + }, + { + "id": 207, + "index": 145, + "char": "Ï", + "width": 31, + "height": 60, + "xoffset": -9, + "yoffset": -16, + "xadvance": 14, + "chnl": 15, + "x": 229, + "y": 122, + "page": 0 + }, + { + "id": 209, + "index": 147, + "char": "Ñ", + "width": 42, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 260, + "y": 61, + "page": 0 + }, + { + "id": 213, + "index": 151, + "char": "Õ", + "width": 45, + "height": 60, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 266, + "y": 0, + "page": 0 + }, + { + "id": 214, + "index": 152, + "char": "Ö", + "width": 45, + "height": 60, + "xoffset": -5, + "yoffset": -16, + "xadvance": 35, + "chnl": 15, + "x": 247, + "y": 183, + "page": 0 + }, + { + "id": 220, + "index": 158, + "char": "Ü", + "width": 42, + "height": 60, + "xoffset": -4, + "yoffset": -16, + "xadvance": 34, + "chnl": 15, + "x": 261, + "y": 122, + "page": 0 + }, + { + "id": 221, + "index": 159, + "char": "Ý", + "width": 46, + "height": 60, + "xoffset": -7, + "yoffset": -16, + "xadvance": 33, + "chnl": 15, + "x": 303, + "y": 61, + "page": 0 + }, + { + "id": 223, + "index": 161, + "char": "ß", + "width": 38, + "height": 60, + "xoffset": -5, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 312, + "y": 0, + "page": 0 + }, + { + "id": 253, + "index": 191, + "char": "ý", + "width": 40, + "height": 60, + "xoffset": -7, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 293, + "y": 183, + "page": 0 + }, + { + "id": 254, + "index": 192, + "char": "þ", + "width": 37, + "height": 60, + "xoffset": -4, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 304, + "y": 122, + "page": 0 + }, + { + "id": 255, + "index": 193, + "char": "ÿ", + "width": 40, + "height": 60, + "xoffset": -7, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 334, + "y": 183, + "page": 0 + }, + { + "id": 376, + "index": 196, + "char": "Ÿ", + "width": 46, + "height": 60, + "xoffset": -7, + "yoffset": -16, + "xadvance": 33, + "chnl": 15, + "x": 342, + "y": 122, + "page": 0 + }, + { + "id": 182, + "index": 120, + "char": "¶", + "width": 42, + "height": 59, + "xoffset": -7, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 92, + "y": 124, + "page": 0 + }, + { + "id": 36, + "index": 7, + "char": "$", + "width": 39, + "height": 58, + "xoffset": -6, + "yoffset": -10, + "xadvance": 28, + "chnl": 15, + "x": 46, + "y": 125, + "page": 0 + }, + { + "id": 197, + "index": 135, + "char": "Å", + "width": 47, + "height": 57, + "xoffset": -6, + "yoffset": -13, + "xadvance": 34, + "chnl": 15, + "x": 350, + "y": 61, + "page": 0 + }, + { + "id": 338, + "index": 194, + "char": "Œ", + "width": 57, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 45, + "chnl": 15, + "x": 351, + "y": 0, + "page": 0 + }, + { + "id": 40, + "index": 11, + "char": "(", + "width": 28, + "height": 56, + "xoffset": -5, + "yoffset": -6, + "xadvance": 17, + "chnl": 15, + "x": 0, + "y": 127, + "page": 0 + }, + { + "id": 41, + "index": 12, + "char": ")", + "width": 28, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 17, + "chnl": 15, + "x": 375, + "y": 183, + "page": 0 + }, + { + "id": 47, + "index": 18, + "char": "/", + "width": 41, + "height": 56, + "xoffset": -9, + "yoffset": -6, + "xadvance": 24, + "chnl": 15, + "x": 389, + "y": 119, + "page": 0 + }, + { + "id": 91, + "index": 62, + "char": "[", + "width": 28, + "height": 56, + "xoffset": -3, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 398, + "y": 52, + "page": 0 + }, + { + "id": 92, + "index": 63, + "char": "\\", + "width": 41, + "height": 56, + "xoffset": -9, + "yoffset": -6, + "xadvance": 24, + "chnl": 15, + "x": 404, + "y": 176, + "page": 0 + }, + { + "id": 93, + "index": 64, + "char": "]", + "width": 28, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 446, + "y": 0, + "page": 0 + }, + { + "id": 123, + "index": 94, + "char": "{", + "width": 30, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 427, + "y": 57, + "page": 0 + }, + { + "id": 125, + "index": 96, + "char": "}", + "width": 30, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 18, + "chnl": 15, + "x": 431, + "y": 114, + "page": 0 + }, + { + "id": 166, + "index": 104, + "char": "¦", + "width": 22, + "height": 56, + "xoffset": -4, + "yoffset": -7, + "xadvance": 14, + "chnl": 15, + "x": 458, + "y": 57, + "page": 0 + }, + { + "id": 167, + "index": 105, + "char": "§", + "width": 37, + "height": 56, + "xoffset": -6, + "yoffset": -6, + "xadvance": 25, + "chnl": 15, + "x": 475, + "y": 0, + "page": 0 + }, + { + "id": 198, + "index": 136, + "char": "Æ", + "width": 56, + "height": 50, + "xoffset": -9, + "yoffset": -6, + "xadvance": 42, + "chnl": 15, + "x": 446, + "y": 171, + "page": 0 + }, + { + "id": 229, + "index": 167, + "char": "å", + "width": 37, + "height": 56, + "xoffset": -6, + "yoffset": -11, + "xadvance": 26, + "chnl": 15, + "x": 462, + "y": 114, + "page": 0 + }, + { + "id": 339, + "index": 195, + "char": "œ", + "width": 55, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 43, + "chnl": 15, + "x": 446, + "y": 222, + "page": 0 + }, + { + "id": 190, + "index": 128, + "char": "¾", + "width": 54, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 39, + "chnl": 15, + "x": 375, + "y": 240, + "page": 0 + }, + { + "id": 81, + "index": 52, + "char": "Q", + "width": 45, + "height": 53, + "xoffset": -5, + "yoffset": -6, + "xadvance": 35, + "chnl": 15, + "x": 430, + "y": 265, + "page": 0 + }, + { + "id": 87, + "index": 58, + "char": "W", + "width": 53, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 247, + "y": 244, + "page": 0 + }, + { + "id": 230, + "index": 168, + "char": "æ", + "width": 53, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 41, + "chnl": 15, + "x": 176, + "y": 245, + "page": 0 + }, + { + "id": 231, + "index": 169, + "char": "ç", + "width": 37, + "height": 53, + "xoffset": -6, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 301, + "y": 244, + "page": 0 + }, + { + "id": 37, + "index": 8, + "char": "%", + "width": 52, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 339, + "y": 292, + "page": 0 + }, + { + "id": 224, + "index": 162, + "char": "à", + "width": 37, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 392, + "y": 292, + "page": 0 + }, + { + "id": 225, + "index": 163, + "char": "á", + "width": 37, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 430, + "y": 319, + "page": 0 + }, + { + "id": 226, + "index": 164, + "char": "â", + "width": 37, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 468, + "y": 319, + "page": 0 + }, + { + "id": 232, + "index": 170, + "char": "è", + "width": 38, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 372, + "page": 0 + }, + { + "id": 233, + "index": 171, + "char": "é", + "width": 38, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 298, + "page": 0 + }, + { + "id": 234, + "index": 172, + "char": "ê", + "width": 38, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 245, + "page": 0 + }, + { + "id": 240, + "index": 178, + "char": "ð", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 0, + "y": 425, + "page": 0 + }, + { + "id": 242, + "index": 180, + "char": "ò", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 39, + "y": 372, + "page": 0 + }, + { + "id": 243, + "index": 181, + "char": "ó", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 79, + "y": 245, + "page": 0 + }, + { + "id": 244, + "index": 182, + "char": "ô", + "width": 39, + "height": 52, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 39, + "y": 245, + "page": 0 + }, + { + "id": 249, + "index": 187, + "char": "ù", + "width": 37, + "height": 52, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 119, + "y": 246, + "page": 0 + }, + { + "id": 250, + "index": 188, + "char": "ú", + "width": 37, + "height": 52, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 79, + "y": 298, + "page": 0 + }, + { + "id": 251, + "index": 189, + "char": "û", + "width": 37, + "height": 52, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 39, + "y": 298, + "page": 0 + }, + { + "id": 33, + "index": 4, + "char": "!", + "width": 24, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 14, + "chnl": 15, + "x": 409, + "y": 0, + "page": 0 + }, + { + "id": 38, + "index": 9, + "char": "&", + "width": 46, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 40, + "y": 425, + "page": 0 + }, + { + "id": 48, + "index": 19, + "char": "0", + "width": 40, + "height": 51, + "xoffset": -4, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 79, + "y": 372, + "page": 0 + }, + { + "id": 51, + "index": 22, + "char": "3", + "width": 40, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 117, + "y": 299, + "page": 0 + }, + { + "id": 54, + "index": 25, + "char": "6", + "width": 40, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 87, + "y": 424, + "page": 0 + }, + { + "id": 56, + "index": 27, + "char": "8", + "width": 40, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 30, + "chnl": 15, + "x": 120, + "y": 372, + "page": 0 + }, + { + "id": 57, + "index": 28, + "char": "9", + "width": 40, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 158, + "y": 298, + "page": 0 + }, + { + "id": 63, + "index": 34, + "char": "?", + "width": 37, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 24, + "chnl": 15, + "x": 199, + "y": 288, + "page": 0 + }, + { + "id": 64, + "index": 35, + "char": "@", + "width": 50, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 40, + "chnl": 15, + "x": 237, + "y": 295, + "page": 0 + }, + { + "id": 67, + "index": 38, + "char": "C", + "width": 42, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 288, + "y": 298, + "page": 0 + }, + { + "id": 71, + "index": 42, + "char": "G", + "width": 43, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 331, + "y": 344, + "page": 0 + }, + { + "id": 79, + "index": 50, + "char": "O", + "width": 45, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 35, + "chnl": 15, + "x": 375, + "y": 345, + "page": 0 + }, + { + "id": 83, + "index": 54, + "char": "S", + "width": 40, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 128, + "y": 424, + "page": 0 + }, + { + "id": 98, + "index": 69, + "char": "b", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 199, + "y": 340, + "page": 0 + }, + { + "id": 100, + "index": 71, + "char": "d", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 161, + "y": 350, + "page": 0 + }, + { + "id": 103, + "index": 74, + "char": "g", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 237, + "y": 347, + "page": 0 + }, + { + "id": 104, + "index": 75, + "char": "h", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 199, + "y": 392, + "page": 0 + }, + { + "id": 105, + "index": 76, + "char": "i", + "width": 23, + "height": 51, + "xoffset": -5, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 476, + "y": 265, + "page": 0 + }, + { + "id": 107, + "index": 78, + "char": "k", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 169, + "y": 444, + "page": 0 + }, + { + "id": 108, + "index": 79, + "char": "l", + "width": 22, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 481, + "y": 57, + "page": 0 + }, + { + "id": 112, + "index": 83, + "char": "p", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 207, + "y": 444, + "page": 0 + }, + { + "id": 113, + "index": 84, + "char": "q", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 275, + "y": 350, + "page": 0 + }, + { + "id": 116, + "index": 87, + "char": "t", + "width": 31, + "height": 51, + "xoffset": -7, + "yoffset": -7, + "xadvance": 19, + "chnl": 15, + "x": 313, + "y": 396, + "page": 0 + }, + { + "id": 161, + "index": 99, + "char": "¡", + "width": 24, + "height": 51, + "xoffset": -5, + "yoffset": 3, + "xadvance": 14, + "chnl": 15, + "x": 245, + "y": 399, + "page": 0 + }, + { + "id": 162, + "index": 100, + "char": "¢", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -6, + "xadvance": 26, + "chnl": 15, + "x": 270, + "y": 402, + "page": 0 + }, + { + "id": 169, + "index": 107, + "char": "©", + "width": 51, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 245, + "y": 454, + "page": 0 + }, + { + "id": 174, + "index": 112, + "char": "®", + "width": 51, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 41, + "chnl": 15, + "x": 297, + "y": 454, + "page": 0 + }, + { + "id": 188, + "index": 126, + "char": "¼", + "width": 48, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 345, + "y": 397, + "page": 0 + }, + { + "id": 189, + "index": 127, + "char": "½", + "width": 49, + "height": 51, + "xoffset": -7, + "yoffset": -6, + "xadvance": 36, + "chnl": 15, + "x": 349, + "y": 449, + "page": 0 + }, + { + "id": 191, + "index": 129, + "char": "¿", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": 3, + "xadvance": 24, + "chnl": 15, + "x": 394, + "y": 397, + "page": 0 + }, + { + "id": 216, + "index": 154, + "char": "Ø", + "width": 45, + "height": 51, + "xoffset": -5, + "yoffset": -6, + "xadvance": 35, + "chnl": 15, + "x": 399, + "y": 449, + "page": 0 + }, + { + "id": 227, + "index": 165, + "char": "ã", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 432, + "y": 372, + "page": 0 + }, + { + "id": 228, + "index": 166, + "char": "ä", + "width": 37, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 26, + "chnl": 15, + "x": 470, + "y": 372, + "page": 0 + }, + { + "id": 235, + "index": 173, + "char": "ë", + "width": 38, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 445, + "y": 424, + "page": 0 + }, + { + "id": 236, + "index": 174, + "char": "ì", + "width": 30, + "height": 51, + "xoffset": -10, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 500, + "y": 265, + "page": 0 + }, + { + "id": 237, + "index": 175, + "char": "í", + "width": 30, + "height": 51, + "xoffset": -7, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 506, + "y": 317, + "page": 0 + }, + { + "id": 238, + "index": 176, + "char": "î", + "width": 35, + "height": 51, + "xoffset": -11, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 500, + "y": 109, + "page": 0 + }, + { + "id": 239, + "index": 177, + "char": "ï", + "width": 31, + "height": 51, + "xoffset": -9, + "yoffset": -7, + "xadvance": 13, + "chnl": 15, + "x": 504, + "y": 57, + "page": 0 + }, + { + "id": 241, + "index": 179, + "char": "ñ", + "width": 37, + "height": 51, + "xoffset": -4, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 513, + "y": 0, + "page": 0 + }, + { + "id": 245, + "index": 183, + "char": "õ", + "width": 39, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 484, + "y": 424, + "page": 0 + }, + { + "id": 246, + "index": 184, + "char": "ö", + "width": 39, + "height": 51, + "xoffset": -6, + "yoffset": -7, + "xadvance": 27, + "chnl": 15, + "x": 508, + "y": 369, + "page": 0 + }, + { + "id": 252, + "index": 190, + "char": "ü", + "width": 37, + "height": 51, + "xoffset": -5, + "yoffset": -7, + "xadvance": 28, + "chnl": 15, + "x": 524, + "y": 421, + "page": 0 + }, + { + "id": 35, + "index": 6, + "char": "#", + "width": 45, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 503, + "y": 161, + "page": 0 + }, + { + "id": 49, + "index": 20, + "char": "1", + "width": 27, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 20, + "chnl": 15, + "x": 503, + "y": 212, + "page": 0 + }, + { + "id": 50, + "index": 21, + "char": "2", + "width": 39, + "height": 50, + "xoffset": -5, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 531, + "y": 212, + "page": 0 + }, + { + "id": 52, + "index": 23, + "char": "4", + "width": 44, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 531, + "y": 263, + "page": 0 + }, + { + "id": 53, + "index": 24, + "char": "5", + "width": 39, + "height": 50, + "xoffset": -5, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 537, + "y": 314, + "page": 0 + }, + { + "id": 55, + "index": 26, + "char": "7", + "width": 39, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 26, + "chnl": 15, + "x": 548, + "y": 365, + "page": 0 + }, + { + "id": 65, + "index": 36, + "char": "A", + "width": 47, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 562, + "y": 416, + "page": 0 + }, + { + "id": 66, + "index": 37, + "char": "B", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 536, + "y": 52, + "page": 0 + }, + { + "id": 68, + "index": 39, + "char": "D", + "width": 43, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 551, + "y": 0, + "page": 0 + }, + { + "id": 69, + "index": 40, + "char": "E", + "width": 39, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 536, + "y": 103, + "page": 0 + }, + { + "id": 70, + "index": 41, + "char": "F", + "width": 38, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 549, + "y": 154, + "page": 0 + }, + { + "id": 72, + "index": 43, + "char": "H", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 576, + "y": 103, + "page": 0 + }, + { + "id": 73, + "index": 44, + "char": "I", + "width": 22, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 14, + "chnl": 15, + "x": 579, + "y": 51, + "page": 0 + }, + { + "id": 74, + "index": 45, + "char": "J", + "width": 38, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 595, + "y": 0, + "page": 0 + }, + { + "id": 75, + "index": 46, + "char": "K", + "width": 44, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 602, + "y": 51, + "page": 0 + }, + { + "id": 76, + "index": 47, + "char": "L", + "width": 38, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 27, + "chnl": 15, + "x": 634, + "y": 0, + "page": 0 + }, + { + "id": 77, + "index": 48, + "char": "M", + "width": 47, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 39, + "chnl": 15, + "x": 571, + "y": 205, + "page": 0 + }, + { + "id": 78, + "index": 49, + "char": "N", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 588, + "y": 154, + "page": 0 + }, + { + "id": 80, + "index": 51, + "char": "P", + "width": 41, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 619, + "y": 102, + "page": 0 + }, + { + "id": 82, + "index": 53, + "char": "R", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 647, + "y": 51, + "page": 0 + }, + { + "id": 84, + "index": 55, + "char": "T", + "width": 41, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 673, + "y": 0, + "page": 0 + }, + { + "id": 85, + "index": 56, + "char": "U", + "width": 42, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 34, + "chnl": 15, + "x": 576, + "y": 256, + "page": 0 + }, + { + "id": 86, + "index": 57, + "char": "V", + "width": 45, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 32, + "chnl": 15, + "x": 577, + "y": 307, + "page": 0 + }, + { + "id": 88, + "index": 59, + "char": "X", + "width": 44, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 32, + "chnl": 15, + "x": 588, + "y": 358, + "page": 0 + }, + { + "id": 89, + "index": 60, + "char": "Y", + "width": 46, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 619, + "y": 205, + "page": 0 + }, + { + "id": 90, + "index": 61, + "char": "Z", + "width": 42, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 619, + "y": 256, + "page": 0 + }, + { + "id": 102, + "index": 73, + "char": "f", + "width": 31, + "height": 50, + "xoffset": -6, + "yoffset": -6, + "xadvance": 17, + "chnl": 15, + "x": 623, + "y": 307, + "page": 0 + }, + { + "id": 109, + "index": 80, + "char": "m", + "width": 50, + "height": 41, + "xoffset": -4, + "yoffset": 3, + "xadvance": 41, + "chnl": 15, + "x": 562, + "y": 467, + "page": 0 + }, + { + "id": 121, + "index": 92, + "char": "y", + "width": 40, + "height": 50, + "xoffset": -7, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 631, + "y": 153, + "page": 0 + }, + { + "id": 163, + "index": 101, + "char": "£", + "width": 40, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 29, + "chnl": 15, + "x": 661, + "y": 102, + "page": 0 + }, + { + "id": 165, + "index": 103, + "char": "¥", + "width": 43, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 28, + "chnl": 15, + "x": 690, + "y": 51, + "page": 0 + }, + { + "id": 181, + "index": 119, + "char": "µ", + "width": 37, + "height": 50, + "xoffset": -4, + "yoffset": 3, + "xadvance": 28, + "chnl": 15, + "x": 715, + "y": 0, + "page": 0 + }, + { + "id": 208, + "index": 146, + "char": "Ð", + "width": 46, + "height": 50, + "xoffset": -7, + "yoffset": -6, + "xadvance": 33, + "chnl": 15, + "x": 610, + "y": 409, + "page": 0 + }, + { + "id": 222, + "index": 160, + "char": "Þ", + "width": 41, + "height": 50, + "xoffset": -4, + "yoffset": -6, + "xadvance": 31, + "chnl": 15, + "x": 633, + "y": 358, + "page": 0 + }, + { + "id": 119, + "index": 90, + "char": "w", + "width": 48, + "height": 41, + "xoffset": -6, + "yoffset": 3, + "xadvance": 35, + "chnl": 15, + "x": 655, + "y": 307, + "page": 0 + }, + { + "id": 256, + "index": 0, + "char": "Ā", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 662, + "y": 256, + "page": 0 + }, + { + "id": 257, + "index": 0, + "char": "ā", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 666, + "y": 204, + "page": 0 + }, + { + "id": 258, + "index": 0, + "char": "Ă", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 672, + "y": 153, + "page": 0 + }, + { + "id": 259, + "index": 0, + "char": "ă", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 613, + "y": 460, + "page": 0 + }, + { + "id": 260, + "index": 0, + "char": "Ą", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 643, + "y": 460, + "page": 0 + }, + { + "id": 261, + "index": 0, + "char": "ą", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 657, + "y": 409, + "page": 0 + }, + { + "id": 262, + "index": 0, + "char": "Ć", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 692, + "y": 253, + "page": 0 + }, + { + "id": 263, + "index": 0, + "char": "ć", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 696, + "y": 202, + "page": 0 + }, + { + "id": 264, + "index": 0, + "char": "Ĉ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 673, + "y": 458, + "page": 0 + }, + { + "id": 265, + "index": 0, + "char": "ĉ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 675, + "y": 349, + "page": 0 + }, + { + "id": 266, + "index": 0, + "char": "Ċ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 687, + "y": 398, + "page": 0 + }, + { + "id": 267, + "index": 0, + "char": "ċ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 703, + "y": 447, + "page": 0 + }, + { + "id": 268, + "index": 0, + "char": "Č", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 705, + "y": 302, + "page": 0 + }, + { + "id": 269, + "index": 0, + "char": "č", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 722, + "y": 251, + "page": 0 + }, + { + "id": 270, + "index": 0, + "char": "Ď", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 717, + "y": 351, + "page": 0 + }, + { + "id": 271, + "index": 0, + "char": "ď", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 735, + "y": 300, + "page": 0 + }, + { + "id": 272, + "index": 0, + "char": "Đ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 702, + "y": 102, + "page": 0 + }, + { + "id": 273, + "index": 0, + "char": "đ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 702, + "y": 151, + "page": 0 + }, + { + "id": 274, + "index": 0, + "char": "Ē", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 726, + "y": 200, + "page": 0 + }, + { + "id": 275, + "index": 0, + "char": "ē", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 752, + "y": 249, + "page": 0 + }, + { + "id": 276, + "index": 0, + "char": "Ĕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 732, + "y": 102, + "page": 0 + }, + { + "id": 277, + "index": 0, + "char": "ĕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 732, + "y": 151, + "page": 0 + }, + { + "id": 278, + "index": 0, + "char": "Ė", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 756, + "y": 200, + "page": 0 + }, + { + "id": 279, + "index": 0, + "char": "ė", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 734, + "y": 51, + "page": 0 + }, + { + "id": 280, + "index": 0, + "char": "Ę", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 753, + "y": 0, + "page": 0 + }, + { + "id": 281, + "index": 0, + "char": "ę", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 762, + "y": 100, + "page": 0 + }, + { + "id": 282, + "index": 0, + "char": "Ě", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 762, + "y": 149, + "page": 0 + }, + { + "id": 283, + "index": 0, + "char": "ě", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 764, + "y": 49, + "page": 0 + }, + { + "id": 284, + "index": 0, + "char": "Ĝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 783, + "y": 0, + "page": 0 + }, + { + "id": 285, + "index": 0, + "char": "ĝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 733, + "y": 400, + "page": 0 + }, + { + "id": 286, + "index": 0, + "char": "Ğ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 747, + "y": 349, + "page": 0 + }, + { + "id": 287, + "index": 0, + "char": "ğ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 765, + "y": 298, + "page": 0 + }, + { + "id": 288, + "index": 0, + "char": "Ġ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 782, + "y": 249, + "page": 0 + }, + { + "id": 289, + "index": 0, + "char": "ġ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 786, + "y": 198, + "page": 0 + }, + { + "id": 290, + "index": 0, + "char": "Ģ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 733, + "y": 449, + "page": 0 + }, + { + "id": 291, + "index": 0, + "char": "ģ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 792, + "y": 98, + "page": 0 + }, + { + "id": 292, + "index": 0, + "char": "Ĥ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 794, + "y": 49, + "page": 0 + }, + { + "id": 293, + "index": 0, + "char": "ĥ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 813, + "y": 0, + "page": 0 + }, + { + "id": 294, + "index": 0, + "char": "Ħ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 792, + "y": 147, + "page": 0 + }, + { + "id": 295, + "index": 0, + "char": "ħ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 763, + "y": 398, + "page": 0 + }, + { + "id": 296, + "index": 0, + "char": "Ĩ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 777, + "y": 347, + "page": 0 + }, + { + "id": 297, + "index": 0, + "char": "ĩ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 795, + "y": 298, + "page": 0 + }, + { + "id": 298, + "index": 0, + "char": "Ī", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 812, + "y": 247, + "page": 0 + }, + { + "id": 299, + "index": 0, + "char": "ī", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 816, + "y": 196, + "page": 0 + }, + { + "id": 300, + "index": 0, + "char": "Ĭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 763, + "y": 447, + "page": 0 + }, + { + "id": 301, + "index": 0, + "char": "ĭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 822, + "y": 98, + "page": 0 + }, + { + "id": 302, + "index": 0, + "char": "Į", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 822, + "y": 147, + "page": 0 + }, + { + "id": 303, + "index": 0, + "char": "į", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 824, + "y": 49, + "page": 0 + }, + { + "id": 304, + "index": 0, + "char": "İ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 843, + "y": 0, + "page": 0 + }, + { + "id": 305, + "index": 0, + "char": "ı", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 793, + "y": 396, + "page": 0 + }, + { + "id": 306, + "index": 0, + "char": "IJ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 807, + "y": 347, + "page": 0 + }, + { + "id": 307, + "index": 0, + "char": "ij", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 825, + "y": 296, + "page": 0 + }, + { + "id": 308, + "index": 0, + "char": "Ĵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 842, + "y": 245, + "page": 0 + }, + { + "id": 309, + "index": 0, + "char": "ĵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 846, + "y": 196, + "page": 0 + }, + { + "id": 310, + "index": 0, + "char": "Ķ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 793, + "y": 445, + "page": 0 + }, + { + "id": 311, + "index": 0, + "char": "ķ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 852, + "y": 98, + "page": 0 + }, + { + "id": 312, + "index": 0, + "char": "ĸ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 852, + "y": 147, + "page": 0 + }, + { + "id": 313, + "index": 0, + "char": "Ĺ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 854, + "y": 49, + "page": 0 + }, + { + "id": 314, + "index": 0, + "char": "ĺ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 873, + "y": 0, + "page": 0 + }, + { + "id": 315, + "index": 0, + "char": "Ļ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 823, + "y": 396, + "page": 0 + }, + { + "id": 316, + "index": 0, + "char": "ļ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 837, + "y": 345, + "page": 0 + }, + { + "id": 317, + "index": 0, + "char": "Ľ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 855, + "y": 294, + "page": 0 + }, + { + "id": 318, + "index": 0, + "char": "ľ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 872, + "y": 245, + "page": 0 + }, + { + "id": 319, + "index": 0, + "char": "Ŀ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 876, + "y": 196, + "page": 0 + }, + { + "id": 320, + "index": 0, + "char": "ŀ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 823, + "y": 445, + "page": 0 + }, + { + "id": 321, + "index": 0, + "char": "Ł", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 882, + "y": 98, + "page": 0 + }, + { + "id": 322, + "index": 0, + "char": "ł", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 882, + "y": 147, + "page": 0 + }, + { + "id": 323, + "index": 0, + "char": "Ń", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 884, + "y": 49, + "page": 0 + }, + { + "id": 324, + "index": 0, + "char": "ń", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 903, + "y": 0, + "page": 0 + }, + { + "id": 325, + "index": 0, + "char": "Ņ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 933, + "y": 0, + "page": 0 + }, + { + "id": 326, + "index": 0, + "char": "ņ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 963, + "y": 0, + "page": 0 + }, + { + "id": 327, + "index": 0, + "char": "Ň", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 993, + "y": 0, + "page": 0 + }, + { + "id": 328, + "index": 0, + "char": "ň", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 853, + "y": 394, + "page": 0 + }, + { + "id": 329, + "index": 0, + "char": "ʼn", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 867, + "y": 343, + "page": 0 + }, + { + "id": 330, + "index": 0, + "char": "Ŋ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 885, + "y": 294, + "page": 0 + }, + { + "id": 331, + "index": 0, + "char": "ŋ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 902, + "y": 245, + "page": 0 + }, + { + "id": 332, + "index": 0, + "char": "Ō", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 906, + "y": 196, + "page": 0 + }, + { + "id": 333, + "index": 0, + "char": "ō", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 853, + "y": 443, + "page": 0 + }, + { + "id": 334, + "index": 0, + "char": "Ŏ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 912, + "y": 98, + "page": 0 + }, + { + "id": 335, + "index": 0, + "char": "ŏ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 912, + "y": 147, + "page": 0 + }, + { + "id": 336, + "index": 0, + "char": "Ő", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 914, + "y": 49, + "page": 0 + }, + { + "id": 337, + "index": 0, + "char": "ő", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 944, + "y": 49, + "page": 0 + }, + { + "id": 340, + "index": 0, + "char": "Ŕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 974, + "y": 49, + "page": 0 + }, + { + "id": 341, + "index": 0, + "char": "ŕ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 942, + "y": 98, + "page": 0 + }, + { + "id": 342, + "index": 0, + "char": "Ŗ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 972, + "y": 98, + "page": 0 + }, + { + "id": 343, + "index": 0, + "char": "ŗ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 942, + "y": 147, + "page": 0 + }, + { + "id": 344, + "index": 0, + "char": "Ř", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 972, + "y": 147, + "page": 0 + }, + { + "id": 345, + "index": 0, + "char": "ř", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 936, + "y": 196, + "page": 0 + }, + { + "id": 346, + "index": 0, + "char": "Ś", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 966, + "y": 196, + "page": 0 + }, + { + "id": 347, + "index": 0, + "char": "ś", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 932, + "y": 245, + "page": 0 + }, + { + "id": 348, + "index": 0, + "char": "Ŝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 962, + "y": 245, + "page": 0 + }, + { + "id": 349, + "index": 0, + "char": "ŝ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 992, + "y": 245, + "page": 0 + }, + { + "id": 350, + "index": 0, + "char": "Ş", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 883, + "y": 392, + "page": 0 + }, + { + "id": 351, + "index": 0, + "char": "ş", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 897, + "y": 343, + "page": 0 + }, + { + "id": 352, + "index": 0, + "char": "Š", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 915, + "y": 294, + "page": 0 + }, + { + "id": 353, + "index": 0, + "char": "š", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 883, + "y": 441, + "page": 0 + }, + { + "id": 354, + "index": 0, + "char": "Ţ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 945, + "y": 294, + "page": 0 + }, + { + "id": 355, + "index": 0, + "char": "ţ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 975, + "y": 294, + "page": 0 + }, + { + "id": 356, + "index": 0, + "char": "Ť", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 927, + "y": 343, + "page": 0 + }, + { + "id": 357, + "index": 0, + "char": "ť", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 957, + "y": 343, + "page": 0 + }, + { + "id": 358, + "index": 0, + "char": "Ŧ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 987, + "y": 343, + "page": 0 + }, + { + "id": 359, + "index": 0, + "char": "ŧ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 913, + "y": 392, + "page": 0 + }, + { + "id": 360, + "index": 0, + "char": "Ũ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 913, + "y": 441, + "page": 0 + }, + { + "id": 361, + "index": 0, + "char": "ũ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 943, + "y": 392, + "page": 0 + }, + { + "id": 362, + "index": 0, + "char": "Ū", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 973, + "y": 392, + "page": 0 + }, + { + "id": 363, + "index": 0, + "char": "ū", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 943, + "y": 441, + "page": 0 + }, + { + "id": 364, + "index": 0, + "char": "Ŭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 973, + "y": 441, + "page": 0 + }, + { + "id": 365, + "index": 0, + "char": "ŭ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 703, + "y": 496, + "page": 0 + }, + { + "id": 366, + "index": 0, + "char": "Ů", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 673, + "y": 507, + "page": 0 + }, + { + "id": 367, + "index": 0, + "char": "ů", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 524, + "y": 473, + "page": 0 + }, + { + "id": 368, + "index": 0, + "char": "Ű", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 445, + "y": 476, + "page": 0 + }, + { + "id": 369, + "index": 0, + "char": "ű", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 475, + "y": 476, + "page": 0 + }, + { + "id": 370, + "index": 0, + "char": "Ų", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 87, + "y": 476, + "page": 0 + }, + { + "id": 371, + "index": 0, + "char": "ų", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 40, + "y": 477, + "page": 0 + }, + { + "id": 372, + "index": 0, + "char": "Ŵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 0, + "y": 478, + "page": 0 + }, + { + "id": 373, + "index": 0, + "char": "ŵ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 117, + "y": 476, + "page": 0 + }, + { + "id": 374, + "index": 0, + "char": "Ŷ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 349, + "y": 501, + "page": 0 + }, + { + "id": 375, + "index": 0, + "char": "ŷ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 379, + "y": 501, + "page": 0 + }, + { + "id": 377, + "index": 0, + "char": "Ź", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 409, + "y": 501, + "page": 0 + }, + { + "id": 378, + "index": 0, + "char": "ź", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 147, + "y": 496, + "page": 0 + }, + { + "id": 379, + "index": 0, + "char": "Ż", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 177, + "y": 496, + "page": 0 + }, + { + "id": 380, + "index": 0, + "char": "ż", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 207, + "y": 496, + "page": 0 + }, + { + "id": 381, + "index": 0, + "char": "Ž", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 70, + "y": 525, + "page": 0 + }, + { + "id": 382, + "index": 0, + "char": "ž", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 30, + "y": 526, + "page": 0 + }, + { + "id": 383, + "index": 0, + "char": "ſ", + "width": 29, + "height": 48, + "xoffset": -6, + "yoffset": -4, + "xadvance": 18, + "chnl": 15, + "x": 0, + "y": 527, + "page": 0 + }, + { + "id": 59, + "index": 30, + "char": ";", + "width": 24, + "height": 46, + "xoffset": -6, + "yoffset": 5, + "xadvance": 11, + "chnl": 15, + "x": 339, + "y": 244, + "page": 0 + }, + { + "id": 177, + "index": 115, + "char": "±", + "width": 39, + "height": 45, + "xoffset": -4, + "yoffset": -1, + "xadvance": 30, + "chnl": 15, + "x": 100, + "y": 525, + "page": 0 + }, + { + "id": 164, + "index": 102, + "char": "¤", + "width": 43, + "height": 43, + "xoffset": -6, + "yoffset": -2, + "xadvance": 31, + "chnl": 15, + "x": 237, + "y": 506, + "page": 0 + }, + { + "id": 97, + "index": 68, + "char": "a", + "width": 37, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 281, + "y": 506, + "page": 0 + }, + { + "id": 99, + "index": 70, + "char": "c", + "width": 37, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 281, + "y": 549, + "page": 0 + }, + { + "id": 101, + "index": 72, + "char": "e", + "width": 38, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 140, + "y": 545, + "page": 0 + }, + { + "id": 111, + "index": 82, + "char": "o", + "width": 39, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 100, + "y": 571, + "page": 0 + }, + { + "id": 115, + "index": 86, + "char": "s", + "width": 35, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 23, + "chnl": 15, + "x": 60, + "y": 574, + "page": 0 + }, + { + "id": 248, + "index": 186, + "char": "ø", + "width": 39, + "height": 42, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 179, + "y": 545, + "page": 0 + }, + { + "id": 110, + "index": 81, + "char": "n", + "width": 37, + "height": 41, + "xoffset": -4, + "yoffset": 3, + "xadvance": 28, + "chnl": 15, + "x": 0, + "y": 576, + "page": 0 + }, + { + "id": 114, + "index": 85, + "char": "r", + "width": 31, + "height": 41, + "xoffset": -4, + "yoffset": 3, + "xadvance": 20, + "chnl": 15, + "x": 219, + "y": 550, + "page": 0 + }, + { + "id": 117, + "index": 88, + "char": "u", + "width": 37, + "height": 41, + "xoffset": -5, + "yoffset": 3, + "xadvance": 28, + "chnl": 15, + "x": 140, + "y": 588, + "page": 0 + }, + { + "id": 118, + "index": 89, + "char": "v", + "width": 40, + "height": 41, + "xoffset": -7, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 178, + "y": 588, + "page": 0 + }, + { + "id": 120, + "index": 91, + "char": "x", + "width": 40, + "height": 41, + "xoffset": -6, + "yoffset": 3, + "xadvance": 27, + "chnl": 15, + "x": 96, + "y": 614, + "page": 0 + }, + { + "id": 122, + "index": 93, + "char": "z", + "width": 36, + "height": 41, + "xoffset": -5, + "yoffset": 3, + "xadvance": 25, + "chnl": 15, + "x": 38, + "y": 617, + "page": 0 + }, + { + "id": 171, + "index": 109, + "char": "«", + "width": 40, + "height": 41, + "xoffset": -7, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 554, + "y": 509, + "page": 0 + }, + { + "id": 187, + "index": 125, + "char": "»", + "width": 40, + "height": 41, + "xoffset": -7, + "yoffset": 3, + "xadvance": 26, + "chnl": 15, + "x": 505, + "y": 522, + "page": 0 + }, + { + "id": 58, + "index": 29, + "char": ":", + "width": 24, + "height": 40, + "xoffset": -6, + "yoffset": 5, + "xadvance": 11, + "chnl": 15, + "x": 169, + "y": 402, + "page": 0 + }, + { + "id": 94, + "index": 65, + "char": "^", + "width": 40, + "height": 34, + "xoffset": -5, + "yoffset": -6, + "xadvance": 30, + "chnl": 15, + "x": 439, + "y": 525, + "page": 0 + }, + { + "id": 95, + "index": 66, + "char": "_", + "width": 40, + "height": 21, + "xoffset": -8, + "yoffset": 32, + "xadvance": 24, + "chnl": 15, + "x": 595, + "y": 509, + "page": 0 + }, + { + "id": 247, + "index": 185, + "char": "÷", + "width": 39, + "height": 40, + "xoffset": -4, + "yoffset": -1, + "xadvance": 30, + "chnl": 15, + "x": 595, + "y": 531, + "page": 0 + }, + { + "id": 43, + "index": 14, + "char": "+", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 546, + "y": 551, + "page": 0 + }, + { + "id": 60, + "index": 31, + "char": "<", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 319, + "y": 550, + "page": 0 + }, + { + "id": 61, + "index": 32, + "char": "=", + "width": 39, + "height": 31, + "xoffset": -4, + "yoffset": 4, + "xadvance": 30, + "chnl": 15, + "x": 359, + "y": 550, + "page": 0 + }, + { + "id": 62, + "index": 33, + "char": ">", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 399, + "y": 550, + "page": 0 + }, + { + "id": 126, + "index": 97, + "char": "~", + "width": 39, + "height": 24, + "xoffset": -4, + "yoffset": 7, + "xadvance": 30, + "chnl": 15, + "x": 359, + "y": 582, + "page": 0 + }, + { + "id": 172, + "index": 110, + "char": "¬", + "width": 39, + "height": 31, + "xoffset": -4, + "yoffset": 5, + "xadvance": 30, + "chnl": 15, + "x": 319, + "y": 590, + "page": 0 + }, + { + "id": 215, + "index": 153, + "char": "×", + "width": 39, + "height": 39, + "xoffset": -4, + "yoffset": 0, + "xadvance": 30, + "chnl": 15, + "x": 439, + "y": 560, + "page": 0 + }, + { + "id": 42, + "index": 13, + "char": "*", + "width": 37, + "height": 37, + "xoffset": -6, + "yoffset": -6, + "xadvance": 26, + "chnl": 15, + "x": 0, + "y": 618, + "page": 0 + }, + { + "id": 170, + "index": 108, + "char": "ª", + "width": 33, + "height": 37, + "xoffset": -6, + "yoffset": -6, + "xadvance": 21, + "chnl": 15, + "x": 636, + "y": 509, + "page": 0 + }, + { + "id": 178, + "index": 116, + "char": "²", + "width": 30, + "height": 37, + "xoffset": -7, + "yoffset": -14, + "xadvance": 17, + "chnl": 15, + "x": 0, + "y": 656, + "page": 0 + }, + { + "id": 179, + "index": 117, + "char": "³", + "width": 31, + "height": 37, + "xoffset": -7, + "yoffset": -14, + "xadvance": 17, + "chnl": 15, + "x": 635, + "y": 547, + "page": 0 + }, + { + "id": 186, + "index": 124, + "char": "º", + "width": 34, + "height": 37, + "xoffset": -6, + "yoffset": -6, + "xadvance": 22, + "chnl": 15, + "x": 399, + "y": 590, + "page": 0 + }, + { + "id": 185, + "index": 123, + "char": "¹", + "width": 23, + "height": 36, + "xoffset": -8, + "yoffset": -14, + "xadvance": 10, + "chnl": 15, + "x": 480, + "y": 525, + "page": 0 + }, + { + "id": 34, + "index": 5, + "char": "\"", + "width": 33, + "height": 30, + "xoffset": -6, + "yoffset": -6, + "xadvance": 21, + "chnl": 15, + "x": 359, + "y": 607, + "page": 0 + }, + { + "id": 176, + "index": 114, + "char": "°", + "width": 33, + "height": 33, + "xoffset": -7, + "yoffset": -11, + "xadvance": 19, + "chnl": 15, + "x": 586, + "y": 572, + "page": 0 + }, + { + "id": 175, + "index": 113, + "char": "¯", + "width": 32, + "height": 21, + "xoffset": -5, + "yoffset": -5, + "xadvance": 21, + "chnl": 15, + "x": 479, + "y": 564, + "page": 0 + }, + { + "id": 168, + "index": 106, + "char": "¨", + "width": 31, + "height": 23, + "xoffset": -5, + "yoffset": -7, + "xadvance": 21, + "chnl": 15, + "x": 512, + "y": 564, + "page": 0 + }, + { + "id": 39, + "index": 10, + "char": "'", + "width": 23, + "height": 30, + "xoffset": -6, + "yoffset": -6, + "xadvance": 11, + "chnl": 15, + "x": 996, + "y": 196, + "page": 0 + }, + { + "id": 44, + "index": 15, + "char": ",", + "width": 24, + "height": 30, + "xoffset": -6, + "yoffset": 21, + "xadvance": 11, + "chnl": 15, + "x": 251, + "y": 550, + "page": 0 + }, + { + "id": 96, + "index": 67, + "char": "`", + "width": 30, + "height": 23, + "xoffset": -5, + "yoffset": -7, + "xadvance": 21, + "chnl": 15, + "x": 479, + "y": 586, + "page": 0 + }, + { + "id": 180, + "index": 118, + "char": "´", + "width": 30, + "height": 23, + "xoffset": -4, + "yoffset": -7, + "xadvance": 21, + "chnl": 15, + "x": 510, + "y": 588, + "page": 0 + }, + { + "id": 45, + "index": 16, + "char": "-", + "width": 28, + "height": 21, + "xoffset": -4, + "yoffset": 11, + "xadvance": 19, + "chnl": 15, + "x": 319, + "y": 506, + "page": 0 + }, + { + "id": 173, + "index": 111, + "char": "­", + "width": 28, + "height": 21, + "xoffset": -4, + "yoffset": 11, + "xadvance": 19, + "chnl": 15, + "x": 319, + "y": 528, + "page": 0 + }, + { + "id": 184, + "index": 122, + "char": "¸", + "width": 27, + "height": 28, + "xoffset": -3, + "yoffset": 28, + "xadvance": 21, + "chnl": 15, + "x": 251, + "y": 581, + "page": 0 + }, + { + "id": 46, + "index": 17, + "char": ".", + "width": 24, + "height": 24, + "xoffset": -6, + "yoffset": 21, + "xadvance": 11, + "chnl": 15, + "x": 219, + "y": 592, + "page": 0 + }, + { + "id": 183, + "index": 121, + "char": "·", + "width": 24, + "height": 24, + "xoffset": -6, + "yoffset": 7, + "xadvance": 11, + "chnl": 15, + "x": 279, + "y": 592, + "page": 0 + }, + { + "id": 32, + "index": 3, + "char": " ", + "width": 0, + "height": 0, + "xoffset": -8, + "yoffset": 28, + "xadvance": 11, + "chnl": 15, + "x": 66, + "y": 62, + "page": 0 + }, + { + "id": 160, + "index": 98, + "char": " ", + "width": 0, + "height": 0, + "xoffset": -8, + "yoffset": 28, + "xadvance": 11, + "chnl": 15, + "x": 182, + "y": 61, + "page": 0 + } + ], + "info": { + "face": "overpass-bold", + "size": 48, + "bold": 0, + "italic": 0, + "charset": [ + " ", + "!", + "\"", + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "[", + "\\", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + " ", + "¡", + "¢", + "£", + "¤", + "¥", + "¦", + "§", + "¨", + "©", + "ª", + "«", + "¬", + "­", + "®", + "¯", + "°", + "±", + "²", + "³", + "´", + "µ", + "¶", + "·", + "¸", + "¹", + "º", + "»", + "¼", + "½", + "¾", + "¿", + "À", + "Á", + "Â", + "Ã", + "Ä", + "Å", + "Æ", + "Ç", + "È", + "É", + "Ê", + "Ë", + "Ì", + "Í", + "Î", + "Ï", + "Ð", + "Ñ", + "Ò", + "Ó", + "Ô", + "Õ", + "Ö", + "×", + "Ø", + "Ù", + "Ú", + "Û", + "Ü", + "Ý", + "Þ", + "ß", + "à", + "á", + "â", + "ã", + "ä", + "å", + "æ", + "ç", + "è", + "é", + "ê", + "ë", + "ì", + "í", + "î", + "ï", + "ð", + "ñ", + "ò", + "ó", + "ô", + "õ", + "ö", + "÷", + "ø", + "ù", + "ú", + "û", + "ü", + "ý", + "þ", + "ÿ", + "Ā", + "ā", + "Ă", + "ă", + "Ą", + "ą", + "Ć", + "ć", + "Ĉ", + "ĉ", + "Ċ", + "ċ", + "Č", + "č", + "Ď", + "ď", + "Đ", + "đ", + "Ē", + "ē", + "Ĕ", + "ĕ", + "Ė", + "ė", + "Ę", + "ę", + "Ě", + "ě", + "Ĝ", + "ĝ", + "Ğ", + "ğ", + "Ġ", + "ġ", + "Ģ", + "ģ", + "Ĥ", + "ĥ", + "Ħ", + "ħ", + "Ĩ", + "ĩ", + "Ī", + "ī", + "Ĭ", + "ĭ", + "Į", + "į", + "İ", + "ı", + "IJ", + "ij", + "Ĵ", + "ĵ", + "Ķ", + "ķ", + "ĸ", + "Ĺ", + "ĺ", + "Ļ", + "ļ", + "Ľ", + "ľ", + "Ŀ", + "ŀ", + "Ł", + "ł", + "Ń", + "ń", + "Ņ", + "ņ", + "Ň", + "ň", + "ʼn", + "Ŋ", + "ŋ", + "Ō", + "ō", + "Ŏ", + "ŏ", + "Ő", + "ő", + "Œ", + "œ", + "Ŕ", + "ŕ", + "Ŗ", + "ŗ", + "Ř", + "ř", + "Ś", + "ś", + "Ŝ", + "ŝ", + "Ş", + "ş", + "Š", + "š", + "Ţ", + "ţ", + "Ť", + "ť", + "Ŧ", + "ŧ", + "Ũ", + "ũ", + "Ū", + "ū", + "Ŭ", + "ŭ", + "Ů", + "ů", + "Ű", + "ű", + "Ų", + "ų", + "Ŵ", + "ŵ", + "Ŷ", + "ŷ", + "Ÿ", + "Ź", + "ź", + "Ż", + "ż", + "Ž", + "ž", + "ſ" + ], + "unicode": 1, + "stretchH": 100, + "smooth": 1, + "aa": 1, + "padding": [8, 8, 8, 8], + "spacing": [0, 0], + "outline": 0 + }, + "common": { + "lineHeight": 48, + "base": 36, + "scaleW": 1024, + "scaleH": 1024, + "pages": 1, + "packed": 0, + "alphaChnl": 0, + "redChnl": 0, + "greenChnl": 0, + "blueChnl": 0 + }, + "distanceField": { + "fieldType": "msdf", + "distanceRange": 16 + }, + "kernings": [ + { + "first": 32, + "second": 65, + "amount": -2 + }, + { + "first": 32, + "second": 84, + "amount": -1 + }, + { + "first": 32, + "second": 86, + "amount": -2 + }, + { + "first": 32, + "second": 87, + "amount": 0 + }, + { + "first": 32, + "second": 89, + "amount": -2 + }, + { + "first": 32, + "second": 192, + "amount": -2 + }, + { + "first": 32, + "second": 193, + "amount": -2 + }, + { + "first": 32, + "second": 194, + "amount": -2 + }, + { + "first": 32, + "second": 195, + "amount": -2 + }, + { + "first": 32, + "second": 196, + "amount": -2 + }, + { + "first": 32, + "second": 197, + "amount": -2 + }, + { + "first": 32, + "second": 221, + "amount": -2 + }, + { + "first": 32, + "second": 376, + "amount": -2 + }, + { + "first": 38, + "second": 74, + "amount": 0 + }, + { + "first": 38, + "second": 83, + "amount": 0 + }, + { + "first": 38, + "second": 84, + "amount": -5 + }, + { + "first": 38, + "second": 86, + "amount": -6 + }, + { + "first": 38, + "second": 88, + "amount": 0 + }, + { + "first": 38, + "second": 89, + "amount": -6 + }, + { + "first": 38, + "second": 117, + "amount": -1 + }, + { + "first": 38, + "second": 119, + "amount": -1 + }, + { + "first": 38, + "second": 221, + "amount": -6 + }, + { + "first": 38, + "second": 250, + "amount": -1 + }, + { + "first": 38, + "second": 376, + "amount": -6 + }, + { + "first": 40, + "second": 65, + "amount": -1 + }, + { + "first": 40, + "second": 74, + "amount": -1 + }, + { + "first": 40, + "second": 84, + "amount": 0 + }, + { + "first": 40, + "second": 86, + "amount": -1 + }, + { + "first": 40, + "second": 87, + "amount": 0 + }, + { + "first": 40, + "second": 89, + "amount": -1 + }, + { + "first": 40, + "second": 106, + "amount": 4 + }, + { + "first": 40, + "second": 192, + "amount": -1 + }, + { + "first": 40, + "second": 193, + "amount": -1 + }, + { + "first": 40, + "second": 194, + "amount": -1 + }, + { + "first": 40, + "second": 195, + "amount": -1 + }, + { + "first": 40, + "second": 196, + "amount": -1 + }, + { + "first": 40, + "second": 197, + "amount": -1 + }, + { + "first": 40, + "second": 221, + "amount": -1 + }, + { + "first": 40, + "second": 376, + "amount": -1 + }, + { + "first": 42, + "second": 65, + "amount": -4 + }, + { + "first": 42, + "second": 74, + "amount": -7 + }, + { + "first": 42, + "second": 99, + "amount": -2 + }, + { + "first": 42, + "second": 100, + "amount": -2 + }, + { + "first": 42, + "second": 101, + "amount": -2 + }, + { + "first": 42, + "second": 103, + "amount": -2 + }, + { + "first": 42, + "second": 111, + "amount": -2 + }, + { + "first": 42, + "second": 113, + "amount": -2 + }, + { + "first": 42, + "second": 115, + "amount": -1 + }, + { + "first": 42, + "second": 192, + "amount": -4 + }, + { + "first": 42, + "second": 193, + "amount": -4 + }, + { + "first": 42, + "second": 194, + "amount": -4 + }, + { + "first": 42, + "second": 195, + "amount": -4 + }, + { + "first": 42, + "second": 196, + "amount": -4 + }, + { + "first": 42, + "second": 197, + "amount": -4 + }, + { + "first": 42, + "second": 231, + "amount": -2 + }, + { + "first": 42, + "second": 248, + "amount": -2 + }, + { + "first": 42, + "second": 339, + "amount": -2 + }, + { + "first": 44, + "second": 64, + "amount": -1 + }, + { + "first": 44, + "second": 67, + "amount": -1 + }, + { + "first": 44, + "second": 71, + "amount": -1 + }, + { + "first": 44, + "second": 79, + "amount": -1 + }, + { + "first": 44, + "second": 81, + "amount": -1 + }, + { + "first": 44, + "second": 83, + "amount": 0 + }, + { + "first": 44, + "second": 84, + "amount": -4 + }, + { + "first": 44, + "second": 86, + "amount": -5 + }, + { + "first": 44, + "second": 87, + "amount": -1 + }, + { + "first": 44, + "second": 89, + "amount": -5 + }, + { + "first": 44, + "second": 99, + "amount": -1 + }, + { + "first": 44, + "second": 100, + "amount": -1 + }, + { + "first": 44, + "second": 101, + "amount": -1 + }, + { + "first": 44, + "second": 102, + "amount": 0 + }, + { + "first": 44, + "second": 103, + "amount": -1 + }, + { + "first": 44, + "second": 106, + "amount": 0 + }, + { + "first": 44, + "second": 111, + "amount": -1 + }, + { + "first": 44, + "second": 113, + "amount": -1 + }, + { + "first": 44, + "second": 119, + "amount": -1 + }, + { + "first": 44, + "second": 169, + "amount": -1 + }, + { + "first": 44, + "second": 174, + "amount": -1 + }, + { + "first": 44, + "second": 199, + "amount": -1 + }, + { + "first": 44, + "second": 210, + "amount": -1 + }, + { + "first": 44, + "second": 211, + "amount": -1 + }, + { + "first": 44, + "second": 212, + "amount": -1 + }, + { + "first": 44, + "second": 213, + "amount": -1 + }, + { + "first": 44, + "second": 214, + "amount": -1 + }, + { + "first": 44, + "second": 216, + "amount": -1 + }, + { + "first": 44, + "second": 221, + "amount": -5 + }, + { + "first": 44, + "second": 231, + "amount": -1 + }, + { + "first": 44, + "second": 248, + "amount": -1 + }, + { + "first": 44, + "second": 338, + "amount": -1 + }, + { + "first": 44, + "second": 339, + "amount": -1 + }, + { + "first": 44, + "second": 376, + "amount": -5 + }, + { + "first": 45, + "second": 84, + "amount": -2 + }, + { + "first": 45, + "second": 86, + "amount": -1 + }, + { + "first": 45, + "second": 88, + "amount": -1 + }, + { + "first": 45, + "second": 89, + "amount": -1 + }, + { + "first": 45, + "second": 116, + "amount": 0 + }, + { + "first": 45, + "second": 221, + "amount": -1 + }, + { + "first": 45, + "second": 376, + "amount": -1 + }, + { + "first": 46, + "second": 64, + "amount": -1 + }, + { + "first": 46, + "second": 67, + "amount": -1 + }, + { + "first": 46, + "second": 71, + "amount": -1 + }, + { + "first": 46, + "second": 79, + "amount": -1 + }, + { + "first": 46, + "second": 81, + "amount": -1 + }, + { + "first": 46, + "second": 83, + "amount": 0 + }, + { + "first": 46, + "second": 84, + "amount": -4 + }, + { + "first": 46, + "second": 86, + "amount": -5 + }, + { + "first": 46, + "second": 87, + "amount": -1 + }, + { + "first": 46, + "second": 89, + "amount": -5 + }, + { + "first": 46, + "second": 99, + "amount": -1 + }, + { + "first": 46, + "second": 100, + "amount": -1 + }, + { + "first": 46, + "second": 101, + "amount": -1 + }, + { + "first": 46, + "second": 102, + "amount": 0 + }, + { + "first": 46, + "second": 103, + "amount": -1 + }, + { + "first": 46, + "second": 106, + "amount": 0 + }, + { + "first": 46, + "second": 111, + "amount": -1 + }, + { + "first": 46, + "second": 113, + "amount": -1 + }, + { + "first": 46, + "second": 119, + "amount": -1 + }, + { + "first": 46, + "second": 169, + "amount": -1 + }, + { + "first": 46, + "second": 174, + "amount": -1 + }, + { + "first": 46, + "second": 199, + "amount": -1 + }, + { + "first": 46, + "second": 210, + "amount": -1 + }, + { + "first": 46, + "second": 211, + "amount": -1 + }, + { + "first": 46, + "second": 212, + "amount": -1 + }, + { + "first": 46, + "second": 213, + "amount": -1 + }, + { + "first": 46, + "second": 214, + "amount": -1 + }, + { + "first": 46, + "second": 216, + "amount": -1 + }, + { + "first": 46, + "second": 221, + "amount": -5 + }, + { + "first": 46, + "second": 231, + "amount": -1 + }, + { + "first": 46, + "second": 248, + "amount": -1 + }, + { + "first": 46, + "second": 338, + "amount": -1 + }, + { + "first": 46, + "second": 339, + "amount": -1 + }, + { + "first": 46, + "second": 376, + "amount": -5 + }, + { + "first": 47, + "second": 64, + "amount": -1 + }, + { + "first": 47, + "second": 65, + "amount": -4 + }, + { + "first": 47, + "second": 67, + "amount": -1 + }, + { + "first": 47, + "second": 71, + "amount": -1 + }, + { + "first": 47, + "second": 74, + "amount": -6 + }, + { + "first": 47, + "second": 79, + "amount": -1 + }, + { + "first": 47, + "second": 81, + "amount": -1 + }, + { + "first": 47, + "second": 83, + "amount": 0 + }, + { + "first": 47, + "second": 84, + "amount": 0 + }, + { + "first": 47, + "second": 86, + "amount": 0 + }, + { + "first": 47, + "second": 87, + "amount": 0 + }, + { + "first": 47, + "second": 88, + "amount": -1 + }, + { + "first": 47, + "second": 89, + "amount": 0 + }, + { + "first": 47, + "second": 97, + "amount": -3 + }, + { + "first": 47, + "second": 98, + "amount": 0 + }, + { + "first": 47, + "second": 99, + "amount": -2 + }, + { + "first": 47, + "second": 100, + "amount": -2 + }, + { + "first": 47, + "second": 101, + "amount": -2 + }, + { + "first": 47, + "second": 103, + "amount": -2 + }, + { + "first": 47, + "second": 104, + "amount": 0 + }, + { + "first": 47, + "second": 105, + "amount": 0 + }, + { + "first": 47, + "second": 107, + "amount": 0 + }, + { + "first": 47, + "second": 108, + "amount": 0 + }, + { + "first": 47, + "second": 109, + "amount": -1 + }, + { + "first": 47, + "second": 110, + "amount": -1 + }, + { + "first": 47, + "second": 111, + "amount": -2 + }, + { + "first": 47, + "second": 112, + "amount": -1 + }, + { + "first": 47, + "second": 113, + "amount": -2 + }, + { + "first": 47, + "second": 114, + "amount": -1 + }, + { + "first": 47, + "second": 115, + "amount": -1 + }, + { + "first": 47, + "second": 117, + "amount": -1 + }, + { + "first": 47, + "second": 120, + "amount": -1 + }, + { + "first": 47, + "second": 169, + "amount": -1 + }, + { + "first": 47, + "second": 174, + "amount": -1 + }, + { + "first": 47, + "second": 181, + "amount": -1 + }, + { + "first": 47, + "second": 192, + "amount": -4 + }, + { + "first": 47, + "second": 193, + "amount": -4 + }, + { + "first": 47, + "second": 194, + "amount": -4 + }, + { + "first": 47, + "second": 195, + "amount": -4 + }, + { + "first": 47, + "second": 196, + "amount": -4 + }, + { + "first": 47, + "second": 197, + "amount": -4 + }, + { + "first": 47, + "second": 199, + "amount": -1 + }, + { + "first": 47, + "second": 210, + "amount": -1 + }, + { + "first": 47, + "second": 211, + "amount": -1 + }, + { + "first": 47, + "second": 212, + "amount": -1 + }, + { + "first": 47, + "second": 213, + "amount": -1 + }, + { + "first": 47, + "second": 214, + "amount": -1 + }, + { + "first": 47, + "second": 216, + "amount": -1 + }, + { + "first": 47, + "second": 221, + "amount": 0 + }, + { + "first": 47, + "second": 223, + "amount": 0 + }, + { + "first": 47, + "second": 230, + "amount": -3 + }, + { + "first": 47, + "second": 231, + "amount": -2 + }, + { + "first": 47, + "second": 232, + "amount": -1 + }, + { + "first": 47, + "second": 233, + "amount": -1 + }, + { + "first": 47, + "second": 234, + "amount": -1 + }, + { + "first": 47, + "second": 235, + "amount": -1 + }, + { + "first": 47, + "second": 240, + "amount": -1 + }, + { + "first": 47, + "second": 242, + "amount": -1 + }, + { + "first": 47, + "second": 243, + "amount": -1 + }, + { + "first": 47, + "second": 244, + "amount": -1 + }, + { + "first": 47, + "second": 245, + "amount": -1 + }, + { + "first": 47, + "second": 246, + "amount": -1 + }, + { + "first": 47, + "second": 248, + "amount": -2 + }, + { + "first": 47, + "second": 250, + "amount": -1 + }, + { + "first": 47, + "second": 254, + "amount": 0 + }, + { + "first": 47, + "second": 338, + "amount": -1 + }, + { + "first": 47, + "second": 339, + "amount": -2 + }, + { + "first": 47, + "second": 376, + "amount": 0 + }, + { + "first": 48, + "second": 65, + "amount": -1 + }, + { + "first": 48, + "second": 84, + "amount": -1 + }, + { + "first": 48, + "second": 86, + "amount": -2 + }, + { + "first": 48, + "second": 87, + "amount": 0 + }, + { + "first": 48, + "second": 88, + "amount": -1 + }, + { + "first": 48, + "second": 89, + "amount": -2 + }, + { + "first": 48, + "second": 115, + "amount": 0 + }, + { + "first": 48, + "second": 119, + "amount": 0 + }, + { + "first": 48, + "second": 192, + "amount": -1 + }, + { + "first": 48, + "second": 193, + "amount": -1 + }, + { + "first": 48, + "second": 194, + "amount": -1 + }, + { + "first": 48, + "second": 195, + "amount": -1 + }, + { + "first": 48, + "second": 196, + "amount": -1 + }, + { + "first": 48, + "second": 197, + "amount": -1 + }, + { + "first": 48, + "second": 221, + "amount": -2 + }, + { + "first": 48, + "second": 376, + "amount": -2 + }, + { + "first": 49, + "second": 65, + "amount": -1 + }, + { + "first": 49, + "second": 86, + "amount": -2 + }, + { + "first": 49, + "second": 87, + "amount": 0 + }, + { + "first": 49, + "second": 88, + "amount": -1 + }, + { + "first": 49, + "second": 89, + "amount": -2 + }, + { + "first": 49, + "second": 90, + "amount": -1 + }, + { + "first": 49, + "second": 97, + "amount": -1 + }, + { + "first": 49, + "second": 115, + "amount": 0 + }, + { + "first": 49, + "second": 192, + "amount": -1 + }, + { + "first": 49, + "second": 193, + "amount": -1 + }, + { + "first": 49, + "second": 194, + "amount": -1 + }, + { + "first": 49, + "second": 195, + "amount": -1 + }, + { + "first": 49, + "second": 196, + "amount": -1 + }, + { + "first": 49, + "second": 197, + "amount": -1 + }, + { + "first": 49, + "second": 221, + "amount": -2 + }, + { + "first": 49, + "second": 230, + "amount": -1 + }, + { + "first": 49, + "second": 376, + "amount": -2 + }, + { + "first": 50, + "second": 84, + "amount": 0 + }, + { + "first": 50, + "second": 86, + "amount": -1 + }, + { + "first": 50, + "second": 87, + "amount": 0 + }, + { + "first": 50, + "second": 88, + "amount": 0 + }, + { + "first": 50, + "second": 89, + "amount": -1 + }, + { + "first": 50, + "second": 221, + "amount": -1 + }, + { + "first": 50, + "second": 376, + "amount": -1 + }, + { + "first": 51, + "second": 65, + "amount": -1 + }, + { + "first": 51, + "second": 84, + "amount": -1 + }, + { + "first": 51, + "second": 86, + "amount": -1 + }, + { + "first": 51, + "second": 87, + "amount": 0 + }, + { + "first": 51, + "second": 88, + "amount": -1 + }, + { + "first": 51, + "second": 89, + "amount": -1 + }, + { + "first": 51, + "second": 90, + "amount": 0 + }, + { + "first": 51, + "second": 192, + "amount": -1 + }, + { + "first": 51, + "second": 193, + "amount": -1 + }, + { + "first": 51, + "second": 194, + "amount": -1 + }, + { + "first": 51, + "second": 195, + "amount": -1 + }, + { + "first": 51, + "second": 196, + "amount": -1 + }, + { + "first": 51, + "second": 197, + "amount": -1 + }, + { + "first": 51, + "second": 221, + "amount": -1 + }, + { + "first": 51, + "second": 376, + "amount": -1 + }, + { + "first": 52, + "second": 65, + "amount": 0 + }, + { + "first": 52, + "second": 84, + "amount": -1 + }, + { + "first": 52, + "second": 86, + "amount": -2 + }, + { + "first": 52, + "second": 87, + "amount": -1 + }, + { + "first": 52, + "second": 88, + "amount": 0 + }, + { + "first": 52, + "second": 89, + "amount": -2 + }, + { + "first": 52, + "second": 115, + "amount": 0 + }, + { + "first": 52, + "second": 119, + "amount": 0 + }, + { + "first": 52, + "second": 192, + "amount": 0 + }, + { + "first": 52, + "second": 193, + "amount": 0 + }, + { + "first": 52, + "second": 194, + "amount": 0 + }, + { + "first": 52, + "second": 195, + "amount": 0 + }, + { + "first": 52, + "second": 196, + "amount": 0 + }, + { + "first": 52, + "second": 197, + "amount": 0 + }, + { + "first": 52, + "second": 221, + "amount": -2 + }, + { + "first": 52, + "second": 376, + "amount": -2 + }, + { + "first": 53, + "second": 65, + "amount": -1 + }, + { + "first": 53, + "second": 84, + "amount": 0 + }, + { + "first": 53, + "second": 86, + "amount": -1 + }, + { + "first": 53, + "second": 88, + "amount": 0 + }, + { + "first": 53, + "second": 89, + "amount": -1 + }, + { + "first": 53, + "second": 90, + "amount": 0 + }, + { + "first": 53, + "second": 119, + "amount": 0 + }, + { + "first": 53, + "second": 192, + "amount": -1 + }, + { + "first": 53, + "second": 193, + "amount": -1 + }, + { + "first": 53, + "second": 194, + "amount": -1 + }, + { + "first": 53, + "second": 195, + "amount": -1 + }, + { + "first": 53, + "second": 196, + "amount": -1 + }, + { + "first": 53, + "second": 197, + "amount": -1 + }, + { + "first": 53, + "second": 221, + "amount": -1 + }, + { + "first": 53, + "second": 376, + "amount": -1 + }, + { + "first": 54, + "second": 65, + "amount": -1 + }, + { + "first": 54, + "second": 84, + "amount": -2 + }, + { + "first": 54, + "second": 86, + "amount": -2 + }, + { + "first": 54, + "second": 87, + "amount": 0 + }, + { + "first": 54, + "second": 88, + "amount": -1 + }, + { + "first": 54, + "second": 89, + "amount": -2 + }, + { + "first": 54, + "second": 90, + "amount": 0 + }, + { + "first": 54, + "second": 119, + "amount": 0 + }, + { + "first": 54, + "second": 192, + "amount": -1 + }, + { + "first": 54, + "second": 193, + "amount": -1 + }, + { + "first": 54, + "second": 194, + "amount": -1 + }, + { + "first": 54, + "second": 195, + "amount": -1 + }, + { + "first": 54, + "second": 196, + "amount": -1 + }, + { + "first": 54, + "second": 197, + "amount": -1 + }, + { + "first": 54, + "second": 221, + "amount": -2 + }, + { + "first": 54, + "second": 376, + "amount": -2 + }, + { + "first": 55, + "second": 65, + "amount": -3 + }, + { + "first": 55, + "second": 83, + "amount": 0 + }, + { + "first": 55, + "second": 87, + "amount": 0 + }, + { + "first": 55, + "second": 88, + "amount": 0 + }, + { + "first": 55, + "second": 97, + "amount": -1 + }, + { + "first": 55, + "second": 99, + "amount": -2 + }, + { + "first": 55, + "second": 100, + "amount": -2 + }, + { + "first": 55, + "second": 101, + "amount": -2 + }, + { + "first": 55, + "second": 103, + "amount": -2 + }, + { + "first": 55, + "second": 109, + "amount": 0 + }, + { + "first": 55, + "second": 110, + "amount": 0 + }, + { + "first": 55, + "second": 111, + "amount": -2 + }, + { + "first": 55, + "second": 112, + "amount": 0 + }, + { + "first": 55, + "second": 113, + "amount": -2 + }, + { + "first": 55, + "second": 114, + "amount": 0 + }, + { + "first": 55, + "second": 115, + "amount": -1 + }, + { + "first": 55, + "second": 117, + "amount": -1 + }, + { + "first": 55, + "second": 119, + "amount": 0 + }, + { + "first": 55, + "second": 181, + "amount": 0 + }, + { + "first": 55, + "second": 192, + "amount": -3 + }, + { + "first": 55, + "second": 193, + "amount": -3 + }, + { + "first": 55, + "second": 194, + "amount": -3 + }, + { + "first": 55, + "second": 195, + "amount": -3 + }, + { + "first": 55, + "second": 196, + "amount": -3 + }, + { + "first": 55, + "second": 197, + "amount": -3 + }, + { + "first": 55, + "second": 230, + "amount": -1 + }, + { + "first": 55, + "second": 231, + "amount": -2 + }, + { + "first": 55, + "second": 248, + "amount": -2 + }, + { + "first": 55, + "second": 250, + "amount": -1 + }, + { + "first": 55, + "second": 339, + "amount": -2 + }, + { + "first": 56, + "second": 65, + "amount": -1 + }, + { + "first": 56, + "second": 84, + "amount": -1 + }, + { + "first": 56, + "second": 86, + "amount": -3 + }, + { + "first": 56, + "second": 87, + "amount": 0 + }, + { + "first": 56, + "second": 88, + "amount": 0 + }, + { + "first": 56, + "second": 89, + "amount": -3 + }, + { + "first": 56, + "second": 90, + "amount": 0 + }, + { + "first": 56, + "second": 119, + "amount": 0 + }, + { + "first": 56, + "second": 192, + "amount": -1 + }, + { + "first": 56, + "second": 193, + "amount": -1 + }, + { + "first": 56, + "second": 194, + "amount": -1 + }, + { + "first": 56, + "second": 195, + "amount": -1 + }, + { + "first": 56, + "second": 196, + "amount": -1 + }, + { + "first": 56, + "second": 197, + "amount": -1 + }, + { + "first": 56, + "second": 221, + "amount": -3 + }, + { + "first": 56, + "second": 376, + "amount": -3 + }, + { + "first": 57, + "second": 65, + "amount": -2 + }, + { + "first": 57, + "second": 84, + "amount": 0 + }, + { + "first": 57, + "second": 86, + "amount": -2 + }, + { + "first": 57, + "second": 87, + "amount": 0 + }, + { + "first": 57, + "second": 88, + "amount": -1 + }, + { + "first": 57, + "second": 89, + "amount": -2 + }, + { + "first": 57, + "second": 90, + "amount": 0 + }, + { + "first": 57, + "second": 115, + "amount": 0 + }, + { + "first": 57, + "second": 119, + "amount": 0 + }, + { + "first": 57, + "second": 192, + "amount": -2 + }, + { + "first": 57, + "second": 193, + "amount": -2 + }, + { + "first": 57, + "second": 194, + "amount": -2 + }, + { + "first": 57, + "second": 195, + "amount": -2 + }, + { + "first": 57, + "second": 196, + "amount": -2 + }, + { + "first": 57, + "second": 197, + "amount": -2 + }, + { + "first": 57, + "second": 221, + "amount": -2 + }, + { + "first": 57, + "second": 376, + "amount": -2 + }, + { + "first": 58, + "second": 84, + "amount": -1 + }, + { + "first": 58, + "second": 86, + "amount": -4 + }, + { + "first": 58, + "second": 87, + "amount": 0 + }, + { + "first": 58, + "second": 89, + "amount": -4 + }, + { + "first": 58, + "second": 106, + "amount": 0 + }, + { + "first": 58, + "second": 221, + "amount": -4 + }, + { + "first": 58, + "second": 376, + "amount": -4 + }, + { + "first": 59, + "second": 84, + "amount": -1 + }, + { + "first": 59, + "second": 86, + "amount": -4 + }, + { + "first": 59, + "second": 87, + "amount": 0 + }, + { + "first": 59, + "second": 89, + "amount": -4 + }, + { + "first": 59, + "second": 106, + "amount": 0 + }, + { + "first": 59, + "second": 221, + "amount": -4 + }, + { + "first": 59, + "second": 376, + "amount": -4 + }, + { + "first": 64, + "second": 74, + "amount": -5 + }, + { + "first": 64, + "second": 86, + "amount": -2 + }, + { + "first": 64, + "second": 89, + "amount": -2 + }, + { + "first": 64, + "second": 221, + "amount": -2 + }, + { + "first": 64, + "second": 376, + "amount": -2 + }, + { + "first": 65, + "second": 32, + "amount": -2 + }, + { + "first": 65, + "second": 38, + "amount": -1 + }, + { + "first": 65, + "second": 41, + "amount": -1 + }, + { + "first": 65, + "second": 42, + "amount": -4 + }, + { + "first": 65, + "second": 48, + "amount": -1 + }, + { + "first": 65, + "second": 49, + "amount": 0 + }, + { + "first": 65, + "second": 53, + "amount": 0 + }, + { + "first": 65, + "second": 54, + "amount": -1 + }, + { + "first": 65, + "second": 55, + "amount": -3 + }, + { + "first": 65, + "second": 56, + "amount": -1 + }, + { + "first": 65, + "second": 57, + "amount": -2 + }, + { + "first": 65, + "second": 64, + "amount": -2 + }, + { + "first": 65, + "second": 66, + "amount": -1 + }, + { + "first": 65, + "second": 67, + "amount": -2 + }, + { + "first": 65, + "second": 68, + "amount": -1 + }, + { + "first": 65, + "second": 69, + "amount": -1 + }, + { + "first": 65, + "second": 70, + "amount": -1 + }, + { + "first": 65, + "second": 71, + "amount": -2 + }, + { + "first": 65, + "second": 72, + "amount": -1 + }, + { + "first": 65, + "second": 73, + "amount": -1 + }, + { + "first": 65, + "second": 74, + "amount": 0 + }, + { + "first": 65, + "second": 75, + "amount": -1 + }, + { + "first": 65, + "second": 76, + "amount": -1 + }, + { + "first": 65, + "second": 77, + "amount": -1 + }, + { + "first": 65, + "second": 78, + "amount": -1 + }, + { + "first": 65, + "second": 79, + "amount": -2 + }, + { + "first": 65, + "second": 80, + "amount": -1 + }, + { + "first": 65, + "second": 81, + "amount": -2 + }, + { + "first": 65, + "second": 82, + "amount": -1 + }, + { + "first": 65, + "second": 83, + "amount": -1 + }, + { + "first": 65, + "second": 84, + "amount": -5 + }, + { + "first": 65, + "second": 85, + "amount": -1 + }, + { + "first": 65, + "second": 86, + "amount": -5 + }, + { + "first": 65, + "second": 87, + "amount": -2 + }, + { + "first": 65, + "second": 88, + "amount": -1 + }, + { + "first": 65, + "second": 89, + "amount": -5 + }, + { + "first": 65, + "second": 92, + "amount": -4 + }, + { + "first": 65, + "second": 97, + "amount": 0 + }, + { + "first": 65, + "second": 99, + "amount": 0 + }, + { + "first": 65, + "second": 100, + "amount": 0 + }, + { + "first": 65, + "second": 101, + "amount": 0 + }, + { + "first": 65, + "second": 102, + "amount": 0 + }, + { + "first": 65, + "second": 103, + "amount": 0 + }, + { + "first": 65, + "second": 106, + "amount": 3 + }, + { + "first": 65, + "second": 111, + "amount": 0 + }, + { + "first": 65, + "second": 113, + "amount": 0 + }, + { + "first": 65, + "second": 115, + "amount": 0 + }, + { + "first": 65, + "second": 116, + "amount": -1 + }, + { + "first": 65, + "second": 117, + "amount": 0 + }, + { + "first": 65, + "second": 118, + "amount": -2 + }, + { + "first": 65, + "second": 119, + "amount": -2 + }, + { + "first": 65, + "second": 121, + "amount": -2 + }, + { + "first": 65, + "second": 169, + "amount": -2 + }, + { + "first": 65, + "second": 171, + "amount": 0 + }, + { + "first": 65, + "second": 174, + "amount": -2 + }, + { + "first": 65, + "second": 199, + "amount": -2 + }, + { + "first": 65, + "second": 200, + "amount": -1 + }, + { + "first": 65, + "second": 201, + "amount": -1 + }, + { + "first": 65, + "second": 202, + "amount": -1 + }, + { + "first": 65, + "second": 203, + "amount": -1 + }, + { + "first": 65, + "second": 204, + "amount": -1 + }, + { + "first": 65, + "second": 205, + "amount": -1 + }, + { + "first": 65, + "second": 206, + "amount": -1 + }, + { + "first": 65, + "second": 207, + "amount": -1 + }, + { + "first": 65, + "second": 209, + "amount": -1 + }, + { + "first": 65, + "second": 210, + "amount": -2 + }, + { + "first": 65, + "second": 211, + "amount": -2 + }, + { + "first": 65, + "second": 212, + "amount": -2 + }, + { + "first": 65, + "second": 213, + "amount": -2 + }, + { + "first": 65, + "second": 214, + "amount": -2 + }, + { + "first": 65, + "second": 216, + "amount": -2 + }, + { + "first": 65, + "second": 217, + "amount": -1 + }, + { + "first": 65, + "second": 218, + "amount": -1 + }, + { + "first": 65, + "second": 219, + "amount": -1 + }, + { + "first": 65, + "second": 220, + "amount": -1 + }, + { + "first": 65, + "second": 221, + "amount": -5 + }, + { + "first": 65, + "second": 222, + "amount": -1 + }, + { + "first": 65, + "second": 230, + "amount": 0 + }, + { + "first": 65, + "second": 231, + "amount": 0 + }, + { + "first": 65, + "second": 232, + "amount": 0 + }, + { + "first": 65, + "second": 233, + "amount": 0 + }, + { + "first": 65, + "second": 234, + "amount": 0 + }, + { + "first": 65, + "second": 235, + "amount": 0 + }, + { + "first": 65, + "second": 240, + "amount": 0 + }, + { + "first": 65, + "second": 242, + "amount": 0 + }, + { + "first": 65, + "second": 243, + "amount": 0 + }, + { + "first": 65, + "second": 244, + "amount": 0 + }, + { + "first": 65, + "second": 245, + "amount": 0 + }, + { + "first": 65, + "second": 246, + "amount": 0 + }, + { + "first": 65, + "second": 248, + "amount": 0 + }, + { + "first": 65, + "second": 250, + "amount": 0 + }, + { + "first": 65, + "second": 338, + "amount": -2 + }, + { + "first": 65, + "second": 339, + "amount": 0 + }, + { + "first": 65, + "second": 376, + "amount": -5 + }, + { + "first": 66, + "second": 44, + "amount": 0 + }, + { + "first": 66, + "second": 46, + "amount": 0 + }, + { + "first": 66, + "second": 47, + "amount": -1 + }, + { + "first": 66, + "second": 48, + "amount": -1 + }, + { + "first": 66, + "second": 50, + "amount": -1 + }, + { + "first": 66, + "second": 55, + "amount": -1 + }, + { + "first": 66, + "second": 57, + "amount": -1 + }, + { + "first": 66, + "second": 64, + "amount": 0 + }, + { + "first": 66, + "second": 65, + "amount": -1 + }, + { + "first": 66, + "second": 67, + "amount": 0 + }, + { + "first": 66, + "second": 71, + "amount": 0 + }, + { + "first": 66, + "second": 79, + "amount": 0 + }, + { + "first": 66, + "second": 81, + "amount": 0 + }, + { + "first": 66, + "second": 84, + "amount": 0 + }, + { + "first": 66, + "second": 85, + "amount": 0 + }, + { + "first": 66, + "second": 86, + "amount": -1 + }, + { + "first": 66, + "second": 87, + "amount": -1 + }, + { + "first": 66, + "second": 88, + "amount": -1 + }, + { + "first": 66, + "second": 89, + "amount": -1 + }, + { + "first": 66, + "second": 92, + "amount": -1 + }, + { + "first": 66, + "second": 102, + "amount": 0 + }, + { + "first": 66, + "second": 116, + "amount": 0 + }, + { + "first": 66, + "second": 118, + "amount": 0 + }, + { + "first": 66, + "second": 119, + "amount": 0 + }, + { + "first": 66, + "second": 120, + "amount": -1 + }, + { + "first": 66, + "second": 121, + "amount": 0 + }, + { + "first": 66, + "second": 125, + "amount": 0 + }, + { + "first": 66, + "second": 169, + "amount": 0 + }, + { + "first": 66, + "second": 174, + "amount": 0 + }, + { + "first": 66, + "second": 192, + "amount": -1 + }, + { + "first": 66, + "second": 193, + "amount": -1 + }, + { + "first": 66, + "second": 194, + "amount": -1 + }, + { + "first": 66, + "second": 195, + "amount": -1 + }, + { + "first": 66, + "second": 196, + "amount": -1 + }, + { + "first": 66, + "second": 197, + "amount": -1 + }, + { + "first": 66, + "second": 199, + "amount": 0 + }, + { + "first": 66, + "second": 210, + "amount": 0 + }, + { + "first": 66, + "second": 211, + "amount": 0 + }, + { + "first": 66, + "second": 212, + "amount": 0 + }, + { + "first": 66, + "second": 213, + "amount": 0 + }, + { + "first": 66, + "second": 214, + "amount": 0 + }, + { + "first": 66, + "second": 216, + "amount": 0 + }, + { + "first": 66, + "second": 217, + "amount": 0 + }, + { + "first": 66, + "second": 218, + "amount": 0 + }, + { + "first": 66, + "second": 219, + "amount": 0 + }, + { + "first": 66, + "second": 220, + "amount": 0 + }, + { + "first": 66, + "second": 221, + "amount": -1 + }, + { + "first": 66, + "second": 338, + "amount": 0 + }, + { + "first": 66, + "second": 376, + "amount": -1 + }, + { + "first": 67, + "second": 47, + "amount": -1 + }, + { + "first": 67, + "second": 64, + "amount": 0 + }, + { + "first": 67, + "second": 65, + "amount": -1 + }, + { + "first": 67, + "second": 67, + "amount": 0 + }, + { + "first": 67, + "second": 71, + "amount": 0 + }, + { + "first": 67, + "second": 74, + "amount": -1 + }, + { + "first": 67, + "second": 79, + "amount": 0 + }, + { + "first": 67, + "second": 81, + "amount": 0 + }, + { + "first": 67, + "second": 84, + "amount": 0 + }, + { + "first": 67, + "second": 86, + "amount": 0 + }, + { + "first": 67, + "second": 87, + "amount": 0 + }, + { + "first": 67, + "second": 88, + "amount": -1 + }, + { + "first": 67, + "second": 89, + "amount": 0 + }, + { + "first": 67, + "second": 90, + "amount": -1 + }, + { + "first": 67, + "second": 99, + "amount": 0 + }, + { + "first": 67, + "second": 100, + "amount": 0 + }, + { + "first": 67, + "second": 101, + "amount": 0 + }, + { + "first": 67, + "second": 103, + "amount": 0 + }, + { + "first": 67, + "second": 111, + "amount": 0 + }, + { + "first": 67, + "second": 113, + "amount": 0 + }, + { + "first": 67, + "second": 115, + "amount": 0 + }, + { + "first": 67, + "second": 118, + "amount": 0 + }, + { + "first": 67, + "second": 119, + "amount": 0 + }, + { + "first": 67, + "second": 120, + "amount": -1 + }, + { + "first": 67, + "second": 121, + "amount": 0 + }, + { + "first": 67, + "second": 122, + "amount": 0 + }, + { + "first": 67, + "second": 169, + "amount": 0 + }, + { + "first": 67, + "second": 171, + "amount": 0 + }, + { + "first": 67, + "second": 174, + "amount": 0 + }, + { + "first": 67, + "second": 187, + "amount": 0 + }, + { + "first": 67, + "second": 192, + "amount": -1 + }, + { + "first": 67, + "second": 193, + "amount": -1 + }, + { + "first": 67, + "second": 194, + "amount": -1 + }, + { + "first": 67, + "second": 195, + "amount": -1 + }, + { + "first": 67, + "second": 196, + "amount": -1 + }, + { + "first": 67, + "second": 197, + "amount": -1 + }, + { + "first": 67, + "second": 199, + "amount": 0 + }, + { + "first": 67, + "second": 210, + "amount": 0 + }, + { + "first": 67, + "second": 211, + "amount": 0 + }, + { + "first": 67, + "second": 212, + "amount": 0 + }, + { + "first": 67, + "second": 213, + "amount": 0 + }, + { + "first": 67, + "second": 214, + "amount": 0 + }, + { + "first": 67, + "second": 216, + "amount": 0 + }, + { + "first": 67, + "second": 221, + "amount": 0 + }, + { + "first": 67, + "second": 231, + "amount": 0 + }, + { + "first": 67, + "second": 248, + "amount": 0 + }, + { + "first": 67, + "second": 338, + "amount": 0 + }, + { + "first": 67, + "second": 339, + "amount": 0 + }, + { + "first": 67, + "second": 376, + "amount": 0 + }, + { + "first": 68, + "second": 44, + "amount": -1 + }, + { + "first": 68, + "second": 46, + "amount": -1 + }, + { + "first": 68, + "second": 47, + "amount": -1 + }, + { + "first": 68, + "second": 65, + "amount": -2 + }, + { + "first": 68, + "second": 74, + "amount": 0 + }, + { + "first": 68, + "second": 84, + "amount": -2 + }, + { + "first": 68, + "second": 86, + "amount": -3 + }, + { + "first": 68, + "second": 87, + "amount": -1 + }, + { + "first": 68, + "second": 88, + "amount": -1 + }, + { + "first": 68, + "second": 89, + "amount": -3 + }, + { + "first": 68, + "second": 90, + "amount": -1 + }, + { + "first": 68, + "second": 97, + "amount": 0 + }, + { + "first": 68, + "second": 99, + "amount": 0 + }, + { + "first": 68, + "second": 100, + "amount": 0 + }, + { + "first": 68, + "second": 101, + "amount": 0 + }, + { + "first": 68, + "second": 103, + "amount": 0 + }, + { + "first": 68, + "second": 111, + "amount": 0 + }, + { + "first": 68, + "second": 113, + "amount": 0 + }, + { + "first": 68, + "second": 115, + "amount": 0 + }, + { + "first": 68, + "second": 118, + "amount": 0 + }, + { + "first": 68, + "second": 119, + "amount": 0 + }, + { + "first": 68, + "second": 120, + "amount": 0 + }, + { + "first": 68, + "second": 121, + "amount": 0 + }, + { + "first": 68, + "second": 122, + "amount": 0 + }, + { + "first": 68, + "second": 192, + "amount": -2 + }, + { + "first": 68, + "second": 193, + "amount": -2 + }, + { + "first": 68, + "second": 194, + "amount": -2 + }, + { + "first": 68, + "second": 195, + "amount": -2 + }, + { + "first": 68, + "second": 196, + "amount": -2 + }, + { + "first": 68, + "second": 197, + "amount": -2 + }, + { + "first": 68, + "second": 221, + "amount": -3 + }, + { + "first": 68, + "second": 230, + "amount": 0 + }, + { + "first": 68, + "second": 231, + "amount": 0 + }, + { + "first": 68, + "second": 248, + "amount": 0 + }, + { + "first": 68, + "second": 339, + "amount": 0 + }, + { + "first": 68, + "second": 376, + "amount": -3 + }, + { + "first": 69, + "second": 38, + "amount": 0 + }, + { + "first": 69, + "second": 64, + "amount": -1 + }, + { + "first": 69, + "second": 67, + "amount": -1 + }, + { + "first": 69, + "second": 71, + "amount": -1 + }, + { + "first": 69, + "second": 74, + "amount": 0 + }, + { + "first": 69, + "second": 79, + "amount": -1 + }, + { + "first": 69, + "second": 81, + "amount": -1 + }, + { + "first": 69, + "second": 87, + "amount": 0 + }, + { + "first": 69, + "second": 97, + "amount": 0 + }, + { + "first": 69, + "second": 99, + "amount": 0 + }, + { + "first": 69, + "second": 100, + "amount": 0 + }, + { + "first": 69, + "second": 101, + "amount": 0 + }, + { + "first": 69, + "second": 102, + "amount": -1 + }, + { + "first": 69, + "second": 103, + "amount": 0 + }, + { + "first": 69, + "second": 106, + "amount": 1 + }, + { + "first": 69, + "second": 111, + "amount": 0 + }, + { + "first": 69, + "second": 113, + "amount": 0 + }, + { + "first": 69, + "second": 115, + "amount": 0 + }, + { + "first": 69, + "second": 116, + "amount": 0 + }, + { + "first": 69, + "second": 118, + "amount": -1 + }, + { + "first": 69, + "second": 119, + "amount": -1 + }, + { + "first": 69, + "second": 121, + "amount": -1 + }, + { + "first": 69, + "second": 169, + "amount": -1 + }, + { + "first": 69, + "second": 171, + "amount": -1 + }, + { + "first": 69, + "second": 174, + "amount": -1 + }, + { + "first": 69, + "second": 180, + "amount": 0 + }, + { + "first": 69, + "second": 199, + "amount": -1 + }, + { + "first": 69, + "second": 210, + "amount": -1 + }, + { + "first": 69, + "second": 211, + "amount": -1 + }, + { + "first": 69, + "second": 212, + "amount": -1 + }, + { + "first": 69, + "second": 213, + "amount": -1 + }, + { + "first": 69, + "second": 214, + "amount": -1 + }, + { + "first": 69, + "second": 216, + "amount": -1 + }, + { + "first": 69, + "second": 224, + "amount": 0 + }, + { + "first": 69, + "second": 225, + "amount": 0 + }, + { + "first": 69, + "second": 226, + "amount": 0 + }, + { + "first": 69, + "second": 227, + "amount": 0 + }, + { + "first": 69, + "second": 228, + "amount": 0 + }, + { + "first": 69, + "second": 229, + "amount": 0 + }, + { + "first": 69, + "second": 230, + "amount": 0 + }, + { + "first": 69, + "second": 231, + "amount": 0 + }, + { + "first": 69, + "second": 248, + "amount": 0 + }, + { + "first": 69, + "second": 338, + "amount": -1 + }, + { + "first": 69, + "second": 339, + "amount": 0 + }, + { + "first": 70, + "second": 38, + "amount": -3 + }, + { + "first": 70, + "second": 44, + "amount": -4 + }, + { + "first": 70, + "second": 46, + "amount": -4 + }, + { + "first": 70, + "second": 47, + "amount": -7 + }, + { + "first": 70, + "second": 48, + "amount": -1 + }, + { + "first": 70, + "second": 50, + "amount": 0 + }, + { + "first": 70, + "second": 52, + "amount": -4 + }, + { + "first": 70, + "second": 54, + "amount": -1 + }, + { + "first": 70, + "second": 55, + "amount": 0 + }, + { + "first": 70, + "second": 56, + "amount": -1 + }, + { + "first": 70, + "second": 58, + "amount": -1 + }, + { + "first": 70, + "second": 59, + "amount": -1 + }, + { + "first": 70, + "second": 64, + "amount": -1 + }, + { + "first": 70, + "second": 65, + "amount": -6 + }, + { + "first": 70, + "second": 67, + "amount": -1 + }, + { + "first": 70, + "second": 71, + "amount": -1 + }, + { + "first": 70, + "second": 74, + "amount": -7 + }, + { + "first": 70, + "second": 79, + "amount": -1 + }, + { + "first": 70, + "second": 81, + "amount": -1 + }, + { + "first": 70, + "second": 83, + "amount": 0 + }, + { + "first": 70, + "second": 86, + "amount": 0 + }, + { + "first": 70, + "second": 89, + "amount": 0 + }, + { + "first": 70, + "second": 97, + "amount": -2 + }, + { + "first": 70, + "second": 99, + "amount": -2 + }, + { + "first": 70, + "second": 100, + "amount": -2 + }, + { + "first": 70, + "second": 101, + "amount": -2 + }, + { + "first": 70, + "second": 102, + "amount": 0 + }, + { + "first": 70, + "second": 103, + "amount": -2 + }, + { + "first": 70, + "second": 109, + "amount": -2 + }, + { + "first": 70, + "second": 110, + "amount": -2 + }, + { + "first": 70, + "second": 111, + "amount": -2 + }, + { + "first": 70, + "second": 112, + "amount": -2 + }, + { + "first": 70, + "second": 113, + "amount": -2 + }, + { + "first": 70, + "second": 114, + "amount": -2 + }, + { + "first": 70, + "second": 115, + "amount": -2 + }, + { + "first": 70, + "second": 116, + "amount": 0 + }, + { + "first": 70, + "second": 117, + "amount": -2 + }, + { + "first": 70, + "second": 118, + "amount": -1 + }, + { + "first": 70, + "second": 119, + "amount": -1 + }, + { + "first": 70, + "second": 120, + "amount": -2 + }, + { + "first": 70, + "second": 121, + "amount": -1 + }, + { + "first": 70, + "second": 122, + "amount": -1 + }, + { + "first": 70, + "second": 169, + "amount": -1 + }, + { + "first": 70, + "second": 171, + "amount": -1 + }, + { + "first": 70, + "second": 174, + "amount": -1 + }, + { + "first": 70, + "second": 180, + "amount": -1 + }, + { + "first": 70, + "second": 181, + "amount": -2 + }, + { + "first": 70, + "second": 192, + "amount": -6 + }, + { + "first": 70, + "second": 193, + "amount": -6 + }, + { + "first": 70, + "second": 194, + "amount": -6 + }, + { + "first": 70, + "second": 195, + "amount": -6 + }, + { + "first": 70, + "second": 196, + "amount": -6 + }, + { + "first": 70, + "second": 197, + "amount": -6 + }, + { + "first": 70, + "second": 199, + "amount": -1 + }, + { + "first": 70, + "second": 210, + "amount": -1 + }, + { + "first": 70, + "second": 211, + "amount": -1 + }, + { + "first": 70, + "second": 212, + "amount": -1 + }, + { + "first": 70, + "second": 213, + "amount": -1 + }, + { + "first": 70, + "second": 214, + "amount": -1 + }, + { + "first": 70, + "second": 216, + "amount": -1 + }, + { + "first": 70, + "second": 221, + "amount": 0 + }, + { + "first": 70, + "second": 224, + "amount": -1 + }, + { + "first": 70, + "second": 225, + "amount": -1 + }, + { + "first": 70, + "second": 226, + "amount": -1 + }, + { + "first": 70, + "second": 227, + "amount": -1 + }, + { + "first": 70, + "second": 228, + "amount": -1 + }, + { + "first": 70, + "second": 229, + "amount": -1 + }, + { + "first": 70, + "second": 230, + "amount": -2 + }, + { + "first": 70, + "second": 231, + "amount": -2 + }, + { + "first": 70, + "second": 232, + "amount": -1 + }, + { + "first": 70, + "second": 233, + "amount": -1 + }, + { + "first": 70, + "second": 234, + "amount": -1 + }, + { + "first": 70, + "second": 235, + "amount": -1 + }, + { + "first": 70, + "second": 236, + "amount": 2 + }, + { + "first": 70, + "second": 237, + "amount": 2 + }, + { + "first": 70, + "second": 238, + "amount": 2 + }, + { + "first": 70, + "second": 239, + "amount": 2 + }, + { + "first": 70, + "second": 240, + "amount": -1 + }, + { + "first": 70, + "second": 242, + "amount": -1 + }, + { + "first": 70, + "second": 243, + "amount": -1 + }, + { + "first": 70, + "second": 244, + "amount": -1 + }, + { + "first": 70, + "second": 245, + "amount": -1 + }, + { + "first": 70, + "second": 246, + "amount": -1 + }, + { + "first": 70, + "second": 248, + "amount": -2 + }, + { + "first": 70, + "second": 249, + "amount": -1 + }, + { + "first": 70, + "second": 250, + "amount": -2 + }, + { + "first": 70, + "second": 251, + "amount": -1 + }, + { + "first": 70, + "second": 252, + "amount": -1 + }, + { + "first": 70, + "second": 338, + "amount": -1 + }, + { + "first": 70, + "second": 339, + "amount": -2 + }, + { + "first": 70, + "second": 376, + "amount": 0 + }, + { + "first": 71, + "second": 47, + "amount": -1 + }, + { + "first": 71, + "second": 51, + "amount": 0 + }, + { + "first": 71, + "second": 63, + "amount": 0 + }, + { + "first": 71, + "second": 65, + "amount": -1 + }, + { + "first": 71, + "second": 74, + "amount": 0 + }, + { + "first": 71, + "second": 86, + "amount": 0 + }, + { + "first": 71, + "second": 88, + "amount": 0 + }, + { + "first": 71, + "second": 89, + "amount": 0 + }, + { + "first": 71, + "second": 102, + "amount": 0 + }, + { + "first": 71, + "second": 115, + "amount": 0 + }, + { + "first": 71, + "second": 118, + "amount": -1 + }, + { + "first": 71, + "second": 119, + "amount": -1 + }, + { + "first": 71, + "second": 120, + "amount": -1 + }, + { + "first": 71, + "second": 121, + "amount": -1 + }, + { + "first": 71, + "second": 122, + "amount": 0 + }, + { + "first": 71, + "second": 192, + "amount": -1 + }, + { + "first": 71, + "second": 193, + "amount": -1 + }, + { + "first": 71, + "second": 194, + "amount": -1 + }, + { + "first": 71, + "second": 195, + "amount": -1 + }, + { + "first": 71, + "second": 196, + "amount": -1 + }, + { + "first": 71, + "second": 197, + "amount": -1 + }, + { + "first": 71, + "second": 221, + "amount": 0 + }, + { + "first": 71, + "second": 376, + "amount": 0 + }, + { + "first": 72, + "second": 65, + "amount": -1 + }, + { + "first": 72, + "second": 74, + "amount": 0 + }, + { + "first": 72, + "second": 86, + "amount": -1 + }, + { + "first": 72, + "second": 87, + "amount": -1 + }, + { + "first": 72, + "second": 89, + "amount": -1 + }, + { + "first": 72, + "second": 192, + "amount": -1 + }, + { + "first": 72, + "second": 193, + "amount": -1 + }, + { + "first": 72, + "second": 194, + "amount": -1 + }, + { + "first": 72, + "second": 195, + "amount": -1 + }, + { + "first": 72, + "second": 196, + "amount": -1 + }, + { + "first": 72, + "second": 197, + "amount": -1 + }, + { + "first": 72, + "second": 221, + "amount": -1 + }, + { + "first": 72, + "second": 376, + "amount": -1 + }, + { + "first": 73, + "second": 65, + "amount": -1 + }, + { + "first": 73, + "second": 74, + "amount": 0 + }, + { + "first": 73, + "second": 86, + "amount": -1 + }, + { + "first": 73, + "second": 87, + "amount": -1 + }, + { + "first": 73, + "second": 89, + "amount": -1 + }, + { + "first": 73, + "second": 192, + "amount": -1 + }, + { + "first": 73, + "second": 193, + "amount": -1 + }, + { + "first": 73, + "second": 194, + "amount": -1 + }, + { + "first": 73, + "second": 195, + "amount": -1 + }, + { + "first": 73, + "second": 196, + "amount": -1 + }, + { + "first": 73, + "second": 197, + "amount": -1 + }, + { + "first": 73, + "second": 221, + "amount": -1 + }, + { + "first": 73, + "second": 376, + "amount": -1 + }, + { + "first": 74, + "second": 44, + "amount": -2 + }, + { + "first": 74, + "second": 46, + "amount": -2 + }, + { + "first": 74, + "second": 47, + "amount": -3 + }, + { + "first": 74, + "second": 65, + "amount": -2 + }, + { + "first": 74, + "second": 88, + "amount": 0 + }, + { + "first": 74, + "second": 192, + "amount": -2 + }, + { + "first": 74, + "second": 193, + "amount": -2 + }, + { + "first": 74, + "second": 194, + "amount": -2 + }, + { + "first": 74, + "second": 195, + "amount": -2 + }, + { + "first": 74, + "second": 196, + "amount": -2 + }, + { + "first": 74, + "second": 197, + "amount": -2 + }, + { + "first": 75, + "second": 38, + "amount": -1 + }, + { + "first": 75, + "second": 48, + "amount": -1 + }, + { + "first": 75, + "second": 49, + "amount": 0 + }, + { + "first": 75, + "second": 51, + "amount": -1 + }, + { + "first": 75, + "second": 52, + "amount": 0 + }, + { + "first": 75, + "second": 53, + "amount": 0 + }, + { + "first": 75, + "second": 54, + "amount": -1 + }, + { + "first": 75, + "second": 55, + "amount": -1 + }, + { + "first": 75, + "second": 56, + "amount": -1 + }, + { + "first": 75, + "second": 57, + "amount": -2 + }, + { + "first": 75, + "second": 63, + "amount": -2 + }, + { + "first": 75, + "second": 64, + "amount": -2 + }, + { + "first": 75, + "second": 67, + "amount": -2 + }, + { + "first": 75, + "second": 71, + "amount": -2 + }, + { + "first": 75, + "second": 74, + "amount": 0 + }, + { + "first": 75, + "second": 79, + "amount": -2 + }, + { + "first": 75, + "second": 81, + "amount": -2 + }, + { + "first": 75, + "second": 83, + "amount": -1 + }, + { + "first": 75, + "second": 84, + "amount": -1 + }, + { + "first": 75, + "second": 85, + "amount": -1 + }, + { + "first": 75, + "second": 86, + "amount": -1 + }, + { + "first": 75, + "second": 87, + "amount": -1 + }, + { + "first": 75, + "second": 89, + "amount": -1 + }, + { + "first": 75, + "second": 97, + "amount": 0 + }, + { + "first": 75, + "second": 99, + "amount": -1 + }, + { + "first": 75, + "second": 100, + "amount": -1 + }, + { + "first": 75, + "second": 101, + "amount": -1 + }, + { + "first": 75, + "second": 102, + "amount": 0 + }, + { + "first": 75, + "second": 103, + "amount": -1 + }, + { + "first": 75, + "second": 111, + "amount": -1 + }, + { + "first": 75, + "second": 113, + "amount": -1 + }, + { + "first": 75, + "second": 115, + "amount": -1 + }, + { + "first": 75, + "second": 116, + "amount": 0 + }, + { + "first": 75, + "second": 117, + "amount": 0 + }, + { + "first": 75, + "second": 118, + "amount": -2 + }, + { + "first": 75, + "second": 119, + "amount": -2 + }, + { + "first": 75, + "second": 121, + "amount": -2 + }, + { + "first": 75, + "second": 169, + "amount": -2 + }, + { + "first": 75, + "second": 174, + "amount": -2 + }, + { + "first": 75, + "second": 199, + "amount": -2 + }, + { + "first": 75, + "second": 210, + "amount": -2 + }, + { + "first": 75, + "second": 211, + "amount": -2 + }, + { + "first": 75, + "second": 212, + "amount": -2 + }, + { + "first": 75, + "second": 213, + "amount": -2 + }, + { + "first": 75, + "second": 214, + "amount": -2 + }, + { + "first": 75, + "second": 216, + "amount": -2 + }, + { + "first": 75, + "second": 217, + "amount": -1 + }, + { + "first": 75, + "second": 218, + "amount": -1 + }, + { + "first": 75, + "second": 219, + "amount": -1 + }, + { + "first": 75, + "second": 220, + "amount": -1 + }, + { + "first": 75, + "second": 221, + "amount": -1 + }, + { + "first": 75, + "second": 230, + "amount": 0 + }, + { + "first": 75, + "second": 231, + "amount": -1 + }, + { + "first": 75, + "second": 248, + "amount": -1 + }, + { + "first": 75, + "second": 250, + "amount": 0 + }, + { + "first": 75, + "second": 338, + "amount": -2 + }, + { + "first": 75, + "second": 339, + "amount": -1 + }, + { + "first": 75, + "second": 376, + "amount": -1 + }, + { + "first": 76, + "second": 42, + "amount": -5 + }, + { + "first": 76, + "second": 48, + "amount": 0 + }, + { + "first": 76, + "second": 52, + "amount": 1 + }, + { + "first": 76, + "second": 55, + "amount": -2 + }, + { + "first": 76, + "second": 57, + "amount": -1 + }, + { + "first": 76, + "second": 63, + "amount": -2 + }, + { + "first": 76, + "second": 64, + "amount": -1 + }, + { + "first": 76, + "second": 65, + "amount": 0 + }, + { + "first": 76, + "second": 67, + "amount": -1 + }, + { + "first": 76, + "second": 71, + "amount": -1 + }, + { + "first": 76, + "second": 74, + "amount": 1 + }, + { + "first": 76, + "second": 79, + "amount": -1 + }, + { + "first": 76, + "second": 81, + "amount": -1 + }, + { + "first": 76, + "second": 84, + "amount": -4 + }, + { + "first": 76, + "second": 86, + "amount": -4 + }, + { + "first": 76, + "second": 89, + "amount": -4 + }, + { + "first": 76, + "second": 90, + "amount": 1 + }, + { + "first": 76, + "second": 92, + "amount": -2 + }, + { + "first": 76, + "second": 102, + "amount": 0 + }, + { + "first": 76, + "second": 116, + "amount": 0 + }, + { + "first": 76, + "second": 118, + "amount": -2 + }, + { + "first": 76, + "second": 119, + "amount": -1 + }, + { + "first": 76, + "second": 121, + "amount": -2 + }, + { + "first": 76, + "second": 169, + "amount": -1 + }, + { + "first": 76, + "second": 174, + "amount": -1 + }, + { + "first": 76, + "second": 192, + "amount": 0 + }, + { + "first": 76, + "second": 193, + "amount": 0 + }, + { + "first": 76, + "second": 194, + "amount": 0 + }, + { + "first": 76, + "second": 195, + "amount": 0 + }, + { + "first": 76, + "second": 196, + "amount": 0 + }, + { + "first": 76, + "second": 197, + "amount": 0 + }, + { + "first": 76, + "second": 198, + "amount": 2 + }, + { + "first": 76, + "second": 199, + "amount": -1 + }, + { + "first": 76, + "second": 210, + "amount": -1 + }, + { + "first": 76, + "second": 211, + "amount": -1 + }, + { + "first": 76, + "second": 212, + "amount": -1 + }, + { + "first": 76, + "second": 213, + "amount": -1 + }, + { + "first": 76, + "second": 214, + "amount": -1 + }, + { + "first": 76, + "second": 216, + "amount": -1 + }, + { + "first": 76, + "second": 221, + "amount": -4 + }, + { + "first": 76, + "second": 338, + "amount": -1 + }, + { + "first": 76, + "second": 376, + "amount": -4 + }, + { + "first": 77, + "second": 65, + "amount": -1 + }, + { + "first": 77, + "second": 74, + "amount": 0 + }, + { + "first": 77, + "second": 86, + "amount": -1 + }, + { + "first": 77, + "second": 87, + "amount": -1 + }, + { + "first": 77, + "second": 89, + "amount": -1 + }, + { + "first": 77, + "second": 192, + "amount": -1 + }, + { + "first": 77, + "second": 193, + "amount": -1 + }, + { + "first": 77, + "second": 194, + "amount": -1 + }, + { + "first": 77, + "second": 195, + "amount": -1 + }, + { + "first": 77, + "second": 196, + "amount": -1 + }, + { + "first": 77, + "second": 197, + "amount": -1 + }, + { + "first": 77, + "second": 221, + "amount": -1 + }, + { + "first": 77, + "second": 376, + "amount": -1 + }, + { + "first": 78, + "second": 65, + "amount": -1 + }, + { + "first": 78, + "second": 74, + "amount": 0 + }, + { + "first": 78, + "second": 86, + "amount": -1 + }, + { + "first": 78, + "second": 87, + "amount": -1 + }, + { + "first": 78, + "second": 89, + "amount": -1 + }, + { + "first": 78, + "second": 192, + "amount": -1 + }, + { + "first": 78, + "second": 193, + "amount": -1 + }, + { + "first": 78, + "second": 194, + "amount": -1 + }, + { + "first": 78, + "second": 195, + "amount": -1 + }, + { + "first": 78, + "second": 196, + "amount": -1 + }, + { + "first": 78, + "second": 197, + "amount": -1 + }, + { + "first": 78, + "second": 221, + "amount": -1 + }, + { + "first": 78, + "second": 376, + "amount": -1 + }, + { + "first": 79, + "second": 44, + "amount": -1 + }, + { + "first": 79, + "second": 46, + "amount": -1 + }, + { + "first": 79, + "second": 47, + "amount": -1 + }, + { + "first": 79, + "second": 65, + "amount": -2 + }, + { + "first": 79, + "second": 74, + "amount": 0 + }, + { + "first": 79, + "second": 84, + "amount": -2 + }, + { + "first": 79, + "second": 86, + "amount": -3 + }, + { + "first": 79, + "second": 87, + "amount": -1 + }, + { + "first": 79, + "second": 88, + "amount": -1 + }, + { + "first": 79, + "second": 89, + "amount": -3 + }, + { + "first": 79, + "second": 90, + "amount": -1 + }, + { + "first": 79, + "second": 97, + "amount": 0 + }, + { + "first": 79, + "second": 99, + "amount": 0 + }, + { + "first": 79, + "second": 100, + "amount": 0 + }, + { + "first": 79, + "second": 101, + "amount": 0 + }, + { + "first": 79, + "second": 103, + "amount": 0 + }, + { + "first": 79, + "second": 111, + "amount": 0 + }, + { + "first": 79, + "second": 113, + "amount": 0 + }, + { + "first": 79, + "second": 115, + "amount": 0 + }, + { + "first": 79, + "second": 118, + "amount": 0 + }, + { + "first": 79, + "second": 119, + "amount": 0 + }, + { + "first": 79, + "second": 120, + "amount": 0 + }, + { + "first": 79, + "second": 121, + "amount": 0 + }, + { + "first": 79, + "second": 122, + "amount": 0 + }, + { + "first": 79, + "second": 192, + "amount": -2 + }, + { + "first": 79, + "second": 193, + "amount": -2 + }, + { + "first": 79, + "second": 194, + "amount": -2 + }, + { + "first": 79, + "second": 195, + "amount": -2 + }, + { + "first": 79, + "second": 196, + "amount": -2 + }, + { + "first": 79, + "second": 197, + "amount": -2 + }, + { + "first": 79, + "second": 221, + "amount": -3 + }, + { + "first": 79, + "second": 230, + "amount": 0 + }, + { + "first": 79, + "second": 231, + "amount": 0 + }, + { + "first": 79, + "second": 248, + "amount": 0 + }, + { + "first": 79, + "second": 339, + "amount": 0 + }, + { + "first": 79, + "second": 376, + "amount": -3 + }, + { + "first": 80, + "second": 38, + "amount": -2 + }, + { + "first": 80, + "second": 41, + "amount": -1 + }, + { + "first": 80, + "second": 44, + "amount": -6 + }, + { + "first": 80, + "second": 46, + "amount": -6 + }, + { + "first": 80, + "second": 47, + "amount": -5 + }, + { + "first": 80, + "second": 50, + "amount": 0 + }, + { + "first": 80, + "second": 51, + "amount": -1 + }, + { + "first": 80, + "second": 52, + "amount": -1 + }, + { + "first": 80, + "second": 54, + "amount": -1 + }, + { + "first": 80, + "second": 56, + "amount": 0 + }, + { + "first": 80, + "second": 64, + "amount": 0 + }, + { + "first": 80, + "second": 65, + "amount": -5 + }, + { + "first": 80, + "second": 67, + "amount": 0 + }, + { + "first": 80, + "second": 71, + "amount": 0 + }, + { + "first": 80, + "second": 74, + "amount": -6 + }, + { + "first": 80, + "second": 79, + "amount": 0 + }, + { + "first": 80, + "second": 81, + "amount": 0 + }, + { + "first": 80, + "second": 83, + "amount": -1 + }, + { + "first": 80, + "second": 84, + "amount": 0 + }, + { + "first": 80, + "second": 85, + "amount": 0 + }, + { + "first": 80, + "second": 86, + "amount": -1 + }, + { + "first": 80, + "second": 87, + "amount": -1 + }, + { + "first": 80, + "second": 88, + "amount": -2 + }, + { + "first": 80, + "second": 89, + "amount": -1 + }, + { + "first": 80, + "second": 90, + "amount": -1 + }, + { + "first": 80, + "second": 97, + "amount": -1 + }, + { + "first": 80, + "second": 99, + "amount": -1 + }, + { + "first": 80, + "second": 100, + "amount": -1 + }, + { + "first": 80, + "second": 101, + "amount": -1 + }, + { + "first": 80, + "second": 102, + "amount": 0 + }, + { + "first": 80, + "second": 103, + "amount": -1 + }, + { + "first": 80, + "second": 111, + "amount": -1 + }, + { + "first": 80, + "second": 113, + "amount": -1 + }, + { + "first": 80, + "second": 115, + "amount": 0 + }, + { + "first": 80, + "second": 116, + "amount": 1 + }, + { + "first": 80, + "second": 117, + "amount": 0 + }, + { + "first": 80, + "second": 118, + "amount": 0 + }, + { + "first": 80, + "second": 119, + "amount": 0 + }, + { + "first": 80, + "second": 120, + "amount": 0 + }, + { + "first": 80, + "second": 121, + "amount": 0 + }, + { + "first": 80, + "second": 122, + "amount": 0 + }, + { + "first": 80, + "second": 169, + "amount": 0 + }, + { + "first": 80, + "second": 171, + "amount": -1 + }, + { + "first": 80, + "second": 174, + "amount": 0 + }, + { + "first": 80, + "second": 180, + "amount": -1 + }, + { + "first": 80, + "second": 192, + "amount": -5 + }, + { + "first": 80, + "second": 193, + "amount": -5 + }, + { + "first": 80, + "second": 194, + "amount": -5 + }, + { + "first": 80, + "second": 195, + "amount": -5 + }, + { + "first": 80, + "second": 196, + "amount": -5 + }, + { + "first": 80, + "second": 197, + "amount": -5 + }, + { + "first": 80, + "second": 198, + "amount": -5 + }, + { + "first": 80, + "second": 199, + "amount": 0 + }, + { + "first": 80, + "second": 210, + "amount": 0 + }, + { + "first": 80, + "second": 211, + "amount": 0 + }, + { + "first": 80, + "second": 212, + "amount": 0 + }, + { + "first": 80, + "second": 213, + "amount": 0 + }, + { + "first": 80, + "second": 214, + "amount": 0 + }, + { + "first": 80, + "second": 216, + "amount": 0 + }, + { + "first": 80, + "second": 217, + "amount": 0 + }, + { + "first": 80, + "second": 218, + "amount": 0 + }, + { + "first": 80, + "second": 219, + "amount": 0 + }, + { + "first": 80, + "second": 220, + "amount": 0 + }, + { + "first": 80, + "second": 221, + "amount": -1 + }, + { + "first": 80, + "second": 224, + "amount": -1 + }, + { + "first": 80, + "second": 225, + "amount": -1 + }, + { + "first": 80, + "second": 226, + "amount": -1 + }, + { + "first": 80, + "second": 227, + "amount": -1 + }, + { + "first": 80, + "second": 228, + "amount": -1 + }, + { + "first": 80, + "second": 229, + "amount": -1 + }, + { + "first": 80, + "second": 230, + "amount": -1 + }, + { + "first": 80, + "second": 231, + "amount": -1 + }, + { + "first": 80, + "second": 236, + "amount": 3 + }, + { + "first": 80, + "second": 237, + "amount": 3 + }, + { + "first": 80, + "second": 238, + "amount": 3 + }, + { + "first": 80, + "second": 239, + "amount": 3 + }, + { + "first": 80, + "second": 248, + "amount": -1 + }, + { + "first": 80, + "second": 250, + "amount": 0 + }, + { + "first": 80, + "second": 253, + "amount": 0 + }, + { + "first": 80, + "second": 255, + "amount": 0 + }, + { + "first": 80, + "second": 338, + "amount": 0 + }, + { + "first": 80, + "second": 339, + "amount": -1 + }, + { + "first": 80, + "second": 376, + "amount": -1 + }, + { + "first": 81, + "second": 44, + "amount": -1 + }, + { + "first": 81, + "second": 46, + "amount": -1 + }, + { + "first": 81, + "second": 47, + "amount": -1 + }, + { + "first": 81, + "second": 65, + "amount": -2 + }, + { + "first": 81, + "second": 74, + "amount": 0 + }, + { + "first": 81, + "second": 84, + "amount": -2 + }, + { + "first": 81, + "second": 86, + "amount": -3 + }, + { + "first": 81, + "second": 87, + "amount": -1 + }, + { + "first": 81, + "second": 88, + "amount": -1 + }, + { + "first": 81, + "second": 89, + "amount": -3 + }, + { + "first": 81, + "second": 90, + "amount": -1 + }, + { + "first": 81, + "second": 97, + "amount": 0 + }, + { + "first": 81, + "second": 99, + "amount": 0 + }, + { + "first": 81, + "second": 100, + "amount": 0 + }, + { + "first": 81, + "second": 101, + "amount": 0 + }, + { + "first": 81, + "second": 103, + "amount": 0 + }, + { + "first": 81, + "second": 111, + "amount": 0 + }, + { + "first": 81, + "second": 113, + "amount": 0 + }, + { + "first": 81, + "second": 115, + "amount": 0 + }, + { + "first": 81, + "second": 118, + "amount": 0 + }, + { + "first": 81, + "second": 119, + "amount": 0 + }, + { + "first": 81, + "second": 120, + "amount": 0 + }, + { + "first": 81, + "second": 121, + "amount": 0 + }, + { + "first": 81, + "second": 122, + "amount": 0 + }, + { + "first": 81, + "second": 192, + "amount": -2 + }, + { + "first": 81, + "second": 193, + "amount": -2 + }, + { + "first": 81, + "second": 194, + "amount": -2 + }, + { + "first": 81, + "second": 195, + "amount": -2 + }, + { + "first": 81, + "second": 196, + "amount": -2 + }, + { + "first": 81, + "second": 197, + "amount": -2 + }, + { + "first": 81, + "second": 221, + "amount": -3 + }, + { + "first": 81, + "second": 230, + "amount": 0 + }, + { + "first": 81, + "second": 231, + "amount": 0 + }, + { + "first": 81, + "second": 248, + "amount": 0 + }, + { + "first": 81, + "second": 339, + "amount": 0 + }, + { + "first": 81, + "second": 376, + "amount": -3 + }, + { + "first": 82, + "second": 38, + "amount": -1 + }, + { + "first": 82, + "second": 44, + "amount": 0 + }, + { + "first": 82, + "second": 46, + "amount": 0 + }, + { + "first": 82, + "second": 48, + "amount": 0 + }, + { + "first": 82, + "second": 50, + "amount": 0 + }, + { + "first": 82, + "second": 51, + "amount": 0 + }, + { + "first": 82, + "second": 52, + "amount": 0 + }, + { + "first": 82, + "second": 54, + "amount": 0 + }, + { + "first": 82, + "second": 58, + "amount": 0 + }, + { + "first": 82, + "second": 59, + "amount": 0 + }, + { + "first": 82, + "second": 64, + "amount": 0 + }, + { + "first": 82, + "second": 65, + "amount": -1 + }, + { + "first": 82, + "second": 67, + "amount": 0 + }, + { + "first": 82, + "second": 71, + "amount": 0 + }, + { + "first": 82, + "second": 74, + "amount": -1 + }, + { + "first": 82, + "second": 79, + "amount": 0 + }, + { + "first": 82, + "second": 81, + "amount": 0 + }, + { + "first": 82, + "second": 83, + "amount": 0 + }, + { + "first": 82, + "second": 84, + "amount": -1 + }, + { + "first": 82, + "second": 85, + "amount": 0 + }, + { + "first": 82, + "second": 86, + "amount": 0 + }, + { + "first": 82, + "second": 87, + "amount": 0 + }, + { + "first": 82, + "second": 88, + "amount": -1 + }, + { + "first": 82, + "second": 89, + "amount": -1 + }, + { + "first": 82, + "second": 90, + "amount": 0 + }, + { + "first": 82, + "second": 97, + "amount": -1 + }, + { + "first": 82, + "second": 99, + "amount": -1 + }, + { + "first": 82, + "second": 100, + "amount": -1 + }, + { + "first": 82, + "second": 101, + "amount": -1 + }, + { + "first": 82, + "second": 103, + "amount": -1 + }, + { + "first": 82, + "second": 111, + "amount": -1 + }, + { + "first": 82, + "second": 113, + "amount": -1 + }, + { + "first": 82, + "second": 115, + "amount": 0 + }, + { + "first": 82, + "second": 117, + "amount": 0 + }, + { + "first": 82, + "second": 118, + "amount": 0 + }, + { + "first": 82, + "second": 119, + "amount": 0 + }, + { + "first": 82, + "second": 120, + "amount": 0 + }, + { + "first": 82, + "second": 121, + "amount": 0 + }, + { + "first": 82, + "second": 122, + "amount": 0 + }, + { + "first": 82, + "second": 169, + "amount": 0 + }, + { + "first": 82, + "second": 171, + "amount": -1 + }, + { + "first": 82, + "second": 174, + "amount": 0 + }, + { + "first": 82, + "second": 180, + "amount": -1 + }, + { + "first": 82, + "second": 192, + "amount": -1 + }, + { + "first": 82, + "second": 193, + "amount": -1 + }, + { + "first": 82, + "second": 194, + "amount": -1 + }, + { + "first": 82, + "second": 195, + "amount": -1 + }, + { + "first": 82, + "second": 196, + "amount": -1 + }, + { + "first": 82, + "second": 197, + "amount": -1 + }, + { + "first": 82, + "second": 199, + "amount": 0 + }, + { + "first": 82, + "second": 210, + "amount": 0 + }, + { + "first": 82, + "second": 211, + "amount": 0 + }, + { + "first": 82, + "second": 212, + "amount": 0 + }, + { + "first": 82, + "second": 213, + "amount": 0 + }, + { + "first": 82, + "second": 214, + "amount": 0 + }, + { + "first": 82, + "second": 216, + "amount": 0 + }, + { + "first": 82, + "second": 217, + "amount": 0 + }, + { + "first": 82, + "second": 218, + "amount": 0 + }, + { + "first": 82, + "second": 219, + "amount": 0 + }, + { + "first": 82, + "second": 220, + "amount": 0 + }, + { + "first": 82, + "second": 221, + "amount": -1 + }, + { + "first": 82, + "second": 224, + "amount": -1 + }, + { + "first": 82, + "second": 225, + "amount": -1 + }, + { + "first": 82, + "second": 226, + "amount": -1 + }, + { + "first": 82, + "second": 227, + "amount": -1 + }, + { + "first": 82, + "second": 228, + "amount": -1 + }, + { + "first": 82, + "second": 229, + "amount": -1 + }, + { + "first": 82, + "second": 230, + "amount": -1 + }, + { + "first": 82, + "second": 231, + "amount": -1 + }, + { + "first": 82, + "second": 248, + "amount": -1 + }, + { + "first": 82, + "second": 250, + "amount": 0 + }, + { + "first": 82, + "second": 338, + "amount": 0 + }, + { + "first": 82, + "second": 339, + "amount": -1 + }, + { + "first": 82, + "second": 376, + "amount": -1 + }, + { + "first": 83, + "second": 47, + "amount": -1 + }, + { + "first": 83, + "second": 55, + "amount": 0 + }, + { + "first": 83, + "second": 65, + "amount": -1 + }, + { + "first": 83, + "second": 86, + "amount": -2 + }, + { + "first": 83, + "second": 88, + "amount": -1 + }, + { + "first": 83, + "second": 89, + "amount": -2 + }, + { + "first": 83, + "second": 90, + "amount": 0 + }, + { + "first": 83, + "second": 99, + "amount": 0 + }, + { + "first": 83, + "second": 100, + "amount": 0 + }, + { + "first": 83, + "second": 101, + "amount": 0 + }, + { + "first": 83, + "second": 102, + "amount": 0 + }, + { + "first": 83, + "second": 103, + "amount": 0 + }, + { + "first": 83, + "second": 111, + "amount": 0 + }, + { + "first": 83, + "second": 113, + "amount": 0 + }, + { + "first": 83, + "second": 118, + "amount": -1 + }, + { + "first": 83, + "second": 119, + "amount": 0 + }, + { + "first": 83, + "second": 120, + "amount": -1 + }, + { + "first": 83, + "second": 121, + "amount": -1 + }, + { + "first": 83, + "second": 192, + "amount": -1 + }, + { + "first": 83, + "second": 193, + "amount": -1 + }, + { + "first": 83, + "second": 194, + "amount": -1 + }, + { + "first": 83, + "second": 195, + "amount": -1 + }, + { + "first": 83, + "second": 196, + "amount": -1 + }, + { + "first": 83, + "second": 197, + "amount": -1 + }, + { + "first": 83, + "second": 221, + "amount": -2 + }, + { + "first": 83, + "second": 231, + "amount": 0 + }, + { + "first": 83, + "second": 248, + "amount": 0 + }, + { + "first": 83, + "second": 339, + "amount": 0 + }, + { + "first": 83, + "second": 376, + "amount": -2 + }, + { + "first": 84, + "second": 32, + "amount": -1 + }, + { + "first": 84, + "second": 38, + "amount": -3 + }, + { + "first": 84, + "second": 41, + "amount": 0 + }, + { + "first": 84, + "second": 44, + "amount": -4 + }, + { + "first": 84, + "second": 45, + "amount": -2 + }, + { + "first": 84, + "second": 46, + "amount": -4 + }, + { + "first": 84, + "second": 47, + "amount": -5 + }, + { + "first": 84, + "second": 48, + "amount": -1 + }, + { + "first": 84, + "second": 49, + "amount": 0 + }, + { + "first": 84, + "second": 50, + "amount": -1 + }, + { + "first": 84, + "second": 51, + "amount": -1 + }, + { + "first": 84, + "second": 52, + "amount": -3 + }, + { + "first": 84, + "second": 54, + "amount": -3 + }, + { + "first": 84, + "second": 55, + "amount": 0 + }, + { + "first": 84, + "second": 56, + "amount": -1 + }, + { + "first": 84, + "second": 58, + "amount": -1 + }, + { + "first": 84, + "second": 59, + "amount": -1 + }, + { + "first": 84, + "second": 64, + "amount": -2 + }, + { + "first": 84, + "second": 65, + "amount": -5 + }, + { + "first": 84, + "second": 67, + "amount": -2 + }, + { + "first": 84, + "second": 71, + "amount": -2 + }, + { + "first": 84, + "second": 74, + "amount": -5 + }, + { + "first": 84, + "second": 79, + "amount": -2 + }, + { + "first": 84, + "second": 81, + "amount": -2 + }, + { + "first": 84, + "second": 88, + "amount": 0 + }, + { + "first": 84, + "second": 92, + "amount": 0 + }, + { + "first": 84, + "second": 97, + "amount": -3 + }, + { + "first": 84, + "second": 99, + "amount": -4 + }, + { + "first": 84, + "second": 100, + "amount": -4 + }, + { + "first": 84, + "second": 101, + "amount": -4 + }, + { + "first": 84, + "second": 102, + "amount": -1 + }, + { + "first": 84, + "second": 103, + "amount": -4 + }, + { + "first": 84, + "second": 109, + "amount": -2 + }, + { + "first": 84, + "second": 110, + "amount": -2 + }, + { + "first": 84, + "second": 111, + "amount": -4 + }, + { + "first": 84, + "second": 112, + "amount": -2 + }, + { + "first": 84, + "second": 113, + "amount": -4 + }, + { + "first": 84, + "second": 114, + "amount": -2 + }, + { + "first": 84, + "second": 115, + "amount": -3 + }, + { + "first": 84, + "second": 116, + "amount": -1 + }, + { + "first": 84, + "second": 117, + "amount": -3 + }, + { + "first": 84, + "second": 118, + "amount": -2 + }, + { + "first": 84, + "second": 119, + "amount": -2 + }, + { + "first": 84, + "second": 120, + "amount": -2 + }, + { + "first": 84, + "second": 121, + "amount": -2 + }, + { + "first": 84, + "second": 122, + "amount": -2 + }, + { + "first": 84, + "second": 169, + "amount": -2 + }, + { + "first": 84, + "second": 171, + "amount": -3 + }, + { + "first": 84, + "second": 174, + "amount": -2 + }, + { + "first": 84, + "second": 180, + "amount": -2 + }, + { + "first": 84, + "second": 181, + "amount": -2 + }, + { + "first": 84, + "second": 187, + "amount": -2 + }, + { + "first": 84, + "second": 192, + "amount": -5 + }, + { + "first": 84, + "second": 193, + "amount": -5 + }, + { + "first": 84, + "second": 194, + "amount": -5 + }, + { + "first": 84, + "second": 195, + "amount": -5 + }, + { + "first": 84, + "second": 196, + "amount": -5 + }, + { + "first": 84, + "second": 197, + "amount": -5 + }, + { + "first": 84, + "second": 198, + "amount": -4 + }, + { + "first": 84, + "second": 199, + "amount": -2 + }, + { + "first": 84, + "second": 210, + "amount": -2 + }, + { + "first": 84, + "second": 211, + "amount": -2 + }, + { + "first": 84, + "second": 212, + "amount": -2 + }, + { + "first": 84, + "second": 213, + "amount": -2 + }, + { + "first": 84, + "second": 214, + "amount": -2 + }, + { + "first": 84, + "second": 216, + "amount": -2 + }, + { + "first": 84, + "second": 224, + "amount": -2 + }, + { + "first": 84, + "second": 225, + "amount": -2 + }, + { + "first": 84, + "second": 226, + "amount": -2 + }, + { + "first": 84, + "second": 227, + "amount": -2 + }, + { + "first": 84, + "second": 228, + "amount": -2 + }, + { + "first": 84, + "second": 229, + "amount": -2 + }, + { + "first": 84, + "second": 230, + "amount": -3 + }, + { + "first": 84, + "second": 231, + "amount": -4 + }, + { + "first": 84, + "second": 232, + "amount": -3 + }, + { + "first": 84, + "second": 233, + "amount": -3 + }, + { + "first": 84, + "second": 234, + "amount": -3 + }, + { + "first": 84, + "second": 235, + "amount": -3 + }, + { + "first": 84, + "second": 236, + "amount": 3 + }, + { + "first": 84, + "second": 237, + "amount": 3 + }, + { + "first": 84, + "second": 238, + "amount": 3 + }, + { + "first": 84, + "second": 239, + "amount": 3 + }, + { + "first": 84, + "second": 240, + "amount": -3 + }, + { + "first": 84, + "second": 242, + "amount": -3 + }, + { + "first": 84, + "second": 243, + "amount": -3 + }, + { + "first": 84, + "second": 244, + "amount": -3 + }, + { + "first": 84, + "second": 245, + "amount": -3 + }, + { + "first": 84, + "second": 246, + "amount": -3 + }, + { + "first": 84, + "second": 248, + "amount": -4 + }, + { + "first": 84, + "second": 249, + "amount": -1 + }, + { + "first": 84, + "second": 250, + "amount": -3 + }, + { + "first": 84, + "second": 251, + "amount": -1 + }, + { + "first": 84, + "second": 252, + "amount": -1 + }, + { + "first": 84, + "second": 253, + "amount": -1 + }, + { + "first": 84, + "second": 255, + "amount": -1 + }, + { + "first": 84, + "second": 338, + "amount": -2 + }, + { + "first": 84, + "second": 339, + "amount": -4 + }, + { + "first": 85, + "second": 44, + "amount": -2 + }, + { + "first": 85, + "second": 46, + "amount": -2 + }, + { + "first": 85, + "second": 47, + "amount": -2 + }, + { + "first": 85, + "second": 65, + "amount": -1 + }, + { + "first": 85, + "second": 88, + "amount": 0 + }, + { + "first": 85, + "second": 115, + "amount": 0 + }, + { + "first": 85, + "second": 120, + "amount": 0 + }, + { + "first": 85, + "second": 192, + "amount": -1 + }, + { + "first": 85, + "second": 193, + "amount": -1 + }, + { + "first": 85, + "second": 194, + "amount": -1 + }, + { + "first": 85, + "second": 195, + "amount": -1 + }, + { + "first": 85, + "second": 196, + "amount": -1 + }, + { + "first": 85, + "second": 197, + "amount": -1 + }, + { + "first": 86, + "second": 32, + "amount": -2 + }, + { + "first": 86, + "second": 38, + "amount": -3 + }, + { + "first": 86, + "second": 41, + "amount": -1 + }, + { + "first": 86, + "second": 44, + "amount": -5 + }, + { + "first": 86, + "second": 45, + "amount": -1 + }, + { + "first": 86, + "second": 46, + "amount": -5 + }, + { + "first": 86, + "second": 47, + "amount": -6 + }, + { + "first": 86, + "second": 48, + "amount": -2 + }, + { + "first": 86, + "second": 50, + "amount": -1 + }, + { + "first": 86, + "second": 51, + "amount": -1 + }, + { + "first": 86, + "second": 52, + "amount": -4 + }, + { + "first": 86, + "second": 53, + "amount": -1 + }, + { + "first": 86, + "second": 54, + "amount": -3 + }, + { + "first": 86, + "second": 55, + "amount": 0 + }, + { + "first": 86, + "second": 56, + "amount": -3 + }, + { + "first": 86, + "second": 57, + "amount": -2 + }, + { + "first": 86, + "second": 58, + "amount": -4 + }, + { + "first": 86, + "second": 59, + "amount": -4 + }, + { + "first": 86, + "second": 64, + "amount": -3 + }, + { + "first": 86, + "second": 65, + "amount": -5 + }, + { + "first": 86, + "second": 66, + "amount": -1 + }, + { + "first": 86, + "second": 67, + "amount": -3 + }, + { + "first": 86, + "second": 68, + "amount": -1 + }, + { + "first": 86, + "second": 69, + "amount": -1 + }, + { + "first": 86, + "second": 70, + "amount": -1 + }, + { + "first": 86, + "second": 71, + "amount": -3 + }, + { + "first": 86, + "second": 72, + "amount": -1 + }, + { + "first": 86, + "second": 73, + "amount": -1 + }, + { + "first": 86, + "second": 74, + "amount": -6 + }, + { + "first": 86, + "second": 75, + "amount": -1 + }, + { + "first": 86, + "second": 76, + "amount": -1 + }, + { + "first": 86, + "second": 77, + "amount": -1 + }, + { + "first": 86, + "second": 78, + "amount": -1 + }, + { + "first": 86, + "second": 79, + "amount": -3 + }, + { + "first": 86, + "second": 80, + "amount": -1 + }, + { + "first": 86, + "second": 81, + "amount": -3 + }, + { + "first": 86, + "second": 82, + "amount": -1 + }, + { + "first": 86, + "second": 83, + "amount": -1 + }, + { + "first": 86, + "second": 88, + "amount": -1 + }, + { + "first": 86, + "second": 97, + "amount": -4 + }, + { + "first": 86, + "second": 99, + "amount": -4 + }, + { + "first": 86, + "second": 100, + "amount": -4 + }, + { + "first": 86, + "second": 101, + "amount": -4 + }, + { + "first": 86, + "second": 102, + "amount": -1 + }, + { + "first": 86, + "second": 103, + "amount": -4 + }, + { + "first": 86, + "second": 109, + "amount": -1 + }, + { + "first": 86, + "second": 110, + "amount": -1 + }, + { + "first": 86, + "second": 111, + "amount": -4 + }, + { + "first": 86, + "second": 112, + "amount": -1 + }, + { + "first": 86, + "second": 113, + "amount": -4 + }, + { + "first": 86, + "second": 114, + "amount": -1 + }, + { + "first": 86, + "second": 115, + "amount": -3 + }, + { + "first": 86, + "second": 117, + "amount": -1 + }, + { + "first": 86, + "second": 118, + "amount": -2 + }, + { + "first": 86, + "second": 119, + "amount": -1 + }, + { + "first": 86, + "second": 120, + "amount": -3 + }, + { + "first": 86, + "second": 121, + "amount": -2 + }, + { + "first": 86, + "second": 122, + "amount": -2 + }, + { + "first": 86, + "second": 169, + "amount": -3 + }, + { + "first": 86, + "second": 171, + "amount": -4 + }, + { + "first": 86, + "second": 174, + "amount": -3 + }, + { + "first": 86, + "second": 180, + "amount": -3 + }, + { + "first": 86, + "second": 181, + "amount": -1 + }, + { + "first": 86, + "second": 187, + "amount": -1 + }, + { + "first": 86, + "second": 192, + "amount": -5 + }, + { + "first": 86, + "second": 193, + "amount": -5 + }, + { + "first": 86, + "second": 194, + "amount": -5 + }, + { + "first": 86, + "second": 195, + "amount": -5 + }, + { + "first": 86, + "second": 196, + "amount": -5 + }, + { + "first": 86, + "second": 197, + "amount": -5 + }, + { + "first": 86, + "second": 198, + "amount": -5 + }, + { + "first": 86, + "second": 199, + "amount": -3 + }, + { + "first": 86, + "second": 200, + "amount": -1 + }, + { + "first": 86, + "second": 201, + "amount": -1 + }, + { + "first": 86, + "second": 202, + "amount": -1 + }, + { + "first": 86, + "second": 203, + "amount": -1 + }, + { + "first": 86, + "second": 204, + "amount": -1 + }, + { + "first": 86, + "second": 205, + "amount": -1 + }, + { + "first": 86, + "second": 206, + "amount": -1 + }, + { + "first": 86, + "second": 207, + "amount": -1 + }, + { + "first": 86, + "second": 209, + "amount": -1 + }, + { + "first": 86, + "second": 210, + "amount": -3 + }, + { + "first": 86, + "second": 211, + "amount": -3 + }, + { + "first": 86, + "second": 212, + "amount": -3 + }, + { + "first": 86, + "second": 213, + "amount": -3 + }, + { + "first": 86, + "second": 214, + "amount": -3 + }, + { + "first": 86, + "second": 216, + "amount": -3 + }, + { + "first": 86, + "second": 222, + "amount": -1 + }, + { + "first": 86, + "second": 224, + "amount": -3 + }, + { + "first": 86, + "second": 225, + "amount": -3 + }, + { + "first": 86, + "second": 226, + "amount": -3 + }, + { + "first": 86, + "second": 227, + "amount": -3 + }, + { + "first": 86, + "second": 228, + "amount": -3 + }, + { + "first": 86, + "second": 229, + "amount": -3 + }, + { + "first": 86, + "second": 230, + "amount": -4 + }, + { + "first": 86, + "second": 231, + "amount": -4 + }, + { + "first": 86, + "second": 232, + "amount": -3 + }, + { + "first": 86, + "second": 233, + "amount": -3 + }, + { + "first": 86, + "second": 234, + "amount": -3 + }, + { + "first": 86, + "second": 235, + "amount": -3 + }, + { + "first": 86, + "second": 236, + "amount": 3 + }, + { + "first": 86, + "second": 237, + "amount": 3 + }, + { + "first": 86, + "second": 238, + "amount": 3 + }, + { + "first": 86, + "second": 239, + "amount": 3 + }, + { + "first": 86, + "second": 240, + "amount": -3 + }, + { + "first": 86, + "second": 241, + "amount": -1 + }, + { + "first": 86, + "second": 242, + "amount": -3 + }, + { + "first": 86, + "second": 243, + "amount": -3 + }, + { + "first": 86, + "second": 244, + "amount": -3 + }, + { + "first": 86, + "second": 245, + "amount": -3 + }, + { + "first": 86, + "second": 246, + "amount": -3 + }, + { + "first": 86, + "second": 248, + "amount": -4 + }, + { + "first": 86, + "second": 249, + "amount": -1 + }, + { + "first": 86, + "second": 250, + "amount": -1 + }, + { + "first": 86, + "second": 251, + "amount": -1 + }, + { + "first": 86, + "second": 252, + "amount": -1 + }, + { + "first": 86, + "second": 253, + "amount": -2 + }, + { + "first": 86, + "second": 255, + "amount": -2 + }, + { + "first": 86, + "second": 338, + "amount": -3 + }, + { + "first": 86, + "second": 339, + "amount": -4 + }, + { + "first": 87, + "second": 32, + "amount": 0 + }, + { + "first": 87, + "second": 41, + "amount": 0 + }, + { + "first": 87, + "second": 44, + "amount": -1 + }, + { + "first": 87, + "second": 46, + "amount": -1 + }, + { + "first": 87, + "second": 47, + "amount": -4 + }, + { + "first": 87, + "second": 48, + "amount": 0 + }, + { + "first": 87, + "second": 50, + "amount": 0 + }, + { + "first": 87, + "second": 51, + "amount": 0 + }, + { + "first": 87, + "second": 52, + "amount": -1 + }, + { + "first": 87, + "second": 53, + "amount": 0 + }, + { + "first": 87, + "second": 54, + "amount": 0 + }, + { + "first": 87, + "second": 56, + "amount": 0 + }, + { + "first": 87, + "second": 57, + "amount": 0 + }, + { + "first": 87, + "second": 58, + "amount": 0 + }, + { + "first": 87, + "second": 59, + "amount": 0 + }, + { + "first": 87, + "second": 63, + "amount": 0 + }, + { + "first": 87, + "second": 64, + "amount": -1 + }, + { + "first": 87, + "second": 65, + "amount": -2 + }, + { + "first": 87, + "second": 66, + "amount": -1 + }, + { + "first": 87, + "second": 67, + "amount": -1 + }, + { + "first": 87, + "second": 68, + "amount": -1 + }, + { + "first": 87, + "second": 69, + "amount": -1 + }, + { + "first": 87, + "second": 70, + "amount": -1 + }, + { + "first": 87, + "second": 71, + "amount": -1 + }, + { + "first": 87, + "second": 72, + "amount": -1 + }, + { + "first": 87, + "second": 73, + "amount": -1 + }, + { + "first": 87, + "second": 74, + "amount": -2 + }, + { + "first": 87, + "second": 75, + "amount": -1 + }, + { + "first": 87, + "second": 76, + "amount": -1 + }, + { + "first": 87, + "second": 77, + "amount": -1 + }, + { + "first": 87, + "second": 78, + "amount": -1 + }, + { + "first": 87, + "second": 79, + "amount": -1 + }, + { + "first": 87, + "second": 80, + "amount": -1 + }, + { + "first": 87, + "second": 81, + "amount": -1 + }, + { + "first": 87, + "second": 82, + "amount": -1 + }, + { + "first": 87, + "second": 88, + "amount": -1 + }, + { + "first": 87, + "second": 92, + "amount": 0 + }, + { + "first": 87, + "second": 97, + "amount": -2 + }, + { + "first": 87, + "second": 99, + "amount": -1 + }, + { + "first": 87, + "second": 100, + "amount": -1 + }, + { + "first": 87, + "second": 101, + "amount": -1 + }, + { + "first": 87, + "second": 102, + "amount": 0 + }, + { + "first": 87, + "second": 103, + "amount": -1 + }, + { + "first": 87, + "second": 109, + "amount": -1 + }, + { + "first": 87, + "second": 110, + "amount": -1 + }, + { + "first": 87, + "second": 111, + "amount": -1 + }, + { + "first": 87, + "second": 112, + "amount": -1 + }, + { + "first": 87, + "second": 113, + "amount": -1 + }, + { + "first": 87, + "second": 114, + "amount": -1 + }, + { + "first": 87, + "second": 118, + "amount": 0 + }, + { + "first": 87, + "second": 119, + "amount": -1 + }, + { + "first": 87, + "second": 120, + "amount": -1 + }, + { + "first": 87, + "second": 121, + "amount": 0 + }, + { + "first": 87, + "second": 122, + "amount": 0 + }, + { + "first": 87, + "second": 169, + "amount": -1 + }, + { + "first": 87, + "second": 171, + "amount": -1 + }, + { + "first": 87, + "second": 174, + "amount": -1 + }, + { + "first": 87, + "second": 180, + "amount": -3 + }, + { + "first": 87, + "second": 181, + "amount": -1 + }, + { + "first": 87, + "second": 192, + "amount": -2 + }, + { + "first": 87, + "second": 193, + "amount": -2 + }, + { + "first": 87, + "second": 194, + "amount": -2 + }, + { + "first": 87, + "second": 195, + "amount": -2 + }, + { + "first": 87, + "second": 196, + "amount": -2 + }, + { + "first": 87, + "second": 197, + "amount": -2 + }, + { + "first": 87, + "second": 198, + "amount": -2 + }, + { + "first": 87, + "second": 199, + "amount": -1 + }, + { + "first": 87, + "second": 200, + "amount": -1 + }, + { + "first": 87, + "second": 201, + "amount": -1 + }, + { + "first": 87, + "second": 202, + "amount": -1 + }, + { + "first": 87, + "second": 203, + "amount": -1 + }, + { + "first": 87, + "second": 204, + "amount": -1 + }, + { + "first": 87, + "second": 205, + "amount": -1 + }, + { + "first": 87, + "second": 206, + "amount": -1 + }, + { + "first": 87, + "second": 207, + "amount": -1 + }, + { + "first": 87, + "second": 209, + "amount": -1 + }, + { + "first": 87, + "second": 210, + "amount": -1 + }, + { + "first": 87, + "second": 211, + "amount": -1 + }, + { + "first": 87, + "second": 212, + "amount": -1 + }, + { + "first": 87, + "second": 213, + "amount": -1 + }, + { + "first": 87, + "second": 214, + "amount": -1 + }, + { + "first": 87, + "second": 216, + "amount": -1 + }, + { + "first": 87, + "second": 222, + "amount": -1 + }, + { + "first": 87, + "second": 224, + "amount": -3 + }, + { + "first": 87, + "second": 225, + "amount": -3 + }, + { + "first": 87, + "second": 226, + "amount": -3 + }, + { + "first": 87, + "second": 227, + "amount": -3 + }, + { + "first": 87, + "second": 228, + "amount": -3 + }, + { + "first": 87, + "second": 229, + "amount": -3 + }, + { + "first": 87, + "second": 230, + "amount": -2 + }, + { + "first": 87, + "second": 231, + "amount": -1 + }, + { + "first": 87, + "second": 236, + "amount": 3 + }, + { + "first": 87, + "second": 237, + "amount": 3 + }, + { + "first": 87, + "second": 238, + "amount": 3 + }, + { + "first": 87, + "second": 239, + "amount": 3 + }, + { + "first": 87, + "second": 248, + "amount": -1 + }, + { + "first": 87, + "second": 249, + "amount": 0 + }, + { + "first": 87, + "second": 251, + "amount": 0 + }, + { + "first": 87, + "second": 252, + "amount": 0 + }, + { + "first": 87, + "second": 338, + "amount": -1 + }, + { + "first": 87, + "second": 339, + "amount": -1 + }, + { + "first": 88, + "second": 38, + "amount": -1 + }, + { + "first": 88, + "second": 45, + "amount": -1 + }, + { + "first": 88, + "second": 48, + "amount": -1 + }, + { + "first": 88, + "second": 51, + "amount": 0 + }, + { + "first": 88, + "second": 52, + "amount": 0 + }, + { + "first": 88, + "second": 54, + "amount": -1 + }, + { + "first": 88, + "second": 56, + "amount": 0 + }, + { + "first": 88, + "second": 57, + "amount": -1 + }, + { + "first": 88, + "second": 63, + "amount": 0 + }, + { + "first": 88, + "second": 64, + "amount": -2 + }, + { + "first": 88, + "second": 65, + "amount": -1 + }, + { + "first": 88, + "second": 66, + "amount": 0 + }, + { + "first": 88, + "second": 67, + "amount": -2 + }, + { + "first": 88, + "second": 68, + "amount": 0 + }, + { + "first": 88, + "second": 69, + "amount": 0 + }, + { + "first": 88, + "second": 70, + "amount": 0 + }, + { + "first": 88, + "second": 71, + "amount": -2 + }, + { + "first": 88, + "second": 72, + "amount": 0 + }, + { + "first": 88, + "second": 73, + "amount": 0 + }, + { + "first": 88, + "second": 74, + "amount": -1 + }, + { + "first": 88, + "second": 75, + "amount": 0 + }, + { + "first": 88, + "second": 76, + "amount": 0 + }, + { + "first": 88, + "second": 77, + "amount": 0 + }, + { + "first": 88, + "second": 78, + "amount": 0 + }, + { + "first": 88, + "second": 79, + "amount": -2 + }, + { + "first": 88, + "second": 80, + "amount": 0 + }, + { + "first": 88, + "second": 81, + "amount": -2 + }, + { + "first": 88, + "second": 82, + "amount": 0 + }, + { + "first": 88, + "second": 83, + "amount": -1 + }, + { + "first": 88, + "second": 84, + "amount": 0 + }, + { + "first": 88, + "second": 85, + "amount": 0 + }, + { + "first": 88, + "second": 86, + "amount": -1 + }, + { + "first": 88, + "second": 87, + "amount": 0 + }, + { + "first": 88, + "second": 89, + "amount": -1 + }, + { + "first": 88, + "second": 90, + "amount": 0 + }, + { + "first": 88, + "second": 97, + "amount": -1 + }, + { + "first": 88, + "second": 98, + "amount": 0 + }, + { + "first": 88, + "second": 99, + "amount": -1 + }, + { + "first": 88, + "second": 100, + "amount": -1 + }, + { + "first": 88, + "second": 101, + "amount": -1 + }, + { + "first": 88, + "second": 102, + "amount": -1 + }, + { + "first": 88, + "second": 103, + "amount": -1 + }, + { + "first": 88, + "second": 104, + "amount": 0 + }, + { + "first": 88, + "second": 105, + "amount": 0 + }, + { + "first": 88, + "second": 106, + "amount": 0 + }, + { + "first": 88, + "second": 107, + "amount": 0 + }, + { + "first": 88, + "second": 108, + "amount": 0 + }, + { + "first": 88, + "second": 109, + "amount": 0 + }, + { + "first": 88, + "second": 110, + "amount": 0 + }, + { + "first": 88, + "second": 111, + "amount": -1 + }, + { + "first": 88, + "second": 112, + "amount": 0 + }, + { + "first": 88, + "second": 113, + "amount": -1 + }, + { + "first": 88, + "second": 114, + "amount": 0 + }, + { + "first": 88, + "second": 115, + "amount": -1 + }, + { + "first": 88, + "second": 116, + "amount": -1 + }, + { + "first": 88, + "second": 117, + "amount": -1 + }, + { + "first": 88, + "second": 118, + "amount": -1 + }, + { + "first": 88, + "second": 119, + "amount": -1 + }, + { + "first": 88, + "second": 120, + "amount": 0 + }, + { + "first": 88, + "second": 121, + "amount": -1 + }, + { + "first": 88, + "second": 122, + "amount": 0 + }, + { + "first": 88, + "second": 169, + "amount": -2 + }, + { + "first": 88, + "second": 171, + "amount": -1 + }, + { + "first": 88, + "second": 174, + "amount": -2 + }, + { + "first": 88, + "second": 181, + "amount": 0 + }, + { + "first": 88, + "second": 192, + "amount": -1 + }, + { + "first": 88, + "second": 193, + "amount": -1 + }, + { + "first": 88, + "second": 194, + "amount": -1 + }, + { + "first": 88, + "second": 195, + "amount": -1 + }, + { + "first": 88, + "second": 196, + "amount": -1 + }, + { + "first": 88, + "second": 197, + "amount": -1 + }, + { + "first": 88, + "second": 199, + "amount": -2 + }, + { + "first": 88, + "second": 200, + "amount": 0 + }, + { + "first": 88, + "second": 201, + "amount": 0 + }, + { + "first": 88, + "second": 202, + "amount": 0 + }, + { + "first": 88, + "second": 203, + "amount": 0 + }, + { + "first": 88, + "second": 204, + "amount": 0 + }, + { + "first": 88, + "second": 205, + "amount": 0 + }, + { + "first": 88, + "second": 206, + "amount": 0 + }, + { + "first": 88, + "second": 207, + "amount": 0 + }, + { + "first": 88, + "second": 209, + "amount": 0 + }, + { + "first": 88, + "second": 210, + "amount": -2 + }, + { + "first": 88, + "second": 211, + "amount": -2 + }, + { + "first": 88, + "second": 212, + "amount": -2 + }, + { + "first": 88, + "second": 213, + "amount": -2 + }, + { + "first": 88, + "second": 214, + "amount": -2 + }, + { + "first": 88, + "second": 216, + "amount": -2 + }, + { + "first": 88, + "second": 217, + "amount": 0 + }, + { + "first": 88, + "second": 218, + "amount": 0 + }, + { + "first": 88, + "second": 219, + "amount": 0 + }, + { + "first": 88, + "second": 220, + "amount": 0 + }, + { + "first": 88, + "second": 221, + "amount": -1 + }, + { + "first": 88, + "second": 222, + "amount": 0 + }, + { + "first": 88, + "second": 223, + "amount": 0 + }, + { + "first": 88, + "second": 230, + "amount": -1 + }, + { + "first": 88, + "second": 231, + "amount": -1 + }, + { + "first": 88, + "second": 248, + "amount": -1 + }, + { + "first": 88, + "second": 250, + "amount": -1 + }, + { + "first": 88, + "second": 254, + "amount": 0 + }, + { + "first": 88, + "second": 338, + "amount": -2 + }, + { + "first": 88, + "second": 339, + "amount": -1 + }, + { + "first": 88, + "second": 376, + "amount": -1 + }, + { + "first": 89, + "second": 32, + "amount": -2 + }, + { + "first": 89, + "second": 38, + "amount": -3 + }, + { + "first": 89, + "second": 41, + "amount": -1 + }, + { + "first": 89, + "second": 44, + "amount": -5 + }, + { + "first": 89, + "second": 45, + "amount": -1 + }, + { + "first": 89, + "second": 46, + "amount": -5 + }, + { + "first": 89, + "second": 47, + "amount": -6 + }, + { + "first": 89, + "second": 48, + "amount": -2 + }, + { + "first": 89, + "second": 50, + "amount": -1 + }, + { + "first": 89, + "second": 51, + "amount": -1 + }, + { + "first": 89, + "second": 52, + "amount": -4 + }, + { + "first": 89, + "second": 53, + "amount": -1 + }, + { + "first": 89, + "second": 54, + "amount": -3 + }, + { + "first": 89, + "second": 55, + "amount": 0 + }, + { + "first": 89, + "second": 56, + "amount": -3 + }, + { + "first": 89, + "second": 57, + "amount": -2 + }, + { + "first": 89, + "second": 58, + "amount": -4 + }, + { + "first": 89, + "second": 59, + "amount": -4 + }, + { + "first": 89, + "second": 64, + "amount": -3 + }, + { + "first": 89, + "second": 65, + "amount": -5 + }, + { + "first": 89, + "second": 66, + "amount": -1 + }, + { + "first": 89, + "second": 67, + "amount": -3 + }, + { + "first": 89, + "second": 68, + "amount": -1 + }, + { + "first": 89, + "second": 69, + "amount": -1 + }, + { + "first": 89, + "second": 70, + "amount": -1 + }, + { + "first": 89, + "second": 71, + "amount": -3 + }, + { + "first": 89, + "second": 72, + "amount": -1 + }, + { + "first": 89, + "second": 73, + "amount": -1 + }, + { + "first": 89, + "second": 74, + "amount": -6 + }, + { + "first": 89, + "second": 75, + "amount": -1 + }, + { + "first": 89, + "second": 76, + "amount": -1 + }, + { + "first": 89, + "second": 77, + "amount": -1 + }, + { + "first": 89, + "second": 78, + "amount": -1 + }, + { + "first": 89, + "second": 79, + "amount": -3 + }, + { + "first": 89, + "second": 80, + "amount": -1 + }, + { + "first": 89, + "second": 81, + "amount": -3 + }, + { + "first": 89, + "second": 82, + "amount": -1 + }, + { + "first": 89, + "second": 83, + "amount": -1 + }, + { + "first": 89, + "second": 88, + "amount": -1 + }, + { + "first": 89, + "second": 97, + "amount": -4 + }, + { + "first": 89, + "second": 99, + "amount": -4 + }, + { + "first": 89, + "second": 100, + "amount": -4 + }, + { + "first": 89, + "second": 101, + "amount": -4 + }, + { + "first": 89, + "second": 102, + "amount": -1 + }, + { + "first": 89, + "second": 103, + "amount": -4 + }, + { + "first": 89, + "second": 109, + "amount": -1 + }, + { + "first": 89, + "second": 110, + "amount": -1 + }, + { + "first": 89, + "second": 111, + "amount": -4 + }, + { + "first": 89, + "second": 112, + "amount": -1 + }, + { + "first": 89, + "second": 113, + "amount": -4 + }, + { + "first": 89, + "second": 114, + "amount": -1 + }, + { + "first": 89, + "second": 115, + "amount": -3 + }, + { + "first": 89, + "second": 117, + "amount": -1 + }, + { + "first": 89, + "second": 118, + "amount": -2 + }, + { + "first": 89, + "second": 119, + "amount": -1 + }, + { + "first": 89, + "second": 120, + "amount": -3 + }, + { + "first": 89, + "second": 121, + "amount": -2 + }, + { + "first": 89, + "second": 122, + "amount": -2 + }, + { + "first": 89, + "second": 169, + "amount": -3 + }, + { + "first": 89, + "second": 171, + "amount": -4 + }, + { + "first": 89, + "second": 174, + "amount": -3 + }, + { + "first": 89, + "second": 180, + "amount": -3 + }, + { + "first": 89, + "second": 181, + "amount": -1 + }, + { + "first": 89, + "second": 187, + "amount": -1 + }, + { + "first": 89, + "second": 192, + "amount": -5 + }, + { + "first": 89, + "second": 193, + "amount": -5 + }, + { + "first": 89, + "second": 194, + "amount": -5 + }, + { + "first": 89, + "second": 195, + "amount": -5 + }, + { + "first": 89, + "second": 196, + "amount": -5 + }, + { + "first": 89, + "second": 197, + "amount": -5 + }, + { + "first": 89, + "second": 198, + "amount": -5 + }, + { + "first": 89, + "second": 199, + "amount": -3 + }, + { + "first": 89, + "second": 200, + "amount": -1 + }, + { + "first": 89, + "second": 201, + "amount": -1 + }, + { + "first": 89, + "second": 202, + "amount": -1 + }, + { + "first": 89, + "second": 203, + "amount": -1 + }, + { + "first": 89, + "second": 204, + "amount": -1 + }, + { + "first": 89, + "second": 205, + "amount": -1 + }, + { + "first": 89, + "second": 206, + "amount": -1 + }, + { + "first": 89, + "second": 207, + "amount": -1 + }, + { + "first": 89, + "second": 209, + "amount": -1 + }, + { + "first": 89, + "second": 210, + "amount": -3 + }, + { + "first": 89, + "second": 211, + "amount": -3 + }, + { + "first": 89, + "second": 212, + "amount": -3 + }, + { + "first": 89, + "second": 213, + "amount": -3 + }, + { + "first": 89, + "second": 214, + "amount": -3 + }, + { + "first": 89, + "second": 216, + "amount": -3 + }, + { + "first": 89, + "second": 222, + "amount": -1 + }, + { + "first": 89, + "second": 224, + "amount": -3 + }, + { + "first": 89, + "second": 225, + "amount": -3 + }, + { + "first": 89, + "second": 226, + "amount": -3 + }, + { + "first": 89, + "second": 227, + "amount": -3 + }, + { + "first": 89, + "second": 228, + "amount": -3 + }, + { + "first": 89, + "second": 229, + "amount": -3 + }, + { + "first": 89, + "second": 230, + "amount": -4 + }, + { + "first": 89, + "second": 231, + "amount": -4 + }, + { + "first": 89, + "second": 232, + "amount": -3 + }, + { + "first": 89, + "second": 233, + "amount": -3 + }, + { + "first": 89, + "second": 234, + "amount": -3 + }, + { + "first": 89, + "second": 235, + "amount": -3 + }, + { + "first": 89, + "second": 236, + "amount": 3 + }, + { + "first": 89, + "second": 237, + "amount": 3 + }, + { + "first": 89, + "second": 238, + "amount": 3 + }, + { + "first": 89, + "second": 239, + "amount": 3 + }, + { + "first": 89, + "second": 240, + "amount": -3 + }, + { + "first": 89, + "second": 241, + "amount": -1 + }, + { + "first": 89, + "second": 242, + "amount": -3 + }, + { + "first": 89, + "second": 243, + "amount": -3 + }, + { + "first": 89, + "second": 244, + "amount": -3 + }, + { + "first": 89, + "second": 245, + "amount": -3 + }, + { + "first": 89, + "second": 246, + "amount": -3 + }, + { + "first": 89, + "second": 248, + "amount": -4 + }, + { + "first": 89, + "second": 249, + "amount": -1 + }, + { + "first": 89, + "second": 250, + "amount": -1 + }, + { + "first": 89, + "second": 251, + "amount": -1 + }, + { + "first": 89, + "second": 252, + "amount": -1 + }, + { + "first": 89, + "second": 253, + "amount": -2 + }, + { + "first": 89, + "second": 255, + "amount": -2 + }, + { + "first": 89, + "second": 338, + "amount": -3 + }, + { + "first": 89, + "second": 339, + "amount": -4 + }, + { + "first": 90, + "second": 54, + "amount": 0 + }, + { + "first": 90, + "second": 56, + "amount": 0 + }, + { + "first": 90, + "second": 64, + "amount": -1 + }, + { + "first": 90, + "second": 67, + "amount": -1 + }, + { + "first": 90, + "second": 71, + "amount": -1 + }, + { + "first": 90, + "second": 79, + "amount": -1 + }, + { + "first": 90, + "second": 81, + "amount": -1 + }, + { + "first": 90, + "second": 83, + "amount": 0 + }, + { + "first": 90, + "second": 99, + "amount": 0 + }, + { + "first": 90, + "second": 100, + "amount": 0 + }, + { + "first": 90, + "second": 101, + "amount": 0 + }, + { + "first": 90, + "second": 102, + "amount": 0 + }, + { + "first": 90, + "second": 103, + "amount": 0 + }, + { + "first": 90, + "second": 111, + "amount": 0 + }, + { + "first": 90, + "second": 113, + "amount": 0 + }, + { + "first": 90, + "second": 115, + "amount": 0 + }, + { + "first": 90, + "second": 116, + "amount": 0 + }, + { + "first": 90, + "second": 118, + "amount": 0 + }, + { + "first": 90, + "second": 119, + "amount": -1 + }, + { + "first": 90, + "second": 120, + "amount": 0 + }, + { + "first": 90, + "second": 121, + "amount": 0 + }, + { + "first": 90, + "second": 169, + "amount": -1 + }, + { + "first": 90, + "second": 174, + "amount": -1 + }, + { + "first": 90, + "second": 199, + "amount": -1 + }, + { + "first": 90, + "second": 210, + "amount": -1 + }, + { + "first": 90, + "second": 211, + "amount": -1 + }, + { + "first": 90, + "second": 212, + "amount": -1 + }, + { + "first": 90, + "second": 213, + "amount": -1 + }, + { + "first": 90, + "second": 214, + "amount": -1 + }, + { + "first": 90, + "second": 216, + "amount": -1 + }, + { + "first": 90, + "second": 231, + "amount": 0 + }, + { + "first": 90, + "second": 248, + "amount": 0 + }, + { + "first": 90, + "second": 338, + "amount": -1 + }, + { + "first": 90, + "second": 339, + "amount": 0 + }, + { + "first": 91, + "second": 74, + "amount": -1 + }, + { + "first": 91, + "second": 106, + "amount": 4 + }, + { + "first": 92, + "second": 74, + "amount": -1 + }, + { + "first": 92, + "second": 83, + "amount": -1 + }, + { + "first": 92, + "second": 84, + "amount": -3 + }, + { + "first": 92, + "second": 85, + "amount": -2 + }, + { + "first": 92, + "second": 86, + "amount": -3 + }, + { + "first": 92, + "second": 88, + "amount": -1 + }, + { + "first": 92, + "second": 89, + "amount": -3 + }, + { + "first": 92, + "second": 97, + "amount": -1 + }, + { + "first": 92, + "second": 106, + "amount": 2 + }, + { + "first": 92, + "second": 120, + "amount": 0 + }, + { + "first": 92, + "second": 217, + "amount": -2 + }, + { + "first": 92, + "second": 218, + "amount": -2 + }, + { + "first": 92, + "second": 219, + "amount": -2 + }, + { + "first": 92, + "second": 220, + "amount": -2 + }, + { + "first": 92, + "second": 221, + "amount": -3 + }, + { + "first": 92, + "second": 230, + "amount": -1 + }, + { + "first": 92, + "second": 376, + "amount": -3 + }, + { + "first": 95, + "second": 106, + "amount": 3 + }, + { + "first": 97, + "second": 55, + "amount": -1 + }, + { + "first": 97, + "second": 84, + "amount": -2 + }, + { + "first": 97, + "second": 86, + "amount": -3 + }, + { + "first": 97, + "second": 87, + "amount": -1 + }, + { + "first": 97, + "second": 89, + "amount": -3 + }, + { + "first": 97, + "second": 92, + "amount": -1 + }, + { + "first": 97, + "second": 106, + "amount": 1 + }, + { + "first": 97, + "second": 118, + "amount": 0 + }, + { + "first": 97, + "second": 119, + "amount": 0 + }, + { + "first": 97, + "second": 121, + "amount": 0 + }, + { + "first": 97, + "second": 221, + "amount": -3 + }, + { + "first": 97, + "second": 253, + "amount": 0 + }, + { + "first": 97, + "second": 255, + "amount": 0 + }, + { + "first": 97, + "second": 376, + "amount": -3 + }, + { + "first": 98, + "second": 42, + "amount": -2 + }, + { + "first": 98, + "second": 44, + "amount": -1 + }, + { + "first": 98, + "second": 46, + "amount": -1 + }, + { + "first": 98, + "second": 55, + "amount": -2 + }, + { + "first": 98, + "second": 64, + "amount": 0 + }, + { + "first": 98, + "second": 65, + "amount": 0 + }, + { + "first": 98, + "second": 67, + "amount": 0 + }, + { + "first": 98, + "second": 71, + "amount": 0 + }, + { + "first": 98, + "second": 79, + "amount": 0 + }, + { + "first": 98, + "second": 81, + "amount": 0 + }, + { + "first": 98, + "second": 83, + "amount": 0 + }, + { + "first": 98, + "second": 84, + "amount": -4 + }, + { + "first": 98, + "second": 86, + "amount": -4 + }, + { + "first": 98, + "second": 87, + "amount": -1 + }, + { + "first": 98, + "second": 88, + "amount": -2 + }, + { + "first": 98, + "second": 89, + "amount": -4 + }, + { + "first": 98, + "second": 90, + "amount": 0 + }, + { + "first": 98, + "second": 102, + "amount": 0 + }, + { + "first": 98, + "second": 115, + "amount": 0 + }, + { + "first": 98, + "second": 116, + "amount": 0 + }, + { + "first": 98, + "second": 118, + "amount": 0 + }, + { + "first": 98, + "second": 119, + "amount": 0 + }, + { + "first": 98, + "second": 120, + "amount": -1 + }, + { + "first": 98, + "second": 121, + "amount": 0 + }, + { + "first": 98, + "second": 122, + "amount": 0 + }, + { + "first": 98, + "second": 169, + "amount": 0 + }, + { + "first": 98, + "second": 174, + "amount": 0 + }, + { + "first": 98, + "second": 192, + "amount": 0 + }, + { + "first": 98, + "second": 193, + "amount": 0 + }, + { + "first": 98, + "second": 194, + "amount": 0 + }, + { + "first": 98, + "second": 195, + "amount": 0 + }, + { + "first": 98, + "second": 196, + "amount": 0 + }, + { + "first": 98, + "second": 197, + "amount": 0 + }, + { + "first": 98, + "second": 199, + "amount": 0 + }, + { + "first": 98, + "second": 210, + "amount": 0 + }, + { + "first": 98, + "second": 211, + "amount": 0 + }, + { + "first": 98, + "second": 212, + "amount": 0 + }, + { + "first": 98, + "second": 213, + "amount": 0 + }, + { + "first": 98, + "second": 214, + "amount": 0 + }, + { + "first": 98, + "second": 216, + "amount": 0 + }, + { + "first": 98, + "second": 221, + "amount": -4 + }, + { + "first": 98, + "second": 338, + "amount": 0 + }, + { + "first": 98, + "second": 376, + "amount": -4 + }, + { + "first": 99, + "second": 47, + "amount": -1 + }, + { + "first": 99, + "second": 64, + "amount": 0 + }, + { + "first": 99, + "second": 67, + "amount": 0 + }, + { + "first": 99, + "second": 71, + "amount": 0 + }, + { + "first": 99, + "second": 79, + "amount": 0 + }, + { + "first": 99, + "second": 81, + "amount": 0 + }, + { + "first": 99, + "second": 84, + "amount": -4 + }, + { + "first": 99, + "second": 85, + "amount": 0 + }, + { + "first": 99, + "second": 86, + "amount": -2 + }, + { + "first": 99, + "second": 87, + "amount": -1 + }, + { + "first": 99, + "second": 88, + "amount": -1 + }, + { + "first": 99, + "second": 89, + "amount": -2 + }, + { + "first": 99, + "second": 90, + "amount": 0 + }, + { + "first": 99, + "second": 92, + "amount": -1 + }, + { + "first": 99, + "second": 97, + "amount": 0 + }, + { + "first": 99, + "second": 99, + "amount": 0 + }, + { + "first": 99, + "second": 100, + "amount": 0 + }, + { + "first": 99, + "second": 101, + "amount": 0 + }, + { + "first": 99, + "second": 103, + "amount": 0 + }, + { + "first": 99, + "second": 111, + "amount": 0 + }, + { + "first": 99, + "second": 113, + "amount": 0 + }, + { + "first": 99, + "second": 115, + "amount": 0 + }, + { + "first": 99, + "second": 116, + "amount": 0 + }, + { + "first": 99, + "second": 117, + "amount": 0 + }, + { + "first": 99, + "second": 118, + "amount": 0 + }, + { + "first": 99, + "second": 119, + "amount": 0 + }, + { + "first": 99, + "second": 120, + "amount": -1 + }, + { + "first": 99, + "second": 121, + "amount": 0 + }, + { + "first": 99, + "second": 122, + "amount": 0 + }, + { + "first": 99, + "second": 169, + "amount": 0 + }, + { + "first": 99, + "second": 171, + "amount": 0 + }, + { + "first": 99, + "second": 174, + "amount": 0 + }, + { + "first": 99, + "second": 180, + "amount": 0 + }, + { + "first": 99, + "second": 187, + "amount": 0 + }, + { + "first": 99, + "second": 199, + "amount": 0 + }, + { + "first": 99, + "second": 210, + "amount": 0 + }, + { + "first": 99, + "second": 211, + "amount": 0 + }, + { + "first": 99, + "second": 212, + "amount": 0 + }, + { + "first": 99, + "second": 213, + "amount": 0 + }, + { + "first": 99, + "second": 214, + "amount": 0 + }, + { + "first": 99, + "second": 216, + "amount": 0 + }, + { + "first": 99, + "second": 217, + "amount": 0 + }, + { + "first": 99, + "second": 218, + "amount": 0 + }, + { + "first": 99, + "second": 219, + "amount": 0 + }, + { + "first": 99, + "second": 220, + "amount": 0 + }, + { + "first": 99, + "second": 221, + "amount": -2 + }, + { + "first": 99, + "second": 224, + "amount": 0 + }, + { + "first": 99, + "second": 225, + "amount": 0 + }, + { + "first": 99, + "second": 226, + "amount": 0 + }, + { + "first": 99, + "second": 227, + "amount": 0 + }, + { + "first": 99, + "second": 228, + "amount": 0 + }, + { + "first": 99, + "second": 229, + "amount": 0 + }, + { + "first": 99, + "second": 230, + "amount": 0 + }, + { + "first": 99, + "second": 231, + "amount": 0 + }, + { + "first": 99, + "second": 248, + "amount": 0 + }, + { + "first": 99, + "second": 249, + "amount": 0 + }, + { + "first": 99, + "second": 250, + "amount": 0 + }, + { + "first": 99, + "second": 251, + "amount": 0 + }, + { + "first": 99, + "second": 252, + "amount": 0 + }, + { + "first": 99, + "second": 338, + "amount": 0 + }, + { + "first": 99, + "second": 339, + "amount": 0 + }, + { + "first": 99, + "second": 376, + "amount": -2 + }, + { + "first": 100, + "second": 88, + "amount": 0 + }, + { + "first": 101, + "second": 42, + "amount": -2 + }, + { + "first": 101, + "second": 47, + "amount": -2 + }, + { + "first": 101, + "second": 64, + "amount": 0 + }, + { + "first": 101, + "second": 67, + "amount": 0 + }, + { + "first": 101, + "second": 71, + "amount": 0 + }, + { + "first": 101, + "second": 79, + "amount": 0 + }, + { + "first": 101, + "second": 81, + "amount": 0 + }, + { + "first": 101, + "second": 84, + "amount": -4 + }, + { + "first": 101, + "second": 86, + "amount": -2 + }, + { + "first": 101, + "second": 87, + "amount": -1 + }, + { + "first": 101, + "second": 88, + "amount": -1 + }, + { + "first": 101, + "second": 89, + "amount": -2 + }, + { + "first": 101, + "second": 90, + "amount": 0 + }, + { + "first": 101, + "second": 92, + "amount": -1 + }, + { + "first": 101, + "second": 102, + "amount": 0 + }, + { + "first": 101, + "second": 115, + "amount": 0 + }, + { + "first": 101, + "second": 116, + "amount": 0 + }, + { + "first": 101, + "second": 118, + "amount": 0 + }, + { + "first": 101, + "second": 119, + "amount": 0 + }, + { + "first": 101, + "second": 120, + "amount": -1 + }, + { + "first": 101, + "second": 121, + "amount": 0 + }, + { + "first": 101, + "second": 122, + "amount": 0 + }, + { + "first": 101, + "second": 169, + "amount": 0 + }, + { + "first": 101, + "second": 174, + "amount": 0 + }, + { + "first": 101, + "second": 199, + "amount": 0 + }, + { + "first": 101, + "second": 210, + "amount": 0 + }, + { + "first": 101, + "second": 211, + "amount": 0 + }, + { + "first": 101, + "second": 212, + "amount": 0 + }, + { + "first": 101, + "second": 213, + "amount": 0 + }, + { + "first": 101, + "second": 214, + "amount": 0 + }, + { + "first": 101, + "second": 216, + "amount": 0 + }, + { + "first": 101, + "second": 221, + "amount": -2 + }, + { + "first": 101, + "second": 338, + "amount": 0 + }, + { + "first": 101, + "second": 376, + "amount": -2 + }, + { + "first": 102, + "second": 41, + "amount": 0 + }, + { + "first": 102, + "second": 42, + "amount": 0 + }, + { + "first": 102, + "second": 44, + "amount": 0 + }, + { + "first": 102, + "second": 46, + "amount": 0 + }, + { + "first": 102, + "second": 63, + "amount": 2 + }, + { + "first": 102, + "second": 84, + "amount": 2 + }, + { + "first": 102, + "second": 86, + "amount": 1 + }, + { + "first": 102, + "second": 87, + "amount": 0 + }, + { + "first": 102, + "second": 89, + "amount": 1 + }, + { + "first": 102, + "second": 92, + "amount": 3 + }, + { + "first": 102, + "second": 99, + "amount": 0 + }, + { + "first": 102, + "second": 100, + "amount": 0 + }, + { + "first": 102, + "second": 101, + "amount": 0 + }, + { + "first": 102, + "second": 102, + "amount": 0 + }, + { + "first": 102, + "second": 103, + "amount": 0 + }, + { + "first": 102, + "second": 111, + "amount": 0 + }, + { + "first": 102, + "second": 113, + "amount": 0 + }, + { + "first": 102, + "second": 116, + "amount": 0 + }, + { + "first": 102, + "second": 118, + "amount": 0 + }, + { + "first": 102, + "second": 119, + "amount": 0 + }, + { + "first": 102, + "second": 120, + "amount": 0 + }, + { + "first": 102, + "second": 121, + "amount": 0 + }, + { + "first": 102, + "second": 187, + "amount": 0 + }, + { + "first": 102, + "second": 221, + "amount": 1 + }, + { + "first": 102, + "second": 231, + "amount": 0 + }, + { + "first": 102, + "second": 232, + "amount": 0 + }, + { + "first": 102, + "second": 233, + "amount": 0 + }, + { + "first": 102, + "second": 234, + "amount": 0 + }, + { + "first": 102, + "second": 235, + "amount": 0 + }, + { + "first": 102, + "second": 236, + "amount": 2 + }, + { + "first": 102, + "second": 237, + "amount": 2 + }, + { + "first": 102, + "second": 238, + "amount": 2 + }, + { + "first": 102, + "second": 239, + "amount": 2 + }, + { + "first": 102, + "second": 240, + "amount": 0 + }, + { + "first": 102, + "second": 242, + "amount": 0 + }, + { + "first": 102, + "second": 243, + "amount": 0 + }, + { + "first": 102, + "second": 244, + "amount": 0 + }, + { + "first": 102, + "second": 245, + "amount": 0 + }, + { + "first": 102, + "second": 246, + "amount": 0 + }, + { + "first": 102, + "second": 248, + "amount": 0 + }, + { + "first": 102, + "second": 339, + "amount": 0 + }, + { + "first": 102, + "second": 376, + "amount": 1 + }, + { + "first": 103, + "second": 55, + "amount": -2 + }, + { + "first": 103, + "second": 84, + "amount": -2 + }, + { + "first": 103, + "second": 86, + "amount": -1 + }, + { + "first": 103, + "second": 87, + "amount": -1 + }, + { + "first": 103, + "second": 88, + "amount": 0 + }, + { + "first": 103, + "second": 89, + "amount": -1 + }, + { + "first": 103, + "second": 106, + "amount": 2 + }, + { + "first": 103, + "second": 221, + "amount": -1 + }, + { + "first": 103, + "second": 376, + "amount": -1 + }, + { + "first": 104, + "second": 55, + "amount": -1 + }, + { + "first": 104, + "second": 84, + "amount": -2 + }, + { + "first": 104, + "second": 86, + "amount": -3 + }, + { + "first": 104, + "second": 87, + "amount": -1 + }, + { + "first": 104, + "second": 89, + "amount": -3 + }, + { + "first": 104, + "second": 92, + "amount": -1 + }, + { + "first": 104, + "second": 106, + "amount": 1 + }, + { + "first": 104, + "second": 118, + "amount": 0 + }, + { + "first": 104, + "second": 119, + "amount": 0 + }, + { + "first": 104, + "second": 121, + "amount": 0 + }, + { + "first": 104, + "second": 221, + "amount": -3 + }, + { + "first": 104, + "second": 253, + "amount": 0 + }, + { + "first": 104, + "second": 255, + "amount": 0 + }, + { + "first": 104, + "second": 376, + "amount": -3 + }, + { + "first": 105, + "second": 88, + "amount": 0 + }, + { + "first": 107, + "second": 38, + "amount": -1 + }, + { + "first": 107, + "second": 42, + "amount": -1 + }, + { + "first": 107, + "second": 45, + "amount": -1 + }, + { + "first": 107, + "second": 48, + "amount": 0 + }, + { + "first": 107, + "second": 51, + "amount": 0 + }, + { + "first": 107, + "second": 53, + "amount": 0 + }, + { + "first": 107, + "second": 54, + "amount": 0 + }, + { + "first": 107, + "second": 55, + "amount": -1 + }, + { + "first": 107, + "second": 56, + "amount": 0 + }, + { + "first": 107, + "second": 57, + "amount": 0 + }, + { + "first": 107, + "second": 64, + "amount": -1 + }, + { + "first": 107, + "second": 67, + "amount": -1 + }, + { + "first": 107, + "second": 71, + "amount": -1 + }, + { + "first": 107, + "second": 74, + "amount": 0 + }, + { + "first": 107, + "second": 79, + "amount": -1 + }, + { + "first": 107, + "second": 81, + "amount": -1 + }, + { + "first": 107, + "second": 83, + "amount": -1 + }, + { + "first": 107, + "second": 84, + "amount": -2 + }, + { + "first": 107, + "second": 85, + "amount": 0 + }, + { + "first": 107, + "second": 86, + "amount": -3 + }, + { + "first": 107, + "second": 87, + "amount": -1 + }, + { + "first": 107, + "second": 88, + "amount": 0 + }, + { + "first": 107, + "second": 89, + "amount": -3 + }, + { + "first": 107, + "second": 90, + "amount": 0 + }, + { + "first": 107, + "second": 92, + "amount": -1 + }, + { + "first": 107, + "second": 97, + "amount": -1 + }, + { + "first": 107, + "second": 99, + "amount": 0 + }, + { + "first": 107, + "second": 100, + "amount": 0 + }, + { + "first": 107, + "second": 101, + "amount": 0 + }, + { + "first": 107, + "second": 102, + "amount": 0 + }, + { + "first": 107, + "second": 103, + "amount": 0 + }, + { + "first": 107, + "second": 111, + "amount": 0 + }, + { + "first": 107, + "second": 113, + "amount": 0 + }, + { + "first": 107, + "second": 115, + "amount": 0 + }, + { + "first": 107, + "second": 116, + "amount": 0 + }, + { + "first": 107, + "second": 117, + "amount": -1 + }, + { + "first": 107, + "second": 118, + "amount": 0 + }, + { + "first": 107, + "second": 119, + "amount": -1 + }, + { + "first": 107, + "second": 121, + "amount": 0 + }, + { + "first": 107, + "second": 169, + "amount": -1 + }, + { + "first": 107, + "second": 171, + "amount": -1 + }, + { + "first": 107, + "second": 174, + "amount": -1 + }, + { + "first": 107, + "second": 180, + "amount": -1 + }, + { + "first": 107, + "second": 199, + "amount": -1 + }, + { + "first": 107, + "second": 210, + "amount": -1 + }, + { + "first": 107, + "second": 211, + "amount": -1 + }, + { + "first": 107, + "second": 212, + "amount": -1 + }, + { + "first": 107, + "second": 213, + "amount": -1 + }, + { + "first": 107, + "second": 214, + "amount": -1 + }, + { + "first": 107, + "second": 216, + "amount": -1 + }, + { + "first": 107, + "second": 217, + "amount": 0 + }, + { + "first": 107, + "second": 218, + "amount": 0 + }, + { + "first": 107, + "second": 219, + "amount": 0 + }, + { + "first": 107, + "second": 220, + "amount": 0 + }, + { + "first": 107, + "second": 221, + "amount": -3 + }, + { + "first": 107, + "second": 224, + "amount": -1 + }, + { + "first": 107, + "second": 225, + "amount": -1 + }, + { + "first": 107, + "second": 226, + "amount": -1 + }, + { + "first": 107, + "second": 227, + "amount": -1 + }, + { + "first": 107, + "second": 228, + "amount": -1 + }, + { + "first": 107, + "second": 229, + "amount": -1 + }, + { + "first": 107, + "second": 230, + "amount": -1 + }, + { + "first": 107, + "second": 231, + "amount": 0 + }, + { + "first": 107, + "second": 248, + "amount": 0 + }, + { + "first": 107, + "second": 249, + "amount": 0 + }, + { + "first": 107, + "second": 250, + "amount": -1 + }, + { + "first": 107, + "second": 251, + "amount": 0 + }, + { + "first": 107, + "second": 252, + "amount": 0 + }, + { + "first": 107, + "second": 338, + "amount": -1 + }, + { + "first": 107, + "second": 339, + "amount": 0 + }, + { + "first": 107, + "second": 376, + "amount": -3 + }, + { + "first": 108, + "second": 88, + "amount": 0 + }, + { + "first": 109, + "second": 55, + "amount": -1 + }, + { + "first": 109, + "second": 84, + "amount": -2 + }, + { + "first": 109, + "second": 86, + "amount": -3 + }, + { + "first": 109, + "second": 87, + "amount": -1 + }, + { + "first": 109, + "second": 89, + "amount": -3 + }, + { + "first": 109, + "second": 92, + "amount": -1 + }, + { + "first": 109, + "second": 106, + "amount": 1 + }, + { + "first": 109, + "second": 118, + "amount": 0 + }, + { + "first": 109, + "second": 119, + "amount": 0 + }, + { + "first": 109, + "second": 121, + "amount": 0 + }, + { + "first": 109, + "second": 221, + "amount": -3 + }, + { + "first": 109, + "second": 253, + "amount": 0 + }, + { + "first": 109, + "second": 255, + "amount": 0 + }, + { + "first": 109, + "second": 376, + "amount": -3 + }, + { + "first": 110, + "second": 55, + "amount": -1 + }, + { + "first": 110, + "second": 84, + "amount": -2 + }, + { + "first": 110, + "second": 86, + "amount": -3 + }, + { + "first": 110, + "second": 87, + "amount": -1 + }, + { + "first": 110, + "second": 89, + "amount": -3 + }, + { + "first": 110, + "second": 92, + "amount": -1 + }, + { + "first": 110, + "second": 106, + "amount": 1 + }, + { + "first": 110, + "second": 118, + "amount": 0 + }, + { + "first": 110, + "second": 119, + "amount": 0 + }, + { + "first": 110, + "second": 121, + "amount": 0 + }, + { + "first": 110, + "second": 221, + "amount": -3 + }, + { + "first": 110, + "second": 253, + "amount": 0 + }, + { + "first": 110, + "second": 255, + "amount": 0 + }, + { + "first": 110, + "second": 376, + "amount": -3 + }, + { + "first": 111, + "second": 42, + "amount": -2 + }, + { + "first": 111, + "second": 44, + "amount": -1 + }, + { + "first": 111, + "second": 46, + "amount": -1 + }, + { + "first": 111, + "second": 55, + "amount": -2 + }, + { + "first": 111, + "second": 64, + "amount": 0 + }, + { + "first": 111, + "second": 65, + "amount": 0 + }, + { + "first": 111, + "second": 67, + "amount": 0 + }, + { + "first": 111, + "second": 71, + "amount": 0 + }, + { + "first": 111, + "second": 79, + "amount": 0 + }, + { + "first": 111, + "second": 81, + "amount": 0 + }, + { + "first": 111, + "second": 83, + "amount": 0 + }, + { + "first": 111, + "second": 84, + "amount": -4 + }, + { + "first": 111, + "second": 86, + "amount": -4 + }, + { + "first": 111, + "second": 87, + "amount": -1 + }, + { + "first": 111, + "second": 88, + "amount": -2 + }, + { + "first": 111, + "second": 89, + "amount": -4 + }, + { + "first": 111, + "second": 90, + "amount": 0 + }, + { + "first": 111, + "second": 102, + "amount": 0 + }, + { + "first": 111, + "second": 115, + "amount": 0 + }, + { + "first": 111, + "second": 116, + "amount": 0 + }, + { + "first": 111, + "second": 118, + "amount": 0 + }, + { + "first": 111, + "second": 119, + "amount": 0 + }, + { + "first": 111, + "second": 120, + "amount": -1 + }, + { + "first": 111, + "second": 121, + "amount": 0 + }, + { + "first": 111, + "second": 122, + "amount": 0 + }, + { + "first": 111, + "second": 169, + "amount": 0 + }, + { + "first": 111, + "second": 174, + "amount": 0 + }, + { + "first": 111, + "second": 192, + "amount": 0 + }, + { + "first": 111, + "second": 193, + "amount": 0 + }, + { + "first": 111, + "second": 194, + "amount": 0 + }, + { + "first": 111, + "second": 195, + "amount": 0 + }, + { + "first": 111, + "second": 196, + "amount": 0 + }, + { + "first": 111, + "second": 197, + "amount": 0 + }, + { + "first": 111, + "second": 199, + "amount": 0 + }, + { + "first": 111, + "second": 210, + "amount": 0 + }, + { + "first": 111, + "second": 211, + "amount": 0 + }, + { + "first": 111, + "second": 212, + "amount": 0 + }, + { + "first": 111, + "second": 213, + "amount": 0 + }, + { + "first": 111, + "second": 214, + "amount": 0 + }, + { + "first": 111, + "second": 216, + "amount": 0 + }, + { + "first": 111, + "second": 221, + "amount": -4 + }, + { + "first": 111, + "second": 338, + "amount": 0 + }, + { + "first": 111, + "second": 376, + "amount": -4 + }, + { + "first": 112, + "second": 42, + "amount": -2 + }, + { + "first": 112, + "second": 44, + "amount": -1 + }, + { + "first": 112, + "second": 46, + "amount": -1 + }, + { + "first": 112, + "second": 55, + "amount": -2 + }, + { + "first": 112, + "second": 64, + "amount": 0 + }, + { + "first": 112, + "second": 65, + "amount": 0 + }, + { + "first": 112, + "second": 67, + "amount": 0 + }, + { + "first": 112, + "second": 71, + "amount": 0 + }, + { + "first": 112, + "second": 79, + "amount": 0 + }, + { + "first": 112, + "second": 81, + "amount": 0 + }, + { + "first": 112, + "second": 83, + "amount": 0 + }, + { + "first": 112, + "second": 84, + "amount": -4 + }, + { + "first": 112, + "second": 86, + "amount": -4 + }, + { + "first": 112, + "second": 87, + "amount": -1 + }, + { + "first": 112, + "second": 88, + "amount": -2 + }, + { + "first": 112, + "second": 89, + "amount": -4 + }, + { + "first": 112, + "second": 90, + "amount": 0 + }, + { + "first": 112, + "second": 102, + "amount": 0 + }, + { + "first": 112, + "second": 115, + "amount": 0 + }, + { + "first": 112, + "second": 116, + "amount": 0 + }, + { + "first": 112, + "second": 118, + "amount": 0 + }, + { + "first": 112, + "second": 119, + "amount": 0 + }, + { + "first": 112, + "second": 120, + "amount": -1 + }, + { + "first": 112, + "second": 121, + "amount": 0 + }, + { + "first": 112, + "second": 122, + "amount": 0 + }, + { + "first": 112, + "second": 169, + "amount": 0 + }, + { + "first": 112, + "second": 174, + "amount": 0 + }, + { + "first": 112, + "second": 192, + "amount": 0 + }, + { + "first": 112, + "second": 193, + "amount": 0 + }, + { + "first": 112, + "second": 194, + "amount": 0 + }, + { + "first": 112, + "second": 195, + "amount": 0 + }, + { + "first": 112, + "second": 196, + "amount": 0 + }, + { + "first": 112, + "second": 197, + "amount": 0 + }, + { + "first": 112, + "second": 199, + "amount": 0 + }, + { + "first": 112, + "second": 210, + "amount": 0 + }, + { + "first": 112, + "second": 211, + "amount": 0 + }, + { + "first": 112, + "second": 212, + "amount": 0 + }, + { + "first": 112, + "second": 213, + "amount": 0 + }, + { + "first": 112, + "second": 214, + "amount": 0 + }, + { + "first": 112, + "second": 216, + "amount": 0 + }, + { + "first": 112, + "second": 221, + "amount": -4 + }, + { + "first": 112, + "second": 338, + "amount": 0 + }, + { + "first": 112, + "second": 376, + "amount": -4 + }, + { + "first": 113, + "second": 55, + "amount": -2 + }, + { + "first": 113, + "second": 84, + "amount": -2 + }, + { + "first": 113, + "second": 86, + "amount": -1 + }, + { + "first": 113, + "second": 87, + "amount": -1 + }, + { + "first": 113, + "second": 88, + "amount": 0 + }, + { + "first": 113, + "second": 89, + "amount": -1 + }, + { + "first": 113, + "second": 106, + "amount": 2 + }, + { + "first": 113, + "second": 221, + "amount": -1 + }, + { + "first": 113, + "second": 376, + "amount": -1 + }, + { + "first": 114, + "second": 38, + "amount": -2 + }, + { + "first": 114, + "second": 45, + "amount": 0 + }, + { + "first": 114, + "second": 50, + "amount": -1 + }, + { + "first": 114, + "second": 51, + "amount": -1 + }, + { + "first": 114, + "second": 55, + "amount": -1 + }, + { + "first": 114, + "second": 56, + "amount": 0 + }, + { + "first": 114, + "second": 64, + "amount": -1 + }, + { + "first": 114, + "second": 65, + "amount": -2 + }, + { + "first": 114, + "second": 67, + "amount": -1 + }, + { + "first": 114, + "second": 71, + "amount": -1 + }, + { + "first": 114, + "second": 74, + "amount": -4 + }, + { + "first": 114, + "second": 79, + "amount": -1 + }, + { + "first": 114, + "second": 81, + "amount": -1 + }, + { + "first": 114, + "second": 83, + "amount": 0 + }, + { + "first": 114, + "second": 84, + "amount": -1 + }, + { + "first": 114, + "second": 86, + "amount": -1 + }, + { + "first": 114, + "second": 87, + "amount": 0 + }, + { + "first": 114, + "second": 88, + "amount": -2 + }, + { + "first": 114, + "second": 89, + "amount": -1 + }, + { + "first": 114, + "second": 90, + "amount": -1 + }, + { + "first": 114, + "second": 97, + "amount": -1 + }, + { + "first": 114, + "second": 99, + "amount": 0 + }, + { + "first": 114, + "second": 100, + "amount": 0 + }, + { + "first": 114, + "second": 101, + "amount": 0 + }, + { + "first": 114, + "second": 102, + "amount": 0 + }, + { + "first": 114, + "second": 103, + "amount": 0 + }, + { + "first": 114, + "second": 109, + "amount": 0 + }, + { + "first": 114, + "second": 110, + "amount": 0 + }, + { + "first": 114, + "second": 111, + "amount": 0 + }, + { + "first": 114, + "second": 112, + "amount": 0 + }, + { + "first": 114, + "second": 113, + "amount": 0 + }, + { + "first": 114, + "second": 114, + "amount": 0 + }, + { + "first": 114, + "second": 115, + "amount": 0 + }, + { + "first": 114, + "second": 116, + "amount": 1 + }, + { + "first": 114, + "second": 117, + "amount": 0 + }, + { + "first": 114, + "second": 118, + "amount": 0 + }, + { + "first": 114, + "second": 119, + "amount": 0 + }, + { + "first": 114, + "second": 120, + "amount": 0 + }, + { + "first": 114, + "second": 121, + "amount": 0 + }, + { + "first": 114, + "second": 169, + "amount": -1 + }, + { + "first": 114, + "second": 171, + "amount": -1 + }, + { + "first": 114, + "second": 174, + "amount": -1 + }, + { + "first": 114, + "second": 180, + "amount": -1 + }, + { + "first": 114, + "second": 181, + "amount": 0 + }, + { + "first": 114, + "second": 187, + "amount": 0 + }, + { + "first": 114, + "second": 192, + "amount": -2 + }, + { + "first": 114, + "second": 193, + "amount": -2 + }, + { + "first": 114, + "second": 194, + "amount": -2 + }, + { + "first": 114, + "second": 195, + "amount": -2 + }, + { + "first": 114, + "second": 196, + "amount": -2 + }, + { + "first": 114, + "second": 197, + "amount": -2 + }, + { + "first": 114, + "second": 199, + "amount": -1 + }, + { + "first": 114, + "second": 210, + "amount": -1 + }, + { + "first": 114, + "second": 211, + "amount": -1 + }, + { + "first": 114, + "second": 212, + "amount": -1 + }, + { + "first": 114, + "second": 213, + "amount": -1 + }, + { + "first": 114, + "second": 214, + "amount": -1 + }, + { + "first": 114, + "second": 216, + "amount": -1 + }, + { + "first": 114, + "second": 221, + "amount": -1 + }, + { + "first": 114, + "second": 224, + "amount": -1 + }, + { + "first": 114, + "second": 225, + "amount": -1 + }, + { + "first": 114, + "second": 226, + "amount": -1 + }, + { + "first": 114, + "second": 227, + "amount": -1 + }, + { + "first": 114, + "second": 228, + "amount": -1 + }, + { + "first": 114, + "second": 229, + "amount": -1 + }, + { + "first": 114, + "second": 230, + "amount": -1 + }, + { + "first": 114, + "second": 231, + "amount": 0 + }, + { + "first": 114, + "second": 248, + "amount": 0 + }, + { + "first": 114, + "second": 250, + "amount": 0 + }, + { + "first": 114, + "second": 338, + "amount": -1 + }, + { + "first": 114, + "second": 339, + "amount": 0 + }, + { + "first": 114, + "second": 376, + "amount": -1 + }, + { + "first": 115, + "second": 42, + "amount": -1 + }, + { + "first": 115, + "second": 48, + "amount": 0 + }, + { + "first": 115, + "second": 52, + "amount": 0 + }, + { + "first": 115, + "second": 55, + "amount": -2 + }, + { + "first": 115, + "second": 57, + "amount": -1 + }, + { + "first": 115, + "second": 64, + "amount": 0 + }, + { + "first": 115, + "second": 65, + "amount": 0 + }, + { + "first": 115, + "second": 67, + "amount": 0 + }, + { + "first": 115, + "second": 71, + "amount": 0 + }, + { + "first": 115, + "second": 79, + "amount": 0 + }, + { + "first": 115, + "second": 81, + "amount": 0 + }, + { + "first": 115, + "second": 84, + "amount": -3 + }, + { + "first": 115, + "second": 85, + "amount": 0 + }, + { + "first": 115, + "second": 86, + "amount": -4 + }, + { + "first": 115, + "second": 88, + "amount": -1 + }, + { + "first": 115, + "second": 89, + "amount": -4 + }, + { + "first": 115, + "second": 90, + "amount": 0 + }, + { + "first": 115, + "second": 97, + "amount": 0 + }, + { + "first": 115, + "second": 99, + "amount": 0 + }, + { + "first": 115, + "second": 100, + "amount": 0 + }, + { + "first": 115, + "second": 101, + "amount": 0 + }, + { + "first": 115, + "second": 102, + "amount": 0 + }, + { + "first": 115, + "second": 103, + "amount": 0 + }, + { + "first": 115, + "second": 111, + "amount": 0 + }, + { + "first": 115, + "second": 113, + "amount": 0 + }, + { + "first": 115, + "second": 116, + "amount": 0 + }, + { + "first": 115, + "second": 118, + "amount": 0 + }, + { + "first": 115, + "second": 119, + "amount": 0 + }, + { + "first": 115, + "second": 120, + "amount": 0 + }, + { + "first": 115, + "second": 121, + "amount": 0 + }, + { + "first": 115, + "second": 122, + "amount": 0 + }, + { + "first": 115, + "second": 169, + "amount": 0 + }, + { + "first": 115, + "second": 174, + "amount": 0 + }, + { + "first": 115, + "second": 180, + "amount": 0 + }, + { + "first": 115, + "second": 192, + "amount": 0 + }, + { + "first": 115, + "second": 193, + "amount": 0 + }, + { + "first": 115, + "second": 194, + "amount": 0 + }, + { + "first": 115, + "second": 195, + "amount": 0 + }, + { + "first": 115, + "second": 196, + "amount": 0 + }, + { + "first": 115, + "second": 197, + "amount": 0 + }, + { + "first": 115, + "second": 199, + "amount": 0 + }, + { + "first": 115, + "second": 210, + "amount": 0 + }, + { + "first": 115, + "second": 211, + "amount": 0 + }, + { + "first": 115, + "second": 212, + "amount": 0 + }, + { + "first": 115, + "second": 213, + "amount": 0 + }, + { + "first": 115, + "second": 214, + "amount": 0 + }, + { + "first": 115, + "second": 216, + "amount": 0 + }, + { + "first": 115, + "second": 217, + "amount": 0 + }, + { + "first": 115, + "second": 218, + "amount": 0 + }, + { + "first": 115, + "second": 219, + "amount": 0 + }, + { + "first": 115, + "second": 220, + "amount": 0 + }, + { + "first": 115, + "second": 221, + "amount": -4 + }, + { + "first": 115, + "second": 224, + "amount": 0 + }, + { + "first": 115, + "second": 225, + "amount": 0 + }, + { + "first": 115, + "second": 226, + "amount": 0 + }, + { + "first": 115, + "second": 227, + "amount": 0 + }, + { + "first": 115, + "second": 228, + "amount": 0 + }, + { + "first": 115, + "second": 229, + "amount": 0 + }, + { + "first": 115, + "second": 230, + "amount": 0 + }, + { + "first": 115, + "second": 231, + "amount": 0 + }, + { + "first": 115, + "second": 248, + "amount": 0 + }, + { + "first": 115, + "second": 338, + "amount": 0 + }, + { + "first": 115, + "second": 339, + "amount": 0 + }, + { + "first": 115, + "second": 376, + "amount": -4 + }, + { + "first": 116, + "second": 45, + "amount": 0 + }, + { + "first": 116, + "second": 87, + "amount": 0 + }, + { + "first": 116, + "second": 88, + "amount": 0 + }, + { + "first": 116, + "second": 97, + "amount": 0 + }, + { + "first": 116, + "second": 99, + "amount": 0 + }, + { + "first": 116, + "second": 100, + "amount": 0 + }, + { + "first": 116, + "second": 101, + "amount": 0 + }, + { + "first": 116, + "second": 102, + "amount": 0 + }, + { + "first": 116, + "second": 103, + "amount": 0 + }, + { + "first": 116, + "second": 111, + "amount": 0 + }, + { + "first": 116, + "second": 113, + "amount": 0 + }, + { + "first": 116, + "second": 115, + "amount": 0 + }, + { + "first": 116, + "second": 116, + "amount": -1 + }, + { + "first": 116, + "second": 117, + "amount": -1 + }, + { + "first": 116, + "second": 118, + "amount": 0 + }, + { + "first": 116, + "second": 119, + "amount": 0 + }, + { + "first": 116, + "second": 120, + "amount": -1 + }, + { + "first": 116, + "second": 121, + "amount": 0 + }, + { + "first": 116, + "second": 122, + "amount": 0 + }, + { + "first": 116, + "second": 171, + "amount": 0 + }, + { + "first": 116, + "second": 180, + "amount": 0 + }, + { + "first": 116, + "second": 224, + "amount": 0 + }, + { + "first": 116, + "second": 225, + "amount": 0 + }, + { + "first": 116, + "second": 226, + "amount": 0 + }, + { + "first": 116, + "second": 227, + "amount": 0 + }, + { + "first": 116, + "second": 228, + "amount": 0 + }, + { + "first": 116, + "second": 229, + "amount": 0 + }, + { + "first": 116, + "second": 230, + "amount": 0 + }, + { + "first": 116, + "second": 231, + "amount": 0 + }, + { + "first": 116, + "second": 232, + "amount": 0 + }, + { + "first": 116, + "second": 233, + "amount": 0 + }, + { + "first": 116, + "second": 234, + "amount": 0 + }, + { + "first": 116, + "second": 235, + "amount": 0 + }, + { + "first": 116, + "second": 240, + "amount": 0 + }, + { + "first": 116, + "second": 241, + "amount": -1 + }, + { + "first": 116, + "second": 242, + "amount": 0 + }, + { + "first": 116, + "second": 243, + "amount": 0 + }, + { + "first": 116, + "second": 244, + "amount": 0 + }, + { + "first": 116, + "second": 245, + "amount": 0 + }, + { + "first": 116, + "second": 246, + "amount": 0 + }, + { + "first": 116, + "second": 248, + "amount": 0 + }, + { + "first": 116, + "second": 250, + "amount": -1 + }, + { + "first": 116, + "second": 339, + "amount": 0 + }, + { + "first": 117, + "second": 55, + "amount": -2 + }, + { + "first": 117, + "second": 84, + "amount": -2 + }, + { + "first": 117, + "second": 86, + "amount": -1 + }, + { + "first": 117, + "second": 87, + "amount": -1 + }, + { + "first": 117, + "second": 88, + "amount": 0 + }, + { + "first": 117, + "second": 89, + "amount": -1 + }, + { + "first": 117, + "second": 106, + "amount": 2 + }, + { + "first": 117, + "second": 221, + "amount": -1 + }, + { + "first": 117, + "second": 376, + "amount": -1 + }, + { + "first": 118, + "second": 64, + "amount": -1 + }, + { + "first": 118, + "second": 65, + "amount": -2 + }, + { + "first": 118, + "second": 67, + "amount": -1 + }, + { + "first": 118, + "second": 71, + "amount": -1 + }, + { + "first": 118, + "second": 74, + "amount": -1 + }, + { + "first": 118, + "second": 79, + "amount": -1 + }, + { + "first": 118, + "second": 81, + "amount": -1 + }, + { + "first": 118, + "second": 83, + "amount": 0 + }, + { + "first": 118, + "second": 84, + "amount": -2 + }, + { + "first": 118, + "second": 86, + "amount": -2 + }, + { + "first": 118, + "second": 87, + "amount": 0 + }, + { + "first": 118, + "second": 88, + "amount": -1 + }, + { + "first": 118, + "second": 89, + "amount": -2 + }, + { + "first": 118, + "second": 90, + "amount": 0 + }, + { + "first": 118, + "second": 97, + "amount": 0 + }, + { + "first": 118, + "second": 99, + "amount": 0 + }, + { + "first": 118, + "second": 100, + "amount": 0 + }, + { + "first": 118, + "second": 101, + "amount": 0 + }, + { + "first": 118, + "second": 103, + "amount": 0 + }, + { + "first": 118, + "second": 111, + "amount": 0 + }, + { + "first": 118, + "second": 113, + "amount": 0 + }, + { + "first": 118, + "second": 115, + "amount": 0 + }, + { + "first": 118, + "second": 122, + "amount": 0 + }, + { + "first": 118, + "second": 169, + "amount": -1 + }, + { + "first": 118, + "second": 174, + "amount": -1 + }, + { + "first": 118, + "second": 180, + "amount": 0 + }, + { + "first": 118, + "second": 192, + "amount": -2 + }, + { + "first": 118, + "second": 193, + "amount": -2 + }, + { + "first": 118, + "second": 194, + "amount": -2 + }, + { + "first": 118, + "second": 195, + "amount": -2 + }, + { + "first": 118, + "second": 196, + "amount": -2 + }, + { + "first": 118, + "second": 197, + "amount": -2 + }, + { + "first": 118, + "second": 199, + "amount": -1 + }, + { + "first": 118, + "second": 210, + "amount": -1 + }, + { + "first": 118, + "second": 211, + "amount": -1 + }, + { + "first": 118, + "second": 212, + "amount": -1 + }, + { + "first": 118, + "second": 213, + "amount": -1 + }, + { + "first": 118, + "second": 214, + "amount": -1 + }, + { + "first": 118, + "second": 216, + "amount": -1 + }, + { + "first": 118, + "second": 221, + "amount": -2 + }, + { + "first": 118, + "second": 224, + "amount": 0 + }, + { + "first": 118, + "second": 225, + "amount": 0 + }, + { + "first": 118, + "second": 226, + "amount": 0 + }, + { + "first": 118, + "second": 227, + "amount": 0 + }, + { + "first": 118, + "second": 228, + "amount": 0 + }, + { + "first": 118, + "second": 229, + "amount": 0 + }, + { + "first": 118, + "second": 230, + "amount": 0 + }, + { + "first": 118, + "second": 231, + "amount": 0 + }, + { + "first": 118, + "second": 248, + "amount": 0 + }, + { + "first": 118, + "second": 338, + "amount": -1 + }, + { + "first": 118, + "second": 339, + "amount": 0 + }, + { + "first": 118, + "second": 376, + "amount": -2 + }, + { + "first": 119, + "second": 38, + "amount": 0 + }, + { + "first": 119, + "second": 44, + "amount": -1 + }, + { + "first": 119, + "second": 46, + "amount": -1 + }, + { + "first": 119, + "second": 48, + "amount": 0 + }, + { + "first": 119, + "second": 50, + "amount": 0 + }, + { + "first": 119, + "second": 51, + "amount": 0 + }, + { + "first": 119, + "second": 52, + "amount": 0 + }, + { + "first": 119, + "second": 53, + "amount": 0 + }, + { + "first": 119, + "second": 54, + "amount": 0 + }, + { + "first": 119, + "second": 55, + "amount": -1 + }, + { + "first": 119, + "second": 56, + "amount": 0 + }, + { + "first": 119, + "second": 64, + "amount": -1 + }, + { + "first": 119, + "second": 65, + "amount": -2 + }, + { + "first": 119, + "second": 67, + "amount": -1 + }, + { + "first": 119, + "second": 71, + "amount": -1 + }, + { + "first": 119, + "second": 74, + "amount": -1 + }, + { + "first": 119, + "second": 79, + "amount": -1 + }, + { + "first": 119, + "second": 81, + "amount": -1 + }, + { + "first": 119, + "second": 83, + "amount": 0 + }, + { + "first": 119, + "second": 84, + "amount": -2 + }, + { + "first": 119, + "second": 86, + "amount": -1 + }, + { + "first": 119, + "second": 87, + "amount": -1 + }, + { + "first": 119, + "second": 88, + "amount": -2 + }, + { + "first": 119, + "second": 89, + "amount": -1 + }, + { + "first": 119, + "second": 90, + "amount": -1 + }, + { + "first": 119, + "second": 97, + "amount": 0 + }, + { + "first": 119, + "second": 99, + "amount": 0 + }, + { + "first": 119, + "second": 100, + "amount": 0 + }, + { + "first": 119, + "second": 101, + "amount": 0 + }, + { + "first": 119, + "second": 103, + "amount": 0 + }, + { + "first": 119, + "second": 111, + "amount": 0 + }, + { + "first": 119, + "second": 113, + "amount": 0 + }, + { + "first": 119, + "second": 115, + "amount": 0 + }, + { + "first": 119, + "second": 120, + "amount": 0 + }, + { + "first": 119, + "second": 122, + "amount": 0 + }, + { + "first": 119, + "second": 169, + "amount": -1 + }, + { + "first": 119, + "second": 174, + "amount": -1 + }, + { + "first": 119, + "second": 180, + "amount": 0 + }, + { + "first": 119, + "second": 192, + "amount": -2 + }, + { + "first": 119, + "second": 193, + "amount": -2 + }, + { + "first": 119, + "second": 194, + "amount": -2 + }, + { + "first": 119, + "second": 195, + "amount": -2 + }, + { + "first": 119, + "second": 196, + "amount": -2 + }, + { + "first": 119, + "second": 197, + "amount": -2 + }, + { + "first": 119, + "second": 199, + "amount": -1 + }, + { + "first": 119, + "second": 210, + "amount": -1 + }, + { + "first": 119, + "second": 211, + "amount": -1 + }, + { + "first": 119, + "second": 212, + "amount": -1 + }, + { + "first": 119, + "second": 213, + "amount": -1 + }, + { + "first": 119, + "second": 214, + "amount": -1 + }, + { + "first": 119, + "second": 216, + "amount": -1 + }, + { + "first": 119, + "second": 221, + "amount": -1 + }, + { + "first": 119, + "second": 224, + "amount": 0 + }, + { + "first": 119, + "second": 225, + "amount": 0 + }, + { + "first": 119, + "second": 226, + "amount": 0 + }, + { + "first": 119, + "second": 227, + "amount": 0 + }, + { + "first": 119, + "second": 228, + "amount": 0 + }, + { + "first": 119, + "second": 229, + "amount": 0 + }, + { + "first": 119, + "second": 230, + "amount": 0 + }, + { + "first": 119, + "second": 231, + "amount": 0 + }, + { + "first": 119, + "second": 232, + "amount": 0 + }, + { + "first": 119, + "second": 233, + "amount": 0 + }, + { + "first": 119, + "second": 234, + "amount": 0 + }, + { + "first": 119, + "second": 235, + "amount": 0 + }, + { + "first": 119, + "second": 240, + "amount": 0 + }, + { + "first": 119, + "second": 242, + "amount": 0 + }, + { + "first": 119, + "second": 243, + "amount": 0 + }, + { + "first": 119, + "second": 244, + "amount": 0 + }, + { + "first": 119, + "second": 245, + "amount": 0 + }, + { + "first": 119, + "second": 246, + "amount": 0 + }, + { + "first": 119, + "second": 248, + "amount": 0 + }, + { + "first": 119, + "second": 338, + "amount": -1 + }, + { + "first": 119, + "second": 339, + "amount": 0 + }, + { + "first": 119, + "second": 376, + "amount": -1 + }, + { + "first": 120, + "second": 38, + "amount": -1 + }, + { + "first": 120, + "second": 42, + "amount": -1 + }, + { + "first": 120, + "second": 45, + "amount": -1 + }, + { + "first": 120, + "second": 48, + "amount": 0 + }, + { + "first": 120, + "second": 51, + "amount": 0 + }, + { + "first": 120, + "second": 53, + "amount": 0 + }, + { + "first": 120, + "second": 54, + "amount": 0 + }, + { + "first": 120, + "second": 55, + "amount": -1 + }, + { + "first": 120, + "second": 56, + "amount": 0 + }, + { + "first": 120, + "second": 57, + "amount": 0 + }, + { + "first": 120, + "second": 64, + "amount": -1 + }, + { + "first": 120, + "second": 67, + "amount": -1 + }, + { + "first": 120, + "second": 71, + "amount": -1 + }, + { + "first": 120, + "second": 74, + "amount": 0 + }, + { + "first": 120, + "second": 79, + "amount": -1 + }, + { + "first": 120, + "second": 81, + "amount": -1 + }, + { + "first": 120, + "second": 83, + "amount": -1 + }, + { + "first": 120, + "second": 84, + "amount": -2 + }, + { + "first": 120, + "second": 85, + "amount": 0 + }, + { + "first": 120, + "second": 86, + "amount": -3 + }, + { + "first": 120, + "second": 87, + "amount": -1 + }, + { + "first": 120, + "second": 88, + "amount": 0 + }, + { + "first": 120, + "second": 89, + "amount": -3 + }, + { + "first": 120, + "second": 90, + "amount": 0 + }, + { + "first": 120, + "second": 92, + "amount": -1 + }, + { + "first": 120, + "second": 97, + "amount": -1 + }, + { + "first": 120, + "second": 99, + "amount": 0 + }, + { + "first": 120, + "second": 100, + "amount": 0 + }, + { + "first": 120, + "second": 101, + "amount": 0 + }, + { + "first": 120, + "second": 102, + "amount": 0 + }, + { + "first": 120, + "second": 103, + "amount": 0 + }, + { + "first": 120, + "second": 111, + "amount": 0 + }, + { + "first": 120, + "second": 113, + "amount": 0 + }, + { + "first": 120, + "second": 115, + "amount": 0 + }, + { + "first": 120, + "second": 116, + "amount": 0 + }, + { + "first": 120, + "second": 117, + "amount": -1 + }, + { + "first": 120, + "second": 118, + "amount": 0 + }, + { + "first": 120, + "second": 119, + "amount": -1 + }, + { + "first": 120, + "second": 121, + "amount": 0 + }, + { + "first": 120, + "second": 169, + "amount": -1 + }, + { + "first": 120, + "second": 171, + "amount": -1 + }, + { + "first": 120, + "second": 174, + "amount": -1 + }, + { + "first": 120, + "second": 180, + "amount": -1 + }, + { + "first": 120, + "second": 199, + "amount": -1 + }, + { + "first": 120, + "second": 210, + "amount": -1 + }, + { + "first": 120, + "second": 211, + "amount": -1 + }, + { + "first": 120, + "second": 212, + "amount": -1 + }, + { + "first": 120, + "second": 213, + "amount": -1 + }, + { + "first": 120, + "second": 214, + "amount": -1 + }, + { + "first": 120, + "second": 216, + "amount": -1 + }, + { + "first": 120, + "second": 217, + "amount": 0 + }, + { + "first": 120, + "second": 218, + "amount": 0 + }, + { + "first": 120, + "second": 219, + "amount": 0 + }, + { + "first": 120, + "second": 220, + "amount": 0 + }, + { + "first": 120, + "second": 221, + "amount": -3 + }, + { + "first": 120, + "second": 224, + "amount": -1 + }, + { + "first": 120, + "second": 225, + "amount": -1 + }, + { + "first": 120, + "second": 226, + "amount": -1 + }, + { + "first": 120, + "second": 227, + "amount": -1 + }, + { + "first": 120, + "second": 228, + "amount": -1 + }, + { + "first": 120, + "second": 229, + "amount": -1 + }, + { + "first": 120, + "second": 230, + "amount": -1 + }, + { + "first": 120, + "second": 231, + "amount": 0 + }, + { + "first": 120, + "second": 248, + "amount": 0 + }, + { + "first": 120, + "second": 249, + "amount": 0 + }, + { + "first": 120, + "second": 250, + "amount": -1 + }, + { + "first": 120, + "second": 251, + "amount": 0 + }, + { + "first": 120, + "second": 252, + "amount": 0 + }, + { + "first": 120, + "second": 338, + "amount": -1 + }, + { + "first": 120, + "second": 339, + "amount": 0 + }, + { + "first": 120, + "second": 376, + "amount": -3 + }, + { + "first": 121, + "second": 64, + "amount": -1 + }, + { + "first": 121, + "second": 65, + "amount": -2 + }, + { + "first": 121, + "second": 67, + "amount": -1 + }, + { + "first": 121, + "second": 71, + "amount": -1 + }, + { + "first": 121, + "second": 74, + "amount": -1 + }, + { + "first": 121, + "second": 79, + "amount": -1 + }, + { + "first": 121, + "second": 81, + "amount": -1 + }, + { + "first": 121, + "second": 83, + "amount": 0 + }, + { + "first": 121, + "second": 84, + "amount": -2 + }, + { + "first": 121, + "second": 86, + "amount": -2 + }, + { + "first": 121, + "second": 87, + "amount": 0 + }, + { + "first": 121, + "second": 88, + "amount": -1 + }, + { + "first": 121, + "second": 89, + "amount": -2 + }, + { + "first": 121, + "second": 90, + "amount": 0 + }, + { + "first": 121, + "second": 97, + "amount": 0 + }, + { + "first": 121, + "second": 99, + "amount": 0 + }, + { + "first": 121, + "second": 100, + "amount": 0 + }, + { + "first": 121, + "second": 101, + "amount": 0 + }, + { + "first": 121, + "second": 103, + "amount": 0 + }, + { + "first": 121, + "second": 111, + "amount": 0 + }, + { + "first": 121, + "second": 113, + "amount": 0 + }, + { + "first": 121, + "second": 115, + "amount": 0 + }, + { + "first": 121, + "second": 122, + "amount": 0 + }, + { + "first": 121, + "second": 169, + "amount": -1 + }, + { + "first": 121, + "second": 174, + "amount": -1 + }, + { + "first": 121, + "second": 180, + "amount": 0 + }, + { + "first": 121, + "second": 192, + "amount": -2 + }, + { + "first": 121, + "second": 193, + "amount": -2 + }, + { + "first": 121, + "second": 194, + "amount": -2 + }, + { + "first": 121, + "second": 195, + "amount": -2 + }, + { + "first": 121, + "second": 196, + "amount": -2 + }, + { + "first": 121, + "second": 197, + "amount": -2 + }, + { + "first": 121, + "second": 199, + "amount": -1 + }, + { + "first": 121, + "second": 210, + "amount": -1 + }, + { + "first": 121, + "second": 211, + "amount": -1 + }, + { + "first": 121, + "second": 212, + "amount": -1 + }, + { + "first": 121, + "second": 213, + "amount": -1 + }, + { + "first": 121, + "second": 214, + "amount": -1 + }, + { + "first": 121, + "second": 216, + "amount": -1 + }, + { + "first": 121, + "second": 221, + "amount": -2 + }, + { + "first": 121, + "second": 224, + "amount": 0 + }, + { + "first": 121, + "second": 225, + "amount": 0 + }, + { + "first": 121, + "second": 226, + "amount": 0 + }, + { + "first": 121, + "second": 227, + "amount": 0 + }, + { + "first": 121, + "second": 228, + "amount": 0 + }, + { + "first": 121, + "second": 229, + "amount": 0 + }, + { + "first": 121, + "second": 230, + "amount": 0 + }, + { + "first": 121, + "second": 231, + "amount": 0 + }, + { + "first": 121, + "second": 248, + "amount": 0 + }, + { + "first": 121, + "second": 338, + "amount": -1 + }, + { + "first": 121, + "second": 339, + "amount": 0 + }, + { + "first": 121, + "second": 376, + "amount": -2 + }, + { + "first": 122, + "second": 64, + "amount": 0 + }, + { + "first": 122, + "second": 67, + "amount": 0 + }, + { + "first": 122, + "second": 71, + "amount": 0 + }, + { + "first": 122, + "second": 79, + "amount": 0 + }, + { + "first": 122, + "second": 81, + "amount": 0 + }, + { + "first": 122, + "second": 84, + "amount": -2 + }, + { + "first": 122, + "second": 86, + "amount": -2 + }, + { + "first": 122, + "second": 87, + "amount": 0 + }, + { + "first": 122, + "second": 88, + "amount": 0 + }, + { + "first": 122, + "second": 89, + "amount": -2 + }, + { + "first": 122, + "second": 99, + "amount": 0 + }, + { + "first": 122, + "second": 100, + "amount": 0 + }, + { + "first": 122, + "second": 101, + "amount": 0 + }, + { + "first": 122, + "second": 103, + "amount": 0 + }, + { + "first": 122, + "second": 111, + "amount": 0 + }, + { + "first": 122, + "second": 113, + "amount": 0 + }, + { + "first": 122, + "second": 115, + "amount": 0 + }, + { + "first": 122, + "second": 118, + "amount": 0 + }, + { + "first": 122, + "second": 119, + "amount": 0 + }, + { + "first": 122, + "second": 121, + "amount": 0 + }, + { + "first": 122, + "second": 169, + "amount": 0 + }, + { + "first": 122, + "second": 174, + "amount": 0 + }, + { + "first": 122, + "second": 199, + "amount": 0 + }, + { + "first": 122, + "second": 210, + "amount": 0 + }, + { + "first": 122, + "second": 211, + "amount": 0 + }, + { + "first": 122, + "second": 212, + "amount": 0 + }, + { + "first": 122, + "second": 213, + "amount": 0 + }, + { + "first": 122, + "second": 214, + "amount": 0 + }, + { + "first": 122, + "second": 216, + "amount": 0 + }, + { + "first": 122, + "second": 221, + "amount": -2 + }, + { + "first": 122, + "second": 231, + "amount": 0 + }, + { + "first": 122, + "second": 248, + "amount": 0 + }, + { + "first": 122, + "second": 338, + "amount": 0 + }, + { + "first": 122, + "second": 339, + "amount": 0 + }, + { + "first": 122, + "second": 376, + "amount": -2 + }, + { + "first": 123, + "second": 74, + "amount": -1 + }, + { + "first": 123, + "second": 106, + "amount": 4 + }, + { + "first": 161, + "second": 106, + "amount": 2 + }, + { + "first": 169, + "second": 44, + "amount": -1 + }, + { + "first": 169, + "second": 46, + "amount": -1 + }, + { + "first": 169, + "second": 47, + "amount": -1 + }, + { + "first": 169, + "second": 65, + "amount": -2 + }, + { + "first": 169, + "second": 74, + "amount": 0 + }, + { + "first": 169, + "second": 84, + "amount": -2 + }, + { + "first": 169, + "second": 86, + "amount": -3 + }, + { + "first": 169, + "second": 87, + "amount": -1 + }, + { + "first": 169, + "second": 88, + "amount": -1 + }, + { + "first": 169, + "second": 89, + "amount": -3 + }, + { + "first": 169, + "second": 90, + "amount": -1 + }, + { + "first": 169, + "second": 97, + "amount": 0 + }, + { + "first": 169, + "second": 99, + "amount": 0 + }, + { + "first": 169, + "second": 100, + "amount": 0 + }, + { + "first": 169, + "second": 101, + "amount": 0 + }, + { + "first": 169, + "second": 103, + "amount": 0 + }, + { + "first": 169, + "second": 111, + "amount": 0 + }, + { + "first": 169, + "second": 113, + "amount": 0 + }, + { + "first": 169, + "second": 115, + "amount": 0 + }, + { + "first": 169, + "second": 118, + "amount": 0 + }, + { + "first": 169, + "second": 119, + "amount": 0 + }, + { + "first": 169, + "second": 120, + "amount": 0 + }, + { + "first": 169, + "second": 121, + "amount": 0 + }, + { + "first": 169, + "second": 122, + "amount": 0 + }, + { + "first": 169, + "second": 192, + "amount": -2 + }, + { + "first": 169, + "second": 193, + "amount": -2 + }, + { + "first": 169, + "second": 194, + "amount": -2 + }, + { + "first": 169, + "second": 195, + "amount": -2 + }, + { + "first": 169, + "second": 196, + "amount": -2 + }, + { + "first": 169, + "second": 197, + "amount": -2 + }, + { + "first": 169, + "second": 221, + "amount": -3 + }, + { + "first": 169, + "second": 230, + "amount": 0 + }, + { + "first": 169, + "second": 231, + "amount": 0 + }, + { + "first": 169, + "second": 248, + "amount": 0 + }, + { + "first": 169, + "second": 339, + "amount": 0 + }, + { + "first": 169, + "second": 376, + "amount": -3 + }, + { + "first": 171, + "second": 84, + "amount": -2 + }, + { + "first": 171, + "second": 86, + "amount": -1 + }, + { + "first": 171, + "second": 89, + "amount": -1 + }, + { + "first": 171, + "second": 221, + "amount": -1 + }, + { + "first": 171, + "second": 376, + "amount": -1 + }, + { + "first": 174, + "second": 44, + "amount": -1 + }, + { + "first": 174, + "second": 46, + "amount": -1 + }, + { + "first": 174, + "second": 47, + "amount": -1 + }, + { + "first": 174, + "second": 65, + "amount": -2 + }, + { + "first": 174, + "second": 74, + "amount": 0 + }, + { + "first": 174, + "second": 84, + "amount": -2 + }, + { + "first": 174, + "second": 86, + "amount": -3 + }, + { + "first": 174, + "second": 87, + "amount": -1 + }, + { + "first": 174, + "second": 88, + "amount": -1 + }, + { + "first": 174, + "second": 89, + "amount": -3 + }, + { + "first": 174, + "second": 90, + "amount": -1 + }, + { + "first": 174, + "second": 97, + "amount": 0 + }, + { + "first": 174, + "second": 99, + "amount": 0 + }, + { + "first": 174, + "second": 100, + "amount": 0 + }, + { + "first": 174, + "second": 101, + "amount": 0 + }, + { + "first": 174, + "second": 103, + "amount": 0 + }, + { + "first": 174, + "second": 111, + "amount": 0 + }, + { + "first": 174, + "second": 113, + "amount": 0 + }, + { + "first": 174, + "second": 115, + "amount": 0 + }, + { + "first": 174, + "second": 118, + "amount": 0 + }, + { + "first": 174, + "second": 119, + "amount": 0 + }, + { + "first": 174, + "second": 120, + "amount": 0 + }, + { + "first": 174, + "second": 121, + "amount": 0 + }, + { + "first": 174, + "second": 122, + "amount": 0 + }, + { + "first": 174, + "second": 192, + "amount": -2 + }, + { + "first": 174, + "second": 193, + "amount": -2 + }, + { + "first": 174, + "second": 194, + "amount": -2 + }, + { + "first": 174, + "second": 195, + "amount": -2 + }, + { + "first": 174, + "second": 196, + "amount": -2 + }, + { + "first": 174, + "second": 197, + "amount": -2 + }, + { + "first": 174, + "second": 221, + "amount": -3 + }, + { + "first": 174, + "second": 230, + "amount": 0 + }, + { + "first": 174, + "second": 231, + "amount": 0 + }, + { + "first": 174, + "second": 248, + "amount": 0 + }, + { + "first": 174, + "second": 339, + "amount": 0 + }, + { + "first": 174, + "second": 376, + "amount": -3 + }, + { + "first": 180, + "second": 84, + "amount": -2 + }, + { + "first": 180, + "second": 86, + "amount": -3 + }, + { + "first": 180, + "second": 89, + "amount": -3 + }, + { + "first": 180, + "second": 118, + "amount": 0 + }, + { + "first": 180, + "second": 119, + "amount": 0 + }, + { + "first": 180, + "second": 121, + "amount": 0 + }, + { + "first": 180, + "second": 221, + "amount": -3 + }, + { + "first": 180, + "second": 376, + "amount": -3 + }, + { + "first": 181, + "second": 55, + "amount": -2 + }, + { + "first": 181, + "second": 84, + "amount": -2 + }, + { + "first": 181, + "second": 86, + "amount": -1 + }, + { + "first": 181, + "second": 87, + "amount": -1 + }, + { + "first": 181, + "second": 88, + "amount": 0 + }, + { + "first": 181, + "second": 89, + "amount": -1 + }, + { + "first": 181, + "second": 106, + "amount": 2 + }, + { + "first": 181, + "second": 221, + "amount": -1 + }, + { + "first": 181, + "second": 376, + "amount": -1 + }, + { + "first": 187, + "second": 65, + "amount": 0 + }, + { + "first": 187, + "second": 84, + "amount": -3 + }, + { + "first": 187, + "second": 86, + "amount": -4 + }, + { + "first": 187, + "second": 87, + "amount": -1 + }, + { + "first": 187, + "second": 88, + "amount": -1 + }, + { + "first": 187, + "second": 89, + "amount": -4 + }, + { + "first": 187, + "second": 116, + "amount": 0 + }, + { + "first": 187, + "second": 120, + "amount": -1 + }, + { + "first": 187, + "second": 192, + "amount": 0 + }, + { + "first": 187, + "second": 193, + "amount": 0 + }, + { + "first": 187, + "second": 194, + "amount": 0 + }, + { + "first": 187, + "second": 195, + "amount": 0 + }, + { + "first": 187, + "second": 196, + "amount": 0 + }, + { + "first": 187, + "second": 197, + "amount": 0 + }, + { + "first": 187, + "second": 221, + "amount": -4 + }, + { + "first": 187, + "second": 376, + "amount": -4 + }, + { + "first": 191, + "second": 84, + "amount": -2 + }, + { + "first": 191, + "second": 85, + "amount": -1 + }, + { + "first": 191, + "second": 86, + "amount": -3 + }, + { + "first": 191, + "second": 87, + "amount": -1 + }, + { + "first": 191, + "second": 89, + "amount": -3 + }, + { + "first": 191, + "second": 106, + "amount": 2 + }, + { + "first": 191, + "second": 119, + "amount": -1 + }, + { + "first": 191, + "second": 217, + "amount": -1 + }, + { + "first": 191, + "second": 218, + "amount": -1 + }, + { + "first": 191, + "second": 219, + "amount": -1 + }, + { + "first": 191, + "second": 220, + "amount": -1 + }, + { + "first": 191, + "second": 221, + "amount": -3 + }, + { + "first": 191, + "second": 376, + "amount": -3 + }, + { + "first": 192, + "second": 32, + "amount": -2 + }, + { + "first": 192, + "second": 38, + "amount": -1 + }, + { + "first": 192, + "second": 41, + "amount": -1 + }, + { + "first": 192, + "second": 42, + "amount": -4 + }, + { + "first": 192, + "second": 48, + "amount": -1 + }, + { + "first": 192, + "second": 49, + "amount": 0 + }, + { + "first": 192, + "second": 53, + "amount": 0 + }, + { + "first": 192, + "second": 54, + "amount": -1 + }, + { + "first": 192, + "second": 55, + "amount": -3 + }, + { + "first": 192, + "second": 56, + "amount": -1 + }, + { + "first": 192, + "second": 57, + "amount": -2 + }, + { + "first": 192, + "second": 64, + "amount": -2 + }, + { + "first": 192, + "second": 66, + "amount": -1 + }, + { + "first": 192, + "second": 67, + "amount": -2 + }, + { + "first": 192, + "second": 68, + "amount": -1 + }, + { + "first": 192, + "second": 69, + "amount": -1 + }, + { + "first": 192, + "second": 70, + "amount": -1 + }, + { + "first": 192, + "second": 71, + "amount": -2 + }, + { + "first": 192, + "second": 72, + "amount": -1 + }, + { + "first": 192, + "second": 73, + "amount": -1 + }, + { + "first": 192, + "second": 74, + "amount": 0 + }, + { + "first": 192, + "second": 75, + "amount": -1 + }, + { + "first": 192, + "second": 76, + "amount": -1 + }, + { + "first": 192, + "second": 77, + "amount": -1 + }, + { + "first": 192, + "second": 78, + "amount": -1 + }, + { + "first": 192, + "second": 79, + "amount": -2 + }, + { + "first": 192, + "second": 80, + "amount": -1 + }, + { + "first": 192, + "second": 81, + "amount": -2 + }, + { + "first": 192, + "second": 82, + "amount": -1 + }, + { + "first": 192, + "second": 83, + "amount": -1 + }, + { + "first": 192, + "second": 84, + "amount": -5 + }, + { + "first": 192, + "second": 85, + "amount": -1 + }, + { + "first": 192, + "second": 86, + "amount": -5 + }, + { + "first": 192, + "second": 87, + "amount": -2 + }, + { + "first": 192, + "second": 88, + "amount": -1 + }, + { + "first": 192, + "second": 89, + "amount": -5 + }, + { + "first": 192, + "second": 92, + "amount": -4 + }, + { + "first": 192, + "second": 97, + "amount": 0 + }, + { + "first": 192, + "second": 99, + "amount": 0 + }, + { + "first": 192, + "second": 100, + "amount": 0 + }, + { + "first": 192, + "second": 101, + "amount": 0 + }, + { + "first": 192, + "second": 102, + "amount": 0 + }, + { + "first": 192, + "second": 103, + "amount": 0 + }, + { + "first": 192, + "second": 106, + "amount": 3 + }, + { + "first": 192, + "second": 111, + "amount": 0 + }, + { + "first": 192, + "second": 113, + "amount": 0 + }, + { + "first": 192, + "second": 115, + "amount": 0 + }, + { + "first": 192, + "second": 116, + "amount": -1 + }, + { + "first": 192, + "second": 117, + "amount": 0 + }, + { + "first": 192, + "second": 118, + "amount": -2 + }, + { + "first": 192, + "second": 119, + "amount": -2 + }, + { + "first": 192, + "second": 121, + "amount": -2 + }, + { + "first": 192, + "second": 169, + "amount": -2 + }, + { + "first": 192, + "second": 171, + "amount": 0 + }, + { + "first": 192, + "second": 174, + "amount": -2 + }, + { + "first": 192, + "second": 199, + "amount": -2 + }, + { + "first": 192, + "second": 200, + "amount": -1 + }, + { + "first": 192, + "second": 201, + "amount": -1 + }, + { + "first": 192, + "second": 202, + "amount": -1 + }, + { + "first": 192, + "second": 203, + "amount": -1 + }, + { + "first": 192, + "second": 204, + "amount": -1 + }, + { + "first": 192, + "second": 205, + "amount": -1 + }, + { + "first": 192, + "second": 206, + "amount": -1 + }, + { + "first": 192, + "second": 207, + "amount": -1 + }, + { + "first": 192, + "second": 209, + "amount": -1 + }, + { + "first": 192, + "second": 210, + "amount": -2 + }, + { + "first": 192, + "second": 211, + "amount": -2 + }, + { + "first": 192, + "second": 212, + "amount": -2 + }, + { + "first": 192, + "second": 213, + "amount": -2 + }, + { + "first": 192, + "second": 214, + "amount": -2 + }, + { + "first": 192, + "second": 216, + "amount": -2 + }, + { + "first": 192, + "second": 217, + "amount": -1 + }, + { + "first": 192, + "second": 218, + "amount": -1 + }, + { + "first": 192, + "second": 219, + "amount": -1 + }, + { + "first": 192, + "second": 220, + "amount": -1 + }, + { + "first": 192, + "second": 221, + "amount": -5 + }, + { + "first": 192, + "second": 222, + "amount": -1 + }, + { + "first": 192, + "second": 230, + "amount": 0 + }, + { + "first": 192, + "second": 231, + "amount": 0 + }, + { + "first": 192, + "second": 232, + "amount": 0 + }, + { + "first": 192, + "second": 233, + "amount": 0 + }, + { + "first": 192, + "second": 234, + "amount": 0 + }, + { + "first": 192, + "second": 235, + "amount": 0 + }, + { + "first": 192, + "second": 240, + "amount": 0 + }, + { + "first": 192, + "second": 242, + "amount": 0 + }, + { + "first": 192, + "second": 243, + "amount": 0 + }, + { + "first": 192, + "second": 244, + "amount": 0 + }, + { + "first": 192, + "second": 245, + "amount": 0 + }, + { + "first": 192, + "second": 246, + "amount": 0 + }, + { + "first": 192, + "second": 248, + "amount": 0 + }, + { + "first": 192, + "second": 250, + "amount": 0 + }, + { + "first": 192, + "second": 338, + "amount": -2 + }, + { + "first": 192, + "second": 339, + "amount": 0 + }, + { + "first": 192, + "second": 376, + "amount": -5 + }, + { + "first": 193, + "second": 32, + "amount": -2 + }, + { + "first": 193, + "second": 38, + "amount": -1 + }, + { + "first": 193, + "second": 41, + "amount": -1 + }, + { + "first": 193, + "second": 42, + "amount": -4 + }, + { + "first": 193, + "second": 48, + "amount": -1 + }, + { + "first": 193, + "second": 49, + "amount": 0 + }, + { + "first": 193, + "second": 53, + "amount": 0 + }, + { + "first": 193, + "second": 54, + "amount": -1 + }, + { + "first": 193, + "second": 55, + "amount": -3 + }, + { + "first": 193, + "second": 56, + "amount": -1 + }, + { + "first": 193, + "second": 57, + "amount": -2 + }, + { + "first": 193, + "second": 64, + "amount": -2 + }, + { + "first": 193, + "second": 66, + "amount": -1 + }, + { + "first": 193, + "second": 67, + "amount": -2 + }, + { + "first": 193, + "second": 68, + "amount": -1 + }, + { + "first": 193, + "second": 69, + "amount": -1 + }, + { + "first": 193, + "second": 70, + "amount": -1 + }, + { + "first": 193, + "second": 71, + "amount": -2 + }, + { + "first": 193, + "second": 72, + "amount": -1 + }, + { + "first": 193, + "second": 73, + "amount": -1 + }, + { + "first": 193, + "second": 74, + "amount": 0 + }, + { + "first": 193, + "second": 75, + "amount": -1 + }, + { + "first": 193, + "second": 76, + "amount": -1 + }, + { + "first": 193, + "second": 77, + "amount": -1 + }, + { + "first": 193, + "second": 78, + "amount": -1 + }, + { + "first": 193, + "second": 79, + "amount": -2 + }, + { + "first": 193, + "second": 80, + "amount": -1 + }, + { + "first": 193, + "second": 81, + "amount": -2 + }, + { + "first": 193, + "second": 82, + "amount": -1 + }, + { + "first": 193, + "second": 83, + "amount": -1 + }, + { + "first": 193, + "second": 84, + "amount": -5 + }, + { + "first": 193, + "second": 85, + "amount": -1 + }, + { + "first": 193, + "second": 86, + "amount": -5 + }, + { + "first": 193, + "second": 87, + "amount": -2 + }, + { + "first": 193, + "second": 88, + "amount": -1 + }, + { + "first": 193, + "second": 89, + "amount": -5 + }, + { + "first": 193, + "second": 92, + "amount": -4 + }, + { + "first": 193, + "second": 97, + "amount": 0 + }, + { + "first": 193, + "second": 99, + "amount": 0 + }, + { + "first": 193, + "second": 100, + "amount": 0 + }, + { + "first": 193, + "second": 101, + "amount": 0 + }, + { + "first": 193, + "second": 102, + "amount": 0 + }, + { + "first": 193, + "second": 103, + "amount": 0 + }, + { + "first": 193, + "second": 106, + "amount": 3 + }, + { + "first": 193, + "second": 111, + "amount": 0 + }, + { + "first": 193, + "second": 113, + "amount": 0 + }, + { + "first": 193, + "second": 115, + "amount": 0 + }, + { + "first": 193, + "second": 116, + "amount": -1 + }, + { + "first": 193, + "second": 117, + "amount": 0 + }, + { + "first": 193, + "second": 118, + "amount": -2 + }, + { + "first": 193, + "second": 119, + "amount": -2 + }, + { + "first": 193, + "second": 121, + "amount": -2 + }, + { + "first": 193, + "second": 169, + "amount": -2 + }, + { + "first": 193, + "second": 171, + "amount": 0 + }, + { + "first": 193, + "second": 174, + "amount": -2 + }, + { + "first": 193, + "second": 199, + "amount": -2 + }, + { + "first": 193, + "second": 200, + "amount": -1 + }, + { + "first": 193, + "second": 201, + "amount": -1 + }, + { + "first": 193, + "second": 202, + "amount": -1 + }, + { + "first": 193, + "second": 203, + "amount": -1 + }, + { + "first": 193, + "second": 204, + "amount": -1 + }, + { + "first": 193, + "second": 205, + "amount": -1 + }, + { + "first": 193, + "second": 206, + "amount": -1 + }, + { + "first": 193, + "second": 207, + "amount": -1 + }, + { + "first": 193, + "second": 209, + "amount": -1 + }, + { + "first": 193, + "second": 210, + "amount": -2 + }, + { + "first": 193, + "second": 211, + "amount": -2 + }, + { + "first": 193, + "second": 212, + "amount": -2 + }, + { + "first": 193, + "second": 213, + "amount": -2 + }, + { + "first": 193, + "second": 214, + "amount": -2 + }, + { + "first": 193, + "second": 216, + "amount": -2 + }, + { + "first": 193, + "second": 217, + "amount": -1 + }, + { + "first": 193, + "second": 218, + "amount": -1 + }, + { + "first": 193, + "second": 219, + "amount": -1 + }, + { + "first": 193, + "second": 220, + "amount": -1 + }, + { + "first": 193, + "second": 221, + "amount": -5 + }, + { + "first": 193, + "second": 222, + "amount": -1 + }, + { + "first": 193, + "second": 230, + "amount": 0 + }, + { + "first": 193, + "second": 231, + "amount": 0 + }, + { + "first": 193, + "second": 232, + "amount": 0 + }, + { + "first": 193, + "second": 233, + "amount": 0 + }, + { + "first": 193, + "second": 234, + "amount": 0 + }, + { + "first": 193, + "second": 235, + "amount": 0 + }, + { + "first": 193, + "second": 240, + "amount": 0 + }, + { + "first": 193, + "second": 242, + "amount": 0 + }, + { + "first": 193, + "second": 243, + "amount": 0 + }, + { + "first": 193, + "second": 244, + "amount": 0 + }, + { + "first": 193, + "second": 245, + "amount": 0 + }, + { + "first": 193, + "second": 246, + "amount": 0 + }, + { + "first": 193, + "second": 248, + "amount": 0 + }, + { + "first": 193, + "second": 250, + "amount": 0 + }, + { + "first": 193, + "second": 338, + "amount": -2 + }, + { + "first": 193, + "second": 339, + "amount": 0 + }, + { + "first": 193, + "second": 376, + "amount": -5 + }, + { + "first": 194, + "second": 32, + "amount": -2 + }, + { + "first": 194, + "second": 38, + "amount": -1 + }, + { + "first": 194, + "second": 41, + "amount": -1 + }, + { + "first": 194, + "second": 42, + "amount": -4 + }, + { + "first": 194, + "second": 48, + "amount": -1 + }, + { + "first": 194, + "second": 49, + "amount": 0 + }, + { + "first": 194, + "second": 53, + "amount": 0 + }, + { + "first": 194, + "second": 54, + "amount": -1 + }, + { + "first": 194, + "second": 55, + "amount": -3 + }, + { + "first": 194, + "second": 56, + "amount": -1 + }, + { + "first": 194, + "second": 57, + "amount": -2 + }, + { + "first": 194, + "second": 64, + "amount": -2 + }, + { + "first": 194, + "second": 66, + "amount": -1 + }, + { + "first": 194, + "second": 67, + "amount": -2 + }, + { + "first": 194, + "second": 68, + "amount": -1 + }, + { + "first": 194, + "second": 69, + "amount": -1 + }, + { + "first": 194, + "second": 70, + "amount": -1 + }, + { + "first": 194, + "second": 71, + "amount": -2 + }, + { + "first": 194, + "second": 72, + "amount": -1 + }, + { + "first": 194, + "second": 73, + "amount": -1 + }, + { + "first": 194, + "second": 74, + "amount": 0 + }, + { + "first": 194, + "second": 75, + "amount": -1 + }, + { + "first": 194, + "second": 76, + "amount": -1 + }, + { + "first": 194, + "second": 77, + "amount": -1 + }, + { + "first": 194, + "second": 78, + "amount": -1 + }, + { + "first": 194, + "second": 79, + "amount": -2 + }, + { + "first": 194, + "second": 80, + "amount": -1 + }, + { + "first": 194, + "second": 81, + "amount": -2 + }, + { + "first": 194, + "second": 82, + "amount": -1 + }, + { + "first": 194, + "second": 83, + "amount": -1 + }, + { + "first": 194, + "second": 84, + "amount": -5 + }, + { + "first": 194, + "second": 85, + "amount": -1 + }, + { + "first": 194, + "second": 86, + "amount": -5 + }, + { + "first": 194, + "second": 87, + "amount": -2 + }, + { + "first": 194, + "second": 88, + "amount": -1 + }, + { + "first": 194, + "second": 89, + "amount": -5 + }, + { + "first": 194, + "second": 92, + "amount": -4 + }, + { + "first": 194, + "second": 97, + "amount": 0 + }, + { + "first": 194, + "second": 99, + "amount": 0 + }, + { + "first": 194, + "second": 100, + "amount": 0 + }, + { + "first": 194, + "second": 101, + "amount": 0 + }, + { + "first": 194, + "second": 102, + "amount": 0 + }, + { + "first": 194, + "second": 103, + "amount": 0 + }, + { + "first": 194, + "second": 106, + "amount": 3 + }, + { + "first": 194, + "second": 111, + "amount": 0 + }, + { + "first": 194, + "second": 113, + "amount": 0 + }, + { + "first": 194, + "second": 115, + "amount": 0 + }, + { + "first": 194, + "second": 116, + "amount": -1 + }, + { + "first": 194, + "second": 117, + "amount": 0 + }, + { + "first": 194, + "second": 118, + "amount": -2 + }, + { + "first": 194, + "second": 119, + "amount": -2 + }, + { + "first": 194, + "second": 121, + "amount": -2 + }, + { + "first": 194, + "second": 169, + "amount": -2 + }, + { + "first": 194, + "second": 171, + "amount": 0 + }, + { + "first": 194, + "second": 174, + "amount": -2 + }, + { + "first": 194, + "second": 199, + "amount": -2 + }, + { + "first": 194, + "second": 200, + "amount": -1 + }, + { + "first": 194, + "second": 201, + "amount": -1 + }, + { + "first": 194, + "second": 202, + "amount": -1 + }, + { + "first": 194, + "second": 203, + "amount": -1 + }, + { + "first": 194, + "second": 204, + "amount": -1 + }, + { + "first": 194, + "second": 205, + "amount": -1 + }, + { + "first": 194, + "second": 206, + "amount": -1 + }, + { + "first": 194, + "second": 207, + "amount": -1 + }, + { + "first": 194, + "second": 209, + "amount": -1 + }, + { + "first": 194, + "second": 210, + "amount": -2 + }, + { + "first": 194, + "second": 211, + "amount": -2 + }, + { + "first": 194, + "second": 212, + "amount": -2 + }, + { + "first": 194, + "second": 213, + "amount": -2 + }, + { + "first": 194, + "second": 214, + "amount": -2 + }, + { + "first": 194, + "second": 216, + "amount": -2 + }, + { + "first": 194, + "second": 217, + "amount": -1 + }, + { + "first": 194, + "second": 218, + "amount": -1 + }, + { + "first": 194, + "second": 219, + "amount": -1 + }, + { + "first": 194, + "second": 220, + "amount": -1 + }, + { + "first": 194, + "second": 221, + "amount": -5 + }, + { + "first": 194, + "second": 222, + "amount": -1 + }, + { + "first": 194, + "second": 230, + "amount": 0 + }, + { + "first": 194, + "second": 231, + "amount": 0 + }, + { + "first": 194, + "second": 232, + "amount": 0 + }, + { + "first": 194, + "second": 233, + "amount": 0 + }, + { + "first": 194, + "second": 234, + "amount": 0 + }, + { + "first": 194, + "second": 235, + "amount": 0 + }, + { + "first": 194, + "second": 240, + "amount": 0 + }, + { + "first": 194, + "second": 242, + "amount": 0 + }, + { + "first": 194, + "second": 243, + "amount": 0 + }, + { + "first": 194, + "second": 244, + "amount": 0 + }, + { + "first": 194, + "second": 245, + "amount": 0 + }, + { + "first": 194, + "second": 246, + "amount": 0 + }, + { + "first": 194, + "second": 248, + "amount": 0 + }, + { + "first": 194, + "second": 250, + "amount": 0 + }, + { + "first": 194, + "second": 338, + "amount": -2 + }, + { + "first": 194, + "second": 339, + "amount": 0 + }, + { + "first": 194, + "second": 376, + "amount": -5 + }, + { + "first": 195, + "second": 32, + "amount": -2 + }, + { + "first": 195, + "second": 38, + "amount": -1 + }, + { + "first": 195, + "second": 41, + "amount": -1 + }, + { + "first": 195, + "second": 42, + "amount": -4 + }, + { + "first": 195, + "second": 48, + "amount": -1 + }, + { + "first": 195, + "second": 49, + "amount": 0 + }, + { + "first": 195, + "second": 53, + "amount": 0 + }, + { + "first": 195, + "second": 54, + "amount": -1 + }, + { + "first": 195, + "second": 55, + "amount": -3 + }, + { + "first": 195, + "second": 56, + "amount": -1 + }, + { + "first": 195, + "second": 57, + "amount": -2 + }, + { + "first": 195, + "second": 64, + "amount": -2 + }, + { + "first": 195, + "second": 66, + "amount": -1 + }, + { + "first": 195, + "second": 67, + "amount": -2 + }, + { + "first": 195, + "second": 68, + "amount": -1 + }, + { + "first": 195, + "second": 69, + "amount": -1 + }, + { + "first": 195, + "second": 70, + "amount": -1 + }, + { + "first": 195, + "second": 71, + "amount": -2 + }, + { + "first": 195, + "second": 72, + "amount": -1 + }, + { + "first": 195, + "second": 73, + "amount": -1 + }, + { + "first": 195, + "second": 74, + "amount": 0 + }, + { + "first": 195, + "second": 75, + "amount": -1 + }, + { + "first": 195, + "second": 76, + "amount": -1 + }, + { + "first": 195, + "second": 77, + "amount": -1 + }, + { + "first": 195, + "second": 78, + "amount": -1 + }, + { + "first": 195, + "second": 79, + "amount": -2 + }, + { + "first": 195, + "second": 80, + "amount": -1 + }, + { + "first": 195, + "second": 81, + "amount": -2 + }, + { + "first": 195, + "second": 82, + "amount": -1 + }, + { + "first": 195, + "second": 83, + "amount": -1 + }, + { + "first": 195, + "second": 84, + "amount": -5 + }, + { + "first": 195, + "second": 85, + "amount": -1 + }, + { + "first": 195, + "second": 86, + "amount": -5 + }, + { + "first": 195, + "second": 87, + "amount": -2 + }, + { + "first": 195, + "second": 88, + "amount": -1 + }, + { + "first": 195, + "second": 89, + "amount": -5 + }, + { + "first": 195, + "second": 92, + "amount": -4 + }, + { + "first": 195, + "second": 97, + "amount": 0 + }, + { + "first": 195, + "second": 99, + "amount": 0 + }, + { + "first": 195, + "second": 100, + "amount": 0 + }, + { + "first": 195, + "second": 101, + "amount": 0 + }, + { + "first": 195, + "second": 102, + "amount": 0 + }, + { + "first": 195, + "second": 103, + "amount": 0 + }, + { + "first": 195, + "second": 106, + "amount": 3 + }, + { + "first": 195, + "second": 111, + "amount": 0 + }, + { + "first": 195, + "second": 113, + "amount": 0 + }, + { + "first": 195, + "second": 115, + "amount": 0 + }, + { + "first": 195, + "second": 116, + "amount": -1 + }, + { + "first": 195, + "second": 117, + "amount": 0 + }, + { + "first": 195, + "second": 118, + "amount": -2 + }, + { + "first": 195, + "second": 119, + "amount": -2 + }, + { + "first": 195, + "second": 121, + "amount": -2 + }, + { + "first": 195, + "second": 169, + "amount": -2 + }, + { + "first": 195, + "second": 171, + "amount": 0 + }, + { + "first": 195, + "second": 174, + "amount": -2 + }, + { + "first": 195, + "second": 199, + "amount": -2 + }, + { + "first": 195, + "second": 200, + "amount": -1 + }, + { + "first": 195, + "second": 201, + "amount": -1 + }, + { + "first": 195, + "second": 202, + "amount": -1 + }, + { + "first": 195, + "second": 203, + "amount": -1 + }, + { + "first": 195, + "second": 204, + "amount": -1 + }, + { + "first": 195, + "second": 205, + "amount": -1 + }, + { + "first": 195, + "second": 206, + "amount": -1 + }, + { + "first": 195, + "second": 207, + "amount": -1 + }, + { + "first": 195, + "second": 209, + "amount": -1 + }, + { + "first": 195, + "second": 210, + "amount": -2 + }, + { + "first": 195, + "second": 211, + "amount": -2 + }, + { + "first": 195, + "second": 212, + "amount": -2 + }, + { + "first": 195, + "second": 213, + "amount": -2 + }, + { + "first": 195, + "second": 214, + "amount": -2 + }, + { + "first": 195, + "second": 216, + "amount": -2 + }, + { + "first": 195, + "second": 217, + "amount": -1 + }, + { + "first": 195, + "second": 218, + "amount": -1 + }, + { + "first": 195, + "second": 219, + "amount": -1 + }, + { + "first": 195, + "second": 220, + "amount": -1 + }, + { + "first": 195, + "second": 221, + "amount": -5 + }, + { + "first": 195, + "second": 222, + "amount": -1 + }, + { + "first": 195, + "second": 230, + "amount": 0 + }, + { + "first": 195, + "second": 231, + "amount": 0 + }, + { + "first": 195, + "second": 232, + "amount": 0 + }, + { + "first": 195, + "second": 233, + "amount": 0 + }, + { + "first": 195, + "second": 234, + "amount": 0 + }, + { + "first": 195, + "second": 235, + "amount": 0 + }, + { + "first": 195, + "second": 240, + "amount": 0 + }, + { + "first": 195, + "second": 242, + "amount": 0 + }, + { + "first": 195, + "second": 243, + "amount": 0 + }, + { + "first": 195, + "second": 244, + "amount": 0 + }, + { + "first": 195, + "second": 245, + "amount": 0 + }, + { + "first": 195, + "second": 246, + "amount": 0 + }, + { + "first": 195, + "second": 248, + "amount": 0 + }, + { + "first": 195, + "second": 250, + "amount": 0 + }, + { + "first": 195, + "second": 338, + "amount": -2 + }, + { + "first": 195, + "second": 339, + "amount": 0 + }, + { + "first": 195, + "second": 376, + "amount": -5 + }, + { + "first": 196, + "second": 32, + "amount": -2 + }, + { + "first": 196, + "second": 38, + "amount": -1 + }, + { + "first": 196, + "second": 41, + "amount": -1 + }, + { + "first": 196, + "second": 42, + "amount": -4 + }, + { + "first": 196, + "second": 48, + "amount": -1 + }, + { + "first": 196, + "second": 49, + "amount": 0 + }, + { + "first": 196, + "second": 53, + "amount": 0 + }, + { + "first": 196, + "second": 54, + "amount": -1 + }, + { + "first": 196, + "second": 55, + "amount": -3 + }, + { + "first": 196, + "second": 56, + "amount": -1 + }, + { + "first": 196, + "second": 57, + "amount": -2 + }, + { + "first": 196, + "second": 64, + "amount": -2 + }, + { + "first": 196, + "second": 66, + "amount": -1 + }, + { + "first": 196, + "second": 67, + "amount": -2 + }, + { + "first": 196, + "second": 68, + "amount": -1 + }, + { + "first": 196, + "second": 69, + "amount": -1 + }, + { + "first": 196, + "second": 70, + "amount": -1 + }, + { + "first": 196, + "second": 71, + "amount": -2 + }, + { + "first": 196, + "second": 72, + "amount": -1 + }, + { + "first": 196, + "second": 73, + "amount": -1 + }, + { + "first": 196, + "second": 74, + "amount": 0 + }, + { + "first": 196, + "second": 75, + "amount": -1 + }, + { + "first": 196, + "second": 76, + "amount": -1 + }, + { + "first": 196, + "second": 77, + "amount": -1 + }, + { + "first": 196, + "second": 78, + "amount": -1 + }, + { + "first": 196, + "second": 79, + "amount": -2 + }, + { + "first": 196, + "second": 80, + "amount": -1 + }, + { + "first": 196, + "second": 81, + "amount": -2 + }, + { + "first": 196, + "second": 82, + "amount": -1 + }, + { + "first": 196, + "second": 83, + "amount": -1 + }, + { + "first": 196, + "second": 84, + "amount": -5 + }, + { + "first": 196, + "second": 85, + "amount": -1 + }, + { + "first": 196, + "second": 86, + "amount": -5 + }, + { + "first": 196, + "second": 87, + "amount": -2 + }, + { + "first": 196, + "second": 88, + "amount": -1 + }, + { + "first": 196, + "second": 89, + "amount": -5 + }, + { + "first": 196, + "second": 92, + "amount": -4 + }, + { + "first": 196, + "second": 97, + "amount": 0 + }, + { + "first": 196, + "second": 99, + "amount": 0 + }, + { + "first": 196, + "second": 100, + "amount": 0 + }, + { + "first": 196, + "second": 101, + "amount": 0 + }, + { + "first": 196, + "second": 102, + "amount": 0 + }, + { + "first": 196, + "second": 103, + "amount": 0 + }, + { + "first": 196, + "second": 106, + "amount": 3 + }, + { + "first": 196, + "second": 111, + "amount": 0 + }, + { + "first": 196, + "second": 113, + "amount": 0 + }, + { + "first": 196, + "second": 115, + "amount": 0 + }, + { + "first": 196, + "second": 116, + "amount": -1 + }, + { + "first": 196, + "second": 117, + "amount": 0 + }, + { + "first": 196, + "second": 118, + "amount": -2 + }, + { + "first": 196, + "second": 119, + "amount": -2 + }, + { + "first": 196, + "second": 121, + "amount": -2 + }, + { + "first": 196, + "second": 169, + "amount": -2 + }, + { + "first": 196, + "second": 171, + "amount": 0 + }, + { + "first": 196, + "second": 174, + "amount": -2 + }, + { + "first": 196, + "second": 199, + "amount": -2 + }, + { + "first": 196, + "second": 200, + "amount": -1 + }, + { + "first": 196, + "second": 201, + "amount": -1 + }, + { + "first": 196, + "second": 202, + "amount": -1 + }, + { + "first": 196, + "second": 203, + "amount": -1 + }, + { + "first": 196, + "second": 204, + "amount": -1 + }, + { + "first": 196, + "second": 205, + "amount": -1 + }, + { + "first": 196, + "second": 206, + "amount": -1 + }, + { + "first": 196, + "second": 207, + "amount": -1 + }, + { + "first": 196, + "second": 209, + "amount": -1 + }, + { + "first": 196, + "second": 210, + "amount": -2 + }, + { + "first": 196, + "second": 211, + "amount": -2 + }, + { + "first": 196, + "second": 212, + "amount": -2 + }, + { + "first": 196, + "second": 213, + "amount": -2 + }, + { + "first": 196, + "second": 214, + "amount": -2 + }, + { + "first": 196, + "second": 216, + "amount": -2 + }, + { + "first": 196, + "second": 217, + "amount": -1 + }, + { + "first": 196, + "second": 218, + "amount": -1 + }, + { + "first": 196, + "second": 219, + "amount": -1 + }, + { + "first": 196, + "second": 220, + "amount": -1 + }, + { + "first": 196, + "second": 221, + "amount": -5 + }, + { + "first": 196, + "second": 222, + "amount": -1 + }, + { + "first": 196, + "second": 230, + "amount": 0 + }, + { + "first": 196, + "second": 231, + "amount": 0 + }, + { + "first": 196, + "second": 232, + "amount": 0 + }, + { + "first": 196, + "second": 233, + "amount": 0 + }, + { + "first": 196, + "second": 234, + "amount": 0 + }, + { + "first": 196, + "second": 235, + "amount": 0 + }, + { + "first": 196, + "second": 240, + "amount": 0 + }, + { + "first": 196, + "second": 242, + "amount": 0 + }, + { + "first": 196, + "second": 243, + "amount": 0 + }, + { + "first": 196, + "second": 244, + "amount": 0 + }, + { + "first": 196, + "second": 245, + "amount": 0 + }, + { + "first": 196, + "second": 246, + "amount": 0 + }, + { + "first": 196, + "second": 248, + "amount": 0 + }, + { + "first": 196, + "second": 250, + "amount": 0 + }, + { + "first": 196, + "second": 338, + "amount": -2 + }, + { + "first": 196, + "second": 339, + "amount": 0 + }, + { + "first": 196, + "second": 376, + "amount": -5 + }, + { + "first": 197, + "second": 32, + "amount": -2 + }, + { + "first": 197, + "second": 38, + "amount": -1 + }, + { + "first": 197, + "second": 41, + "amount": -1 + }, + { + "first": 197, + "second": 42, + "amount": -4 + }, + { + "first": 197, + "second": 48, + "amount": -1 + }, + { + "first": 197, + "second": 49, + "amount": 0 + }, + { + "first": 197, + "second": 53, + "amount": 0 + }, + { + "first": 197, + "second": 54, + "amount": -1 + }, + { + "first": 197, + "second": 55, + "amount": -3 + }, + { + "first": 197, + "second": 56, + "amount": -1 + }, + { + "first": 197, + "second": 57, + "amount": -2 + }, + { + "first": 197, + "second": 64, + "amount": -2 + }, + { + "first": 197, + "second": 66, + "amount": -1 + }, + { + "first": 197, + "second": 67, + "amount": -2 + }, + { + "first": 197, + "second": 68, + "amount": -1 + }, + { + "first": 197, + "second": 69, + "amount": -1 + }, + { + "first": 197, + "second": 70, + "amount": -1 + }, + { + "first": 197, + "second": 71, + "amount": -2 + }, + { + "first": 197, + "second": 72, + "amount": -1 + }, + { + "first": 197, + "second": 73, + "amount": -1 + }, + { + "first": 197, + "second": 74, + "amount": 0 + }, + { + "first": 197, + "second": 75, + "amount": -1 + }, + { + "first": 197, + "second": 76, + "amount": -1 + }, + { + "first": 197, + "second": 77, + "amount": -1 + }, + { + "first": 197, + "second": 78, + "amount": -1 + }, + { + "first": 197, + "second": 79, + "amount": -2 + }, + { + "first": 197, + "second": 80, + "amount": -1 + }, + { + "first": 197, + "second": 81, + "amount": -2 + }, + { + "first": 197, + "second": 82, + "amount": -1 + }, + { + "first": 197, + "second": 83, + "amount": -1 + }, + { + "first": 197, + "second": 84, + "amount": -5 + }, + { + "first": 197, + "second": 85, + "amount": -1 + }, + { + "first": 197, + "second": 86, + "amount": -5 + }, + { + "first": 197, + "second": 87, + "amount": -2 + }, + { + "first": 197, + "second": 88, + "amount": -1 + }, + { + "first": 197, + "second": 89, + "amount": -5 + }, + { + "first": 197, + "second": 92, + "amount": -4 + }, + { + "first": 197, + "second": 97, + "amount": 0 + }, + { + "first": 197, + "second": 99, + "amount": 0 + }, + { + "first": 197, + "second": 100, + "amount": 0 + }, + { + "first": 197, + "second": 101, + "amount": 0 + }, + { + "first": 197, + "second": 102, + "amount": 0 + }, + { + "first": 197, + "second": 103, + "amount": 0 + }, + { + "first": 197, + "second": 106, + "amount": 3 + }, + { + "first": 197, + "second": 111, + "amount": 0 + }, + { + "first": 197, + "second": 113, + "amount": 0 + }, + { + "first": 197, + "second": 115, + "amount": 0 + }, + { + "first": 197, + "second": 116, + "amount": -1 + }, + { + "first": 197, + "second": 117, + "amount": 0 + }, + { + "first": 197, + "second": 118, + "amount": -2 + }, + { + "first": 197, + "second": 119, + "amount": -2 + }, + { + "first": 197, + "second": 121, + "amount": -2 + }, + { + "first": 197, + "second": 169, + "amount": -2 + }, + { + "first": 197, + "second": 171, + "amount": 0 + }, + { + "first": 197, + "second": 174, + "amount": -2 + }, + { + "first": 197, + "second": 199, + "amount": -2 + }, + { + "first": 197, + "second": 200, + "amount": -1 + }, + { + "first": 197, + "second": 201, + "amount": -1 + }, + { + "first": 197, + "second": 202, + "amount": -1 + }, + { + "first": 197, + "second": 203, + "amount": -1 + }, + { + "first": 197, + "second": 204, + "amount": -1 + }, + { + "first": 197, + "second": 205, + "amount": -1 + }, + { + "first": 197, + "second": 206, + "amount": -1 + }, + { + "first": 197, + "second": 207, + "amount": -1 + }, + { + "first": 197, + "second": 209, + "amount": -1 + }, + { + "first": 197, + "second": 210, + "amount": -2 + }, + { + "first": 197, + "second": 211, + "amount": -2 + }, + { + "first": 197, + "second": 212, + "amount": -2 + }, + { + "first": 197, + "second": 213, + "amount": -2 + }, + { + "first": 197, + "second": 214, + "amount": -2 + }, + { + "first": 197, + "second": 216, + "amount": -2 + }, + { + "first": 197, + "second": 217, + "amount": -1 + }, + { + "first": 197, + "second": 218, + "amount": -1 + }, + { + "first": 197, + "second": 219, + "amount": -1 + }, + { + "first": 197, + "second": 220, + "amount": -1 + }, + { + "first": 197, + "second": 221, + "amount": -5 + }, + { + "first": 197, + "second": 222, + "amount": -1 + }, + { + "first": 197, + "second": 230, + "amount": 0 + }, + { + "first": 197, + "second": 231, + "amount": 0 + }, + { + "first": 197, + "second": 232, + "amount": 0 + }, + { + "first": 197, + "second": 233, + "amount": 0 + }, + { + "first": 197, + "second": 234, + "amount": 0 + }, + { + "first": 197, + "second": 235, + "amount": 0 + }, + { + "first": 197, + "second": 240, + "amount": 0 + }, + { + "first": 197, + "second": 242, + "amount": 0 + }, + { + "first": 197, + "second": 243, + "amount": 0 + }, + { + "first": 197, + "second": 244, + "amount": 0 + }, + { + "first": 197, + "second": 245, + "amount": 0 + }, + { + "first": 197, + "second": 246, + "amount": 0 + }, + { + "first": 197, + "second": 248, + "amount": 0 + }, + { + "first": 197, + "second": 250, + "amount": 0 + }, + { + "first": 197, + "second": 338, + "amount": -2 + }, + { + "first": 197, + "second": 339, + "amount": 0 + }, + { + "first": 197, + "second": 376, + "amount": -5 + }, + { + "first": 198, + "second": 38, + "amount": 0 + }, + { + "first": 198, + "second": 64, + "amount": -1 + }, + { + "first": 198, + "second": 67, + "amount": -1 + }, + { + "first": 198, + "second": 71, + "amount": -1 + }, + { + "first": 198, + "second": 74, + "amount": 0 + }, + { + "first": 198, + "second": 79, + "amount": -1 + }, + { + "first": 198, + "second": 81, + "amount": -1 + }, + { + "first": 198, + "second": 87, + "amount": 0 + }, + { + "first": 198, + "second": 97, + "amount": 0 + }, + { + "first": 198, + "second": 99, + "amount": 0 + }, + { + "first": 198, + "second": 100, + "amount": 0 + }, + { + "first": 198, + "second": 101, + "amount": 0 + }, + { + "first": 198, + "second": 102, + "amount": -1 + }, + { + "first": 198, + "second": 103, + "amount": 0 + }, + { + "first": 198, + "second": 106, + "amount": 1 + }, + { + "first": 198, + "second": 111, + "amount": 0 + }, + { + "first": 198, + "second": 113, + "amount": 0 + }, + { + "first": 198, + "second": 115, + "amount": 0 + }, + { + "first": 198, + "second": 116, + "amount": 0 + }, + { + "first": 198, + "second": 118, + "amount": -1 + }, + { + "first": 198, + "second": 119, + "amount": -1 + }, + { + "first": 198, + "second": 121, + "amount": -1 + }, + { + "first": 198, + "second": 169, + "amount": -1 + }, + { + "first": 198, + "second": 171, + "amount": -1 + }, + { + "first": 198, + "second": 174, + "amount": -1 + }, + { + "first": 198, + "second": 180, + "amount": 0 + }, + { + "first": 198, + "second": 199, + "amount": -1 + }, + { + "first": 198, + "second": 210, + "amount": -1 + }, + { + "first": 198, + "second": 211, + "amount": -1 + }, + { + "first": 198, + "second": 212, + "amount": -1 + }, + { + "first": 198, + "second": 213, + "amount": -1 + }, + { + "first": 198, + "second": 214, + "amount": -1 + }, + { + "first": 198, + "second": 216, + "amount": -1 + }, + { + "first": 198, + "second": 224, + "amount": 0 + }, + { + "first": 198, + "second": 225, + "amount": 0 + }, + { + "first": 198, + "second": 226, + "amount": 0 + }, + { + "first": 198, + "second": 227, + "amount": 0 + }, + { + "first": 198, + "second": 228, + "amount": 0 + }, + { + "first": 198, + "second": 229, + "amount": 0 + }, + { + "first": 198, + "second": 230, + "amount": 0 + }, + { + "first": 198, + "second": 231, + "amount": 0 + }, + { + "first": 198, + "second": 248, + "amount": 0 + }, + { + "first": 198, + "second": 338, + "amount": -1 + }, + { + "first": 198, + "second": 339, + "amount": 0 + }, + { + "first": 199, + "second": 47, + "amount": -1 + }, + { + "first": 199, + "second": 64, + "amount": 0 + }, + { + "first": 199, + "second": 65, + "amount": -1 + }, + { + "first": 199, + "second": 67, + "amount": 0 + }, + { + "first": 199, + "second": 71, + "amount": 0 + }, + { + "first": 199, + "second": 74, + "amount": -1 + }, + { + "first": 199, + "second": 79, + "amount": 0 + }, + { + "first": 199, + "second": 81, + "amount": 0 + }, + { + "first": 199, + "second": 84, + "amount": 0 + }, + { + "first": 199, + "second": 86, + "amount": 0 + }, + { + "first": 199, + "second": 87, + "amount": 0 + }, + { + "first": 199, + "second": 88, + "amount": -1 + }, + { + "first": 199, + "second": 89, + "amount": 0 + }, + { + "first": 199, + "second": 90, + "amount": -1 + }, + { + "first": 199, + "second": 99, + "amount": 0 + }, + { + "first": 199, + "second": 100, + "amount": 0 + }, + { + "first": 199, + "second": 101, + "amount": 0 + }, + { + "first": 199, + "second": 103, + "amount": 0 + }, + { + "first": 199, + "second": 111, + "amount": 0 + }, + { + "first": 199, + "second": 113, + "amount": 0 + }, + { + "first": 199, + "second": 115, + "amount": 0 + }, + { + "first": 199, + "second": 118, + "amount": 0 + }, + { + "first": 199, + "second": 119, + "amount": 0 + }, + { + "first": 199, + "second": 120, + "amount": -1 + }, + { + "first": 199, + "second": 121, + "amount": 0 + }, + { + "first": 199, + "second": 122, + "amount": 0 + }, + { + "first": 199, + "second": 169, + "amount": 0 + }, + { + "first": 199, + "second": 171, + "amount": 0 + }, + { + "first": 199, + "second": 174, + "amount": 0 + }, + { + "first": 199, + "second": 187, + "amount": 0 + }, + { + "first": 199, + "second": 192, + "amount": -1 + }, + { + "first": 199, + "second": 193, + "amount": -1 + }, + { + "first": 199, + "second": 194, + "amount": -1 + }, + { + "first": 199, + "second": 195, + "amount": -1 + }, + { + "first": 199, + "second": 196, + "amount": -1 + }, + { + "first": 199, + "second": 197, + "amount": -1 + }, + { + "first": 199, + "second": 199, + "amount": 0 + }, + { + "first": 199, + "second": 210, + "amount": 0 + }, + { + "first": 199, + "second": 211, + "amount": 0 + }, + { + "first": 199, + "second": 212, + "amount": 0 + }, + { + "first": 199, + "second": 213, + "amount": 0 + }, + { + "first": 199, + "second": 214, + "amount": 0 + }, + { + "first": 199, + "second": 216, + "amount": 0 + }, + { + "first": 199, + "second": 221, + "amount": 0 + }, + { + "first": 199, + "second": 231, + "amount": 0 + }, + { + "first": 199, + "second": 248, + "amount": 0 + }, + { + "first": 199, + "second": 338, + "amount": 0 + }, + { + "first": 199, + "second": 339, + "amount": 0 + }, + { + "first": 199, + "second": 376, + "amount": 0 + }, + { + "first": 200, + "second": 38, + "amount": 0 + }, + { + "first": 200, + "second": 64, + "amount": -1 + }, + { + "first": 200, + "second": 67, + "amount": -1 + }, + { + "first": 200, + "second": 71, + "amount": -1 + }, + { + "first": 200, + "second": 74, + "amount": 0 + }, + { + "first": 200, + "second": 79, + "amount": -1 + }, + { + "first": 200, + "second": 81, + "amount": -1 + }, + { + "first": 200, + "second": 87, + "amount": 0 + }, + { + "first": 200, + "second": 97, + "amount": 0 + }, + { + "first": 200, + "second": 99, + "amount": 0 + }, + { + "first": 200, + "second": 100, + "amount": 0 + }, + { + "first": 200, + "second": 101, + "amount": 0 + }, + { + "first": 200, + "second": 102, + "amount": -1 + }, + { + "first": 200, + "second": 103, + "amount": 0 + }, + { + "first": 200, + "second": 106, + "amount": 1 + }, + { + "first": 200, + "second": 111, + "amount": 0 + }, + { + "first": 200, + "second": 113, + "amount": 0 + }, + { + "first": 200, + "second": 115, + "amount": 0 + }, + { + "first": 200, + "second": 116, + "amount": 0 + }, + { + "first": 200, + "second": 118, + "amount": -1 + }, + { + "first": 200, + "second": 119, + "amount": -1 + }, + { + "first": 200, + "second": 121, + "amount": -1 + }, + { + "first": 200, + "second": 169, + "amount": -1 + }, + { + "first": 200, + "second": 171, + "amount": -1 + }, + { + "first": 200, + "second": 174, + "amount": -1 + }, + { + "first": 200, + "second": 180, + "amount": 0 + }, + { + "first": 200, + "second": 199, + "amount": -1 + }, + { + "first": 200, + "second": 210, + "amount": -1 + }, + { + "first": 200, + "second": 211, + "amount": -1 + }, + { + "first": 200, + "second": 212, + "amount": -1 + }, + { + "first": 200, + "second": 213, + "amount": -1 + }, + { + "first": 200, + "second": 214, + "amount": -1 + }, + { + "first": 200, + "second": 216, + "amount": -1 + }, + { + "first": 200, + "second": 224, + "amount": 0 + }, + { + "first": 200, + "second": 225, + "amount": 0 + }, + { + "first": 200, + "second": 226, + "amount": 0 + }, + { + "first": 200, + "second": 227, + "amount": 0 + }, + { + "first": 200, + "second": 228, + "amount": 0 + }, + { + "first": 200, + "second": 229, + "amount": 0 + }, + { + "first": 200, + "second": 230, + "amount": 0 + }, + { + "first": 200, + "second": 231, + "amount": 0 + }, + { + "first": 200, + "second": 248, + "amount": 0 + }, + { + "first": 200, + "second": 338, + "amount": -1 + }, + { + "first": 200, + "second": 339, + "amount": 0 + }, + { + "first": 201, + "second": 38, + "amount": 0 + }, + { + "first": 201, + "second": 64, + "amount": -1 + }, + { + "first": 201, + "second": 67, + "amount": -1 + }, + { + "first": 201, + "second": 71, + "amount": -1 + }, + { + "first": 201, + "second": 74, + "amount": 0 + }, + { + "first": 201, + "second": 79, + "amount": -1 + }, + { + "first": 201, + "second": 81, + "amount": -1 + }, + { + "first": 201, + "second": 87, + "amount": 0 + }, + { + "first": 201, + "second": 97, + "amount": 0 + }, + { + "first": 201, + "second": 99, + "amount": 0 + }, + { + "first": 201, + "second": 100, + "amount": 0 + }, + { + "first": 201, + "second": 101, + "amount": 0 + }, + { + "first": 201, + "second": 102, + "amount": -1 + }, + { + "first": 201, + "second": 103, + "amount": 0 + }, + { + "first": 201, + "second": 106, + "amount": 1 + }, + { + "first": 201, + "second": 111, + "amount": 0 + }, + { + "first": 201, + "second": 113, + "amount": 0 + }, + { + "first": 201, + "second": 115, + "amount": 0 + }, + { + "first": 201, + "second": 116, + "amount": 0 + }, + { + "first": 201, + "second": 118, + "amount": -1 + }, + { + "first": 201, + "second": 119, + "amount": -1 + }, + { + "first": 201, + "second": 121, + "amount": -1 + }, + { + "first": 201, + "second": 169, + "amount": -1 + }, + { + "first": 201, + "second": 171, + "amount": -1 + }, + { + "first": 201, + "second": 174, + "amount": -1 + }, + { + "first": 201, + "second": 180, + "amount": 0 + }, + { + "first": 201, + "second": 199, + "amount": -1 + }, + { + "first": 201, + "second": 210, + "amount": -1 + }, + { + "first": 201, + "second": 211, + "amount": -1 + }, + { + "first": 201, + "second": 212, + "amount": -1 + }, + { + "first": 201, + "second": 213, + "amount": -1 + }, + { + "first": 201, + "second": 214, + "amount": -1 + }, + { + "first": 201, + "second": 216, + "amount": -1 + }, + { + "first": 201, + "second": 224, + "amount": 0 + }, + { + "first": 201, + "second": 225, + "amount": 0 + }, + { + "first": 201, + "second": 226, + "amount": 0 + }, + { + "first": 201, + "second": 227, + "amount": 0 + }, + { + "first": 201, + "second": 228, + "amount": 0 + }, + { + "first": 201, + "second": 229, + "amount": 0 + }, + { + "first": 201, + "second": 230, + "amount": 0 + }, + { + "first": 201, + "second": 231, + "amount": 0 + }, + { + "first": 201, + "second": 248, + "amount": 0 + }, + { + "first": 201, + "second": 338, + "amount": -1 + }, + { + "first": 201, + "second": 339, + "amount": 0 + }, + { + "first": 202, + "second": 38, + "amount": 0 + }, + { + "first": 202, + "second": 64, + "amount": -1 + }, + { + "first": 202, + "second": 67, + "amount": -1 + }, + { + "first": 202, + "second": 71, + "amount": -1 + }, + { + "first": 202, + "second": 74, + "amount": 0 + }, + { + "first": 202, + "second": 79, + "amount": -1 + }, + { + "first": 202, + "second": 81, + "amount": -1 + }, + { + "first": 202, + "second": 87, + "amount": 0 + }, + { + "first": 202, + "second": 97, + "amount": 0 + }, + { + "first": 202, + "second": 99, + "amount": 0 + }, + { + "first": 202, + "second": 100, + "amount": 0 + }, + { + "first": 202, + "second": 101, + "amount": 0 + }, + { + "first": 202, + "second": 102, + "amount": -1 + }, + { + "first": 202, + "second": 103, + "amount": 0 + }, + { + "first": 202, + "second": 106, + "amount": 1 + }, + { + "first": 202, + "second": 111, + "amount": 0 + }, + { + "first": 202, + "second": 113, + "amount": 0 + }, + { + "first": 202, + "second": 115, + "amount": 0 + }, + { + "first": 202, + "second": 116, + "amount": 0 + }, + { + "first": 202, + "second": 118, + "amount": -1 + }, + { + "first": 202, + "second": 119, + "amount": -1 + }, + { + "first": 202, + "second": 121, + "amount": -1 + }, + { + "first": 202, + "second": 169, + "amount": -1 + }, + { + "first": 202, + "second": 171, + "amount": -1 + }, + { + "first": 202, + "second": 174, + "amount": -1 + }, + { + "first": 202, + "second": 180, + "amount": 0 + }, + { + "first": 202, + "second": 199, + "amount": -1 + }, + { + "first": 202, + "second": 210, + "amount": -1 + }, + { + "first": 202, + "second": 211, + "amount": -1 + }, + { + "first": 202, + "second": 212, + "amount": -1 + }, + { + "first": 202, + "second": 213, + "amount": -1 + }, + { + "first": 202, + "second": 214, + "amount": -1 + }, + { + "first": 202, + "second": 216, + "amount": -1 + }, + { + "first": 202, + "second": 224, + "amount": 0 + }, + { + "first": 202, + "second": 225, + "amount": 0 + }, + { + "first": 202, + "second": 226, + "amount": 0 + }, + { + "first": 202, + "second": 227, + "amount": 0 + }, + { + "first": 202, + "second": 228, + "amount": 0 + }, + { + "first": 202, + "second": 229, + "amount": 0 + }, + { + "first": 202, + "second": 230, + "amount": 0 + }, + { + "first": 202, + "second": 231, + "amount": 0 + }, + { + "first": 202, + "second": 248, + "amount": 0 + }, + { + "first": 202, + "second": 338, + "amount": -1 + }, + { + "first": 202, + "second": 339, + "amount": 0 + }, + { + "first": 203, + "second": 38, + "amount": 0 + }, + { + "first": 203, + "second": 64, + "amount": -1 + }, + { + "first": 203, + "second": 67, + "amount": -1 + }, + { + "first": 203, + "second": 71, + "amount": -1 + }, + { + "first": 203, + "second": 74, + "amount": 0 + }, + { + "first": 203, + "second": 79, + "amount": -1 + }, + { + "first": 203, + "second": 81, + "amount": -1 + }, + { + "first": 203, + "second": 87, + "amount": 0 + }, + { + "first": 203, + "second": 97, + "amount": 0 + }, + { + "first": 203, + "second": 99, + "amount": 0 + }, + { + "first": 203, + "second": 100, + "amount": 0 + }, + { + "first": 203, + "second": 101, + "amount": 0 + }, + { + "first": 203, + "second": 102, + "amount": -1 + }, + { + "first": 203, + "second": 103, + "amount": 0 + }, + { + "first": 203, + "second": 106, + "amount": 1 + }, + { + "first": 203, + "second": 111, + "amount": 0 + }, + { + "first": 203, + "second": 113, + "amount": 0 + }, + { + "first": 203, + "second": 115, + "amount": 0 + }, + { + "first": 203, + "second": 116, + "amount": 0 + }, + { + "first": 203, + "second": 118, + "amount": -1 + }, + { + "first": 203, + "second": 119, + "amount": -1 + }, + { + "first": 203, + "second": 121, + "amount": -1 + }, + { + "first": 203, + "second": 169, + "amount": -1 + }, + { + "first": 203, + "second": 171, + "amount": -1 + }, + { + "first": 203, + "second": 174, + "amount": -1 + }, + { + "first": 203, + "second": 180, + "amount": 0 + }, + { + "first": 203, + "second": 199, + "amount": -1 + }, + { + "first": 203, + "second": 210, + "amount": -1 + }, + { + "first": 203, + "second": 211, + "amount": -1 + }, + { + "first": 203, + "second": 212, + "amount": -1 + }, + { + "first": 203, + "second": 213, + "amount": -1 + }, + { + "first": 203, + "second": 214, + "amount": -1 + }, + { + "first": 203, + "second": 216, + "amount": -1 + }, + { + "first": 203, + "second": 224, + "amount": 0 + }, + { + "first": 203, + "second": 225, + "amount": 0 + }, + { + "first": 203, + "second": 226, + "amount": 0 + }, + { + "first": 203, + "second": 227, + "amount": 0 + }, + { + "first": 203, + "second": 228, + "amount": 0 + }, + { + "first": 203, + "second": 229, + "amount": 0 + }, + { + "first": 203, + "second": 230, + "amount": 0 + }, + { + "first": 203, + "second": 231, + "amount": 0 + }, + { + "first": 203, + "second": 248, + "amount": 0 + }, + { + "first": 203, + "second": 338, + "amount": -1 + }, + { + "first": 203, + "second": 339, + "amount": 0 + }, + { + "first": 204, + "second": 65, + "amount": -1 + }, + { + "first": 204, + "second": 74, + "amount": 0 + }, + { + "first": 204, + "second": 86, + "amount": -1 + }, + { + "first": 204, + "second": 87, + "amount": -1 + }, + { + "first": 204, + "second": 89, + "amount": -1 + }, + { + "first": 204, + "second": 192, + "amount": -1 + }, + { + "first": 204, + "second": 193, + "amount": -1 + }, + { + "first": 204, + "second": 194, + "amount": -1 + }, + { + "first": 204, + "second": 195, + "amount": -1 + }, + { + "first": 204, + "second": 196, + "amount": -1 + }, + { + "first": 204, + "second": 197, + "amount": -1 + }, + { + "first": 204, + "second": 221, + "amount": -1 + }, + { + "first": 204, + "second": 376, + "amount": -1 + }, + { + "first": 205, + "second": 65, + "amount": -1 + }, + { + "first": 205, + "second": 74, + "amount": 0 + }, + { + "first": 205, + "second": 86, + "amount": -1 + }, + { + "first": 205, + "second": 87, + "amount": -1 + }, + { + "first": 205, + "second": 89, + "amount": -1 + }, + { + "first": 205, + "second": 192, + "amount": -1 + }, + { + "first": 205, + "second": 193, + "amount": -1 + }, + { + "first": 205, + "second": 194, + "amount": -1 + }, + { + "first": 205, + "second": 195, + "amount": -1 + }, + { + "first": 205, + "second": 196, + "amount": -1 + }, + { + "first": 205, + "second": 197, + "amount": -1 + }, + { + "first": 205, + "second": 221, + "amount": -1 + }, + { + "first": 205, + "second": 376, + "amount": -1 + }, + { + "first": 206, + "second": 65, + "amount": -1 + }, + { + "first": 206, + "second": 74, + "amount": 0 + }, + { + "first": 206, + "second": 86, + "amount": -1 + }, + { + "first": 206, + "second": 87, + "amount": -1 + }, + { + "first": 206, + "second": 89, + "amount": -1 + }, + { + "first": 206, + "second": 192, + "amount": -1 + }, + { + "first": 206, + "second": 193, + "amount": -1 + }, + { + "first": 206, + "second": 194, + "amount": -1 + }, + { + "first": 206, + "second": 195, + "amount": -1 + }, + { + "first": 206, + "second": 196, + "amount": -1 + }, + { + "first": 206, + "second": 197, + "amount": -1 + }, + { + "first": 206, + "second": 221, + "amount": -1 + }, + { + "first": 206, + "second": 376, + "amount": -1 + }, + { + "first": 207, + "second": 65, + "amount": -1 + }, + { + "first": 207, + "second": 74, + "amount": 0 + }, + { + "first": 207, + "second": 86, + "amount": -1 + }, + { + "first": 207, + "second": 87, + "amount": -1 + }, + { + "first": 207, + "second": 89, + "amount": -1 + }, + { + "first": 207, + "second": 192, + "amount": -1 + }, + { + "first": 207, + "second": 193, + "amount": -1 + }, + { + "first": 207, + "second": 194, + "amount": -1 + }, + { + "first": 207, + "second": 195, + "amount": -1 + }, + { + "first": 207, + "second": 196, + "amount": -1 + }, + { + "first": 207, + "second": 197, + "amount": -1 + }, + { + "first": 207, + "second": 221, + "amount": -1 + }, + { + "first": 207, + "second": 376, + "amount": -1 + }, + { + "first": 208, + "second": 44, + "amount": -1 + }, + { + "first": 208, + "second": 46, + "amount": -1 + }, + { + "first": 208, + "second": 47, + "amount": -1 + }, + { + "first": 208, + "second": 65, + "amount": -2 + }, + { + "first": 208, + "second": 74, + "amount": 0 + }, + { + "first": 208, + "second": 84, + "amount": -2 + }, + { + "first": 208, + "second": 86, + "amount": -3 + }, + { + "first": 208, + "second": 87, + "amount": -1 + }, + { + "first": 208, + "second": 88, + "amount": -1 + }, + { + "first": 208, + "second": 89, + "amount": -3 + }, + { + "first": 208, + "second": 90, + "amount": -1 + }, + { + "first": 208, + "second": 97, + "amount": 0 + }, + { + "first": 208, + "second": 99, + "amount": 0 + }, + { + "first": 208, + "second": 100, + "amount": 0 + }, + { + "first": 208, + "second": 101, + "amount": 0 + }, + { + "first": 208, + "second": 103, + "amount": 0 + }, + { + "first": 208, + "second": 111, + "amount": 0 + }, + { + "first": 208, + "second": 113, + "amount": 0 + }, + { + "first": 208, + "second": 115, + "amount": 0 + }, + { + "first": 208, + "second": 118, + "amount": 0 + }, + { + "first": 208, + "second": 119, + "amount": 0 + }, + { + "first": 208, + "second": 120, + "amount": 0 + }, + { + "first": 208, + "second": 121, + "amount": 0 + }, + { + "first": 208, + "second": 122, + "amount": 0 + }, + { + "first": 208, + "second": 192, + "amount": -2 + }, + { + "first": 208, + "second": 193, + "amount": -2 + }, + { + "first": 208, + "second": 194, + "amount": -2 + }, + { + "first": 208, + "second": 195, + "amount": -2 + }, + { + "first": 208, + "second": 196, + "amount": -2 + }, + { + "first": 208, + "second": 197, + "amount": -2 + }, + { + "first": 208, + "second": 221, + "amount": -3 + }, + { + "first": 208, + "second": 230, + "amount": 0 + }, + { + "first": 208, + "second": 231, + "amount": 0 + }, + { + "first": 208, + "second": 248, + "amount": 0 + }, + { + "first": 208, + "second": 339, + "amount": 0 + }, + { + "first": 208, + "second": 376, + "amount": -3 + }, + { + "first": 209, + "second": 65, + "amount": -1 + }, + { + "first": 209, + "second": 74, + "amount": 0 + }, + { + "first": 209, + "second": 86, + "amount": -1 + }, + { + "first": 209, + "second": 87, + "amount": -1 + }, + { + "first": 209, + "second": 89, + "amount": -1 + }, + { + "first": 209, + "second": 192, + "amount": -1 + }, + { + "first": 209, + "second": 193, + "amount": -1 + }, + { + "first": 209, + "second": 194, + "amount": -1 + }, + { + "first": 209, + "second": 195, + "amount": -1 + }, + { + "first": 209, + "second": 196, + "amount": -1 + }, + { + "first": 209, + "second": 197, + "amount": -1 + }, + { + "first": 209, + "second": 221, + "amount": -1 + }, + { + "first": 209, + "second": 376, + "amount": -1 + }, + { + "first": 210, + "second": 44, + "amount": -1 + }, + { + "first": 210, + "second": 46, + "amount": -1 + }, + { + "first": 210, + "second": 47, + "amount": -1 + }, + { + "first": 210, + "second": 65, + "amount": -2 + }, + { + "first": 210, + "second": 74, + "amount": 0 + }, + { + "first": 210, + "second": 84, + "amount": -2 + }, + { + "first": 210, + "second": 86, + "amount": -3 + }, + { + "first": 210, + "second": 87, + "amount": -1 + }, + { + "first": 210, + "second": 88, + "amount": -1 + }, + { + "first": 210, + "second": 89, + "amount": -3 + }, + { + "first": 210, + "second": 90, + "amount": -1 + }, + { + "first": 210, + "second": 97, + "amount": 0 + }, + { + "first": 210, + "second": 99, + "amount": 0 + }, + { + "first": 210, + "second": 100, + "amount": 0 + }, + { + "first": 210, + "second": 101, + "amount": 0 + }, + { + "first": 210, + "second": 103, + "amount": 0 + }, + { + "first": 210, + "second": 111, + "amount": 0 + }, + { + "first": 210, + "second": 113, + "amount": 0 + }, + { + "first": 210, + "second": 115, + "amount": 0 + }, + { + "first": 210, + "second": 118, + "amount": 0 + }, + { + "first": 210, + "second": 119, + "amount": 0 + }, + { + "first": 210, + "second": 120, + "amount": 0 + }, + { + "first": 210, + "second": 121, + "amount": 0 + }, + { + "first": 210, + "second": 122, + "amount": 0 + }, + { + "first": 210, + "second": 192, + "amount": -2 + }, + { + "first": 210, + "second": 193, + "amount": -2 + }, + { + "first": 210, + "second": 194, + "amount": -2 + }, + { + "first": 210, + "second": 195, + "amount": -2 + }, + { + "first": 210, + "second": 196, + "amount": -2 + }, + { + "first": 210, + "second": 197, + "amount": -2 + }, + { + "first": 210, + "second": 221, + "amount": -3 + }, + { + "first": 210, + "second": 230, + "amount": 0 + }, + { + "first": 210, + "second": 231, + "amount": 0 + }, + { + "first": 210, + "second": 248, + "amount": 0 + }, + { + "first": 210, + "second": 339, + "amount": 0 + }, + { + "first": 210, + "second": 376, + "amount": -3 + }, + { + "first": 211, + "second": 44, + "amount": -1 + }, + { + "first": 211, + "second": 46, + "amount": -1 + }, + { + "first": 211, + "second": 47, + "amount": -1 + }, + { + "first": 211, + "second": 65, + "amount": -2 + }, + { + "first": 211, + "second": 74, + "amount": 0 + }, + { + "first": 211, + "second": 84, + "amount": -2 + }, + { + "first": 211, + "second": 86, + "amount": -3 + }, + { + "first": 211, + "second": 87, + "amount": -1 + }, + { + "first": 211, + "second": 88, + "amount": -1 + }, + { + "first": 211, + "second": 89, + "amount": -3 + }, + { + "first": 211, + "second": 90, + "amount": -1 + }, + { + "first": 211, + "second": 97, + "amount": 0 + }, + { + "first": 211, + "second": 99, + "amount": 0 + }, + { + "first": 211, + "second": 100, + "amount": 0 + }, + { + "first": 211, + "second": 101, + "amount": 0 + }, + { + "first": 211, + "second": 103, + "amount": 0 + }, + { + "first": 211, + "second": 111, + "amount": 0 + }, + { + "first": 211, + "second": 113, + "amount": 0 + }, + { + "first": 211, + "second": 115, + "amount": 0 + }, + { + "first": 211, + "second": 118, + "amount": 0 + }, + { + "first": 211, + "second": 119, + "amount": 0 + }, + { + "first": 211, + "second": 120, + "amount": 0 + }, + { + "first": 211, + "second": 121, + "amount": 0 + }, + { + "first": 211, + "second": 122, + "amount": 0 + }, + { + "first": 211, + "second": 192, + "amount": -2 + }, + { + "first": 211, + "second": 193, + "amount": -2 + }, + { + "first": 211, + "second": 194, + "amount": -2 + }, + { + "first": 211, + "second": 195, + "amount": -2 + }, + { + "first": 211, + "second": 196, + "amount": -2 + }, + { + "first": 211, + "second": 197, + "amount": -2 + }, + { + "first": 211, + "second": 221, + "amount": -3 + }, + { + "first": 211, + "second": 230, + "amount": 0 + }, + { + "first": 211, + "second": 231, + "amount": 0 + }, + { + "first": 211, + "second": 248, + "amount": 0 + }, + { + "first": 211, + "second": 339, + "amount": 0 + }, + { + "first": 211, + "second": 376, + "amount": -3 + }, + { + "first": 212, + "second": 44, + "amount": -1 + }, + { + "first": 212, + "second": 46, + "amount": -1 + }, + { + "first": 212, + "second": 47, + "amount": -1 + }, + { + "first": 212, + "second": 65, + "amount": -2 + }, + { + "first": 212, + "second": 74, + "amount": 0 + }, + { + "first": 212, + "second": 84, + "amount": -2 + }, + { + "first": 212, + "second": 86, + "amount": -3 + }, + { + "first": 212, + "second": 87, + "amount": -1 + }, + { + "first": 212, + "second": 88, + "amount": -1 + }, + { + "first": 212, + "second": 89, + "amount": -3 + }, + { + "first": 212, + "second": 90, + "amount": -1 + }, + { + "first": 212, + "second": 97, + "amount": 0 + }, + { + "first": 212, + "second": 99, + "amount": 0 + }, + { + "first": 212, + "second": 100, + "amount": 0 + }, + { + "first": 212, + "second": 101, + "amount": 0 + }, + { + "first": 212, + "second": 103, + "amount": 0 + }, + { + "first": 212, + "second": 111, + "amount": 0 + }, + { + "first": 212, + "second": 113, + "amount": 0 + }, + { + "first": 212, + "second": 115, + "amount": 0 + }, + { + "first": 212, + "second": 118, + "amount": 0 + }, + { + "first": 212, + "second": 119, + "amount": 0 + }, + { + "first": 212, + "second": 120, + "amount": 0 + }, + { + "first": 212, + "second": 121, + "amount": 0 + }, + { + "first": 212, + "second": 122, + "amount": 0 + }, + { + "first": 212, + "second": 192, + "amount": -2 + }, + { + "first": 212, + "second": 193, + "amount": -2 + }, + { + "first": 212, + "second": 194, + "amount": -2 + }, + { + "first": 212, + "second": 195, + "amount": -2 + }, + { + "first": 212, + "second": 196, + "amount": -2 + }, + { + "first": 212, + "second": 197, + "amount": -2 + }, + { + "first": 212, + "second": 221, + "amount": -3 + }, + { + "first": 212, + "second": 230, + "amount": 0 + }, + { + "first": 212, + "second": 231, + "amount": 0 + }, + { + "first": 212, + "second": 248, + "amount": 0 + }, + { + "first": 212, + "second": 339, + "amount": 0 + }, + { + "first": 212, + "second": 376, + "amount": -3 + }, + { + "first": 213, + "second": 44, + "amount": -1 + }, + { + "first": 213, + "second": 46, + "amount": -1 + }, + { + "first": 213, + "second": 47, + "amount": -1 + }, + { + "first": 213, + "second": 65, + "amount": -2 + }, + { + "first": 213, + "second": 74, + "amount": 0 + }, + { + "first": 213, + "second": 84, + "amount": -2 + }, + { + "first": 213, + "second": 86, + "amount": -3 + }, + { + "first": 213, + "second": 87, + "amount": -1 + }, + { + "first": 213, + "second": 88, + "amount": -1 + }, + { + "first": 213, + "second": 89, + "amount": -3 + }, + { + "first": 213, + "second": 90, + "amount": -1 + }, + { + "first": 213, + "second": 97, + "amount": 0 + }, + { + "first": 213, + "second": 99, + "amount": 0 + }, + { + "first": 213, + "second": 100, + "amount": 0 + }, + { + "first": 213, + "second": 101, + "amount": 0 + }, + { + "first": 213, + "second": 103, + "amount": 0 + }, + { + "first": 213, + "second": 111, + "amount": 0 + }, + { + "first": 213, + "second": 113, + "amount": 0 + }, + { + "first": 213, + "second": 115, + "amount": 0 + }, + { + "first": 213, + "second": 118, + "amount": 0 + }, + { + "first": 213, + "second": 119, + "amount": 0 + }, + { + "first": 213, + "second": 120, + "amount": 0 + }, + { + "first": 213, + "second": 121, + "amount": 0 + }, + { + "first": 213, + "second": 122, + "amount": 0 + }, + { + "first": 213, + "second": 192, + "amount": -2 + }, + { + "first": 213, + "second": 193, + "amount": -2 + }, + { + "first": 213, + "second": 194, + "amount": -2 + }, + { + "first": 213, + "second": 195, + "amount": -2 + }, + { + "first": 213, + "second": 196, + "amount": -2 + }, + { + "first": 213, + "second": 197, + "amount": -2 + }, + { + "first": 213, + "second": 221, + "amount": -3 + }, + { + "first": 213, + "second": 230, + "amount": 0 + }, + { + "first": 213, + "second": 231, + "amount": 0 + }, + { + "first": 213, + "second": 248, + "amount": 0 + }, + { + "first": 213, + "second": 339, + "amount": 0 + }, + { + "first": 213, + "second": 376, + "amount": -3 + }, + { + "first": 214, + "second": 44, + "amount": -1 + }, + { + "first": 214, + "second": 46, + "amount": -1 + }, + { + "first": 214, + "second": 47, + "amount": -1 + }, + { + "first": 214, + "second": 65, + "amount": -2 + }, + { + "first": 214, + "second": 74, + "amount": 0 + }, + { + "first": 214, + "second": 84, + "amount": -2 + }, + { + "first": 214, + "second": 86, + "amount": -3 + }, + { + "first": 214, + "second": 87, + "amount": -1 + }, + { + "first": 214, + "second": 88, + "amount": -1 + }, + { + "first": 214, + "second": 89, + "amount": -3 + }, + { + "first": 214, + "second": 90, + "amount": -1 + }, + { + "first": 214, + "second": 97, + "amount": 0 + }, + { + "first": 214, + "second": 99, + "amount": 0 + }, + { + "first": 214, + "second": 100, + "amount": 0 + }, + { + "first": 214, + "second": 101, + "amount": 0 + }, + { + "first": 214, + "second": 103, + "amount": 0 + }, + { + "first": 214, + "second": 111, + "amount": 0 + }, + { + "first": 214, + "second": 113, + "amount": 0 + }, + { + "first": 214, + "second": 115, + "amount": 0 + }, + { + "first": 214, + "second": 118, + "amount": 0 + }, + { + "first": 214, + "second": 119, + "amount": 0 + }, + { + "first": 214, + "second": 120, + "amount": 0 + }, + { + "first": 214, + "second": 121, + "amount": 0 + }, + { + "first": 214, + "second": 122, + "amount": 0 + }, + { + "first": 214, + "second": 192, + "amount": -2 + }, + { + "first": 214, + "second": 193, + "amount": -2 + }, + { + "first": 214, + "second": 194, + "amount": -2 + }, + { + "first": 214, + "second": 195, + "amount": -2 + }, + { + "first": 214, + "second": 196, + "amount": -2 + }, + { + "first": 214, + "second": 197, + "amount": -2 + }, + { + "first": 214, + "second": 221, + "amount": -3 + }, + { + "first": 214, + "second": 230, + "amount": 0 + }, + { + "first": 214, + "second": 231, + "amount": 0 + }, + { + "first": 214, + "second": 248, + "amount": 0 + }, + { + "first": 214, + "second": 339, + "amount": 0 + }, + { + "first": 214, + "second": 376, + "amount": -3 + }, + { + "first": 216, + "second": 44, + "amount": -1 + }, + { + "first": 216, + "second": 46, + "amount": -1 + }, + { + "first": 216, + "second": 47, + "amount": -1 + }, + { + "first": 216, + "second": 65, + "amount": -2 + }, + { + "first": 216, + "second": 74, + "amount": 0 + }, + { + "first": 216, + "second": 84, + "amount": -2 + }, + { + "first": 216, + "second": 86, + "amount": -3 + }, + { + "first": 216, + "second": 87, + "amount": -1 + }, + { + "first": 216, + "second": 88, + "amount": -1 + }, + { + "first": 216, + "second": 89, + "amount": -3 + }, + { + "first": 216, + "second": 90, + "amount": -1 + }, + { + "first": 216, + "second": 97, + "amount": 0 + }, + { + "first": 216, + "second": 99, + "amount": 0 + }, + { + "first": 216, + "second": 100, + "amount": 0 + }, + { + "first": 216, + "second": 101, + "amount": 0 + }, + { + "first": 216, + "second": 103, + "amount": 0 + }, + { + "first": 216, + "second": 111, + "amount": 0 + }, + { + "first": 216, + "second": 113, + "amount": 0 + }, + { + "first": 216, + "second": 115, + "amount": 0 + }, + { + "first": 216, + "second": 118, + "amount": 0 + }, + { + "first": 216, + "second": 119, + "amount": 0 + }, + { + "first": 216, + "second": 120, + "amount": 0 + }, + { + "first": 216, + "second": 121, + "amount": 0 + }, + { + "first": 216, + "second": 122, + "amount": 0 + }, + { + "first": 216, + "second": 192, + "amount": -2 + }, + { + "first": 216, + "second": 193, + "amount": -2 + }, + { + "first": 216, + "second": 194, + "amount": -2 + }, + { + "first": 216, + "second": 195, + "amount": -2 + }, + { + "first": 216, + "second": 196, + "amount": -2 + }, + { + "first": 216, + "second": 197, + "amount": -2 + }, + { + "first": 216, + "second": 221, + "amount": -3 + }, + { + "first": 216, + "second": 230, + "amount": 0 + }, + { + "first": 216, + "second": 231, + "amount": 0 + }, + { + "first": 216, + "second": 248, + "amount": 0 + }, + { + "first": 216, + "second": 339, + "amount": 0 + }, + { + "first": 216, + "second": 376, + "amount": -3 + }, + { + "first": 217, + "second": 44, + "amount": -2 + }, + { + "first": 217, + "second": 46, + "amount": -2 + }, + { + "first": 217, + "second": 47, + "amount": -2 + }, + { + "first": 217, + "second": 65, + "amount": -1 + }, + { + "first": 217, + "second": 88, + "amount": 0 + }, + { + "first": 217, + "second": 115, + "amount": 0 + }, + { + "first": 217, + "second": 120, + "amount": 0 + }, + { + "first": 217, + "second": 192, + "amount": -1 + }, + { + "first": 217, + "second": 193, + "amount": -1 + }, + { + "first": 217, + "second": 194, + "amount": -1 + }, + { + "first": 217, + "second": 195, + "amount": -1 + }, + { + "first": 217, + "second": 196, + "amount": -1 + }, + { + "first": 217, + "second": 197, + "amount": -1 + }, + { + "first": 218, + "second": 44, + "amount": -2 + }, + { + "first": 218, + "second": 46, + "amount": -2 + }, + { + "first": 218, + "second": 47, + "amount": -2 + }, + { + "first": 218, + "second": 65, + "amount": -1 + }, + { + "first": 218, + "second": 88, + "amount": 0 + }, + { + "first": 218, + "second": 115, + "amount": 0 + }, + { + "first": 218, + "second": 120, + "amount": 0 + }, + { + "first": 218, + "second": 192, + "amount": -1 + }, + { + "first": 218, + "second": 193, + "amount": -1 + }, + { + "first": 218, + "second": 194, + "amount": -1 + }, + { + "first": 218, + "second": 195, + "amount": -1 + }, + { + "first": 218, + "second": 196, + "amount": -1 + }, + { + "first": 218, + "second": 197, + "amount": -1 + }, + { + "first": 219, + "second": 44, + "amount": -2 + }, + { + "first": 219, + "second": 46, + "amount": -2 + }, + { + "first": 219, + "second": 47, + "amount": -2 + }, + { + "first": 219, + "second": 65, + "amount": -1 + }, + { + "first": 219, + "second": 88, + "amount": 0 + }, + { + "first": 219, + "second": 115, + "amount": 0 + }, + { + "first": 219, + "second": 120, + "amount": 0 + }, + { + "first": 219, + "second": 192, + "amount": -1 + }, + { + "first": 219, + "second": 193, + "amount": -1 + }, + { + "first": 219, + "second": 194, + "amount": -1 + }, + { + "first": 219, + "second": 195, + "amount": -1 + }, + { + "first": 219, + "second": 196, + "amount": -1 + }, + { + "first": 219, + "second": 197, + "amount": -1 + }, + { + "first": 220, + "second": 44, + "amount": -2 + }, + { + "first": 220, + "second": 46, + "amount": -2 + }, + { + "first": 220, + "second": 47, + "amount": -2 + }, + { + "first": 220, + "second": 65, + "amount": -1 + }, + { + "first": 220, + "second": 88, + "amount": 0 + }, + { + "first": 220, + "second": 115, + "amount": 0 + }, + { + "first": 220, + "second": 120, + "amount": 0 + }, + { + "first": 220, + "second": 192, + "amount": -1 + }, + { + "first": 220, + "second": 193, + "amount": -1 + }, + { + "first": 220, + "second": 194, + "amount": -1 + }, + { + "first": 220, + "second": 195, + "amount": -1 + }, + { + "first": 220, + "second": 196, + "amount": -1 + }, + { + "first": 220, + "second": 197, + "amount": -1 + }, + { + "first": 221, + "second": 32, + "amount": -2 + }, + { + "first": 221, + "second": 38, + "amount": -3 + }, + { + "first": 221, + "second": 41, + "amount": -1 + }, + { + "first": 221, + "second": 44, + "amount": -5 + }, + { + "first": 221, + "second": 45, + "amount": -1 + }, + { + "first": 221, + "second": 46, + "amount": -5 + }, + { + "first": 221, + "second": 47, + "amount": -6 + }, + { + "first": 221, + "second": 48, + "amount": -2 + }, + { + "first": 221, + "second": 50, + "amount": -1 + }, + { + "first": 221, + "second": 51, + "amount": -1 + }, + { + "first": 221, + "second": 52, + "amount": -4 + }, + { + "first": 221, + "second": 53, + "amount": -1 + }, + { + "first": 221, + "second": 54, + "amount": -3 + }, + { + "first": 221, + "second": 55, + "amount": 0 + }, + { + "first": 221, + "second": 56, + "amount": -3 + }, + { + "first": 221, + "second": 57, + "amount": -2 + }, + { + "first": 221, + "second": 58, + "amount": -4 + }, + { + "first": 221, + "second": 59, + "amount": -4 + }, + { + "first": 221, + "second": 64, + "amount": -3 + }, + { + "first": 221, + "second": 65, + "amount": -5 + }, + { + "first": 221, + "second": 66, + "amount": -1 + }, + { + "first": 221, + "second": 67, + "amount": -3 + }, + { + "first": 221, + "second": 68, + "amount": -1 + }, + { + "first": 221, + "second": 69, + "amount": -1 + }, + { + "first": 221, + "second": 70, + "amount": -1 + }, + { + "first": 221, + "second": 71, + "amount": -3 + }, + { + "first": 221, + "second": 72, + "amount": -1 + }, + { + "first": 221, + "second": 73, + "amount": -1 + }, + { + "first": 221, + "second": 74, + "amount": -6 + }, + { + "first": 221, + "second": 75, + "amount": -1 + }, + { + "first": 221, + "second": 76, + "amount": -1 + }, + { + "first": 221, + "second": 77, + "amount": -1 + }, + { + "first": 221, + "second": 78, + "amount": -1 + }, + { + "first": 221, + "second": 79, + "amount": -3 + }, + { + "first": 221, + "second": 80, + "amount": -1 + }, + { + "first": 221, + "second": 81, + "amount": -3 + }, + { + "first": 221, + "second": 82, + "amount": -1 + }, + { + "first": 221, + "second": 83, + "amount": -1 + }, + { + "first": 221, + "second": 88, + "amount": -1 + }, + { + "first": 221, + "second": 97, + "amount": -4 + }, + { + "first": 221, + "second": 99, + "amount": -4 + }, + { + "first": 221, + "second": 100, + "amount": -4 + }, + { + "first": 221, + "second": 101, + "amount": -4 + }, + { + "first": 221, + "second": 102, + "amount": -1 + }, + { + "first": 221, + "second": 103, + "amount": -4 + }, + { + "first": 221, + "second": 109, + "amount": -1 + }, + { + "first": 221, + "second": 110, + "amount": -1 + }, + { + "first": 221, + "second": 111, + "amount": -4 + }, + { + "first": 221, + "second": 112, + "amount": -1 + }, + { + "first": 221, + "second": 113, + "amount": -4 + }, + { + "first": 221, + "second": 114, + "amount": -1 + }, + { + "first": 221, + "second": 115, + "amount": -3 + }, + { + "first": 221, + "second": 117, + "amount": -1 + }, + { + "first": 221, + "second": 118, + "amount": -2 + }, + { + "first": 221, + "second": 119, + "amount": -1 + }, + { + "first": 221, + "second": 120, + "amount": -3 + }, + { + "first": 221, + "second": 121, + "amount": -2 + }, + { + "first": 221, + "second": 122, + "amount": -2 + }, + { + "first": 221, + "second": 169, + "amount": -3 + }, + { + "first": 221, + "second": 171, + "amount": -4 + }, + { + "first": 221, + "second": 174, + "amount": -3 + }, + { + "first": 221, + "second": 180, + "amount": -3 + }, + { + "first": 221, + "second": 181, + "amount": -1 + }, + { + "first": 221, + "second": 187, + "amount": -1 + }, + { + "first": 221, + "second": 192, + "amount": -5 + }, + { + "first": 221, + "second": 193, + "amount": -5 + }, + { + "first": 221, + "second": 194, + "amount": -5 + }, + { + "first": 221, + "second": 195, + "amount": -5 + }, + { + "first": 221, + "second": 196, + "amount": -5 + }, + { + "first": 221, + "second": 197, + "amount": -5 + }, + { + "first": 221, + "second": 198, + "amount": -5 + }, + { + "first": 221, + "second": 199, + "amount": -3 + }, + { + "first": 221, + "second": 200, + "amount": -1 + }, + { + "first": 221, + "second": 201, + "amount": -1 + }, + { + "first": 221, + "second": 202, + "amount": -1 + }, + { + "first": 221, + "second": 203, + "amount": -1 + }, + { + "first": 221, + "second": 204, + "amount": -1 + }, + { + "first": 221, + "second": 205, + "amount": -1 + }, + { + "first": 221, + "second": 206, + "amount": -1 + }, + { + "first": 221, + "second": 207, + "amount": -1 + }, + { + "first": 221, + "second": 209, + "amount": -1 + }, + { + "first": 221, + "second": 210, + "amount": -3 + }, + { + "first": 221, + "second": 211, + "amount": -3 + }, + { + "first": 221, + "second": 212, + "amount": -3 + }, + { + "first": 221, + "second": 213, + "amount": -3 + }, + { + "first": 221, + "second": 214, + "amount": -3 + }, + { + "first": 221, + "second": 216, + "amount": -3 + }, + { + "first": 221, + "second": 222, + "amount": -1 + }, + { + "first": 221, + "second": 224, + "amount": -3 + }, + { + "first": 221, + "second": 225, + "amount": -3 + }, + { + "first": 221, + "second": 226, + "amount": -3 + }, + { + "first": 221, + "second": 227, + "amount": -3 + }, + { + "first": 221, + "second": 228, + "amount": -3 + }, + { + "first": 221, + "second": 229, + "amount": -3 + }, + { + "first": 221, + "second": 230, + "amount": -4 + }, + { + "first": 221, + "second": 231, + "amount": -4 + }, + { + "first": 221, + "second": 232, + "amount": -3 + }, + { + "first": 221, + "second": 233, + "amount": -3 + }, + { + "first": 221, + "second": 234, + "amount": -3 + }, + { + "first": 221, + "second": 235, + "amount": -3 + }, + { + "first": 221, + "second": 236, + "amount": 3 + }, + { + "first": 221, + "second": 237, + "amount": 3 + }, + { + "first": 221, + "second": 238, + "amount": 3 + }, + { + "first": 221, + "second": 239, + "amount": 3 + }, + { + "first": 221, + "second": 240, + "amount": -3 + }, + { + "first": 221, + "second": 241, + "amount": -1 + }, + { + "first": 221, + "second": 242, + "amount": -3 + }, + { + "first": 221, + "second": 243, + "amount": -3 + }, + { + "first": 221, + "second": 244, + "amount": -3 + }, + { + "first": 221, + "second": 245, + "amount": -3 + }, + { + "first": 221, + "second": 246, + "amount": -3 + }, + { + "first": 221, + "second": 248, + "amount": -4 + }, + { + "first": 221, + "second": 249, + "amount": -1 + }, + { + "first": 221, + "second": 250, + "amount": -1 + }, + { + "first": 221, + "second": 251, + "amount": -1 + }, + { + "first": 221, + "second": 252, + "amount": -1 + }, + { + "first": 221, + "second": 253, + "amount": -2 + }, + { + "first": 221, + "second": 255, + "amount": -2 + }, + { + "first": 221, + "second": 338, + "amount": -3 + }, + { + "first": 221, + "second": 339, + "amount": -4 + }, + { + "first": 222, + "second": 65, + "amount": -2 + }, + { + "first": 222, + "second": 86, + "amount": -1 + }, + { + "first": 222, + "second": 89, + "amount": -1 + }, + { + "first": 222, + "second": 192, + "amount": -2 + }, + { + "first": 222, + "second": 193, + "amount": -2 + }, + { + "first": 222, + "second": 194, + "amount": -2 + }, + { + "first": 222, + "second": 195, + "amount": -2 + }, + { + "first": 222, + "second": 196, + "amount": -2 + }, + { + "first": 222, + "second": 197, + "amount": -2 + }, + { + "first": 222, + "second": 221, + "amount": -1 + }, + { + "first": 222, + "second": 376, + "amount": -1 + }, + { + "first": 223, + "second": 42, + "amount": -1 + }, + { + "first": 223, + "second": 48, + "amount": 0 + }, + { + "first": 223, + "second": 52, + "amount": 0 + }, + { + "first": 223, + "second": 55, + "amount": -2 + }, + { + "first": 223, + "second": 57, + "amount": -1 + }, + { + "first": 223, + "second": 64, + "amount": 0 + }, + { + "first": 223, + "second": 65, + "amount": 0 + }, + { + "first": 223, + "second": 67, + "amount": 0 + }, + { + "first": 223, + "second": 71, + "amount": 0 + }, + { + "first": 223, + "second": 79, + "amount": 0 + }, + { + "first": 223, + "second": 81, + "amount": 0 + }, + { + "first": 223, + "second": 84, + "amount": -3 + }, + { + "first": 223, + "second": 85, + "amount": 0 + }, + { + "first": 223, + "second": 86, + "amount": -4 + }, + { + "first": 223, + "second": 88, + "amount": -1 + }, + { + "first": 223, + "second": 89, + "amount": -4 + }, + { + "first": 223, + "second": 90, + "amount": 0 + }, + { + "first": 223, + "second": 97, + "amount": 0 + }, + { + "first": 223, + "second": 99, + "amount": 0 + }, + { + "first": 223, + "second": 100, + "amount": 0 + }, + { + "first": 223, + "second": 101, + "amount": 0 + }, + { + "first": 223, + "second": 102, + "amount": 0 + }, + { + "first": 223, + "second": 103, + "amount": 0 + }, + { + "first": 223, + "second": 111, + "amount": 0 + }, + { + "first": 223, + "second": 113, + "amount": 0 + }, + { + "first": 223, + "second": 116, + "amount": 0 + }, + { + "first": 223, + "second": 118, + "amount": 0 + }, + { + "first": 223, + "second": 119, + "amount": 0 + }, + { + "first": 223, + "second": 120, + "amount": 0 + }, + { + "first": 223, + "second": 121, + "amount": 0 + }, + { + "first": 223, + "second": 122, + "amount": 0 + }, + { + "first": 223, + "second": 169, + "amount": 0 + }, + { + "first": 223, + "second": 174, + "amount": 0 + }, + { + "first": 223, + "second": 180, + "amount": 0 + }, + { + "first": 223, + "second": 192, + "amount": 0 + }, + { + "first": 223, + "second": 193, + "amount": 0 + }, + { + "first": 223, + "second": 194, + "amount": 0 + }, + { + "first": 223, + "second": 195, + "amount": 0 + }, + { + "first": 223, + "second": 196, + "amount": 0 + }, + { + "first": 223, + "second": 197, + "amount": 0 + }, + { + "first": 223, + "second": 199, + "amount": 0 + }, + { + "first": 223, + "second": 210, + "amount": 0 + }, + { + "first": 223, + "second": 211, + "amount": 0 + }, + { + "first": 223, + "second": 212, + "amount": 0 + }, + { + "first": 223, + "second": 213, + "amount": 0 + }, + { + "first": 223, + "second": 214, + "amount": 0 + }, + { + "first": 223, + "second": 216, + "amount": 0 + }, + { + "first": 223, + "second": 217, + "amount": 0 + }, + { + "first": 223, + "second": 218, + "amount": 0 + }, + { + "first": 223, + "second": 219, + "amount": 0 + }, + { + "first": 223, + "second": 220, + "amount": 0 + }, + { + "first": 223, + "second": 221, + "amount": -4 + }, + { + "first": 223, + "second": 224, + "amount": 0 + }, + { + "first": 223, + "second": 225, + "amount": 0 + }, + { + "first": 223, + "second": 226, + "amount": 0 + }, + { + "first": 223, + "second": 227, + "amount": 0 + }, + { + "first": 223, + "second": 228, + "amount": 0 + }, + { + "first": 223, + "second": 229, + "amount": 0 + }, + { + "first": 223, + "second": 230, + "amount": 0 + }, + { + "first": 223, + "second": 231, + "amount": 0 + }, + { + "first": 223, + "second": 248, + "amount": 0 + }, + { + "first": 223, + "second": 338, + "amount": 0 + }, + { + "first": 223, + "second": 339, + "amount": 0 + }, + { + "first": 223, + "second": 376, + "amount": -4 + }, + { + "first": 224, + "second": 84, + "amount": -2 + }, + { + "first": 224, + "second": 86, + "amount": -3 + }, + { + "first": 224, + "second": 89, + "amount": -3 + }, + { + "first": 224, + "second": 118, + "amount": 0 + }, + { + "first": 224, + "second": 119, + "amount": 0 + }, + { + "first": 224, + "second": 121, + "amount": 0 + }, + { + "first": 224, + "second": 221, + "amount": -3 + }, + { + "first": 224, + "second": 376, + "amount": -3 + }, + { + "first": 225, + "second": 84, + "amount": -2 + }, + { + "first": 225, + "second": 86, + "amount": -3 + }, + { + "first": 225, + "second": 89, + "amount": -3 + }, + { + "first": 225, + "second": 118, + "amount": 0 + }, + { + "first": 225, + "second": 119, + "amount": 0 + }, + { + "first": 225, + "second": 121, + "amount": 0 + }, + { + "first": 225, + "second": 221, + "amount": -3 + }, + { + "first": 225, + "second": 376, + "amount": -3 + }, + { + "first": 226, + "second": 84, + "amount": -2 + }, + { + "first": 226, + "second": 86, + "amount": -3 + }, + { + "first": 226, + "second": 89, + "amount": -3 + }, + { + "first": 226, + "second": 118, + "amount": 0 + }, + { + "first": 226, + "second": 119, + "amount": 0 + }, + { + "first": 226, + "second": 121, + "amount": 0 + }, + { + "first": 226, + "second": 221, + "amount": -3 + }, + { + "first": 226, + "second": 376, + "amount": -3 + }, + { + "first": 227, + "second": 84, + "amount": -2 + }, + { + "first": 227, + "second": 86, + "amount": -3 + }, + { + "first": 227, + "second": 89, + "amount": -3 + }, + { + "first": 227, + "second": 118, + "amount": 0 + }, + { + "first": 227, + "second": 119, + "amount": 0 + }, + { + "first": 227, + "second": 121, + "amount": 0 + }, + { + "first": 227, + "second": 221, + "amount": -3 + }, + { + "first": 227, + "second": 376, + "amount": -3 + }, + { + "first": 228, + "second": 84, + "amount": -2 + }, + { + "first": 228, + "second": 86, + "amount": -3 + }, + { + "first": 228, + "second": 89, + "amount": -3 + }, + { + "first": 228, + "second": 118, + "amount": 0 + }, + { + "first": 228, + "second": 119, + "amount": 0 + }, + { + "first": 228, + "second": 121, + "amount": 0 + }, + { + "first": 228, + "second": 221, + "amount": -3 + }, + { + "first": 228, + "second": 376, + "amount": -3 + }, + { + "first": 229, + "second": 84, + "amount": -2 + }, + { + "first": 229, + "second": 86, + "amount": -3 + }, + { + "first": 229, + "second": 89, + "amount": -3 + }, + { + "first": 229, + "second": 118, + "amount": 0 + }, + { + "first": 229, + "second": 119, + "amount": 0 + }, + { + "first": 229, + "second": 121, + "amount": 0 + }, + { + "first": 229, + "second": 221, + "amount": -3 + }, + { + "first": 229, + "second": 376, + "amount": -3 + }, + { + "first": 230, + "second": 42, + "amount": -2 + }, + { + "first": 230, + "second": 47, + "amount": -2 + }, + { + "first": 230, + "second": 64, + "amount": 0 + }, + { + "first": 230, + "second": 67, + "amount": 0 + }, + { + "first": 230, + "second": 71, + "amount": 0 + }, + { + "first": 230, + "second": 79, + "amount": 0 + }, + { + "first": 230, + "second": 81, + "amount": 0 + }, + { + "first": 230, + "second": 84, + "amount": -4 + }, + { + "first": 230, + "second": 86, + "amount": -2 + }, + { + "first": 230, + "second": 87, + "amount": -1 + }, + { + "first": 230, + "second": 88, + "amount": -1 + }, + { + "first": 230, + "second": 89, + "amount": -2 + }, + { + "first": 230, + "second": 90, + "amount": 0 + }, + { + "first": 230, + "second": 92, + "amount": -1 + }, + { + "first": 230, + "second": 102, + "amount": 0 + }, + { + "first": 230, + "second": 115, + "amount": 0 + }, + { + "first": 230, + "second": 116, + "amount": 0 + }, + { + "first": 230, + "second": 118, + "amount": 0 + }, + { + "first": 230, + "second": 119, + "amount": 0 + }, + { + "first": 230, + "second": 120, + "amount": -1 + }, + { + "first": 230, + "second": 121, + "amount": 0 + }, + { + "first": 230, + "second": 122, + "amount": 0 + }, + { + "first": 230, + "second": 169, + "amount": 0 + }, + { + "first": 230, + "second": 174, + "amount": 0 + }, + { + "first": 230, + "second": 199, + "amount": 0 + }, + { + "first": 230, + "second": 210, + "amount": 0 + }, + { + "first": 230, + "second": 211, + "amount": 0 + }, + { + "first": 230, + "second": 212, + "amount": 0 + }, + { + "first": 230, + "second": 213, + "amount": 0 + }, + { + "first": 230, + "second": 214, + "amount": 0 + }, + { + "first": 230, + "second": 216, + "amount": 0 + }, + { + "first": 230, + "second": 221, + "amount": -2 + }, + { + "first": 230, + "second": 338, + "amount": 0 + }, + { + "first": 230, + "second": 376, + "amount": -2 + }, + { + "first": 231, + "second": 47, + "amount": -1 + }, + { + "first": 231, + "second": 64, + "amount": 0 + }, + { + "first": 231, + "second": 67, + "amount": 0 + }, + { + "first": 231, + "second": 71, + "amount": 0 + }, + { + "first": 231, + "second": 79, + "amount": 0 + }, + { + "first": 231, + "second": 81, + "amount": 0 + }, + { + "first": 231, + "second": 84, + "amount": -4 + }, + { + "first": 231, + "second": 85, + "amount": 0 + }, + { + "first": 231, + "second": 86, + "amount": -2 + }, + { + "first": 231, + "second": 87, + "amount": -1 + }, + { + "first": 231, + "second": 88, + "amount": -1 + }, + { + "first": 231, + "second": 89, + "amount": -2 + }, + { + "first": 231, + "second": 90, + "amount": 0 + }, + { + "first": 231, + "second": 92, + "amount": -1 + }, + { + "first": 231, + "second": 97, + "amount": 0 + }, + { + "first": 231, + "second": 99, + "amount": 0 + }, + { + "first": 231, + "second": 100, + "amount": 0 + }, + { + "first": 231, + "second": 101, + "amount": 0 + }, + { + "first": 231, + "second": 103, + "amount": 0 + }, + { + "first": 231, + "second": 111, + "amount": 0 + }, + { + "first": 231, + "second": 113, + "amount": 0 + }, + { + "first": 231, + "second": 115, + "amount": 0 + }, + { + "first": 231, + "second": 116, + "amount": 0 + }, + { + "first": 231, + "second": 117, + "amount": 0 + }, + { + "first": 231, + "second": 118, + "amount": 0 + }, + { + "first": 231, + "second": 119, + "amount": 0 + }, + { + "first": 231, + "second": 120, + "amount": -1 + }, + { + "first": 231, + "second": 121, + "amount": 0 + }, + { + "first": 231, + "second": 122, + "amount": 0 + }, + { + "first": 231, + "second": 169, + "amount": 0 + }, + { + "first": 231, + "second": 171, + "amount": 0 + }, + { + "first": 231, + "second": 174, + "amount": 0 + }, + { + "first": 231, + "second": 180, + "amount": 0 + }, + { + "first": 231, + "second": 187, + "amount": 0 + }, + { + "first": 231, + "second": 199, + "amount": 0 + }, + { + "first": 231, + "second": 210, + "amount": 0 + }, + { + "first": 231, + "second": 211, + "amount": 0 + }, + { + "first": 231, + "second": 212, + "amount": 0 + }, + { + "first": 231, + "second": 213, + "amount": 0 + }, + { + "first": 231, + "second": 214, + "amount": 0 + }, + { + "first": 231, + "second": 216, + "amount": 0 + }, + { + "first": 231, + "second": 217, + "amount": 0 + }, + { + "first": 231, + "second": 218, + "amount": 0 + }, + { + "first": 231, + "second": 219, + "amount": 0 + }, + { + "first": 231, + "second": 220, + "amount": 0 + }, + { + "first": 231, + "second": 221, + "amount": -2 + }, + { + "first": 231, + "second": 224, + "amount": 0 + }, + { + "first": 231, + "second": 225, + "amount": 0 + }, + { + "first": 231, + "second": 226, + "amount": 0 + }, + { + "first": 231, + "second": 227, + "amount": 0 + }, + { + "first": 231, + "second": 228, + "amount": 0 + }, + { + "first": 231, + "second": 229, + "amount": 0 + }, + { + "first": 231, + "second": 230, + "amount": 0 + }, + { + "first": 231, + "second": 231, + "amount": 0 + }, + { + "first": 231, + "second": 248, + "amount": 0 + }, + { + "first": 231, + "second": 249, + "amount": 0 + }, + { + "first": 231, + "second": 250, + "amount": 0 + }, + { + "first": 231, + "second": 251, + "amount": 0 + }, + { + "first": 231, + "second": 252, + "amount": 0 + }, + { + "first": 231, + "second": 338, + "amount": 0 + }, + { + "first": 231, + "second": 339, + "amount": 0 + }, + { + "first": 231, + "second": 376, + "amount": -2 + }, + { + "first": 232, + "second": 42, + "amount": -2 + }, + { + "first": 232, + "second": 47, + "amount": -2 + }, + { + "first": 232, + "second": 64, + "amount": 0 + }, + { + "first": 232, + "second": 67, + "amount": 0 + }, + { + "first": 232, + "second": 71, + "amount": 0 + }, + { + "first": 232, + "second": 79, + "amount": 0 + }, + { + "first": 232, + "second": 81, + "amount": 0 + }, + { + "first": 232, + "second": 84, + "amount": -4 + }, + { + "first": 232, + "second": 86, + "amount": -2 + }, + { + "first": 232, + "second": 87, + "amount": -1 + }, + { + "first": 232, + "second": 88, + "amount": -1 + }, + { + "first": 232, + "second": 89, + "amount": -2 + }, + { + "first": 232, + "second": 90, + "amount": 0 + }, + { + "first": 232, + "second": 92, + "amount": -1 + }, + { + "first": 232, + "second": 102, + "amount": 0 + }, + { + "first": 232, + "second": 115, + "amount": 0 + }, + { + "first": 232, + "second": 116, + "amount": 0 + }, + { + "first": 232, + "second": 118, + "amount": 0 + }, + { + "first": 232, + "second": 119, + "amount": 0 + }, + { + "first": 232, + "second": 120, + "amount": -1 + }, + { + "first": 232, + "second": 121, + "amount": 0 + }, + { + "first": 232, + "second": 122, + "amount": 0 + }, + { + "first": 232, + "second": 169, + "amount": 0 + }, + { + "first": 232, + "second": 174, + "amount": 0 + }, + { + "first": 232, + "second": 199, + "amount": 0 + }, + { + "first": 232, + "second": 210, + "amount": 0 + }, + { + "first": 232, + "second": 211, + "amount": 0 + }, + { + "first": 232, + "second": 212, + "amount": 0 + }, + { + "first": 232, + "second": 213, + "amount": 0 + }, + { + "first": 232, + "second": 214, + "amount": 0 + }, + { + "first": 232, + "second": 216, + "amount": 0 + }, + { + "first": 232, + "second": 221, + "amount": -2 + }, + { + "first": 232, + "second": 338, + "amount": 0 + }, + { + "first": 232, + "second": 376, + "amount": -2 + }, + { + "first": 233, + "second": 42, + "amount": -2 + }, + { + "first": 233, + "second": 47, + "amount": -2 + }, + { + "first": 233, + "second": 64, + "amount": 0 + }, + { + "first": 233, + "second": 67, + "amount": 0 + }, + { + "first": 233, + "second": 71, + "amount": 0 + }, + { + "first": 233, + "second": 79, + "amount": 0 + }, + { + "first": 233, + "second": 81, + "amount": 0 + }, + { + "first": 233, + "second": 84, + "amount": -4 + }, + { + "first": 233, + "second": 86, + "amount": -2 + }, + { + "first": 233, + "second": 87, + "amount": -1 + }, + { + "first": 233, + "second": 88, + "amount": -1 + }, + { + "first": 233, + "second": 89, + "amount": -2 + }, + { + "first": 233, + "second": 90, + "amount": 0 + }, + { + "first": 233, + "second": 92, + "amount": -1 + }, + { + "first": 233, + "second": 102, + "amount": 0 + }, + { + "first": 233, + "second": 115, + "amount": 0 + }, + { + "first": 233, + "second": 116, + "amount": 0 + }, + { + "first": 233, + "second": 118, + "amount": 0 + }, + { + "first": 233, + "second": 119, + "amount": 0 + }, + { + "first": 233, + "second": 120, + "amount": -1 + }, + { + "first": 233, + "second": 121, + "amount": 0 + }, + { + "first": 233, + "second": 122, + "amount": 0 + }, + { + "first": 233, + "second": 169, + "amount": 0 + }, + { + "first": 233, + "second": 174, + "amount": 0 + }, + { + "first": 233, + "second": 199, + "amount": 0 + }, + { + "first": 233, + "second": 210, + "amount": 0 + }, + { + "first": 233, + "second": 211, + "amount": 0 + }, + { + "first": 233, + "second": 212, + "amount": 0 + }, + { + "first": 233, + "second": 213, + "amount": 0 + }, + { + "first": 233, + "second": 214, + "amount": 0 + }, + { + "first": 233, + "second": 216, + "amount": 0 + }, + { + "first": 233, + "second": 221, + "amount": -2 + }, + { + "first": 233, + "second": 338, + "amount": 0 + }, + { + "first": 233, + "second": 376, + "amount": -2 + }, + { + "first": 234, + "second": 42, + "amount": -2 + }, + { + "first": 234, + "second": 47, + "amount": -2 + }, + { + "first": 234, + "second": 64, + "amount": 0 + }, + { + "first": 234, + "second": 67, + "amount": 0 + }, + { + "first": 234, + "second": 71, + "amount": 0 + }, + { + "first": 234, + "second": 79, + "amount": 0 + }, + { + "first": 234, + "second": 81, + "amount": 0 + }, + { + "first": 234, + "second": 84, + "amount": -4 + }, + { + "first": 234, + "second": 86, + "amount": -2 + }, + { + "first": 234, + "second": 87, + "amount": -1 + }, + { + "first": 234, + "second": 88, + "amount": -1 + }, + { + "first": 234, + "second": 89, + "amount": -2 + }, + { + "first": 234, + "second": 90, + "amount": 0 + }, + { + "first": 234, + "second": 92, + "amount": -1 + }, + { + "first": 234, + "second": 102, + "amount": 0 + }, + { + "first": 234, + "second": 115, + "amount": 0 + }, + { + "first": 234, + "second": 116, + "amount": 0 + }, + { + "first": 234, + "second": 118, + "amount": 0 + }, + { + "first": 234, + "second": 119, + "amount": 0 + }, + { + "first": 234, + "second": 120, + "amount": -1 + }, + { + "first": 234, + "second": 121, + "amount": 0 + }, + { + "first": 234, + "second": 122, + "amount": 0 + }, + { + "first": 234, + "second": 169, + "amount": 0 + }, + { + "first": 234, + "second": 174, + "amount": 0 + }, + { + "first": 234, + "second": 199, + "amount": 0 + }, + { + "first": 234, + "second": 210, + "amount": 0 + }, + { + "first": 234, + "second": 211, + "amount": 0 + }, + { + "first": 234, + "second": 212, + "amount": 0 + }, + { + "first": 234, + "second": 213, + "amount": 0 + }, + { + "first": 234, + "second": 214, + "amount": 0 + }, + { + "first": 234, + "second": 216, + "amount": 0 + }, + { + "first": 234, + "second": 221, + "amount": -2 + }, + { + "first": 234, + "second": 338, + "amount": 0 + }, + { + "first": 234, + "second": 376, + "amount": -2 + }, + { + "first": 235, + "second": 42, + "amount": -2 + }, + { + "first": 235, + "second": 47, + "amount": -2 + }, + { + "first": 235, + "second": 64, + "amount": 0 + }, + { + "first": 235, + "second": 67, + "amount": 0 + }, + { + "first": 235, + "second": 71, + "amount": 0 + }, + { + "first": 235, + "second": 79, + "amount": 0 + }, + { + "first": 235, + "second": 81, + "amount": 0 + }, + { + "first": 235, + "second": 84, + "amount": -4 + }, + { + "first": 235, + "second": 86, + "amount": -2 + }, + { + "first": 235, + "second": 87, + "amount": -1 + }, + { + "first": 235, + "second": 88, + "amount": -1 + }, + { + "first": 235, + "second": 89, + "amount": -2 + }, + { + "first": 235, + "second": 90, + "amount": 0 + }, + { + "first": 235, + "second": 92, + "amount": -1 + }, + { + "first": 235, + "second": 102, + "amount": 0 + }, + { + "first": 235, + "second": 115, + "amount": 0 + }, + { + "first": 235, + "second": 116, + "amount": 0 + }, + { + "first": 235, + "second": 118, + "amount": 0 + }, + { + "first": 235, + "second": 119, + "amount": 0 + }, + { + "first": 235, + "second": 120, + "amount": -1 + }, + { + "first": 235, + "second": 121, + "amount": 0 + }, + { + "first": 235, + "second": 122, + "amount": 0 + }, + { + "first": 235, + "second": 169, + "amount": 0 + }, + { + "first": 235, + "second": 174, + "amount": 0 + }, + { + "first": 235, + "second": 199, + "amount": 0 + }, + { + "first": 235, + "second": 210, + "amount": 0 + }, + { + "first": 235, + "second": 211, + "amount": 0 + }, + { + "first": 235, + "second": 212, + "amount": 0 + }, + { + "first": 235, + "second": 213, + "amount": 0 + }, + { + "first": 235, + "second": 214, + "amount": 0 + }, + { + "first": 235, + "second": 216, + "amount": 0 + }, + { + "first": 235, + "second": 221, + "amount": -2 + }, + { + "first": 235, + "second": 338, + "amount": 0 + }, + { + "first": 235, + "second": 376, + "amount": -2 + }, + { + "first": 236, + "second": 84, + "amount": 3 + }, + { + "first": 236, + "second": 86, + "amount": 3 + }, + { + "first": 236, + "second": 89, + "amount": 3 + }, + { + "first": 236, + "second": 221, + "amount": 3 + }, + { + "first": 236, + "second": 376, + "amount": 3 + }, + { + "first": 237, + "second": 84, + "amount": 3 + }, + { + "first": 237, + "second": 86, + "amount": 3 + }, + { + "first": 237, + "second": 89, + "amount": 3 + }, + { + "first": 237, + "second": 221, + "amount": 3 + }, + { + "first": 237, + "second": 376, + "amount": 3 + }, + { + "first": 238, + "second": 84, + "amount": 3 + }, + { + "first": 238, + "second": 86, + "amount": 3 + }, + { + "first": 238, + "second": 89, + "amount": 3 + }, + { + "first": 238, + "second": 221, + "amount": 3 + }, + { + "first": 238, + "second": 376, + "amount": 3 + }, + { + "first": 239, + "second": 84, + "amount": 3 + }, + { + "first": 239, + "second": 86, + "amount": 3 + }, + { + "first": 239, + "second": 89, + "amount": 3 + }, + { + "first": 239, + "second": 221, + "amount": 3 + }, + { + "first": 239, + "second": 376, + "amount": 3 + }, + { + "first": 240, + "second": 65, + "amount": 0 + }, + { + "first": 240, + "second": 84, + "amount": -3 + }, + { + "first": 240, + "second": 86, + "amount": -3 + }, + { + "first": 240, + "second": 87, + "amount": 0 + }, + { + "first": 240, + "second": 88, + "amount": -1 + }, + { + "first": 240, + "second": 89, + "amount": -3 + }, + { + "first": 240, + "second": 102, + "amount": 0 + }, + { + "first": 240, + "second": 116, + "amount": 0 + }, + { + "first": 240, + "second": 119, + "amount": 0 + }, + { + "first": 240, + "second": 120, + "amount": -1 + }, + { + "first": 240, + "second": 192, + "amount": 0 + }, + { + "first": 240, + "second": 193, + "amount": 0 + }, + { + "first": 240, + "second": 194, + "amount": 0 + }, + { + "first": 240, + "second": 195, + "amount": 0 + }, + { + "first": 240, + "second": 196, + "amount": 0 + }, + { + "first": 240, + "second": 197, + "amount": 0 + }, + { + "first": 240, + "second": 221, + "amount": -3 + }, + { + "first": 240, + "second": 376, + "amount": -3 + }, + { + "first": 241, + "second": 84, + "amount": -2 + }, + { + "first": 241, + "second": 86, + "amount": -3 + }, + { + "first": 241, + "second": 89, + "amount": -3 + }, + { + "first": 241, + "second": 118, + "amount": 0 + }, + { + "first": 241, + "second": 119, + "amount": 0 + }, + { + "first": 241, + "second": 121, + "amount": 0 + }, + { + "first": 241, + "second": 221, + "amount": -3 + }, + { + "first": 241, + "second": 376, + "amount": -3 + }, + { + "first": 242, + "second": 65, + "amount": 0 + }, + { + "first": 242, + "second": 84, + "amount": -3 + }, + { + "first": 242, + "second": 86, + "amount": -3 + }, + { + "first": 242, + "second": 87, + "amount": 0 + }, + { + "first": 242, + "second": 88, + "amount": -1 + }, + { + "first": 242, + "second": 89, + "amount": -3 + }, + { + "first": 242, + "second": 102, + "amount": 0 + }, + { + "first": 242, + "second": 116, + "amount": 0 + }, + { + "first": 242, + "second": 119, + "amount": 0 + }, + { + "first": 242, + "second": 120, + "amount": -1 + }, + { + "first": 242, + "second": 192, + "amount": 0 + }, + { + "first": 242, + "second": 193, + "amount": 0 + }, + { + "first": 242, + "second": 194, + "amount": 0 + }, + { + "first": 242, + "second": 195, + "amount": 0 + }, + { + "first": 242, + "second": 196, + "amount": 0 + }, + { + "first": 242, + "second": 197, + "amount": 0 + }, + { + "first": 242, + "second": 221, + "amount": -3 + }, + { + "first": 242, + "second": 376, + "amount": -3 + }, + { + "first": 243, + "second": 65, + "amount": 0 + }, + { + "first": 243, + "second": 84, + "amount": -3 + }, + { + "first": 243, + "second": 86, + "amount": -3 + }, + { + "first": 243, + "second": 87, + "amount": 0 + }, + { + "first": 243, + "second": 88, + "amount": -1 + }, + { + "first": 243, + "second": 89, + "amount": -3 + }, + { + "first": 243, + "second": 102, + "amount": 0 + }, + { + "first": 243, + "second": 116, + "amount": 0 + }, + { + "first": 243, + "second": 119, + "amount": 0 + }, + { + "first": 243, + "second": 120, + "amount": -1 + }, + { + "first": 243, + "second": 192, + "amount": 0 + }, + { + "first": 243, + "second": 193, + "amount": 0 + }, + { + "first": 243, + "second": 194, + "amount": 0 + }, + { + "first": 243, + "second": 195, + "amount": 0 + }, + { + "first": 243, + "second": 196, + "amount": 0 + }, + { + "first": 243, + "second": 197, + "amount": 0 + }, + { + "first": 243, + "second": 221, + "amount": -3 + }, + { + "first": 243, + "second": 376, + "amount": -3 + }, + { + "first": 244, + "second": 65, + "amount": 0 + }, + { + "first": 244, + "second": 84, + "amount": -3 + }, + { + "first": 244, + "second": 86, + "amount": -3 + }, + { + "first": 244, + "second": 87, + "amount": 0 + }, + { + "first": 244, + "second": 88, + "amount": -1 + }, + { + "first": 244, + "second": 89, + "amount": -3 + }, + { + "first": 244, + "second": 102, + "amount": 0 + }, + { + "first": 244, + "second": 116, + "amount": 0 + }, + { + "first": 244, + "second": 119, + "amount": 0 + }, + { + "first": 244, + "second": 120, + "amount": -1 + }, + { + "first": 244, + "second": 192, + "amount": 0 + }, + { + "first": 244, + "second": 193, + "amount": 0 + }, + { + "first": 244, + "second": 194, + "amount": 0 + }, + { + "first": 244, + "second": 195, + "amount": 0 + }, + { + "first": 244, + "second": 196, + "amount": 0 + }, + { + "first": 244, + "second": 197, + "amount": 0 + }, + { + "first": 244, + "second": 221, + "amount": -3 + }, + { + "first": 244, + "second": 376, + "amount": -3 + }, + { + "first": 245, + "second": 65, + "amount": 0 + }, + { + "first": 245, + "second": 84, + "amount": -3 + }, + { + "first": 245, + "second": 86, + "amount": -3 + }, + { + "first": 245, + "second": 87, + "amount": 0 + }, + { + "first": 245, + "second": 88, + "amount": -1 + }, + { + "first": 245, + "second": 89, + "amount": -3 + }, + { + "first": 245, + "second": 102, + "amount": 0 + }, + { + "first": 245, + "second": 116, + "amount": 0 + }, + { + "first": 245, + "second": 119, + "amount": 0 + }, + { + "first": 245, + "second": 120, + "amount": -1 + }, + { + "first": 245, + "second": 192, + "amount": 0 + }, + { + "first": 245, + "second": 193, + "amount": 0 + }, + { + "first": 245, + "second": 194, + "amount": 0 + }, + { + "first": 245, + "second": 195, + "amount": 0 + }, + { + "first": 245, + "second": 196, + "amount": 0 + }, + { + "first": 245, + "second": 197, + "amount": 0 + }, + { + "first": 245, + "second": 221, + "amount": -3 + }, + { + "first": 245, + "second": 376, + "amount": -3 + }, + { + "first": 246, + "second": 65, + "amount": 0 + }, + { + "first": 246, + "second": 84, + "amount": -3 + }, + { + "first": 246, + "second": 86, + "amount": -3 + }, + { + "first": 246, + "second": 87, + "amount": 0 + }, + { + "first": 246, + "second": 88, + "amount": -1 + }, + { + "first": 246, + "second": 89, + "amount": -3 + }, + { + "first": 246, + "second": 102, + "amount": 0 + }, + { + "first": 246, + "second": 116, + "amount": 0 + }, + { + "first": 246, + "second": 119, + "amount": 0 + }, + { + "first": 246, + "second": 120, + "amount": -1 + }, + { + "first": 246, + "second": 192, + "amount": 0 + }, + { + "first": 246, + "second": 193, + "amount": 0 + }, + { + "first": 246, + "second": 194, + "amount": 0 + }, + { + "first": 246, + "second": 195, + "amount": 0 + }, + { + "first": 246, + "second": 196, + "amount": 0 + }, + { + "first": 246, + "second": 197, + "amount": 0 + }, + { + "first": 246, + "second": 221, + "amount": -3 + }, + { + "first": 246, + "second": 376, + "amount": -3 + }, + { + "first": 248, + "second": 42, + "amount": -2 + }, + { + "first": 248, + "second": 44, + "amount": -1 + }, + { + "first": 248, + "second": 46, + "amount": -1 + }, + { + "first": 248, + "second": 55, + "amount": -2 + }, + { + "first": 248, + "second": 64, + "amount": 0 + }, + { + "first": 248, + "second": 65, + "amount": 0 + }, + { + "first": 248, + "second": 67, + "amount": 0 + }, + { + "first": 248, + "second": 71, + "amount": 0 + }, + { + "first": 248, + "second": 79, + "amount": 0 + }, + { + "first": 248, + "second": 81, + "amount": 0 + }, + { + "first": 248, + "second": 83, + "amount": 0 + }, + { + "first": 248, + "second": 84, + "amount": -4 + }, + { + "first": 248, + "second": 86, + "amount": -4 + }, + { + "first": 248, + "second": 87, + "amount": -1 + }, + { + "first": 248, + "second": 88, + "amount": -2 + }, + { + "first": 248, + "second": 89, + "amount": -4 + }, + { + "first": 248, + "second": 90, + "amount": 0 + }, + { + "first": 248, + "second": 102, + "amount": 0 + }, + { + "first": 248, + "second": 115, + "amount": 0 + }, + { + "first": 248, + "second": 116, + "amount": 0 + }, + { + "first": 248, + "second": 118, + "amount": 0 + }, + { + "first": 248, + "second": 119, + "amount": 0 + }, + { + "first": 248, + "second": 120, + "amount": -1 + }, + { + "first": 248, + "second": 121, + "amount": 0 + }, + { + "first": 248, + "second": 122, + "amount": 0 + }, + { + "first": 248, + "second": 169, + "amount": 0 + }, + { + "first": 248, + "second": 174, + "amount": 0 + }, + { + "first": 248, + "second": 192, + "amount": 0 + }, + { + "first": 248, + "second": 193, + "amount": 0 + }, + { + "first": 248, + "second": 194, + "amount": 0 + }, + { + "first": 248, + "second": 195, + "amount": 0 + }, + { + "first": 248, + "second": 196, + "amount": 0 + }, + { + "first": 248, + "second": 197, + "amount": 0 + }, + { + "first": 248, + "second": 199, + "amount": 0 + }, + { + "first": 248, + "second": 210, + "amount": 0 + }, + { + "first": 248, + "second": 211, + "amount": 0 + }, + { + "first": 248, + "second": 212, + "amount": 0 + }, + { + "first": 248, + "second": 213, + "amount": 0 + }, + { + "first": 248, + "second": 214, + "amount": 0 + }, + { + "first": 248, + "second": 216, + "amount": 0 + }, + { + "first": 248, + "second": 221, + "amount": -4 + }, + { + "first": 248, + "second": 338, + "amount": 0 + }, + { + "first": 248, + "second": 376, + "amount": -4 + }, + { + "first": 249, + "second": 86, + "amount": -1 + }, + { + "first": 249, + "second": 89, + "amount": -1 + }, + { + "first": 249, + "second": 221, + "amount": -1 + }, + { + "first": 249, + "second": 376, + "amount": -1 + }, + { + "first": 250, + "second": 86, + "amount": -1 + }, + { + "first": 250, + "second": 89, + "amount": -1 + }, + { + "first": 250, + "second": 221, + "amount": -1 + }, + { + "first": 250, + "second": 376, + "amount": -1 + }, + { + "first": 251, + "second": 86, + "amount": -1 + }, + { + "first": 251, + "second": 89, + "amount": -1 + }, + { + "first": 251, + "second": 221, + "amount": -1 + }, + { + "first": 251, + "second": 376, + "amount": -1 + }, + { + "first": 252, + "second": 86, + "amount": -1 + }, + { + "first": 252, + "second": 89, + "amount": -1 + }, + { + "first": 252, + "second": 221, + "amount": -1 + }, + { + "first": 252, + "second": 376, + "amount": -1 + }, + { + "first": 253, + "second": 84, + "amount": -1 + }, + { + "first": 253, + "second": 86, + "amount": -2 + }, + { + "first": 253, + "second": 89, + "amount": -2 + }, + { + "first": 253, + "second": 97, + "amount": 0 + }, + { + "first": 253, + "second": 221, + "amount": -2 + }, + { + "first": 253, + "second": 230, + "amount": 0 + }, + { + "first": 253, + "second": 376, + "amount": -2 + }, + { + "first": 254, + "second": 42, + "amount": -2 + }, + { + "first": 254, + "second": 44, + "amount": -1 + }, + { + "first": 254, + "second": 46, + "amount": -1 + }, + { + "first": 254, + "second": 55, + "amount": -2 + }, + { + "first": 254, + "second": 64, + "amount": 0 + }, + { + "first": 254, + "second": 65, + "amount": 0 + }, + { + "first": 254, + "second": 67, + "amount": 0 + }, + { + "first": 254, + "second": 71, + "amount": 0 + }, + { + "first": 254, + "second": 79, + "amount": 0 + }, + { + "first": 254, + "second": 81, + "amount": 0 + }, + { + "first": 254, + "second": 83, + "amount": 0 + }, + { + "first": 254, + "second": 84, + "amount": -4 + }, + { + "first": 254, + "second": 86, + "amount": -4 + }, + { + "first": 254, + "second": 87, + "amount": -1 + }, + { + "first": 254, + "second": 88, + "amount": -2 + }, + { + "first": 254, + "second": 89, + "amount": -4 + }, + { + "first": 254, + "second": 90, + "amount": 0 + }, + { + "first": 254, + "second": 102, + "amount": 0 + }, + { + "first": 254, + "second": 115, + "amount": 0 + }, + { + "first": 254, + "second": 116, + "amount": 0 + }, + { + "first": 254, + "second": 118, + "amount": 0 + }, + { + "first": 254, + "second": 119, + "amount": 0 + }, + { + "first": 254, + "second": 120, + "amount": -1 + }, + { + "first": 254, + "second": 121, + "amount": 0 + }, + { + "first": 254, + "second": 122, + "amount": 0 + }, + { + "first": 254, + "second": 169, + "amount": 0 + }, + { + "first": 254, + "second": 174, + "amount": 0 + }, + { + "first": 254, + "second": 192, + "amount": 0 + }, + { + "first": 254, + "second": 193, + "amount": 0 + }, + { + "first": 254, + "second": 194, + "amount": 0 + }, + { + "first": 254, + "second": 195, + "amount": 0 + }, + { + "first": 254, + "second": 196, + "amount": 0 + }, + { + "first": 254, + "second": 197, + "amount": 0 + }, + { + "first": 254, + "second": 199, + "amount": 0 + }, + { + "first": 254, + "second": 210, + "amount": 0 + }, + { + "first": 254, + "second": 211, + "amount": 0 + }, + { + "first": 254, + "second": 212, + "amount": 0 + }, + { + "first": 254, + "second": 213, + "amount": 0 + }, + { + "first": 254, + "second": 214, + "amount": 0 + }, + { + "first": 254, + "second": 216, + "amount": 0 + }, + { + "first": 254, + "second": 221, + "amount": -4 + }, + { + "first": 254, + "second": 338, + "amount": 0 + }, + { + "first": 254, + "second": 376, + "amount": -4 + }, + { + "first": 255, + "second": 84, + "amount": -1 + }, + { + "first": 255, + "second": 86, + "amount": -2 + }, + { + "first": 255, + "second": 89, + "amount": -2 + }, + { + "first": 255, + "second": 97, + "amount": 0 + }, + { + "first": 255, + "second": 221, + "amount": -2 + }, + { + "first": 255, + "second": 230, + "amount": 0 + }, + { + "first": 255, + "second": 376, + "amount": -2 + }, + { + "first": 338, + "second": 38, + "amount": 0 + }, + { + "first": 338, + "second": 64, + "amount": -1 + }, + { + "first": 338, + "second": 67, + "amount": -1 + }, + { + "first": 338, + "second": 71, + "amount": -1 + }, + { + "first": 338, + "second": 74, + "amount": 0 + }, + { + "first": 338, + "second": 79, + "amount": -1 + }, + { + "first": 338, + "second": 81, + "amount": -1 + }, + { + "first": 338, + "second": 87, + "amount": 0 + }, + { + "first": 338, + "second": 97, + "amount": 0 + }, + { + "first": 338, + "second": 99, + "amount": 0 + }, + { + "first": 338, + "second": 100, + "amount": 0 + }, + { + "first": 338, + "second": 101, + "amount": 0 + }, + { + "first": 338, + "second": 102, + "amount": -1 + }, + { + "first": 338, + "second": 103, + "amount": 0 + }, + { + "first": 338, + "second": 106, + "amount": 1 + }, + { + "first": 338, + "second": 111, + "amount": 0 + }, + { + "first": 338, + "second": 113, + "amount": 0 + }, + { + "first": 338, + "second": 115, + "amount": 0 + }, + { + "first": 338, + "second": 116, + "amount": 0 + }, + { + "first": 338, + "second": 118, + "amount": -1 + }, + { + "first": 338, + "second": 119, + "amount": -1 + }, + { + "first": 338, + "second": 121, + "amount": -1 + }, + { + "first": 338, + "second": 169, + "amount": -1 + }, + { + "first": 338, + "second": 171, + "amount": -1 + }, + { + "first": 338, + "second": 174, + "amount": -1 + }, + { + "first": 338, + "second": 180, + "amount": 0 + }, + { + "first": 338, + "second": 199, + "amount": -1 + }, + { + "first": 338, + "second": 210, + "amount": -1 + }, + { + "first": 338, + "second": 211, + "amount": -1 + }, + { + "first": 338, + "second": 212, + "amount": -1 + }, + { + "first": 338, + "second": 213, + "amount": -1 + }, + { + "first": 338, + "second": 214, + "amount": -1 + }, + { + "first": 338, + "second": 216, + "amount": -1 + }, + { + "first": 338, + "second": 224, + "amount": 0 + }, + { + "first": 338, + "second": 225, + "amount": 0 + }, + { + "first": 338, + "second": 226, + "amount": 0 + }, + { + "first": 338, + "second": 227, + "amount": 0 + }, + { + "first": 338, + "second": 228, + "amount": 0 + }, + { + "first": 338, + "second": 229, + "amount": 0 + }, + { + "first": 338, + "second": 230, + "amount": 0 + }, + { + "first": 338, + "second": 231, + "amount": 0 + }, + { + "first": 338, + "second": 248, + "amount": 0 + }, + { + "first": 338, + "second": 338, + "amount": -1 + }, + { + "first": 338, + "second": 339, + "amount": 0 + }, + { + "first": 339, + "second": 42, + "amount": -2 + }, + { + "first": 339, + "second": 47, + "amount": -2 + }, + { + "first": 339, + "second": 64, + "amount": 0 + }, + { + "first": 339, + "second": 67, + "amount": 0 + }, + { + "first": 339, + "second": 71, + "amount": 0 + }, + { + "first": 339, + "second": 79, + "amount": 0 + }, + { + "first": 339, + "second": 81, + "amount": 0 + }, + { + "first": 339, + "second": 84, + "amount": -4 + }, + { + "first": 339, + "second": 86, + "amount": -2 + }, + { + "first": 339, + "second": 87, + "amount": -1 + }, + { + "first": 339, + "second": 88, + "amount": -1 + }, + { + "first": 339, + "second": 89, + "amount": -2 + }, + { + "first": 339, + "second": 90, + "amount": 0 + }, + { + "first": 339, + "second": 92, + "amount": -1 + }, + { + "first": 339, + "second": 102, + "amount": 0 + }, + { + "first": 339, + "second": 115, + "amount": 0 + }, + { + "first": 339, + "second": 116, + "amount": 0 + }, + { + "first": 339, + "second": 118, + "amount": 0 + }, + { + "first": 339, + "second": 119, + "amount": 0 + }, + { + "first": 339, + "second": 120, + "amount": -1 + }, + { + "first": 339, + "second": 121, + "amount": 0 + }, + { + "first": 339, + "second": 122, + "amount": 0 + }, + { + "first": 339, + "second": 169, + "amount": 0 + }, + { + "first": 339, + "second": 174, + "amount": 0 + }, + { + "first": 339, + "second": 199, + "amount": 0 + }, + { + "first": 339, + "second": 210, + "amount": 0 + }, + { + "first": 339, + "second": 211, + "amount": 0 + }, + { + "first": 339, + "second": 212, + "amount": 0 + }, + { + "first": 339, + "second": 213, + "amount": 0 + }, + { + "first": 339, + "second": 214, + "amount": 0 + }, + { + "first": 339, + "second": 216, + "amount": 0 + }, + { + "first": 339, + "second": 221, + "amount": -2 + }, + { + "first": 339, + "second": 338, + "amount": 0 + }, + { + "first": 339, + "second": 376, + "amount": -2 + }, + { + "first": 376, + "second": 32, + "amount": -2 + }, + { + "first": 376, + "second": 38, + "amount": -3 + }, + { + "first": 376, + "second": 41, + "amount": -1 + }, + { + "first": 376, + "second": 44, + "amount": -5 + }, + { + "first": 376, + "second": 45, + "amount": -1 + }, + { + "first": 376, + "second": 46, + "amount": -5 + }, + { + "first": 376, + "second": 47, + "amount": -6 + }, + { + "first": 376, + "second": 48, + "amount": -2 + }, + { + "first": 376, + "second": 50, + "amount": -1 + }, + { + "first": 376, + "second": 51, + "amount": -1 + }, + { + "first": 376, + "second": 52, + "amount": -4 + }, + { + "first": 376, + "second": 53, + "amount": -1 + }, + { + "first": 376, + "second": 54, + "amount": -3 + }, + { + "first": 376, + "second": 55, + "amount": 0 + }, + { + "first": 376, + "second": 56, + "amount": -3 + }, + { + "first": 376, + "second": 57, + "amount": -2 + }, + { + "first": 376, + "second": 58, + "amount": -4 + }, + { + "first": 376, + "second": 59, + "amount": -4 + }, + { + "first": 376, + "second": 64, + "amount": -3 + }, + { + "first": 376, + "second": 65, + "amount": -5 + }, + { + "first": 376, + "second": 66, + "amount": -1 + }, + { + "first": 376, + "second": 67, + "amount": -3 + }, + { + "first": 376, + "second": 68, + "amount": -1 + }, + { + "first": 376, + "second": 69, + "amount": -1 + }, + { + "first": 376, + "second": 70, + "amount": -1 + }, + { + "first": 376, + "second": 71, + "amount": -3 + }, + { + "first": 376, + "second": 72, + "amount": -1 + }, + { + "first": 376, + "second": 73, + "amount": -1 + }, + { + "first": 376, + "second": 74, + "amount": -6 + }, + { + "first": 376, + "second": 75, + "amount": -1 + }, + { + "first": 376, + "second": 76, + "amount": -1 + }, + { + "first": 376, + "second": 77, + "amount": -1 + }, + { + "first": 376, + "second": 78, + "amount": -1 + }, + { + "first": 376, + "second": 79, + "amount": -3 + }, + { + "first": 376, + "second": 80, + "amount": -1 + }, + { + "first": 376, + "second": 81, + "amount": -3 + }, + { + "first": 376, + "second": 82, + "amount": -1 + }, + { + "first": 376, + "second": 83, + "amount": -1 + }, + { + "first": 376, + "second": 88, + "amount": -1 + }, + { + "first": 376, + "second": 97, + "amount": -4 + }, + { + "first": 376, + "second": 99, + "amount": -4 + }, + { + "first": 376, + "second": 100, + "amount": -4 + }, + { + "first": 376, + "second": 101, + "amount": -4 + }, + { + "first": 376, + "second": 102, + "amount": -1 + }, + { + "first": 376, + "second": 103, + "amount": -4 + }, + { + "first": 376, + "second": 109, + "amount": -1 + }, + { + "first": 376, + "second": 110, + "amount": -1 + }, + { + "first": 376, + "second": 111, + "amount": -4 + }, + { + "first": 376, + "second": 112, + "amount": -1 + }, + { + "first": 376, + "second": 113, + "amount": -4 + }, + { + "first": 376, + "second": 114, + "amount": -1 + }, + { + "first": 376, + "second": 115, + "amount": -3 + }, + { + "first": 376, + "second": 117, + "amount": -1 + }, + { + "first": 376, + "second": 118, + "amount": -2 + }, + { + "first": 376, + "second": 119, + "amount": -1 + }, + { + "first": 376, + "second": 120, + "amount": -3 + }, + { + "first": 376, + "second": 121, + "amount": -2 + }, + { + "first": 376, + "second": 122, + "amount": -2 + }, + { + "first": 376, + "second": 169, + "amount": -3 + }, + { + "first": 376, + "second": 171, + "amount": -4 + }, + { + "first": 376, + "second": 174, + "amount": -3 + }, + { + "first": 376, + "second": 180, + "amount": -3 + }, + { + "first": 376, + "second": 181, + "amount": -1 + }, + { + "first": 376, + "second": 187, + "amount": -1 + }, + { + "first": 376, + "second": 192, + "amount": -5 + }, + { + "first": 376, + "second": 193, + "amount": -5 + }, + { + "first": 376, + "second": 194, + "amount": -5 + }, + { + "first": 376, + "second": 195, + "amount": -5 + }, + { + "first": 376, + "second": 196, + "amount": -5 + }, + { + "first": 376, + "second": 197, + "amount": -5 + }, + { + "first": 376, + "second": 198, + "amount": -5 + }, + { + "first": 376, + "second": 199, + "amount": -3 + }, + { + "first": 376, + "second": 200, + "amount": -1 + }, + { + "first": 376, + "second": 201, + "amount": -1 + }, + { + "first": 376, + "second": 202, + "amount": -1 + }, + { + "first": 376, + "second": 203, + "amount": -1 + }, + { + "first": 376, + "second": 204, + "amount": -1 + }, + { + "first": 376, + "second": 205, + "amount": -1 + }, + { + "first": 376, + "second": 206, + "amount": -1 + }, + { + "first": 376, + "second": 207, + "amount": -1 + }, + { + "first": 376, + "second": 209, + "amount": -1 + }, + { + "first": 376, + "second": 210, + "amount": -3 + }, + { + "first": 376, + "second": 211, + "amount": -3 + }, + { + "first": 376, + "second": 212, + "amount": -3 + }, + { + "first": 376, + "second": 213, + "amount": -3 + }, + { + "first": 376, + "second": 214, + "amount": -3 + }, + { + "first": 376, + "second": 216, + "amount": -3 + }, + { + "first": 376, + "second": 222, + "amount": -1 + }, + { + "first": 376, + "second": 224, + "amount": -3 + }, + { + "first": 376, + "second": 225, + "amount": -3 + }, + { + "first": 376, + "second": 226, + "amount": -3 + }, + { + "first": 376, + "second": 227, + "amount": -3 + }, + { + "first": 376, + "second": 228, + "amount": -3 + }, + { + "first": 376, + "second": 229, + "amount": -3 + }, + { + "first": 376, + "second": 230, + "amount": -4 + }, + { + "first": 376, + "second": 231, + "amount": -4 + }, + { + "first": 376, + "second": 232, + "amount": -3 + }, + { + "first": 376, + "second": 233, + "amount": -3 + }, + { + "first": 376, + "second": 234, + "amount": -3 + }, + { + "first": 376, + "second": 235, + "amount": -3 + }, + { + "first": 376, + "second": 236, + "amount": 3 + }, + { + "first": 376, + "second": 237, + "amount": 3 + }, + { + "first": 376, + "second": 238, + "amount": 3 + }, + { + "first": 376, + "second": 239, + "amount": 3 + }, + { + "first": 376, + "second": 240, + "amount": -3 + }, + { + "first": 376, + "second": 241, + "amount": -1 + }, + { + "first": 376, + "second": 242, + "amount": -3 + }, + { + "first": 376, + "second": 243, + "amount": -3 + }, + { + "first": 376, + "second": 244, + "amount": -3 + }, + { + "first": 376, + "second": 245, + "amount": -3 + }, + { + "first": 376, + "second": 246, + "amount": -3 + }, + { + "first": 376, + "second": 248, + "amount": -4 + }, + { + "first": 376, + "second": 249, + "amount": -1 + }, + { + "first": 376, + "second": 250, + "amount": -1 + }, + { + "first": 376, + "second": 251, + "amount": -1 + }, + { + "first": 376, + "second": 252, + "amount": -1 + }, + { + "first": 376, + "second": 253, + "amount": -2 + }, + { + "first": 376, + "second": 255, + "amount": -2 + }, + { + "first": 376, + "second": 338, + "amount": -3 + }, + { + "first": 376, + "second": 339, + "amount": -4 + } + ] +} diff --git a/resources/atlases/msdf-atlas.png b/resources/atlases/msdf-atlas.png new file mode 100644 index 0000000000..d4987e31b8 Binary files /dev/null and b/resources/atlases/msdf-atlas.png differ diff --git a/resources/atlases/status-atlas-meta.json b/resources/atlases/status-atlas-meta.json new file mode 100644 index 0000000000..32cebd1cc0 --- /dev/null +++ b/resources/atlases/status-atlas-meta.json @@ -0,0 +1,19 @@ +{ + "width": 768, + "height": 1024, + "cellSize": 256, + "cols": 3, + "pad": 16, + "icons": { + "crown": 0, + "traitor": 1, + "disconnected": 2, + "alliance": 3, + "allianceRequest": 4, + "target": 5, + "embargo": 6, + "nukeRed": 7, + "nukeWhite": 8, + "allianceFaded": 9 + } +} diff --git a/resources/atlases/status-atlas.png b/resources/atlases/status-atlas.png new file mode 100644 index 0000000000..944dbf2048 Binary files /dev/null and b/resources/atlases/status-atlas.png differ diff --git a/resources/atlases/unit-atlas.png b/resources/atlases/unit-atlas.png new file mode 100644 index 0000000000..c4ddefb249 Binary files /dev/null and b/resources/atlases/unit-atlas.png differ diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 5cb2ad853b..0bae6d0f2d 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -29,10 +29,15 @@ import { } from "../core/game/GameUpdates"; import { GameView, PlayerView } from "../core/game/GameView"; import { loadTerrainMap, TerrainMapData } from "../core/game/TerrainMapLoader"; -import { UserSettings } from "../core/game/UserSettings"; +import { + DARK_MODE_KEY, + USER_SETTINGS_CHANGED_EVENT, + UserSettings, +} from "../core/game/UserSettings"; import { WorkerClient } from "../core/worker/WorkerClient"; import { getPersistentID } from "./Auth"; import { + AlternateViewEvent, AutoUpgradeEvent, DoBoatAttackEvent, DoBreakAllianceEvent, @@ -46,7 +51,9 @@ import { } from "./InputHandler"; import { endGame, startGame, startTime } from "./LocalPersistantStats"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; +import { GoToPlayerEvent } from "./TransformHandler"; import { + MoveWarshipIntentEvent, SendAllianceExtensionIntentEvent, SendAllianceRequestIntentEvent, SendAttackIntentEvent, @@ -58,8 +65,10 @@ import { Transport, } from "./Transport"; import { createCanvas } from "./Utils"; +import { WebGLFrameBuilder } from "./WebGLFrameBuilder"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; -import { GoToPlayerEvent } from "./graphics/TransformHandler"; +import { GameView as WebGLGameView } from "./render/gl"; +import { ALL_UNIT_TYPES } from "./render/types"; import { SoundManager } from "./sound/SoundManager"; export interface LobbyConfig { @@ -225,6 +234,147 @@ export function joinLobby( }; } +// Build the WebGL view + its glCanvas. Must run before createRenderer so the +// controllers can be wired directly to the view. +function createWebGLView(terrainMap: TerrainMapData): { + view: WebGLGameView; + glCanvas: HTMLCanvasElement; + cachedWebGLFrameCallback: { current: FrameRequestCallback | null }; +} { + const gameMap = terrainMap.gameMap; + const mapWidth = gameMap.width(); + const mapHeight = gameMap.height(); + + const terrainBytes = new Uint8Array(mapWidth * mapHeight); + for (let y = 0; y < mapHeight; y++) { + for (let x = 0; x < mapWidth; x++) { + terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y)); + } + } + + const glCanvas = createCanvas(); + glCanvas.id = "webgl-debug-canvas"; + glCanvas.style.pointerEvents = "none"; + document.body.insertBefore(glCanvas, document.body.firstChild); + + // Capture the WebGL renderer's animation-frame callback rather than letting + // it run its own RAF loop. Two independent RAF loops race: when the user + // pans, the WebGL renderer can draw with one-frame-stale camera state + // because its RAF fires before canvas2D's RAF (which would have synced the + // camera). Driving WebGL's draw synchronously from canvas2D's onPreRender + // hook locks them to the same frame. + const cachedWebGLFrameCallback: { current: FrameRequestCallback | null } = { + current: null, + }; + const captureRaf = (cb: FrameRequestCallback): number => { + cachedWebGLFrameCallback.current = cb; + return 0; + }; + const captureCaf = (_id: number): void => { + cachedWebGLFrameCallback.current = null; + }; + + const palette = new Float32Array(4096 * 2 * 4); + const view = new WebGLGameView( + glCanvas, + { + mapWidth, + mapHeight, + unitTypes: [...ALL_UNIT_TYPES], + players: [], + // Pre-allocate renderer textures for up to 1024 players. We add players + // dynamically via view.addPlayers() as they come in from the simulation, + // but the NamePass / palette / relation matrix all need a static upper + // bound at construction time. + maxPlayers: 1024, + }, + terrainBytes, + palette, + captureRaf, + captureCaf, + ); + + (window as unknown as { __webglView?: unknown }).__webglView = view; + + return { view, glCanvas, cachedWebGLFrameCallback }; +} + +function mountWebGLFrameLoop( + terrainMap: TerrainMapData, + view: WebGLGameView, + glCanvas: HTMLCanvasElement, + cachedWebGLFrameCallback: { current: FrameRequestCallback | null }, + transformHandler: import("./TransformHandler").TransformHandler, + gameView: GameView, + eventBus: EventBus, +): { builder: WebGLFrameBuilder } { + const gameMap = terrainMap.gameMap; + const mapWidth = gameMap.width(); + const mapHeight = gameMap.height(); + + // Cache canvas dimensions to avoid forced reflows every frame. Reading + // clientWidth/clientHeight flushes pending layout — at 60fps that's a + // measurable cost. Only update on resize events from the observer. + let cachedCanvasW = glCanvas.clientWidth; + let cachedCanvasH = glCanvas.clientHeight; + const resizeObs = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + cachedCanvasW = width; + cachedCanvasH = height; + } + } + }); + resizeObs.observe(glCanvas); + + const syncCamera = (): void => { + const scale = transformHandler.scale; + const dpr = window.devicePixelRatio || 1; + const centerX = + transformHandler.offsetX + + mapWidth / 2 + + (cachedCanvasW - mapWidth) / (2 * scale); + const centerY = + transformHandler.offsetY + + mapHeight / 2 + + (cachedCanvasH - mapHeight) / (2 * scale); + view.setCameraState(centerX, centerY, scale * dpr); + // Invoke the WebGL renderer's frame callback synchronously, with the just- + // updated camera state. The callback re-arms itself via captureRaf, so + // we'll get a fresh callback ready for the next canvas2D frame. + const cb = cachedWebGLFrameCallback.current; + cachedWebGLFrameCallback.current = null; + cb?.(performance.now()); + }; + + // Move-target chevrons: when the player issues a warship move, show the + // animated chevron pass at the target tile. The renderer needs the target's + // tile x/y and the warship's owner smallID (so the chevrons use the right + // color). + eventBus.on(MoveWarshipIntentEvent, (e) => { + const tile = e.tile; + const tx = gameView.x(tile); + const ty = gameView.y(tile); + // Resolve owner via the first unit in the move set. + const firstUnit = gameView.unit(e.unitIds[0]); + if (firstUnit === undefined) return; + view.showMoveIndicator(tx, ty, firstUnit.owner().smallID()); + }); + + // Self-driving RAF: syncCamera reads the latest camera state from + // TransformHandler, pushes it to WebGL, and synchronously invokes the + // renderer's captured frame callback (which draws). One RAF = one + // synchronized camera-update + WebGL render. + const driveFrame = (): void => { + syncCamera(); + requestAnimationFrame(driveFrame); + }; + requestAnimationFrame(driveFrame); + + return { builder: new WebGLFrameBuilder(view) }; +} + async function createClientGame( lobbyConfig: LobbyConfig, clientID: ClientID | undefined, @@ -266,14 +416,57 @@ async function createClientGame( lobbyConfig.gameStartInfo.players, ); - const canvas = createCanvas(); + // Transparent fullscreen overlay used purely as the pointer-event / + // bounding-rect target for InputHandler + TransformHandler. The actual + // map drawing happens on the WebGL canvas created in createWebGLView. + const inputOverlay = document.createElement("div"); + inputOverlay.id = "game-input-overlay"; + inputOverlay.style.position = "fixed"; + inputOverlay.style.left = "0"; + inputOverlay.style.top = "0"; + inputOverlay.style.width = "100%"; + inputOverlay.style.height = "100%"; + inputOverlay.style.touchAction = "none"; + document.body.appendChild(inputOverlay); + const soundManager = new SoundManager(eventBus, userSettings); try { + const { view, glCanvas, cachedWebGLFrameCallback } = + createWebGLView(gameMap); + + // Bind the WebGL renderer's day/night mode to the existing darkMode + // UserSetting so the in-game map matches the rest of the UI. Initial + // apply + live updates via the per-key settings-changed event. + const applyDayNightMode = (isDark: boolean): void => { + view.getSettings().dayNight.mode = isDark ? "dark" : "light"; + }; + applyDayNightMode(userSettings.darkMode()); + globalThis.addEventListener( + `${USER_SETTINGS_CHANGED_EVENT}:${DARK_MODE_KEY}`, + (e) => applyDayNightMode((e as CustomEvent).detail === "true"), + ); + + // Space-hold (and the settings-modal toggle) drives the affiliation + // recolor. InputHandler emits AlternateViewEvent; the WebGL view needs + // setAltView called to switch passes into alt mode. + eventBus.on(AlternateViewEvent, (e) => view.setAltView(e.alternateView)); + const gameRenderer = createRenderer( - canvas, + inputOverlay, gameView, eventBus, lobbyConfig.playerRole, + view, + ); + + const { builder: webglBuilder } = mountWebGLFrameLoop( + gameMap, + view, + glCanvas, + cachedWebGLFrameCallback, + gameRenderer.transformHandler, + gameView, + eventBus, ); console.log( @@ -285,12 +478,13 @@ async function createClientGame( clientID, eventBus, gameRenderer, - new InputHandler(gameView, gameRenderer.uiState, canvas, eventBus), + new InputHandler(gameView, gameRenderer.uiState, inputOverlay, eventBus), transport, worker, gameView, soundManager, userSettings, + webglBuilder, ); } catch (err) { soundManager.dispose(); @@ -323,6 +517,7 @@ export class ClientGameRunner { private gameView: GameView, private soundManager: SoundManager, private userSettings: UserSettings, + private webglBuilder: WebGLFrameBuilder | null = null, ) { this.lastMessageTime = Date.now(); } @@ -433,6 +628,7 @@ export class ClientGameRunner { this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash)); }); this.gameView.update(gu); + this.webglBuilder?.update(this.gameView); this.renderer.tick(); // Emit tick metrics event for performance overlay diff --git a/src/client/Controller.ts b/src/client/Controller.ts new file mode 100644 index 0000000000..e9151afe1c --- /dev/null +++ b/src/client/Controller.ts @@ -0,0 +1,27 @@ +/** + * Controller — the main-thread analog of the worker's Execution. + * + * A Controller subscribes to events / game state and drives some slice of + * the client side (input, UI state, view updates). The interface is just + * lifecycle hooks; all coordination happens via the EventBus and direct + * references the controller is given at construction time. + * + * Naming: previously "Layer" — the name was a leftover from the canvas2D + * era when each entry in the array drew to the same 2D context. Now nothing + * draws to a shared canvas, so they're plain controllers. + */ +export interface Controller { + /** Called once at game start. Subscribe to events / set up state here. */ + init?: () => void; + + /** + * Called per game tick (10Hz). Optional — pure event subscribers can omit. + * + * If `getTickIntervalMs()` returns > 0, the controller is throttled to that + * wall-clock interval instead of running every tick. + */ + tick?: () => void; + + /** Optional throttle on tick frequency, in milliseconds. */ + getTickIntervalMs?: () => number; +} diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index d2d2de2a21..d0cc7334d2 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -2,8 +2,8 @@ import { EventBus, GameEvent } from "../core/EventBus"; import { PlayerBuildableUnitType, UnitType } from "../core/game/Game"; import { GameView, UnitView } from "../core/game/GameView"; import { UserSettings } from "../core/game/UserSettings"; -import { UIState } from "./graphics/UIState"; import { Platform } from "./Platform"; +import { UIState } from "./UIState"; import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; export class MouseUpEvent implements GameEvent { @@ -92,10 +92,6 @@ export class ToggleStructureEvent implements GameEvent { ) {} } -export class GhostStructureChangedEvent implements GameEvent { - constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {} -} - export class ConfirmGhostStructureEvent implements GameEvent {} export class SwapRocketDirectionEvent implements GameEvent { @@ -235,7 +231,7 @@ export class InputHandler { constructor( private gameView: GameView, public uiState: UIState, - private canvas: HTMLCanvasElement, + private canvas: HTMLElement, private eventBus: EventBus, ) {} @@ -850,7 +846,6 @@ export class InputHandler { private setGhostStructure(ghostStructure: PlayerBuildableUnitType | null) { this.uiState.ghostStructure = ghostStructure; - this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure)); } /** diff --git a/src/client/graphics/TransformHandler.ts b/src/client/TransformHandler.ts similarity index 92% rename from src/client/graphics/TransformHandler.ts rename to src/client/TransformHandler.ts index 90966525c4..abab408380 100644 --- a/src/client/graphics/TransformHandler.ts +++ b/src/client/TransformHandler.ts @@ -1,7 +1,7 @@ -import { EventBus, GameEvent } from "../../core/EventBus"; -import { Cell } from "../../core/game/Game"; -import { GameView, PlayerView, UnitView } from "../../core/game/GameView"; -import { CenterCameraEvent, DragEvent, ZoomEvent } from "../InputHandler"; +import { EventBus, GameEvent } from "../core/EventBus"; +import { Cell } from "../core/game/Game"; +import { GameView, PlayerView, UnitView } from "../core/game/GameView"; +import { CenterCameraEvent, DragEvent, ZoomEvent } from "./InputHandler"; export class GoToPlayerEvent implements GameEvent { constructor( @@ -28,8 +28,8 @@ export const CAMERA_SMOOTHING = 0.03; export class TransformHandler { public scale: number = 1.8; private _boundingRect: DOMRect; - private offsetX: number = -350; - private offsetY: number = -200; + public offsetX: number = -350; + public offsetY: number = -200; private lastGoToCallTime: number | null = null; private target: Cell | null; @@ -40,7 +40,7 @@ export class TransformHandler { constructor( private game: GameView, private eventBus: EventBus, - private canvas: HTMLCanvasElement, + private canvas: HTMLElement, ) { this._boundingRect = this.canvas.getBoundingClientRect(); this.eventBus.on(ZoomEvent, (e) => this.onZoom(e)); @@ -115,17 +115,25 @@ export class TransformHandler { } screenToWorldCoordinates(screenX: number, screenY: number): Cell { - const canvasCoords = this.screenToCanvasCoordinates(screenX, screenY); - - const centerX = - (canvasCoords.x - this.game.width() / 2) / this.scale + this.offsetX; - const centerY = - (canvasCoords.y - this.game.height() / 2) / this.scale + this.offsetY; - - const gameX = centerX + this.game.width() / 2; - const gameY = centerY + this.game.height() / 2; + const f = this.screenToWorldCoordinatesFloat(screenX, screenY); + return new Cell(Math.floor(f.x), Math.floor(f.y)); + } - return new Cell(Math.floor(gameX), Math.floor(gameY)); + /** Like screenToWorldCoordinates but returns sub-tile precision. */ + screenToWorldCoordinatesFloat( + screenX: number, + screenY: number, + ): { x: number; y: number } { + const canvasCoords = this.screenToCanvasCoordinates(screenX, screenY); + const gameX = + (canvasCoords.x - this.game.width() / 2) / this.scale + + this.offsetX + + this.game.width() / 2; + const gameY = + (canvasCoords.y - this.game.height() / 2) / this.scale + + this.offsetY + + this.game.height() / 2; + return { x: gameX, y: gameY }; } canvasToScreenCoordinates( diff --git a/src/client/graphics/UIState.ts b/src/client/UIState.ts similarity index 63% rename from src/client/graphics/UIState.ts rename to src/client/UIState.ts index c43a773f1d..c4f5296156 100644 --- a/src/client/graphics/UIState.ts +++ b/src/client/UIState.ts @@ -1,5 +1,5 @@ -import { PlayerBuildableUnitType } from "../../core/game/Game"; -import { TileRef } from "../../core/game/GameMap"; +import { PlayerBuildableUnitType } from "../core/game/Game"; +import { TileRef } from "../core/game/GameMap"; export interface UIState { attackRatio: number; diff --git a/src/client/WebGLFrameBuilder.ts b/src/client/WebGLFrameBuilder.ts new file mode 100644 index 0000000000..99bb15d402 --- /dev/null +++ b/src/client/WebGLFrameBuilder.ts @@ -0,0 +1,128 @@ +import { Colord } from "colord"; +import { PlayerType } from "../core/game/Game"; +import { GameView } from "../core/game/GameView"; +import { uploadFrameData } from "./render/frame/Upload"; +import { + PlayerStatic, + SpawnCenter, + GameView as WebGLGameView, +} from "./render/gl"; + +const PALETTE_SIZE = 4096; + +/** + * The renderer-side glue between GameView (which already builds the full + * FrameData each tick) and the WebGL view. Two responsibilities: + * + * 1. Palette management — translate PlayerView colors into a Float32Array + * the renderer uploads to a 1D texture, and call view.addPlayers() when + * new players appear (this is a renderer-side lifecycle event, not part + * of FrameData). + * 2. Per-tick upload — pass the FrameData to the renderer's uploadFrameData + * helper, which dispatches to all the view.update*() methods. + */ +export class WebGLFrameBuilder { + private readonly palette: Float32Array; + private readonly knownSmallIDs = new Set(); + // The renderer needs to know which player is "me" so affiliation tint, + // unit colors, and SAM-radius perspective work. Push it once the local + // player's update arrives (may take several ticks during join). + private localPlayerSmallID = 0; + + constructor(private readonly view: WebGLGameView) { + this.palette = new Float32Array(PALETTE_SIZE * 2 * 4); + } + + update(gameView: GameView): void { + this.syncPlayers(gameView); + this.syncLocalPlayer(gameView); + this.syncSpawnOverlay(gameView); + uploadFrameData(this.view, gameView.frameData()); + } + + private syncLocalPlayer(gameView: GameView): void { + const sid = gameView.myPlayer()?.smallID() ?? 0; + if (sid === this.localPlayerSmallID) return; + this.localPlayerSmallID = sid; + this.view.setLocalPlayerID(sid); + } + + /** + * Spawn-phase highlights: each already-spawned human player gets a colored + * ring + tile glow around their starting territory. Pushed every tick + * during spawn phase; the pass animates locally from the snapshot. + */ + private syncSpawnOverlay(gameView: GameView): void { + const inSpawnPhase = gameView.inSpawnPhase(); + if (!inSpawnPhase) { + this.view.updateSpawnOverlay(false, []); + return; + } + const me = gameView.myPlayer(); + const myTeam = me?.team() ?? null; + const centers: SpawnCenter[] = []; + for (const p of gameView.players()) { + if (!p.isPlayer() || p.type() !== PlayerType.Human) continue; + if (!p.hasSpawned()) continue; + const isSelf = me !== null && p.smallID() === me.smallID(); + // myPlayer reads as plain white so the local-player ring is visually + // distinct from any team color; everyone else uses their territory tint. + const c = isSelf + ? { r: 255, g: 255, b: 255 } + : p.territoryColor().toRgb(); + centers.push({ + x: p.nameData?.x ?? 0, + y: p.nameData?.y ?? 0, + r: c.r / 255, + g: c.g / 255, + b: c.b / 255, + isSelf, + isTeammate: + myTeam !== null && + p.team() === myTeam && + p.smallID() !== me?.smallID(), + }); + } + this.view.updateSpawnOverlay(true, centers); + } + + private syncPlayers(gameView: GameView): void { + const newPlayers: PlayerStatic[] = []; + for (const p of gameView.players()) { + const smallID = p.smallID(); + if (this.knownSmallIDs.has(smallID)) continue; + this.knownSmallIDs.add(smallID); + + this.writePaletteEntry(smallID, p.territoryColor(), p.borderColor()); + + newPlayers.push({ + ...p.static, + flag: p.cosmetics.flag, + color: p.territoryColor().toHex(), + }); + } + if (newPlayers.length > 0) { + this.view.addPlayers(newPlayers, this.palette); + } + } + + private writePaletteEntry( + smallID: number, + fill: Colord, + border: Colord, + ): void { + const fillRgba = fill.toRgb(); + const fillOff = smallID * 4; + this.palette[fillOff] = fillRgba.r / 255; + this.palette[fillOff + 1] = fillRgba.g / 255; + this.palette[fillOff + 2] = fillRgba.b / 255; + this.palette[fillOff + 3] = 150 / 255; + + const borderRgba = border.toRgb(); + const borderOff = PALETTE_SIZE * 4 + smallID * 4; + this.palette[borderOff] = borderRgba.r / 255; + this.palette[borderOff + 1] = borderRgba.g / 255; + this.palette[borderOff + 2] = borderRgba.b / 255; + this.palette[borderOff + 3] = 1.0; + } +} diff --git a/src/client/controllers/BuildPreviewController.ts b/src/client/controllers/BuildPreviewController.ts new file mode 100644 index 0000000000..661a65805b --- /dev/null +++ b/src/client/controllers/BuildPreviewController.ts @@ -0,0 +1,456 @@ +/** + * BuildPreviewController — build-ghost state machine + click-to-build flow. + * + * All rendering for the build ghost (outline, range circle, rail snap, + * crosshair) lives in the WebGL renderer. This controller owns the state: + * it queries buildables for the cursor tile, tracks whether the placement + * is valid, and pushes preview data straight to the WebGL view. + */ + +import { EventBus } from "../../core/EventBus"; +import { wouldNukeBreakAlliance } from "../../core/execution/Util"; +import { + BuildableUnit, + PlayerBuildableUnitType, + UnitType, +} from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { GameView } from "../../core/game/GameView"; +import { Controller } from "../Controller"; +import { + ConfirmGhostStructureEvent, + MouseMoveEvent, + MouseUpEvent, +} from "../InputHandler"; +import { GameView as WebGLGameView, buildNukeTrajectory } from "../render/gl"; +import type { SAMInfo } from "../render/gl/utils/NukeTrajectory"; +import type { GhostPreviewData } from "../render/types"; +import { TransformHandler } from "../TransformHandler"; +import { + BuildUnitIntentEvent, + SendUpgradeStructureIntentEvent, +} from "../Transport"; +import { UIState } from "../UIState"; + +/** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */ +export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { + return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb; +} + +export class BuildPreviewController implements Controller { + /** Current ghost (null when no build type is active). */ + private ghostUnit: { buildableUnit: BuildableUnit } | null = null; + private readonly connectedAllySmallIds: Set = new Set(); + private readonly mousePos = { x: 0, y: 0 }; + private lastGhostQueryAt: number = 0; + private pendingConfirm: MouseUpEvent | null = null; + + // Buildable validation runs on the snapped tile under the cursor, but the + // rendered icon follows the cursor at sub-tile precision so motion is + // continuous instead of stepping tile-to-tile. cursorLoop re-emits each + // frame with the current cursor world position. + private lastGhostData: GhostPreviewData | null = null; + + constructor( + private game: GameView, + private eventBus: EventBus, + public uiState: UIState, + private transformHandler: TransformHandler, + private view: WebGLGameView, + ) {} + + init() { + this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); + this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e)); + this.eventBus.on(ConfirmGhostStructureEvent, () => + this.requestConfirmStructure( + new MouseUpEvent(this.mousePos.x, this.mousePos.y), + ), + ); + + // Re-emit the ghost each render frame at the cursor's current world + // position (sub-tile). Buildable validation still runs on the snapped + // tile in renderGhost(); this loop just keeps the icon under the cursor + // so motion is continuous instead of stepping tile-to-tile. + // The shader treats (tileX + 0.5, tileY + 0.5) as the icon center (so an + // integer tile coord centers on that tile), so we subtract 0.5 here to + // place the icon exactly under the cursor. + const cursorLoop = () => { + if (this.lastGhostData !== null) { + const w = this.transformHandler.screenToWorldCoordinatesFloat( + this.mousePos.x, + this.mousePos.y, + ); + this.view.updateGhostPreview({ + ...this.lastGhostData, + tileX: w.x - 0.5, + tileY: w.y - 0.5, + }); + } + requestAnimationFrame(cursorLoop); + }; + requestAnimationFrame(cursorLoop); + } + + tick() { + // Re-query buildables periodically (world state can change — tiles may + // become buildable as troops/territory move). + this.syncGhostState(); + this.renderGhost(); + } + + /** + * Reconcile our internal ghost state with uiState.ghostStructure. Other + * UI bits (build menu, key bindings) toggle uiState; we mirror it here. + */ + private syncGhostState(): void { + const target = this.uiState.ghostStructure; + if (this.ghostUnit) { + if (target === null) { + this.removeGhostStructure(); + } else if (target !== this.ghostUnit.buildableUnit.type) { + this.clearGhostStructure(); + this.createGhostStructure(target); + } + } else if (target !== null) { + this.createGhostStructure(target); + } + } + + renderGhost() { + if (!this.ghostUnit) return; + + const now = performance.now(); + if (now - this.lastGhostQueryAt < 50) return; + this.lastGhostQueryAt = now; + let tileRef: TileRef | undefined; + const tile = this.transformHandler.screenToWorldCoordinates( + this.mousePos.x, + this.mousePos.y, + ); + if (this.game.isValidCoord(tile.x, tile.y)) { + tileRef = this.game.ref(tile.x, tile.y); + } + + // Check if targeting an ally (for nuke warning visual) + let targetingAlly = false; + const myPlayer = this.game.myPlayer(); + const nukeType = this.ghostUnit.buildableUnit.type; + if ( + tileRef && + myPlayer && + (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) + ) { + this.connectedAllySmallIds.clear(); + const allies = myPlayer.allies(); + for (let i = 0; i < allies.length; i++) { + const ally = allies[i]; + if (!ally.isDisconnected()) { + this.connectedAllySmallIds.add(ally.smallID()); + } + } + + if (this.connectedAllySmallIds.size > 0) { + targetingAlly = wouldNukeBreakAlliance({ + game: this.game, + targetTile: tileRef, + magnitude: this.game.config().nukeMagnitudes(nukeType), + allySmallIds: this.connectedAllySmallIds, + threshold: this.game.config().nukeAllianceBreakThreshold(), + }); + } + } + + // targetingAlly is computed above for state purposes; the renderer's + // ghost passes derive their own "warning" visual from canBuild/canUpgrade + // if needed. (Leave the variable here so its eslint-no-unused doesn't trip.) + void targetingAlly; + + this.game + ?.myPlayer() + ?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type]) + .then((buildables) => { + if (!this.ghostUnit) { + this.pendingConfirm = null; + this.emitGhostPreview(tileRef); + return; + } + + const unit = buildables.find( + (u) => u.type === this.ghostUnit!.buildableUnit.type, + ); + if (!unit) { + Object.assign(this.ghostUnit.buildableUnit, { + canBuild: false, + canUpgrade: false, + }); + this.pendingConfirm = null; + this.emitGhostPreview(tileRef); + return; + } + + this.ghostUnit.buildableUnit = unit; + + if (unit.canUpgrade || unit.canBuild === false) { + // No rail-snap overlap for upgrades or invalid placements. + this.uiState.overlappingRailroads = []; + this.uiState.ghostRailPaths = []; + } else { + this.uiState.overlappingRailroads = unit.overlappingRailroads; + this.uiState.ghostRailPaths = unit.ghostRailPaths; + } + + if (this.pendingConfirm !== null) { + const ev = this.pendingConfirm; + this.pendingConfirm = null; + if (this.isGhostReadyForConfirm()) { + this.createStructure(ev); + } + } + + this.emitGhostPreview(tileRef); + }); + } + + /** + * Push a GhostPreviewData snapshot to the WebGL view (StructurePass / + * RangeCirclePass / RailroadPass / CrosshairPass all read it). null when + * the ghost can't be placed. smoothLoop interpolates displayed position + * toward the target tile each frame. + */ + private emitGhostPreview(tileRef: TileRef | undefined): void { + const data = this.buildGhostPreviewData(tileRef); + if (data === null) { + this.lastGhostData = null; + this.view.updateGhostPreview(null); + } else { + this.lastGhostData = data; + } + this.updateNukeTrajectoryPreview(tileRef); + } + + /** + * For AtomBomb / HydrogenBomb ghosts, push the Bezier trajectory preview + * (closest player-owned silo → target, accounting for non-allied SAMs). + * Cleared whenever the ghost isn't a nuke, has no target, or the player + * has no silos. + */ + private updateNukeTrajectoryPreview(tileRef: TileRef | undefined): void { + if (!this.ghostUnit || tileRef === undefined) { + this.view.updateNukeTrajectory(null); + return; + } + const type = this.ghostUnit.buildableUnit.type; + if (type !== UnitType.AtomBomb && type !== UnitType.HydrogenBomb) { + this.view.updateNukeTrajectory(null); + return; + } + const myPlayer = this.game.myPlayer(); + if (!myPlayer) { + this.view.updateNukeTrajectory(null); + return; + } + + const silos = myPlayer + .units(UnitType.MissileSilo) + .filter((u) => u.isActive()); + if (silos.length === 0) { + this.view.updateNukeTrajectory(null); + return; + } + + const dstX = this.game.x(tileRef); + const dstY = this.game.y(tileRef); + let bestSilo = silos[0]; + let bestDistSq = Infinity; + for (const s of silos) { + const sx = this.game.x(s.tile()); + const sy = this.game.y(s.tile()); + const dx = sx - dstX; + const dy = sy - dstY; + const d = dx * dx + dy * dy; + if (d < bestDistSq) { + bestDistSq = d; + bestSilo = s; + } + } + const srcX = this.game.x(bestSilo.tile()); + const srcY = this.game.y(bestSilo.tile()); + + // Non-allied SAMs threaten the trajectory; own + allied SAMs don't. + const allyIds = new Set(); + for (const a of myPlayer.allies()) allyIds.add(a.smallID()); + const sams: SAMInfo[] = []; + for (const s of this.game.units(UnitType.SAMLauncher)) { + if (!s.isActive()) continue; + const owner = s.owner(); + if (owner === myPlayer) continue; + if (allyIds.has(owner.smallID())) continue; + const r = this.game.config().samRange(s.level()); + sams.push({ + x: this.game.x(s.tile()), + y: this.game.y(s.tile()), + rangeSq: r * r, + }); + } + + this.view.updateNukeTrajectory( + buildNukeTrajectory( + srcX, + srcY, + dstX, + dstY, + this.game.height(), + this.uiState.rocketDirectionUp, + sams, + ), + ); + } + + private buildGhostPreviewData( + tileRef: TileRef | undefined, + ): GhostPreviewData | null { + if (!this.ghostUnit) return null; + if (tileRef === undefined) return null; + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return null; + + const u = this.ghostUnit.buildableUnit; + + // Upgrade-target tile — only when upgrading an existing unit. + let upgradeTargetTile: number | null = null; + if (u.canUpgrade !== false) { + upgradeTargetTile = this.game.unit(u.canUpgrade)?.tile() ?? null; + } + + // Range circle: SAM placement preview shows targetable radius; nuke + // previews show the outer blast radius at the target tile. + let rangeRadius = 0; + if (u.type === UnitType.SAMLauncher) { + const level = this.resolveGhostRangeLevel(u) ?? 1; + rangeRadius = this.game.config().samRange(level); + } else if ( + u.type === UnitType.AtomBomb || + u.type === UnitType.HydrogenBomb + ) { + rangeRadius = this.game.config().nukeMagnitudes(u.type).outer; + } + + return { + ghostType: u.type, + tileX: this.game.x(tileRef), + tileY: this.game.y(tileRef), + canBuild: u.canBuild !== false, + canUpgrade: u.canUpgrade !== false, + cost: Number(u.cost), + ghostRailPaths: u.ghostRailPaths, + overlappingRailroads: u.overlappingRailroads, + ownerID: myPlayer.smallID(), + upgradeTargetTile, + rangeRadius, + }; + } + + private isGhostReadyForConfirm(): boolean { + if (!this.ghostUnit) return false; + const bu = this.ghostUnit.buildableUnit; + return bu.canBuild !== false || bu.canUpgrade !== false; + } + + private requestConfirmStructure(e: MouseUpEvent): void { + if (!this.ghostUnit && !this.uiState.ghostStructure) return; + if (this.isGhostReadyForConfirm()) { + this.createStructure(e); + } else { + this.pendingConfirm = e; + } + } + + private createStructure(e: MouseUpEvent) { + if (!this.ghostUnit) return; + if ( + this.ghostUnit.buildableUnit.canBuild === false && + this.ghostUnit.buildableUnit.canUpgrade === false + ) { + this.removeGhostStructure(); + return; + } + const tile = this.transformHandler.screenToWorldCoordinates(e.x, e.y); + if (this.ghostUnit.buildableUnit.canUpgrade !== false) { + this.eventBus.emit( + new SendUpgradeStructureIntentEvent( + this.ghostUnit.buildableUnit.canUpgrade, + this.ghostUnit.buildableUnit.type, + ), + ); + this.removeGhostStructure(); + } else if (this.ghostUnit.buildableUnit.canBuild) { + const unitType = this.ghostUnit.buildableUnit.type; + const rocketDirectionUp = + unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb + ? this.uiState.rocketDirectionUp + : undefined; + this.eventBus.emit( + new BuildUnitIntentEvent( + unitType, + this.game.ref(tile.x, tile.y), + rocketDirectionUp, + ), + ); + if (!shouldPreserveGhostAfterBuild(unitType)) { + this.removeGhostStructure(); + } + } else { + this.removeGhostStructure(); + } + } + + private moveGhost(e: MouseMoveEvent) { + this.mousePos.x = e.x; + this.mousePos.y = e.y; + } + + private createGhostStructure(type: PlayerBuildableUnitType | null) { + if (type === null) return; + if (this.game.myPlayer() === null) return; + this.ghostUnit = { + buildableUnit: { + type, + canBuild: false, + canUpgrade: false, + cost: 0n, + overlappingRailroads: [], + ghostRailPaths: [], + }, + }; + } + + private clearGhostStructure() { + this.pendingConfirm = null; + this.ghostUnit = null; + this.uiState.ghostRailPaths = []; + this.lastGhostData = null; + this.view.updateGhostPreview(null); + this.view.updateNukeTrajectory(null); + } + + private removeGhostStructure() { + this.clearGhostStructure(); + this.uiState.ghostStructure = null; + } + + private resolveGhostRangeLevel( + buildableUnit: BuildableUnit, + ): number | undefined { + if (buildableUnit.type !== UnitType.SAMLauncher) return undefined; + if (buildableUnit.canUpgrade !== false) { + const existing = this.game.unit(buildableUnit.canUpgrade); + if (existing) { + return existing.level() + 1; + } else { + console.error("Failed to find existing SAMLauncher for upgrade"); + } + } + return 1; + } +} diff --git a/src/client/controllers/HoverHighlightController.ts b/src/client/controllers/HoverHighlightController.ts new file mode 100644 index 0000000000..bf1f69552c --- /dev/null +++ b/src/client/controllers/HoverHighlightController.ts @@ -0,0 +1,44 @@ +/** + * HoverHighlightController — pushes the cursor's tile-owner to the WebGL + * view so the territory + border passes can highlight the hovered player. + * + * Replaces the hover path inside the renderer's MapInteraction class (which + * was bound to the WebGL canvas; that canvas has pointer-events: none in the + * current input architecture so its listeners never fired). All input flows + * through InputHandler → MouseMoveEvent on the EventBus, so we just listen. + */ + +import { EventBus } from "../../core/EventBus"; +import { GameView } from "../../core/game/GameView"; +import { Controller } from "../Controller"; +import { MouseMoveEvent } from "../InputHandler"; +import { GameView as WebGLGameView } from "../render/gl"; +import { OWNER_MASK } from "../render/gl/utils/TileCodec"; +import { TransformHandler } from "../TransformHandler"; + +export class HoverHighlightController implements Controller { + private lastOwnerID = 0; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + private view: WebGLGameView, + ) {} + + init() { + this.eventBus.on(MouseMoveEvent, (e) => this.onMouseMove(e)); + } + + private onMouseMove(e: MouseMoveEvent): void { + const cell = this.transformHandler.screenToWorldCoordinates(e.x, e.y); + let ownerID = 0; + if (this.game.isValidCoord(cell.x, cell.y)) { + const ref = this.game.ref(cell.x, cell.y); + ownerID = this.game.tileState(ref) & OWNER_MASK; + } + if (ownerID === this.lastOwnerID) return; + this.lastOwnerID = ownerID; + this.view.setHighlightOwner(ownerID); + } +} diff --git a/src/client/controllers/WarshipSelectionController.ts b/src/client/controllers/WarshipSelectionController.ts new file mode 100644 index 0000000000..fdaa9294b1 --- /dev/null +++ b/src/client/controllers/WarshipSelectionController.ts @@ -0,0 +1,316 @@ +import { Cell } from "src/core/game/Game"; +import { EventBus } from "../../core/EventBus"; +import { UnitType } from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { GameView, UnitView } from "../../core/game/GameView"; +import { Controller } from "../Controller"; +import { + CloseViewEvent, + ContextMenuEvent, + MouseUpEvent, + SelectAllWarshipsEvent, + TouchEvent, + UnitSelectionEvent, + WarshipSelectionBoxCancelEvent, + WarshipSelectionBoxCompleteEvent, + WarshipSelectionBoxUpdateEvent, +} from "../InputHandler"; +import { GameView as WebGLGameView } from "../render/gl"; +import { TransformHandler } from "../TransformHandler"; +import { MoveWarshipIntentEvent } from "../Transport"; + +const WARSHIP_SELECTION_RADIUS = 10; + +/** + * Controller for warship selection state + click handling. + * + * Drawing for selection boxes (single + multi) lives in the WebGL + * SelectionBoxPass (forwarded via UnitSelectionEvent from ClientGameRunner). + * The drag-rectangle preview is a screen-space DOM overlay (dragRectEl) we + * own here. + * + * This class does not render anything to canvas2D — it's purely a state + + * click controller. The "Controller" pattern: main-thread analog of the + * worker's Execution (init + tick + event subscriptions). + */ +export class WarshipSelectionController implements Controller { + // Currently selected single warship (game-logic readers use this; the + // visual is drawn by WebGL SelectionBoxPass). + private selectedUnit: UnitView | null = null; + // Currently multi-selected warships (shift+drag box select). + private multiSelectedWarships: UnitView[] = []; + + // Drag rectangle (shift+drag warship selection box) — a screen-space DOM + // overlay positioned via inline style. + private dragRectEl: HTMLDivElement | null = null; + + constructor( + private game: GameView, + private eventBus: EventBus, + private transformHandler: TransformHandler, + private view: WebGLGameView, + ) {} + + tick() { + // Prune any destroyed warships from the multi-selection so callers + // (move-warship intent) don't try to act on dead units. The WebGL + // SelectionBoxPass also drops them automatically. + this.multiSelectedWarships = this.multiSelectedWarships.filter((u) => + u.isActive(), + ); + } + + init() { + this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e)); + + this.ensureDragRectEl(); + this.eventBus.on(WarshipSelectionBoxUpdateEvent, (e) => { + this.updateDragRect(e.startX, e.startY, e.endX, e.endY); + }); + const clearBox = () => this.hideDragRect(); + this.eventBus.on(WarshipSelectionBoxCompleteEvent, clearBox); + this.eventBus.on(WarshipSelectionBoxCancelEvent, clearBox); + this.eventBus.on(CloseViewEvent, clearBox); + + // Warship select/move click flow (previously in the deleted UnitLayer). + this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); + this.eventBus.on(TouchEvent, (e) => this.onTouch(e)); + this.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) => + this.onSelectionBoxComplete(e), + ); + this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships()); + } + + /** + * Lazily create the shift+drag rectangle overlay. Screen-space DOM element, + * pointer-events: none so it doesn't intercept the drag itself. z-index + * sits above the WebGL/canvas2D map canvases but below HUD modals. + */ + private ensureDragRectEl(): void { + if (this.dragRectEl !== null) return; + const el = document.createElement("div"); + el.id = "warship-drag-rect"; + el.style.position = "fixed"; + el.style.pointerEvents = "none"; + el.style.display = "none"; + el.style.zIndex = "30"; + el.style.borderStyle = "dashed"; + el.style.borderWidth = "1px"; + el.style.boxSizing = "border-box"; + document.body.appendChild(el); + this.dragRectEl = el; + } + + private updateDragRect( + startX: number, + startY: number, + endX: number, + endY: number, + ): void { + const el = this.dragRectEl; + if (el === null) return; + const x1 = Math.min(startX, endX); + const y1 = Math.min(startY, endY); + const w = Math.abs(endX - startX); + const h = Math.abs(endY - startY); + + // Color from the local player's territory tint (matches the canvas2D look). + const myPlayer = this.game.myPlayer(); + const base = myPlayer ? myPlayer.territoryColor().lighten(0.2) : null; + const border = base + ? base.alpha(0.85).toRgbString() + : "rgba(100, 200, 255, 0.85)"; + const fill = base + ? base.alpha(0.06).toRgbString() + : "rgba(100, 200, 255, 0.06)"; + + el.style.left = `${x1}px`; + el.style.top = `${y1}px`; + el.style.width = `${w}px`; + el.style.height = `${h}px`; + el.style.borderColor = border; + el.style.backgroundColor = fill; + el.style.display = "block"; + } + + private hideDragRect(): void { + if (this.dragRectEl !== null) this.dragRectEl.style.display = "none"; + } + + /** + * Find player-owned warships near the given cell, sorted by distance. + */ + private findWarshipsNearCell(clickRef: TileRef): UnitView[] { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return []; + return this.game + .units(UnitType.Warship) + .filter( + (unit) => + unit.isActive() && + unit.owner() === myPlayer && + this.game.manhattanDist(unit.tile(), clickRef) <= + WARSHIP_SELECTION_RADIUS, + ) + .sort( + (a, b) => + this.game.manhattanDist(a.tile(), clickRef) - + this.game.manhattanDist(b.tile(), clickRef), + ); + } + + /** + * Resolve a left-click in the world: + * - multi-selected warships present + clicked water → move them all + * - single selected warship + clicked water → move it, then deselect + * - otherwise → if there's a nearby warship, select the closest one + */ + private onMouseUp( + event: MouseUpEvent, + clickRef?: TileRef, + nearbyWarships?: UnitView[], + ) { + if (clickRef === undefined) { + const cell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + if (!this.game.isValidCoord(cell.x, cell.y)) return; + clickRef = this.game.ref(cell.x, cell.y); + } + if (!this.game.isWater(clickRef)) return; + + if (this.multiSelectedWarships.length > 0) { + const myPlayer = this.game.myPlayer(); + const activeIds = this.multiSelectedWarships + .filter((u) => u.isActive() && u.owner() === myPlayer) + .map((u) => u.id()); + + if (activeIds.length > 0) { + this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef)); + } + this.eventBus.emit(new UnitSelectionEvent(null, false)); + return; + } + + if (this.selectedUnit) { + this.eventBus.emit( + new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef), + ); + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + return; + } + + nearbyWarships ??= this.findWarshipsNearCell(clickRef); + if (nearbyWarships.length > 0) { + this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true)); + } + } + + /** + * Touch handler mirroring mouse-up. On dry land with no selection, falls + * back to opening the radial menu. + */ + private onTouch(event: TouchEvent) { + const cell = this.transformHandler.screenToWorldCoordinates( + event.x, + event.y, + ); + if (!this.game.isValidCoord(cell.x, cell.y)) return; + + const clickRef = this.game.ref(cell.x, cell.y); + if (this.game.inSpawnPhase()) { + if (!this.game.isWater(clickRef)) { + this.eventBus.emit(new MouseUpEvent(event.x, event.y)); + } + return; + } + if (!this.game.isWater(clickRef)) { + this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); + return; + } + if (this.selectedUnit || this.multiSelectedWarships.length > 0) { + this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef); + return; + } + const nearbyWarships = this.findWarshipsNearCell(clickRef); + if (nearbyWarships.length > 0) { + this.onMouseUp( + new MouseUpEvent(event.x, event.y), + clickRef, + nearbyWarships, + ); + } else { + this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); + } + } + + /** + * Resolve a shift+drag selection box: gather all player-owned warships + * whose screen position falls inside the rectangle. + */ + private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) { + const x1 = Math.min(event.startX, event.endX); + const y1 = Math.min(event.startY, event.endY); + const x2 = Math.max(event.startX, event.endX); + const y2 = Math.max(event.startY, event.endY); + + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + + const selected = this.game.units(UnitType.Warship).filter((unit) => { + if (!unit.isActive() || unit.owner() !== myPlayer) return false; + const screen = this.transformHandler.worldToScreenCoordinates( + new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())), + ); + return ( + screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2 + ); + }); + + // Clear single selection if we got a box selection + if (selected.length > 0 && this.selectedUnit) { + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + } + this.eventBus.emit(new UnitSelectionEvent(null, true, selected)); + } + + private onSelectAllWarships() { + const myPlayer = this.game.myPlayer(); + if (!myPlayer) return; + + const allWarships = this.game + .units(UnitType.Warship) + .filter((u) => u.isActive() && u.owner() === myPlayer); + if (allWarships.length === 0) return; + + if (this.selectedUnit) { + this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); + } + this.eventBus.emit(new UnitSelectionEvent(null, true, allWarships)); + } + + /** + * Handle the unit selection event (single or multi). + * When event.units.length > 0 it's a multi-selection from box/select-all. + * When event.unit is set it's a single warship selection. + * When event.isSelected is false it clears all selection state. + */ + private onUnitSelection(event: UnitSelectionEvent) { + this.multiSelectedWarships = []; + this.selectedUnit = null; + + if (!event.isSelected) { + this.view.setSelectedUnits([]); + return; + } + + if ((event.units ?? []).length > 0) { + this.multiSelectedWarships = event.units; + this.view.setSelectedUnits(event.units.map((u) => u.id())); + } else { + this.selectedUnit = event.unit; + this.view.setSelectedUnits(event.unit ? [event.unit.id()] : []); + } + } +} diff --git a/src/client/graphics/AnimatedSprite.ts b/src/client/graphics/AnimatedSprite.ts deleted file mode 100644 index 66648951c9..0000000000 --- a/src/client/graphics/AnimatedSprite.ts +++ /dev/null @@ -1,86 +0,0 @@ -export class AnimatedSprite { - private frameHeight: number; - private frameWidth: number; - private currentFrame: number = 0; - private elapsedTime: number = 0; - private active: boolean = true; - - constructor( - private image: CanvasImageSource, - private frameCount: number, - private frameDuration: number, // in milliseconds - private looping: boolean = false, - private originX: number, - private originY: number, - ) { - if (frameCount <= 0) { - throw new Error("Animated sprite should at least have one frame"); - } - if ("height" in image && "width" in image) { - this.frameHeight = (image as HTMLImageElement | HTMLCanvasElement).height; - this.frameWidth = Math.floor( - (image as HTMLImageElement | HTMLCanvasElement).width / frameCount, - ); - } else { - throw new Error( - "Image source must have 'width' and 'height' properties.", - ); - } - } - - update(deltaTime: number) { - if (!this.active) return; - this.elapsedTime += deltaTime; - if (this.elapsedTime >= this.frameDuration) { - this.elapsedTime -= this.frameDuration; - this.currentFrame++; - - if (this.currentFrame >= this.frameCount) { - if (this.looping) { - this.currentFrame = 0; - } else { - this.currentFrame = this.frameCount - 1; - this.active = false; - } - } - } - } - - isActive(): boolean { - return this.active; - } - - lifeTime(): number | undefined { - if (this.looping) { - return undefined; - } - return this.frameDuration * this.frameCount; - } - - draw(ctx: CanvasRenderingContext2D, x: number, y: number) { - const drawX = x - this.originX; - const drawY = y - this.originY; - - ctx.drawImage( - this.image, - this.currentFrame * this.frameWidth, - 0, - this.frameWidth, - this.frameHeight, - drawX, - drawY, - this.frameWidth, - this.frameHeight, - ); - } - - reset() { - this.currentFrame = 0; - this.elapsedTime = 0; - } - - setOrigin(xRatio: number, yRatio: number) { - this.originX = xRatio; - this.originY = yRatio; - } -} diff --git a/src/client/graphics/AnimatedSpriteLoader.ts b/src/client/graphics/AnimatedSpriteLoader.ts deleted file mode 100644 index d32dfe87bb..0000000000 --- a/src/client/graphics/AnimatedSpriteLoader.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { Theme } from "src/core/configuration/Theme"; -import miniBigSmoke from "../../../resources/sprites/bigsmoke.png"; -import buildingExplosion from "../../../resources/sprites/buildingExplosion.png"; -import conquestSword from "../../../resources/sprites/conquestSword.png"; -import dust from "../../../resources/sprites/dust.png"; -import miniExplosion from "../../../resources/sprites/miniExplosion.png"; -import miniFire from "../../../resources/sprites/minifire.png"; -import nuke from "../../../resources/sprites/nukeExplosion.png"; -import SAMExplosion from "../../../resources/sprites/samExplosion.png"; -import sinkingShip from "../../../resources/sprites/sinkingShip.png"; -import miniSmoke from "../../../resources/sprites/smoke.png"; -import miniSmokeAndFire from "../../../resources/sprites/smokeAndFire.png"; -import unitExplosion from "../../../resources/sprites/unitExplosion.png"; -import { PlayerView } from "../../core/game/GameView"; -import { AnimatedSprite } from "./AnimatedSprite"; -import { FxType } from "./fx/Fx"; -import { colorizeCanvas } from "./SpriteLoader"; - -type AnimatedSpriteConfig = { - url: string; - frameCount: number; - frameDuration: number; // ms per frame - looping?: boolean; - originX: number; - originY: number; -}; - -const ANIMATED_SPRITE_CONFIG: Partial> = { - [FxType.MiniFire]: { - url: miniFire, - frameCount: 6, - frameDuration: 100, - looping: true, - originX: 3, - originY: 11, - }, - [FxType.MiniSmoke]: { - url: miniSmoke, - frameCount: 4, - frameDuration: 120, - looping: true, - originX: 2, - originY: 10, - }, - [FxType.MiniBigSmoke]: { - url: miniBigSmoke, - frameCount: 5, - frameDuration: 120, - looping: true, - originX: 9, - originY: 14, - }, - [FxType.MiniSmokeAndFire]: { - url: miniSmokeAndFire, - frameCount: 6, - frameDuration: 120, - looping: true, - originX: 9, - originY: 14, - }, - [FxType.MiniExplosion]: { - url: miniExplosion, - frameCount: 4, - frameDuration: 70, - looping: false, - originX: 6, - originY: 6, - }, - [FxType.Dust]: { - url: dust, - frameCount: 3, - frameDuration: 100, - looping: false, - originX: 4, - originY: 5, - }, - [FxType.UnitExplosion]: { - url: unitExplosion, - frameCount: 4, - frameDuration: 70, - looping: false, - originX: 9, - originY: 9, - }, - [FxType.BuildingExplosion]: { - url: buildingExplosion, - frameCount: 10, - frameDuration: 70, - looping: false, - originX: 8, - originY: 8, - }, - [FxType.SinkingShip]: { - url: sinkingShip, - frameCount: 14, - frameDuration: 90, - looping: false, - originX: 7, - originY: 7, - }, - [FxType.Nuke]: { - url: nuke, - frameCount: 9, - frameDuration: 70, - looping: false, - originX: 30, - originY: 30, - }, - [FxType.SAMExplosion]: { - url: SAMExplosion, - frameCount: 9, - frameDuration: 70, - looping: false, - originX: 23, - originY: 19, - }, - [FxType.Conquest]: { - url: conquestSword, - frameCount: 10, - frameDuration: 90, - looping: false, - originX: 10, - originY: 16, - }, -}; - -export class AnimatedSpriteLoader { - private animatedSpriteImageMap: Map = new Map(); - // Do not color the same sprite twice - private coloredAnimatedSpriteCache: Map = - new Map(); - - public async loadAllAnimatedSpriteImages(): Promise { - const entries = Object.entries(ANIMATED_SPRITE_CONFIG); - - await Promise.all( - entries.map(async ([fxType, config]) => { - const typedFxType = fxType as FxType; - if (!config?.url) return; - - try { - const img = new Image(); - img.crossOrigin = "anonymous"; - img.src = config.url; - - await new Promise((resolve, reject) => { - img.onload = () => resolve(); - img.onerror = (e) => reject(e); - }); - - const canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; - canvas.getContext("2d")!.drawImage(img, 0, 0); - - this.animatedSpriteImageMap.set(typedFxType, canvas); - } catch (err) { - console.error(`Failed to load sprite for ${typedFxType}:`, err); - } - }), - ); - } - - private createRegularAnimatedSprite(fxType: FxType): AnimatedSprite | null { - const config = ANIMATED_SPRITE_CONFIG[fxType]; - const image = this.animatedSpriteImageMap.get(fxType); - if (!config || !image) return null; - - return new AnimatedSprite( - image, - config.frameCount, - config.frameDuration, - config.looping ?? true, - config.originX, - config.originY, - ); - } - - private getColoredAnimatedSprite( - owner: PlayerView, - fxType: FxType, - theme: Theme, - ): HTMLCanvasElement | null { - const baseImage = this.animatedSpriteImageMap.get(fxType); - const config = ANIMATED_SPRITE_CONFIG[fxType]; - if (!baseImage || !config) return null; - const territoryColor = owner.territoryColor(); - const borderColor = owner.borderColor(); - const spawnHighlightColor = theme.spawnHighlightColor(); - const key = `${fxType}-${owner.id()}`; - let coloredCanvas: HTMLCanvasElement; - if (this.coloredAnimatedSpriteCache.has(key)) { - coloredCanvas = this.coloredAnimatedSpriteCache.get(key)!; - } else { - coloredCanvas = colorizeCanvas( - baseImage, - territoryColor, - borderColor, - spawnHighlightColor, - ); - - this.coloredAnimatedSpriteCache.set(key, coloredCanvas); - } - return coloredCanvas; - } - - private createColoredAnimatedSpriteForUnit( - fxType: FxType, - owner: PlayerView, - theme: Theme, - ): AnimatedSprite | null { - const config = ANIMATED_SPRITE_CONFIG[fxType]; - const image = this.getColoredAnimatedSprite(owner, fxType, theme); - if (!config || !image) return null; - - return new AnimatedSprite( - image, - config.frameCount, - config.frameDuration, - config.looping ?? true, - config.originX, - config.originY, - ); - } - - public createAnimatedSprite( - fxType: FxType, - owner?: PlayerView, - theme?: Theme, - ): AnimatedSprite | null { - if (owner && theme) { - return this.createColoredAnimatedSpriteForUnit(fxType, owner, theme); - } - return this.createRegularAnimatedSprite(fxType); - } -} diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 4df65faccd..1beae5d95d 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -1,11 +1,15 @@ import { EventBus } from "../../core/EventBus"; import { GameView } from "../../core/game/GameView"; import { UserSettings } from "../../core/game/UserSettings"; +import { Controller } from "../Controller"; import { GameStartingModal } from "../GameStartingModal"; -import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler"; +import { TransformHandler } from "../TransformHandler"; +import { UIState } from "../UIState"; +import { BuildPreviewController } from "../controllers/BuildPreviewController"; +import { HoverHighlightController } from "../controllers/HoverHighlightController"; +import { WarshipSelectionController } from "../controllers/WarshipSelectionController"; +import { GameView as WebGLGameView } from "../render/gl"; import { FrameProfiler } from "./FrameProfiler"; -import { TransformHandler } from "./TransformHandler"; -import { UIState } from "./UIState"; import { AlertFrame } from "./layers/AlertFrame"; import { AttackingTroopsOverlay } from "./layers/AttackingTroopsOverlay"; import { AttacksDisplay } from "./layers/AttacksDisplay"; @@ -13,47 +17,34 @@ import { BuildMenu } from "./layers/BuildMenu"; import { ChatDisplay } from "./layers/ChatDisplay"; import { ChatModal } from "./layers/ChatModal"; import { ControlPanel } from "./layers/ControlPanel"; -import { CoordinateGridLayer } from "./layers/CoordinateGridLayer"; -import { DynamicUILayer } from "./layers/DynamicUILayer"; import { EmojiTable } from "./layers/EmojiTable"; import { EventsDisplay } from "./layers/EventsDisplay"; -import { FxLayer } from "./layers/FxLayer"; import { GameLeftSidebar } from "./layers/GameLeftSidebar"; import { GameRightSidebar } from "./layers/GameRightSidebar"; import { HeadsUpMessage } from "./layers/HeadsUpMessage"; import { ImmunityTimer } from "./layers/ImmunityTimer"; import { InGamePromo } from "./layers/InGamePromo"; -import { Layer } from "./layers/Layer"; import { Leaderboard } from "./layers/Leaderboard"; import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; -import { NameLayer } from "./layers/NameLayer"; -import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer"; import { PerformanceOverlay } from "./layers/PerformanceOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; -import { RailroadLayer } from "./layers/RailroadLayer"; import { ReplayPanel } from "./layers/ReplayPanel"; -import { SAMRadiusLayer } from "./layers/SAMRadiusLayer"; import { SettingsModal } from "./layers/SettingsModal"; import { SpawnTimer } from "./layers/SpawnTimer"; -import { StructureIconsLayer } from "./layers/StructureIconsLayer"; -import { StructureLayer } from "./layers/StructureLayer"; import { TeamStats } from "./layers/TeamStats"; -import { TerrainLayer } from "./layers/TerrainLayer"; -import { TerritoryLayer } from "./layers/TerritoryLayer"; -import { UILayer } from "./layers/UILayer"; import { UnitDisplay } from "./layers/UnitDisplay"; -import { UnitLayer } from "./layers/UnitLayer"; import { WinModal } from "./layers/WinModal"; export function createRenderer( - canvas: HTMLCanvasElement, + inputEl: HTMLElement, game: GameView, eventBus: EventBus, playerRole: string | null, + view: WebGLGameView, ): GameRenderer { - const transformHandler = new TransformHandler(game, eventBus, canvas); + const transformHandler = new TransformHandler(game, eventBus, inputEl); const userSettings = new UserSettings(); const uiState: UIState = { @@ -230,9 +221,6 @@ export function createRenderer( } headsUpMessage.game = game; - const structureLayer = new StructureLayer(game, eventBus, transformHandler); - const samRadiusLayer = new SAMRadiusLayer(game, eventBus, uiState); - const performanceOverlay = document.querySelector( "performance-overlay", ) as PerformanceOverlay; @@ -271,23 +259,10 @@ export function createRenderer( } inGamePromo.game = game; - // When updating these layers please be mindful of the order. - // Try to group layers by the return value of shouldTransform. - // Not grouping the layers may cause excessive calls to context.save() and context.restore(). - const layers: Layer[] = [ - new TerrainLayer(game, transformHandler), - new TerritoryLayer(game, eventBus, transformHandler), - new RailroadLayer(game, eventBus, transformHandler, uiState), - new CoordinateGridLayer(game, eventBus, transformHandler), - structureLayer, - samRadiusLayer, - new UnitLayer(game, eventBus, transformHandler), - new FxLayer(game, eventBus, transformHandler), - new UILayer(game, eventBus, transformHandler), - new NukeTrajectoryPreviewLayer(game, eventBus, transformHandler, uiState), - new StructureIconsLayer(game, eventBus, uiState, transformHandler), - new DynamicUILayer(game, transformHandler, eventBus), - new NameLayer(game, transformHandler, eventBus), + const layers: Controller[] = [ + new WarshipSelectionController(game, eventBus, transformHandler, view), + new BuildPreviewController(game, eventBus, uiState, transformHandler, view), + new HoverHighlightController(game, eventBus, transformHandler, view), new AttackingTroopsOverlay(game, transformHandler, eventBus, userSettings), eventsDisplay, attacksDisplay, @@ -323,9 +298,6 @@ export function createRenderer( ]; return new GameRenderer( - game, - eventBus, - canvas, transformHandler, uiState, layers, @@ -334,150 +306,30 @@ export function createRenderer( } export class GameRenderer { - private context: CanvasRenderingContext2D; - private layerTickState = new Map(); - private renderFramesSinceLastTick: number = 0; - private renderLayerDurationsSinceLastTick: Record = {}; + private layerTickState = new Map(); constructor( - private game: GameView, - private eventBus: EventBus, - private canvas: HTMLCanvasElement, public transformHandler: TransformHandler, public uiState: UIState, - private layers: Layer[], + private layers: Controller[], private performanceOverlay: PerformanceOverlay, - ) { - const context = canvas.getContext("2d", { alpha: false }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - } + ) {} initialize() { - this.eventBus.on(RedrawGraphicsEvent, () => this.redraw()); this.layers.forEach((l) => l.init?.()); - // only append the canvas if it's not already in the document to avoid reparenting side-effects - if (!document.body.contains(this.canvas)) { - document.body.appendChild(this.canvas); - } - - window.addEventListener("resize", () => this.resizeCanvas()); - this.resizeCanvas(); + window.addEventListener("resize", () => + this.transformHandler.updateCanvasBoundingRect(), + ); //show whole map on startup this.transformHandler.centerAll(0.9); - - let rafId = requestAnimationFrame(() => this.renderGame()); - this.canvas.addEventListener("contextlost", () => { - cancelAnimationFrame(rafId); - }); - this.canvas.addEventListener("contextrestored", () => { - this.redraw(); - rafId = requestAnimationFrame(() => this.renderGame()); - }); - } - - resizeCanvas() { - this.canvas.width = window.innerWidth; - this.canvas.height = window.innerHeight; - this.transformHandler.updateCanvasBoundingRect(); - //this.redraw() - } - - redraw() { - this.layers.forEach((l) => { - if (l.redraw) { - l.redraw(); - } - }); - } - - renderGame() { - const shouldProfileFrame = FrameProfiler.isEnabled(); - if (shouldProfileFrame) { - FrameProfiler.clear(); - } - const start = performance.now(); - // Set background - this.context.fillStyle = this.game - .config() - .theme() - .backgroundColor() - .toHex(); - this.context.fillRect(0, 0, this.canvas.width, this.canvas.height); - - const handleTransformState = ( - needsTransform: boolean, - active: boolean, - ): boolean => { - if (needsTransform && !active) { - this.context.save(); - this.transformHandler.handleTransform(this.context); - return true; - } else if (!needsTransform && active) { - this.context.restore(); - return false; - } - return active; - }; - - let isTransformActive = false; - - for (const layer of this.layers) { - const needsTransform = layer.shouldTransform?.() ?? false; - isTransformActive = handleTransformState( - needsTransform, - isTransformActive, - ); - - if (shouldProfileFrame) { - const layerStart = FrameProfiler.start(); - layer.renderLayer?.(this.context); - FrameProfiler.end( - layer.constructor?.name ?? "UnknownLayer", - layerStart, - ); - } else { - layer.renderLayer?.(this.context); - } - } - handleTransformState(false, isTransformActive); // Ensure context is clean after rendering - this.transformHandler.resetChanged(); - - requestAnimationFrame(() => this.renderGame()); - const duration = performance.now() - start; - - if (shouldProfileFrame) { - const layerDurations = FrameProfiler.consume(); - this.renderFramesSinceLastTick++; - for (const [name, ms] of Object.entries(layerDurations)) { - this.renderLayerDurationsSinceLastTick[name] = - (this.renderLayerDurationsSinceLastTick[name] ?? 0) + ms; - } - this.performanceOverlay.updateFrameMetrics(duration, layerDurations); - } - - if (duration > 50) { - console.warn( - `tick ${this.game.ticks()} took ${duration}ms to render frame`, - ); - } } tick() { const nowMs = performance.now(); const shouldProfileTick = FrameProfiler.isEnabled(); - if (shouldProfileTick) { - this.performanceOverlay.updateRenderPerTickMetrics( - this.renderFramesSinceLastTick, - this.renderLayerDurationsSinceLastTick, - ); - this.renderFramesSinceLastTick = 0; - this.renderLayerDurationsSinceLastTick = {}; - } - const tickLayerDurations: Record = {}; for (const layer of this.layers) { @@ -511,9 +363,4 @@ export class GameRenderer { this.performanceOverlay.updateTickLayerMetrics(tickLayerDurations); } } - - resize(width: number, height: number): void { - this.canvas.width = Math.ceil(width / window.devicePixelRatio); - this.canvas.height = Math.ceil(height / window.devicePixelRatio); - } } diff --git a/src/client/graphics/ProgressBar.ts b/src/client/graphics/ProgressBar.ts deleted file mode 100644 index 52500bb873..0000000000 --- a/src/client/graphics/ProgressBar.ts +++ /dev/null @@ -1,61 +0,0 @@ -export class ProgressBar { - private static readonly CLEAR_PADDING = 2; - constructor( - private colors: string[] = [], - private ctx: CanvasRenderingContext2D, - private x: number, - private y: number, - private w: number, - private h: number, - private progress: number = 0, // Progress from 0 to 1 - ) { - this.setProgress(progress); - } - - setProgress(progress: number): void { - progress = Math.max(0, Math.min(1, progress)); - this.clear(); - // Draw the loading bar background - this.ctx.fillStyle = "rgba(0, 0, 0, 1)"; - this.ctx.fillRect(this.x - 1, this.y - 1, this.w, this.h); - - // Draw the loading progress - if (this.colors.length === 0) { - this.ctx.fillStyle = "#808080"; // default gray - } else { - const idx = Math.min( - this.colors.length - 1, - Math.floor(progress * this.colors.length), - ); - this.ctx.fillStyle = this.colors[idx]; - } - this.ctx.fillRect( - this.x, - this.y, - Math.max(1, Math.floor(progress * (this.w - 2))), - this.h - 2, - ); - this.progress = progress; - } - - clear() { - this.ctx.clearRect( - this.x - ProgressBar.CLEAR_PADDING, - this.y - ProgressBar.CLEAR_PADDING, - this.w + ProgressBar.CLEAR_PADDING, - this.h + ProgressBar.CLEAR_PADDING, - ); - } - - getX(): number { - return this.x; - } - - getY(): number { - return this.y; - } - - getProgress(): number { - return this.progress; - } -} diff --git a/src/client/graphics/fx/ConquestFx.ts b/src/client/graphics/fx/ConquestFx.ts deleted file mode 100644 index 9f45d21064..0000000000 --- a/src/client/graphics/fx/ConquestFx.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ConquestUpdate } from "../../../core/game/GameUpdates"; -import { GameView } from "../../../core/game/GameView"; -import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; -import { Fx, FxType } from "./Fx"; -import { FadeFx, SpriteFx } from "./SpriteFx"; - -/** - * Conquest FX: - * - conquest sprite - */ -export function conquestFxFactory( - animatedSpriteLoader: AnimatedSpriteLoader, - conquest: ConquestUpdate, - game: GameView, -): Fx { - const conquered = game.player(conquest.conqueredId); - const x = conquered.nameLocation().x; - const y = conquered.nameLocation().y; - - const swordAnimation = new SpriteFx( - animatedSpriteLoader, - x, - y, - FxType.Conquest, - 2500, - ); - return new FadeFx(swordAnimation, 0.1, 0.6); -} diff --git a/src/client/graphics/fx/Fx.ts b/src/client/graphics/fx/Fx.ts deleted file mode 100644 index d4b2066146..0000000000 --- a/src/client/graphics/fx/Fx.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface Fx { - renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean; -} - -export enum FxType { - MiniFire = "MiniFire", - MiniSmoke = "MiniSmoke", - MiniBigSmoke = "MiniBigSmoke", - MiniSmokeAndFire = "MiniSmokeAndFire", - MiniExplosion = "MiniExplosion", - UnitExplosion = "UnitExplosion", - BuildingExplosion = "BuildingExplosion", - SinkingShip = "SinkingShip", - Nuke = "Nuke", - SAMExplosion = "SAMExplosion", - UnderConstruction = "UnderConstruction", - Dust = "Dust", - Conquest = "Conquest", -} diff --git a/src/client/graphics/fx/NukeFx.ts b/src/client/graphics/fx/NukeFx.ts deleted file mode 100644 index 479d68e185..0000000000 --- a/src/client/graphics/fx/NukeFx.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { GameView } from "../../../core/game/GameView"; -import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; -import { Fx, FxType } from "./Fx"; -import { FadeFx, SpriteFx } from "./SpriteFx"; - -/** - * Shockwave effect: draw a growing 1px white circle - */ -export class ShockwaveFx implements Fx { - private lifeTime: number = 0; - constructor( - private x: number, - private y: number, - private duration: number, - private maxRadius: number, - ) {} - - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - this.lifeTime += frameTime; - if (this.lifeTime >= this.duration) { - return false; - } - const t = this.lifeTime / this.duration; - const radius = t * this.maxRadius; - ctx.beginPath(); - ctx.arc(this.x, this.y, radius, 0, Math.PI * 2); - ctx.strokeStyle = "rgba(255, 255, 255, " + (1 - t) + ")"; - ctx.lineWidth = 0.5; - ctx.stroke(); - return true; - } -} - -/** - * Spawn @p number of @p type animation within a perimeter - */ -function addSpriteInCircle( - animatedSpriteLoader: AnimatedSpriteLoader, - x: number, - y: number, - radius: number, - num: number, - type: FxType, - result: Fx[], - game: GameView, -) { - const count = Math.max(0, Math.floor(num)); - for (let i = 0; i < count; i++) { - const angle = Math.random() * 2 * Math.PI; - const distance = Math.random() * (radius / 2); - const spawnX = Math.floor(x + Math.cos(angle) * distance); - const spawnY = Math.floor(y + Math.sin(angle) * distance); - if ( - game.isValidCoord(spawnX, spawnY) && - game.isLand(game.ref(spawnX, spawnY)) - ) { - const sprite = new FadeFx( - new SpriteFx(animatedSpriteLoader, spawnX, spawnY, type, 6000), - 0.1, - 0.8, - ); - result.push(sprite as Fx); - } - } -} - -/** - * Explosion effect: - * - explosion animation - * - shockwave - * - ruins and desolation fx - */ -export function nukeFxFactory( - animatedSpriteLoader: AnimatedSpriteLoader, - x: number, - y: number, - radius: number, - game: GameView, -): Fx[] { - const nukeFx: Fx[] = []; - // Explosion animation - nukeFx.push(new SpriteFx(animatedSpriteLoader, x, y, FxType.Nuke)); - // Shockwave animation - nukeFx.push(new ShockwaveFx(x, y, 1500, radius * 1.5)); - // Ruins and desolation sprites - const debrisPlan: Array<{ - type: FxType; - radiusFactor: number; - density: number; - }> = [ - { type: FxType.MiniFire, radiusFactor: 1.0, density: 1 / 25 }, - { type: FxType.MiniSmoke, radiusFactor: 1.0, density: 1 / 28 }, - { type: FxType.MiniBigSmoke, radiusFactor: 0.9, density: 1 / 70 }, - { type: FxType.MiniSmokeAndFire, radiusFactor: 0.9, density: 1 / 70 }, - ]; - - for (const { type, radiusFactor, density } of debrisPlan) { - addSpriteInCircle( - animatedSpriteLoader, - x, - y, - radius * radiusFactor, - radius * density, - type, - nukeFx, - game, - ); - } - return nukeFx; -} diff --git a/src/client/graphics/fx/SpriteFx.ts b/src/client/graphics/fx/SpriteFx.ts deleted file mode 100644 index cf625d7979..0000000000 --- a/src/client/graphics/fx/SpriteFx.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Theme } from "src/core/configuration/Theme"; -import { PlayerView } from "../../../core/game/GameView"; -import { AnimatedSprite } from "../AnimatedSprite"; -import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; -import { Fx, FxType } from "./Fx"; - -function fadeInOut( - t: number, - fadeIn: number = 0.3, - fadeOut: number = 0.7, -): number { - if (t < fadeIn) { - const f = t / fadeIn; // Map to [0, 1] - return f * f; - } else if (t < fadeOut) { - return 1; - } else { - const f = (t - fadeOut) / (1 - fadeOut); // Map to [0, 1] - return 1 - f * f; - } -} -/** - * Fade in/out another FX - */ -export class FadeFx implements Fx { - constructor( - private fxToFade: SpriteFx, - private fadeIn: number, - private fadeOut: number, - ) {} - - renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean { - const t = this.fxToFade.getElapsedTime() / this.fxToFade.getDuration(); - ctx.save(); - ctx.globalAlpha = fadeInOut(t, this.fadeIn, this.fadeOut); - const result = this.fxToFade.renderTick(duration, ctx); - ctx.restore(); - return result; - } -} - -/** - * Animated sprite. Can be colored if provided an owner/theme - */ -export class SpriteFx implements Fx { - protected animatedSprite: AnimatedSprite | null; - protected elapsedTime = 0; - protected duration: number; - protected waitToTheEnd = false; - constructor( - animatedSpriteLoader: AnimatedSpriteLoader, - protected x: number, - protected y: number, - fxType: FxType, - duration?: number, - owner?: PlayerView, - theme?: Theme, - ) { - this.animatedSprite = animatedSpriteLoader.createAnimatedSprite( - fxType, - owner, - theme, - ); - if (!this.animatedSprite) { - console.error("Could not load animated sprite", fxType); - } else { - this.waitToTheEnd = duration ? true : false; - this.duration = duration ?? this.animatedSprite.lifeTime() ?? 1000; - } - } - - public setPosition(x: number, y: number): void { - this.x = x; - this.y = y; - } - - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - if (!this.animatedSprite) return false; - - this.elapsedTime += frameTime; - if (this.elapsedTime >= this.duration) return false; - - if (!this.animatedSprite.isActive() && !this.waitToTheEnd) return false; - - this.animatedSprite.update(frameTime); - this.animatedSprite.draw(ctx, this.x, this.y); - return true; - } - - getElapsedTime(): number { - return this.elapsedTime; - } - - getDuration(): number { - return this.duration; - } -} diff --git a/src/client/graphics/fx/Timeline.ts b/src/client/graphics/fx/Timeline.ts deleted file mode 100644 index 32310244e4..0000000000 --- a/src/client/graphics/fx/Timeline.ts +++ /dev/null @@ -1,33 +0,0 @@ -type TimedTask = { - delay: number; - action: () => void; - triggered: boolean; -}; - -/** - * Basic timeline to chain actions - */ -export class Timeline { - private tasks: TimedTask[] = []; - private timeElapsed = 0; - - add(delay: number, action: () => void): Timeline { - this.tasks.push({ delay, action, triggered: false }); - return this; - } - - update(dt: number) { - this.timeElapsed += dt; - - for (const task of this.tasks) { - if (!task.triggered && this.timeElapsed >= task.delay) { - task.action(); - task.triggered = true; - } - } - } - - isComplete() { - return this.tasks.every((t) => t.triggered); - } -} diff --git a/src/client/graphics/fx/UnitExplosionFx.ts b/src/client/graphics/fx/UnitExplosionFx.ts deleted file mode 100644 index b77d5f4fa7..0000000000 --- a/src/client/graphics/fx/UnitExplosionFx.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { GameView } from "../../../core/game/GameView"; -import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; -import { Fx, FxType } from "./Fx"; -import { SpriteFx } from "./SpriteFx"; -import { Timeline } from "./Timeline"; - -/** - * Explosion Effect: a few timed explosions - */ -export class UnitExplosionFx implements Fx { - private timeline = new Timeline(); - private explosions: Fx[] = []; - - constructor( - animatedSpriteLoader: AnimatedSpriteLoader, - private x: number, - private y: number, - game: GameView, - ) { - const config = [ - { dx: 0, dy: 0, delay: 0, type: FxType.UnitExplosion }, - { dx: 4, dy: -6, delay: 80, type: FxType.UnitExplosion }, - { dx: -6, dy: 4, delay: 160, type: FxType.UnitExplosion }, - ]; - for (const { dx, dy, delay, type } of config) { - this.timeline.add(delay, () => { - if (game.isValidCoord(x + dx, y + dy)) { - this.explosions.push( - new SpriteFx(animatedSpriteLoader, x + dx, y + dy, type), - ); - } - }); - } - } - - renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean { - this.timeline.update(frameTime); - let allDone = true; - for (const fx of this.explosions) { - if (fx.renderTick(frameTime, ctx)) { - allDone = false; - } - } - - return !allDone || !this.timeline.isComplete(); - } -} diff --git a/src/client/graphics/layers/AlertFrame.ts b/src/client/graphics/layers/AlertFrame.ts index 2b0238f1ff..fcadb4969d 100644 --- a/src/client/graphics/layers/AlertFrame.ts +++ b/src/client/graphics/layers/AlertFrame.ts @@ -7,7 +7,7 @@ import { } from "../../../core/game/GameUpdates"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; -import { Layer } from "./Layer"; +import { Controller } from "../../Controller"; // Parameters for the alert animation const ALERT_SPEED = 1.6; @@ -16,7 +16,7 @@ const RETALIATION_WINDOW_TICKS = 15 * 10; // 15 seconds const ALERT_COOLDOWN_TICKS = 15 * 10; // 15 seconds @customElement("alert-frame") -export class AlertFrame extends LitElement implements Layer { +export class AlertFrame extends LitElement implements Controller { public game: GameView; private userSettings: UserSettings = new UserSettings(); @@ -118,9 +118,6 @@ export class AlertFrame extends LitElement implements Layer { } // The alert frame is not affected by the camera transform - shouldTransform(): boolean { - return false; - } private onBrokeAllianceUpdate(update: BrokeAllianceUpdate) { const myPlayer = this.game.myPlayer(); diff --git a/src/client/graphics/layers/AttackingTroopsOverlay.ts b/src/client/graphics/layers/AttackingTroopsOverlay.ts index b279489a00..bcc4342019 100644 --- a/src/client/graphics/layers/AttackingTroopsOverlay.ts +++ b/src/client/graphics/layers/AttackingTroopsOverlay.ts @@ -2,10 +2,10 @@ import { EventBus } from "../../../core/EventBus"; import { Cell, PlayerType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; +import { Controller } from "../../Controller"; import { AlternateViewEvent } from "../../InputHandler"; +import { TransformHandler } from "../../TransformHandler"; import { renderTroops } from "../../Utils"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; // Match AttacksDisplay: aquarius for outgoing, red-400 for incoming. const OUTGOING_COLOR = "var(--color-aquarius)"; @@ -51,7 +51,7 @@ interface AttackLabel { attackerTroops: number; } -export class AttackingTroopsOverlay implements Layer { +export class AttackingTroopsOverlay implements Controller { private container: HTMLDivElement; private labelTemplate: HTMLDivElement; private labels = new Map(); @@ -70,10 +70,6 @@ export class AttackingTroopsOverlay implements Layer { private readonly userSettings: UserSettings, ) {} - shouldTransform(): boolean { - return false; - } - init() { this.container = document.createElement("div"); this.container.style.position = "fixed"; @@ -91,6 +87,15 @@ export class AttackingTroopsOverlay implements Layer { this.container.style.display = this.isVisible ? "" : "none"; }; this.eventBus.on(AlternateViewEvent, this.onAlternateView); + + // Self-driven RAF: DOM label positions must update every frame so the + // labels track the WebGL camera as the user pans/zooms. (Previously this + // ran via the now-deleted canvas2D RAF loop.) + const drive = () => { + this.updateLabelDOM(); + requestAnimationFrame(drive); + }; + requestAnimationFrame(drive); } destroy() { @@ -200,7 +205,7 @@ export class AttackingTroopsOverlay implements Layer { } } - renderLayer(_context: CanvasRenderingContext2D) { + private updateLabelDOM() { const screenPosOld = this.transformHandler.worldToScreenCoordinates( new Cell(0, 0), ); diff --git a/src/client/graphics/layers/AttacksDisplay.ts b/src/client/graphics/layers/AttacksDisplay.ts index d3889ddf84..13e4fc04f7 100644 --- a/src/client/graphics/layers/AttacksDisplay.ts +++ b/src/client/graphics/layers/AttacksDisplay.ts @@ -9,25 +9,25 @@ import { UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; +import { + GoToPlayerEvent, + GoToPositionEvent, + GoToUnitEvent, +} from "../../TransformHandler"; import { CancelAttackIntentEvent, CancelBoatIntentEvent, SendAttackIntentEvent, } from "../../Transport"; +import { UIState } from "../../UIState"; import { renderTroops, translateText } from "../../Utils"; import { getColoredSprite } from "../SpriteLoader"; -import { - GoToPlayerEvent, - GoToPositionEvent, - GoToUnitEvent, -} from "../TransformHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; const soldierIcon = assetUrl("images/SoldierIcon.svg"); const swordIcon = assetUrl("images/SwordIcon.svg"); @customElement("attacks-display") -export class AttacksDisplay extends LitElement implements Layer { +export class AttacksDisplay extends LitElement implements Controller { public eventBus: EventBus; public game: GameView; public uiState: UIState; @@ -110,12 +110,6 @@ export class AttacksDisplay extends LitElement implements Layer { this.requestUpdate(); } - shouldTransform(): boolean { - return false; - } - - renderLayer(): void {} - private renderButton(options: { content: any; onClick?: () => void; diff --git a/src/client/graphics/layers/BuildMenu.ts b/src/client/graphics/layers/BuildMenu.ts index ad0c9efeef..46d1a8f41d 100644 --- a/src/client/graphics/layers/BuildMenu.ts +++ b/src/client/graphics/layers/BuildMenu.ts @@ -12,20 +12,20 @@ import { } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { CloseViewEvent, MouseDownEvent, ShowBuildMenuEvent, ShowEmojiMenuEvent, } from "../../InputHandler"; +import { TransformHandler } from "../../TransformHandler"; import { BuildUnitIntentEvent, SendUpgradeStructureIntentEvent, } from "../../Transport"; +import { UIState } from "../../UIState"; import { renderNumber } from "../../Utils"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; const warshipIcon = assetUrl("images/BattleshipIconWhite.svg"); const cityIcon = assetUrl("images/CityIconWhite.svg"); const factoryIcon = assetUrl("images/FactoryIconWhite.svg"); @@ -124,7 +124,7 @@ export const buildTable: BuildItemDisplay[][] = [ export const flattenedBuildTable = buildTable.flat(); @customElement("build-menu") -export class BuildMenu extends LitElement implements Layer { +export class BuildMenu extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; public uiState: UIState; diff --git a/src/client/graphics/layers/ChatDisplay.ts b/src/client/graphics/layers/ChatDisplay.ts index 4704e0f518..04018b2cfc 100644 --- a/src/client/graphics/layers/ChatDisplay.ts +++ b/src/client/graphics/layers/ChatDisplay.ts @@ -10,7 +10,7 @@ import { } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { onlyImages } from "../../../core/Util"; -import { Layer } from "./Layer"; +import { Controller } from "../../Controller"; interface ChatEvent { description: string; @@ -20,7 +20,7 @@ interface ChatEvent { } @customElement("chat-display") -export class ChatDisplay extends LitElement implements Layer { +export class ChatDisplay extends LitElement implements Controller { public eventBus: EventBus; public game: GameView; diff --git a/src/client/graphics/layers/ChatModal.ts b/src/client/graphics/layers/ChatModal.ts index 23c0bbd4b0..0237b17ac9 100644 --- a/src/client/graphics/layers/ChatModal.ts +++ b/src/client/graphics/layers/ChatModal.ts @@ -277,7 +277,7 @@ export class ChatModal extends LitElement { console.log("Sent message:", sender); this.players = this.g .players() - .filter((p) => p.isAlive() && p.data.playerType !== PlayerType.Bot); + .filter((p) => p.isAlive() && p.type() !== PlayerType.Bot); this.recipient = recipient; this.sender = sender; @@ -311,7 +311,7 @@ export class ChatModal extends LitElement { if (sender && recipient) { this.players = this.g .players() - .filter((p) => p.isAlive() && p.data.playerType !== PlayerType.Bot); + .filter((p) => p.isAlive() && p.type() !== PlayerType.Bot); this.recipient = recipient; this.sender = sender; diff --git a/src/client/graphics/layers/ControlPanel.ts b/src/client/graphics/layers/ControlPanel.ts index 1bc72d3f53..dce219b5ad 100644 --- a/src/client/graphics/layers/ControlPanel.ts +++ b/src/client/graphics/layers/ControlPanel.ts @@ -6,16 +6,16 @@ import { Gold } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; import { ClientID } from "../../../core/Schemas"; +import { Controller } from "../../Controller"; import { AttackRatioEvent } from "../../InputHandler"; +import { UIState } from "../../UIState"; import { renderNumber, renderTroops } from "../../Utils"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; const goldCoinIcon = assetUrl("images/GoldCoinIcon.svg"); const soldierIcon = assetUrl("images/SoldierIcon.svg"); const swordIcon = assetUrl("images/SwordIcon.svg"); @customElement("control-panel") -export class ControlPanel extends LitElement implements Layer { +export class ControlPanel extends LitElement implements Controller { public game: GameView; public clientID: ClientID; public eventBus: EventBus; @@ -111,14 +111,6 @@ export class ControlPanel extends LitElement implements Layer { this.uiState.attackRatio = newRatio; } - renderLayer(context: CanvasRenderingContext2D) { - // Render any necessary canvas elements - } - - shouldTransform(): boolean { - return false; - } - setVisibile(visible: boolean) { this._isVisible = visible; this.requestUpdate(); diff --git a/src/client/graphics/layers/CoordinateGridLayer.ts b/src/client/graphics/layers/CoordinateGridLayer.ts deleted file mode 100644 index 885f76f82b..0000000000 --- a/src/client/graphics/layers/CoordinateGridLayer.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { EventBus } from "../../../core/EventBus"; -import { Cell } from "../../../core/game/Game"; -import { GameView } from "../../../core/game/GameView"; -import { - AlternateViewEvent, - ToggleCoordinateGridEvent, -} from "../../InputHandler"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -const BASE_CELL_COUNT = 10; -const MAX_COLUMNS = 50; -const MIN_ROWS = 2; -const LABEL_PADDING = 8; - -const toAlphaLabel = (index: number): string => { - let value = index; - let label = ""; - do { - label = String.fromCharCode(65 + (value % 26)) + label; - value = Math.floor(value / 26) - 1; - } while (value >= 0); - return label; -}; - -const computeGrid = (width: number, height: number) => { - // Initial square-ish estimate - let cellSize = Math.min(width, height) / BASE_CELL_COUNT; - let rows = Math.max(1, Math.round(height / cellSize)); - let cols = Math.max(1, Math.round(width / cellSize)); - - // Cap columns and adjust rows accordingly - if (cols > MAX_COLUMNS) { - const maxRowsForCols = Math.floor((MAX_COLUMNS * height) / width); - rows = Math.max(MIN_ROWS, Math.min(rows, maxRowsForCols)); - cols = MAX_COLUMNS; - } - - cellSize = Math.min(width / cols, height / rows); - const fullCols = Math.max(1, Math.floor(width / cellSize)); - const fullRows = Math.max(1, Math.floor(height / cellSize)); - - const remainderX = Math.max(0, width - fullCols * cellSize); - const remainderY = Math.max(0, height - fullRows * cellSize); - - const hasExtraCol = remainderX > 0.001; - const hasExtraRow = remainderY > 0.001; - - const totalCols = fullCols + (hasExtraCol ? 1 : 0); - const totalRows = fullRows + (hasExtraRow ? 1 : 0); - - const lastColWidth = hasExtraCol ? remainderX : cellSize; - const lastRowHeight = hasExtraRow ? remainderY : cellSize; - - return { - cellSize, - rows: totalRows, - cols: totalCols, - fullCols, - fullRows, - lastColWidth, - lastRowHeight, - hasExtraCol, - hasExtraRow, - gridWidth: width, - gridHeight: height, - }; -}; - -export class CoordinateGridLayer implements Layer { - private isVisible = false; - private alternateView = false; - private cachedGridCanvas: HTMLCanvasElement | null = null; - private cachedGridContext: CanvasRenderingContext2D | null = null; - private cachedGridKey = ""; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) {} - - init() { - this.eventBus.on(ToggleCoordinateGridEvent, (event) => { - this.isVisible = event.enabled; - }); - this.eventBus.on(AlternateViewEvent, (event) => { - this.alternateView = event.alternateView; - }); - } - - shouldTransform(): boolean { - return false; - } - - renderLayer(context: CanvasRenderingContext2D) { - if (!this.isVisible && !this.alternateView) return; - - const width = this.game.width(); - const height = this.game.height(); - if (width <= 0 || height <= 0) return; - const canvasWidth = context.canvas.width; - const canvasHeight = context.canvas.height; - - const cacheKey = this.buildCacheKey( - width, - height, - canvasWidth, - canvasHeight, - ); - const cacheContext = this.ensureCacheContext(canvasWidth, canvasHeight); - if (cacheContext === null || this.cachedGridCanvas === null) return; - - if (this.cachedGridKey !== cacheKey) { - cacheContext.clearRect(0, 0, canvasWidth, canvasHeight); - this.drawGrid(cacheContext, width, height); - this.cachedGridKey = cacheKey; - } - - context.drawImage(this.cachedGridCanvas, 0, 0); - } - - private ensureCacheContext( - canvasWidth: number, - canvasHeight: number, - ): CanvasRenderingContext2D | null { - this.cachedGridCanvas ??= document.createElement("canvas"); - - if ( - this.cachedGridCanvas.width !== canvasWidth || - this.cachedGridCanvas.height !== canvasHeight - ) { - this.cachedGridCanvas.width = canvasWidth; - this.cachedGridCanvas.height = canvasHeight; - this.cachedGridContext = null; - this.cachedGridKey = ""; - } - - this.cachedGridContext ??= this.cachedGridCanvas.getContext("2d"); - - return this.cachedGridContext; - } - - private buildCacheKey( - width: number, - height: number, - canvasWidth: number, - canvasHeight: number, - ): string { - const topLeft = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, 0), - ); - const bottomRight = this.transformHandler.worldToCanvasCoordinates( - new Cell(width, height), - ); - const darkMode = this.game.config().userSettings()?.darkMode() ?? false; - return [ - width, - height, - canvasWidth, - canvasHeight, - this.transformHandler.scale.toFixed(4), - topLeft.x.toFixed(2), - topLeft.y.toFixed(2), - bottomRight.x.toFixed(2), - bottomRight.y.toFixed(2), - darkMode ? "1" : "0", - ].join("|"); - } - - private drawGrid( - context: CanvasRenderingContext2D, - width: number, - height: number, - ) { - const { - cellSize, - rows, - cols, - fullCols, - fullRows, - lastColWidth, - lastRowHeight, - hasExtraCol, - hasExtraRow, - gridWidth, - gridHeight, - } = computeGrid(width, height); - const cellWidth = cellSize; - const cellHeight = cellSize; - const canvasWidth = context.canvas.width; - const canvasHeight = context.canvas.height; - - const mapTopScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, 0), - ).y; - const mapBottomScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, height), - ).y; - const mapLeftScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, 0), - ).x; - const mapRightScreenRaw = this.transformHandler.worldToCanvasCoordinates( - new Cell(width, 0), - ).x; - - const mapTopScreen = Math.min(mapTopScreenRaw, mapBottomScreenRaw); - const mapLeftScreen = Math.min(mapLeftScreenRaw, mapRightScreenRaw); - const mapTopWorld = 0; - const mapLeftWorld = 0; - - context.save(); - context.strokeStyle = "rgba(255, 255, 255, 0.35)"; - context.lineWidth = 1.25; - context.beginPath(); - - for (let col = 0; col <= fullCols; col++) { - const worldX = col * cellWidth + mapLeftWorld; - const screenX = this.transformHandler.worldToCanvasCoordinates( - new Cell(worldX, mapTopWorld), - ).x; - if (screenX < -1 || screenX > canvasWidth + 1) continue; - const screenBottom = this.transformHandler.worldToCanvasCoordinates( - new Cell(worldX, gridHeight), - ).y; - context.moveTo(screenX, mapTopScreen); - context.lineTo(screenX, screenBottom); - } - // Final vertical line at map right edge only if grid fits perfectly - if (!hasExtraCol) { - const mapRightLine = this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, mapTopWorld), - ).x; - context.moveTo(mapRightLine, mapTopScreen); - context.lineTo( - mapRightLine, - this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, gridHeight), - ).y, - ); - } - - for (let row = 0; row <= fullRows; row++) { - const worldY = row * cellHeight + mapTopWorld; - const screenY = this.transformHandler.worldToCanvasCoordinates( - new Cell(mapLeftWorld, worldY), - ).y; - if (screenY < -1 || screenY > canvasHeight + 1) continue; - const screenRight = this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, worldY), - ).x; - context.moveTo(mapLeftScreen, screenY); - context.lineTo(screenRight, screenY); - } - // Final horizontal line at map bottom edge only if grid fits perfectly - if (!hasExtraRow) { - const mapBottomLine = this.transformHandler.worldToCanvasCoordinates( - new Cell(mapLeftWorld, gridHeight), - ).y; - context.moveTo(mapLeftScreen, mapBottomLine); - context.lineTo( - this.transformHandler.worldToCanvasCoordinates( - new Cell(gridWidth, gridHeight), - ).x, - mapBottomLine, - ); - } - - context.stroke(); - - context.font = "12px monospace"; - - const isDarkMode = this.game.config().userSettings()?.darkMode() ?? false; - const drawLabel = (text: string, x: number, y: number) => { - context.textAlign = "left"; - context.textBaseline = "top"; - context.fillStyle = isDarkMode - ? "rgba(255, 255, 255, 0.9)" - : "rgba(20, 20, 20, 0.9)"; - context.fillText(text, x, y); - }; - - // Render per-cell labels (like A1, B1, etc.) at cell top-left - const fontSize = Math.min( - 16, - Math.max(9, 10 + (this.transformHandler.scale - 1) * 1.2), - ); - context.font = `${fontSize}px monospace`; - for (let row = 0; row < rows; row++) { - const rowLabel = toAlphaLabel(row); - const startY = row * cellHeight; - const rowHeight = row < fullRows ? cellHeight : lastRowHeight; - const centerY = startY + rowHeight / 2; - const screenY = this.transformHandler.worldToCanvasCoordinates( - new Cell(0, centerY), - ).y; - if (screenY < -LABEL_PADDING || screenY > canvasHeight + LABEL_PADDING) - continue; - - for (let col = 0; col < cols; col++) { - const startX = col * cellWidth; - const colWidth = col < fullCols ? cellWidth : lastColWidth; - const centerX = startX + colWidth / 2; - const screenX = this.transformHandler.worldToCanvasCoordinates( - new Cell(centerX, centerY), - ).x; - if (screenX < -LABEL_PADDING || screenX > canvasWidth + LABEL_PADDING) - continue; - - // Position at cell top-left in screen space - const cellTopLeft = this.transformHandler.worldToCanvasCoordinates( - new Cell(startX, startY), - ); - drawLabel( - `${rowLabel}${col + 1}`, - cellTopLeft.x + LABEL_PADDING, - cellTopLeft.y + LABEL_PADDING, - ); - } - } - - context.restore(); - } -} diff --git a/src/client/graphics/layers/DynamicUILayer.ts b/src/client/graphics/layers/DynamicUILayer.ts deleted file mode 100644 index b151a1b832..0000000000 --- a/src/client/graphics/layers/DynamicUILayer.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { renderNumber } from "src/client/Utils"; -import { EventBus } from "src/core/EventBus"; -import { UnitType } from "src/core/game/Game"; -import { - BonusEventUpdate, - ConquestUpdate, - GameUpdateType, -} from "src/core/game/GameUpdates"; -import type { GameView, UnitView } from "../../../core/game/GameView"; -import { MoveWarshipIntentEvent } from "../../Transport"; -import { TransformHandler } from "../TransformHandler"; -import { MoveIndicatorUI } from "../ui/MoveIndicatorUI"; -import { NavalTarget } from "../ui/NavalTarget"; -import { NukeTelegraph } from "../ui/NukeTelegraph"; -import { TextIndicator } from "../ui/TextIndicator"; -import { UIElement } from "../ui/UIElement"; -import { Layer } from "./Layer"; - -const TEXT_OFFSET_Y = -5; -const TEXT_STACK_SPACING = 8; -const TEXT_DURATION = 2500; - -export class DynamicUILayer implements Layer { - private readonly uiElements: Array = []; - private lastRefresh = Date.now(); - - constructor( - private readonly game: GameView, - private transformHandler: TransformHandler, - private eventBus: EventBus, - ) {} - - init() { - // Listen for warship move clicks for MoveIndicatorUI - this.eventBus.on(MoveWarshipIntentEvent, (e) => { - const x = this.game.x(e.tile); - const y = this.game.y(e.tile); - this.uiElements.push(new MoveIndicatorUI(this.transformHandler, x, y)); - }); - } - - shouldTransform(): boolean { - return false; - } - - tick() { - if (!this.game.config().userSettings()?.fxLayer()) { - return; - } - - const updates = this.game.updatesSinceLastTick(); - if (!updates) return; - - updates[GameUpdateType.Unit]?.forEach((unit) => { - const unitView = this.game.unit(unit.id); - if (!unitView) return; - this.onUnitEvent(unitView); - }); - - updates[GameUpdateType.BonusEvent]?.forEach((bonusEvent) => { - if (bonusEvent === undefined) return; - this.onBonusEvent(bonusEvent); - }); - - updates[GameUpdateType.ConquestEvent]?.forEach((update) => { - if (update === undefined) return; - this.onConquestEvent(update); - }); - } - - onBonusEvent(bonus: BonusEventUpdate) { - // Only display text fx for the current player - if (this.game.player(bonus.player) !== this.game.myPlayer()) { - return; - } - const tile = bonus.tile; - const x = this.game.x(tile); - let y = this.game.y(tile) + TEXT_OFFSET_Y; - const gold = bonus.gold; - const troops = bonus.troops; - - if (gold !== 0) { - this.addNumber(gold, x, y, 1000, 10); - y += TEXT_STACK_SPACING; // increase y so the next popup starts below - } - - if (troops !== 0) { - this.addNumber(troops, x, y, 1000, 10); - } - } - - onConquestEvent(conquest: ConquestUpdate) { - // Only display text for the current player - const conqueror = this.game.player(conquest.conquerorId); - if (conqueror !== this.game.myPlayer()) { - return; - } - const nameLocation = this.game.player(conquest.conqueredId).nameLocation(); - const x = nameLocation.x; - const y = nameLocation.y; - this.addNumber(conquest.gold, x, y + 8, TEXT_DURATION, 0); - } - - onUnitEvent(unit: UnitView) { - switch (unit.type()) { - case UnitType.HydrogenBomb: - case UnitType.AtomBomb: { - this.onBombEvent(unit); - break; - } - case UnitType.TransportShip: { - this.onTransportShipEvent(unit); - break; - } - } - } - - onBombEvent(unit: UnitView) { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return; - } - if ( - this.createdThisTick(unit) && - (unit.owner() === myPlayer || unit.owner().isOnSameTeam(myPlayer)) - ) { - const target = new NukeTelegraph(this.transformHandler, this.game, unit); - this.uiElements.push(target); - } - } - - onTransportShipEvent(unit: UnitView) { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return; - } - if (this.createdThisTick(unit) && unit.owner() === myPlayer) { - const target = new NavalTarget(this.transformHandler, this.game, unit); - this.uiElements.push(target); - } - } - - renderLayer(context: CanvasRenderingContext2D) { - const now = Date.now(); - const dt = now - this.lastRefresh; - this.lastRefresh = now; - if (this.game.config().userSettings()?.fxLayer()) { - this.renderUIElements(context, dt); - } - } - - renderUIElements(context: CanvasRenderingContext2D, delta: number) { - for (let i = this.uiElements.length - 1; i >= 0; i--) { - if (!this.uiElements[i].render(context, delta)) { - this.uiElements.splice(i, 1); - } - } - } - - private createdThisTick(unit: UnitView): boolean { - return unit.createdAt() === this.game.ticks(); - } - - private addNumber( - num: bigint | number, - x: number, - y: number, - duration: number, - riseDistance: number, - ) { - if (BigInt(num) === 0n) return; // Don't show anything for 0 - const absNum = - typeof num === "bigint" ? (num < 0n ? -num : num) : Math.abs(num); - const shortened = renderNumber(absNum, 0); - const sign = num >= 0 ? "+" : "-"; - this.uiElements.push( - new TextIndicator( - this.transformHandler, - `${sign} ${shortened}`, - x, - y, - duration, - riseDistance, - ), - ); - } -} diff --git a/src/client/graphics/layers/EmojiTable.ts b/src/client/graphics/layers/EmojiTable.ts index ef3547e41b..787b837257 100644 --- a/src/client/graphics/layers/EmojiTable.ts +++ b/src/client/graphics/layers/EmojiTable.ts @@ -6,8 +6,8 @@ import { GameView, PlayerView } from "../../../core/game/GameView"; import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler"; +import { TransformHandler } from "../../TransformHandler"; import { SendEmojiIntentEvent } from "../../Transport"; -import { TransformHandler } from "../TransformHandler"; @customElement("emoji-table") export class EmojiTable extends LitElement { diff --git a/src/client/graphics/layers/EventsDisplay.ts b/src/client/graphics/layers/EventsDisplay.ts index cb6eb6211e..96264656b9 100644 --- a/src/client/graphics/layers/EventsDisplay.ts +++ b/src/client/graphics/layers/EventsDisplay.ts @@ -24,21 +24,21 @@ import { TargetPlayerUpdate, UnitIncomingUpdate, } from "../../../core/game/GameUpdates"; +import { Controller } from "../../Controller"; import { SendAllianceExtensionIntentEvent, SendAllianceRejectIntentEvent, SendAllianceRequestIntentEvent, } from "../../Transport"; -import { Layer } from "./Layer"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; import { onlyImages } from "../../../core/Util"; +import { GoToPlayerEvent, GoToUnitEvent } from "../../TransformHandler"; import { renderNumber } from "../../Utils"; -import { GoToPlayerEvent, GoToUnitEvent } from "../TransformHandler"; import { PlaySoundEffectEvent } from "../../sound/Sounds"; +import { UIState } from "../../UIState"; import { getMessageTypeClasses, translateText } from "../../Utils"; -import { UIState } from "../UIState"; const allianceIcon = assetUrl("images/AllianceIconWhite.svg"); const chatIcon = assetUrl("images/ChatIconWhite.svg"); const donateGoldIcon = assetUrl("images/DonateGoldIconWhite.svg"); @@ -68,7 +68,7 @@ interface GameEvent { } @customElement("events-display") -export class EventsDisplay extends LitElement implements Layer { +export class EventsDisplay extends LitElement implements Controller { public eventBus: EventBus; public game: GameView; public uiState: UIState; @@ -359,12 +359,6 @@ export class EventsDisplay extends LitElement implements Layer { ]; } - shouldTransform(): boolean { - return false; - } - - renderLayer(): void {} - private removeAllianceRenewalEvents(allianceID: number) { this.events = this.events.filter( (event) => diff --git a/src/client/graphics/layers/FxLayer.ts b/src/client/graphics/layers/FxLayer.ts deleted file mode 100644 index 22ce78ff5b..0000000000 --- a/src/client/graphics/layers/FxLayer.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { ConquestUpdate, GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, UnitView } from "../../../core/game/GameView"; -import { PlaySoundEffectEvent } from "../../sound/Sounds"; -import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader"; -import { conquestFxFactory } from "../fx/ConquestFx"; -import { Fx, FxType } from "../fx/Fx"; -import { nukeFxFactory, ShockwaveFx } from "../fx/NukeFx"; -import { SpriteFx } from "../fx/SpriteFx"; -import { UnitExplosionFx } from "../fx/UnitExplosionFx"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; -import { RailTileChangedEvent } from "./RailroadLayer"; -export class FxLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - - private lastRefreshMs: number = 0; - private refreshRate: number = 10; - private theme: Theme; - private animatedSpriteLoader: AnimatedSpriteLoader = - new AnimatedSpriteLoader(); - - private allFx: Fx[] = []; - private hasBufferedFrame = false; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) { - this.theme = this.game.config().theme(); - } - - shouldTransform(): boolean { - return true; - } - - private fxEnabled(): boolean { - return this.game.config().userSettings()?.fxLayer() ?? true; - } - - tick() { - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) - ?.forEach((unitView) => { - if (unitView === undefined) return; - this.onUnitEvent(unitView); - }); - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.ConquestEvent]?.forEach((update) => { - if (update === undefined) return; - this.onConquestEvent(update); - }); - } - - onUnitEvent(unit: UnitView) { - // Detect unit creation (launches, warship built) - if (unit.isActive() && unit.createdAt() === this.game.ticks()) { - this.onUnitCreated(unit); - } - - switch (unit.type()) { - case UnitType.AtomBomb: { - this.onNukeEvent(unit, 70); - break; - } - case UnitType.MIRVWarhead: - this.onNukeEvent(unit, 70); - break; - case UnitType.HydrogenBomb: { - this.onNukeEvent(unit, 160); - break; - } - case UnitType.Warship: - this.onWarshipEvent(unit); - break; - case UnitType.Shell: - this.onShellEvent(unit); - break; - case UnitType.Train: - this.onTrainEvent(unit); - break; - case UnitType.DefensePost: - case UnitType.City: - case UnitType.Port: - case UnitType.MissileSilo: - case UnitType.SAMLauncher: - case UnitType.Factory: - this.onStructureEvent(unit); - break; - } - } - - onUnitCreated(unit: UnitView) { - switch (unit.type()) { - case UnitType.AtomBomb: - this.eventBus.emit(new PlaySoundEffectEvent("atom-launch")); - break; - case UnitType.HydrogenBomb: - this.eventBus.emit(new PlaySoundEffectEvent("hydrogen-launch")); - break; - case UnitType.MIRV: - this.eventBus.emit(new PlaySoundEffectEvent("mirv-launch")); - break; - case UnitType.Warship: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-warship")); - } - break; - case UnitType.City: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-city")); - } - break; - case UnitType.Port: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-port")); - } - break; - case UnitType.DefensePost: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("build-defense-post")); - } - break; - case UnitType.SAMLauncher: - if (unit.owner() === this.game.myPlayer()) { - this.eventBus.emit(new PlaySoundEffectEvent("sam-built")); - } - break; - } - } - - onShellEvent(unit: UnitView) { - if (!unit.isActive()) { - if (unit.reachedTarget() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.MiniExplosion, - ); - this.allFx.push(explosion); - } - } - } - - onTrainEvent(unit: UnitView) { - if (!unit.isActive()) { - if (!unit.reachedTarget() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.MiniExplosion, - ); - this.allFx.push(explosion); - } - } - } - - onRailroadEvent(tile: TileRef) { - if (!this.fxEnabled()) return; - // No need for pseudorandom, this is fx - const chanceFx = Math.floor(Math.random() * 3); - if (chanceFx === 0) { - const x = this.game.x(tile); - const y = this.game.y(tile); - const animation = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.Dust, - ); - this.allFx.push(animation); - } - } - - onConquestEvent(conquest: ConquestUpdate) { - // Only display fx for the current player - const conqueror = this.game.player(conquest.conquerorId); - if (conqueror !== this.game.myPlayer()) { - return; - } - - this.eventBus.emit(new PlaySoundEffectEvent("ka-ching")); - - if (this.fxEnabled()) { - this.allFx.push( - conquestFxFactory(this.animatedSpriteLoader, conquest, this.game), - ); - } - } - - onWarshipEvent(unit: UnitView) { - if (!unit.isActive() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const shipExplosion = new UnitExplosionFx( - this.animatedSpriteLoader, - x, - y, - this.game, - ); - this.allFx.push(shipExplosion); - const sinkingShip = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.SinkingShip, - undefined, - unit.owner(), - this.theme, - ); - this.allFx.push(sinkingShip); - } - } - - onStructureEvent(unit: UnitView) { - if (!unit.isActive() && this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.BuildingExplosion, - ); - this.allFx.push(explosion); - } - } - - onNukeEvent(unit: UnitView, radius: number) { - if (!unit.isActive()) { - if (!unit.reachedTarget()) { - this.handleSAMInterception(unit); - } else { - // Kaboom - this.handleNukeExplosion(unit, radius); - } - } - } - - handleNukeExplosion(unit: UnitView, radius: number) { - if (this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const nukeFx = nukeFxFactory( - this.animatedSpriteLoader, - x, - y, - radius, - this.game, - ); - this.allFx = this.allFx.concat(nukeFx); - } - const sound = - unit.type() === UnitType.HydrogenBomb ? "hydrogen-hit" : "atom-hit"; - this.eventBus.emit(new PlaySoundEffectEvent(sound)); - } - - handleSAMInterception(unit: UnitView) { - if (this.fxEnabled()) { - const x = this.game.x(unit.lastTile()); - const y = this.game.y(unit.lastTile()); - const explosion = new SpriteFx( - this.animatedSpriteLoader, - x, - y, - FxType.SAMExplosion, - ); - this.allFx.push(explosion); - const shockwave = new ShockwaveFx(x, y, 800, 40); - this.allFx.push(shockwave); - } - } - - async init() { - this.redraw(); - - this.eventBus.on(RailTileChangedEvent, (e) => { - this.onRailroadEvent(e.tile); - }); - try { - this.animatedSpriteLoader.loadAllAnimatedSpriteImages(); - console.log("FX sprites loaded successfully"); - } catch (err) { - console.error("Failed to load FX sprites:", err); - } - } - - redraw(): void { - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.context.imageSmoothingEnabled = false; - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - } - - renderLayer(context: CanvasRenderingContext2D) { - const nowMs = performance.now(); - - const hasFx = this.allFx.length > 0; - if (!this.game.config().userSettings()?.fxLayer() || !hasFx) { - if (this.hasBufferedFrame) { - // Clear stale pixels once when fx ends/disabled so re-enabling doesn't - // flash old frames. - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.hasBufferedFrame = false; - } - this.lastRefreshMs = nowMs; - return; - } - - const needsRefresh = - !this.hasBufferedFrame || nowMs > this.lastRefreshMs + this.refreshRate; - if (needsRefresh) { - const delta = this.hasBufferedFrame ? nowMs - this.lastRefreshMs : 0; - this.renderAllFx(delta); - this.lastRefreshMs = nowMs; - this.hasBufferedFrame = true; - } - - this.drawVisibleFx(context); - } - - private drawVisibleFx(context: CanvasRenderingContext2D) { - const mapW = this.game.width(); - const mapH = this.game.height(); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const pad = 2; - - const left = Math.max(0, Math.floor(topLeft.x - pad)); - const top = Math.max(0, Math.floor(topLeft.y - pad)); - const right = Math.min(mapW, Math.ceil(bottomRight.x + pad)); - const bottom = Math.min(mapH, Math.ceil(bottomRight.y + pad)); - - const width = Math.max(0, right - left); - const height = Math.max(0, bottom - top); - if (width === 0 || height === 0) return; - - context.drawImage( - this.canvas, - left, - top, - width, - height, - -mapW / 2 + left, - -mapH / 2 + top, - width, - height, - ); - } - - private renderAllFx(delta: number) { - this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); - this.renderContextFx(delta); - } - - renderContextFx(duration: number) { - for (let i = this.allFx.length - 1; i >= 0; i--) { - if (!this.allFx[i].renderTick(duration, this.context)) { - this.allFx.splice(i, 1); - } - } - } -} diff --git a/src/client/graphics/layers/GameLeftSidebar.ts b/src/client/graphics/layers/GameLeftSidebar.ts index 97cad8cc37..a0039e327e 100644 --- a/src/client/graphics/layers/GameLeftSidebar.ts +++ b/src/client/graphics/layers/GameLeftSidebar.ts @@ -5,10 +5,10 @@ import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; import { GameMode, Team } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { Platform } from "../../Platform"; import { getTranslatedPlayerTeamLabel, translateText } from "../../Utils"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; -import { Layer } from "./Layer"; import { SpawnBarVisibleEvent } from "./SpawnTimer"; const leaderboardRegularIcon = assetUrl( "images/LeaderboardIconRegularWhite.svg", @@ -18,7 +18,7 @@ const teamRegularIcon = assetUrl("images/TeamIconRegularWhite.svg"); const teamSolidIcon = assetUrl("images/TeamIconSolidWhite.svg"); @customElement("game-left-sidebar") -export class GameLeftSidebar extends LitElement implements Layer { +export class GameLeftSidebar extends LitElement implements Controller { @state() private isLeaderboardShow = false; @state() diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 6770b0658e..47750465dd 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -4,12 +4,12 @@ import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; import { TogglePauseIntentEvent } from "../../InputHandler"; import { PauseGameIntentEvent, SendWinnerEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; -import { Layer } from "./Layer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; import { ShowSettingsModalEvent } from "./SettingsModal"; import { SpawnBarVisibleEvent } from "./SpawnTimer"; @@ -22,7 +22,7 @@ const fullscreenIcon = assetUrl("images/FullscreenIconWhite.svg"); const exitFullscreenIcon = assetUrl("images/ExitFullscreenIconWhite.svg"); @customElement("game-right-sidebar") -export class GameRightSidebar extends LitElement implements Layer { +export class GameRightSidebar extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 832e5871a3..7f9b95532d 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -3,11 +3,11 @@ import { customElement, state } from "lit/decorators.js"; import { GameType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { translateText } from "../../Utils"; -import { Layer } from "./Layer"; @customElement("heads-up-message") -export class HeadsUpMessage extends LitElement implements Layer { +export class HeadsUpMessage extends LitElement implements Controller { public game: GameView; @state() diff --git a/src/client/graphics/layers/ImmunityTimer.ts b/src/client/graphics/layers/ImmunityTimer.ts index d52baa8de0..4cfefb99c0 100644 --- a/src/client/graphics/layers/ImmunityTimer.ts +++ b/src/client/graphics/layers/ImmunityTimer.ts @@ -3,14 +3,14 @@ import { customElement } from "lit/decorators.js"; import { EventBus, GameEvent } from "../../../core/EventBus"; import { GameMode } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; -import { Layer } from "./Layer"; +import { Controller } from "../../Controller"; export class ImmunityBarVisibleEvent implements GameEvent { constructor(public readonly visible: boolean) {} } @customElement("immunity-timer") -export class ImmunityTimer extends LitElement implements Layer { +export class ImmunityTimer extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; @@ -88,10 +88,6 @@ export class ImmunityTimer extends LitElement implements Layer { } } - shouldTransform(): boolean { - return false; - } - render() { if (!this.isVisible || !this.isActive) { return html``; diff --git a/src/client/graphics/layers/InGamePromo.ts b/src/client/graphics/layers/InGamePromo.ts index cc179ca90c..d26efb85dd 100644 --- a/src/client/graphics/layers/InGamePromo.ts +++ b/src/client/graphics/layers/InGamePromo.ts @@ -1,8 +1,8 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { GameView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { crazyGamesSDK } from "../../CrazyGamesSDK"; -import { Layer } from "./Layer"; const AD_TYPES = [ { type: "standard_iab_left1", selectorId: "in-game-bottom-left-ad" }, @@ -11,7 +11,7 @@ const AD_TYPES = [ ]; @customElement("in-game-promo") -export class InGamePromo extends LitElement implements Layer { +export class InGamePromo extends LitElement implements Controller { public game: GameView; private shouldShow: boolean = false; @@ -169,10 +169,6 @@ export class InGamePromo extends LitElement implements Layer { this.requestUpdate(); } - shouldTransform(): boolean { - return false; - } - render() { if (!this.shouldShow) { return html``; diff --git a/src/client/graphics/layers/Layer.ts b/src/client/graphics/layers/Layer.ts deleted file mode 100644 index 456648f794..0000000000 --- a/src/client/graphics/layers/Layer.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface Layer { - init?: () => void; - tick?: () => void; - // Optional hint to throttle expensive ticks by wall-clock. - // If omitted or <= 0, the layer ticks whenever GameRenderer ticks. - getTickIntervalMs?: () => number; - renderLayer?: (context: CanvasRenderingContext2D) => void; - shouldTransform?: () => boolean; - redraw?: () => void; -} diff --git a/src/client/graphics/layers/Leaderboard.ts b/src/client/graphics/layers/Leaderboard.ts index 3100e88d94..4a805ca20a 100644 --- a/src/client/graphics/layers/Leaderboard.ts +++ b/src/client/graphics/layers/Leaderboard.ts @@ -4,9 +4,9 @@ import { repeat } from "lit/directives/repeat.js"; import { renderTroops, translateText } from "../../../client/Utils"; import { EventBus } from "../../../core/EventBus"; import { GameView, PlayerView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; +import { GoToPlayerEvent } from "../../TransformHandler"; import { formatPercentage, renderNumber } from "../../Utils"; -import { GoToPlayerEvent } from "../TransformHandler"; -import { Layer } from "./Layer"; interface Entry { name: string; @@ -20,7 +20,7 @@ interface Entry { } @customElement("leader-board") -export class Leaderboard extends LitElement implements Layer { +export class Leaderboard extends LitElement implements Controller { public game: GameView | null = null; public eventBus: EventBus | null = null; @@ -157,12 +157,6 @@ export class Leaderboard extends LitElement implements Layer { this.eventBus.emit(new GoToPlayerEvent(player)); } - renderLayer(context: CanvasRenderingContext2D) {} - - shouldTransform(): boolean { - return false; - } - render() { if (!this.visible) { return html``; diff --git a/src/client/graphics/layers/MainRadialMenu.ts b/src/client/graphics/layers/MainRadialMenu.ts index b6adba9291..c615db0d02 100644 --- a/src/client/graphics/layers/MainRadialMenu.ts +++ b/src/client/graphics/layers/MainRadialMenu.ts @@ -5,12 +5,12 @@ import { EventBus } from "../../../core/EventBus"; import { PlayerActions } from "../../../core/game/Game"; import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; +import { Controller } from "../../Controller"; +import { TransformHandler } from "../../TransformHandler"; +import { UIState } from "../../UIState"; import { BuildMenu } from "./BuildMenu"; import { ChatIntegration } from "./ChatIntegration"; import { EmojiTable } from "./EmojiTable"; -import { Layer } from "./Layer"; import { PlayerActionHandler } from "./PlayerActionHandler"; import { PlayerPanel } from "./PlayerPanel"; import { RadialMenu, RadialMenuConfig } from "./RadialMenu"; @@ -26,7 +26,7 @@ const swordIcon = assetUrl("images/SwordIconWhite.svg"); import { ContextMenuEvent } from "../../InputHandler"; @customElement("main-radial-menu") -export class MainRadialMenu extends LitElement implements Layer { +export class MainRadialMenu extends LitElement implements Controller { private radialMenu: RadialMenu; private playerActionHandler: PlayerActionHandler; @@ -173,14 +173,6 @@ export class MainRadialMenu extends LitElement implements Layer { }); } - renderLayer(context: CanvasRenderingContext2D) { - this.radialMenu.renderLayer(context); - } - - shouldTransform(): boolean { - return this.radialMenu.shouldTransform(); - } - closeMenu() { if (this.radialMenu.isMenuVisible()) { this.radialMenu.hideRadialMenu(); diff --git a/src/client/graphics/layers/MultiTabModal.ts b/src/client/graphics/layers/MultiTabModal.ts index f2b5f498e7..1ea12914bb 100644 --- a/src/client/graphics/layers/MultiTabModal.ts +++ b/src/client/graphics/layers/MultiTabModal.ts @@ -4,12 +4,12 @@ import { ClientEnv } from "src/client/ClientEnv"; import { GameEnv } from "../../../core/configuration/Config"; import { GameType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { MultiTabDetector } from "../../MultiTabDetector"; import { translateText } from "../../Utils"; -import { Layer } from "./Layer"; @customElement("multi-tab-modal") -export class MultiTabModal extends LitElement implements Layer { +export class MultiTabModal extends LitElement implements Controller { public game: GameView; private detector: MultiTabDetector; diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts deleted file mode 100644 index ff5741a784..0000000000 --- a/src/client/graphics/layers/NameLayer.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { assetUrl } from "src/core/AssetUrls"; -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { PseudoRandom } from "../../../core/PseudoRandom"; -import { Config } from "../../../core/configuration/Config"; -import { Cell } from "../../../core/game/Game"; -import { GameView, PlayerView } from "../../../core/game/GameView"; -import { UserSettings } from "../../../core/game/UserSettings"; -import { AlternateViewEvent } from "../../InputHandler"; -import { renderTroops } from "../../Utils"; -import { - ALLIANCE_ICON_ID, - AllianceProgressIconRefs, - createAllianceProgressIconRefs, - EMOJI_ICON_KIND, - getFirstPlacePlayer, - getPlayerIcons, - IMAGE_ICON_KIND, - PlayerIconDescriptor, - PlayerIconId, - TRAITOR_ICON_ID, - updateAllianceProgressIconRefs, -} from "../PlayerIcons"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -const PLAYER_NAME = "player-name"; -const PLAYER_NAME_SPAN = "player-name-span"; -const PLAYER_TROOPS = "player-troops"; -const PLAYER_ICONS = "player-icons"; -const PLAYER_FLAG = "player-flag"; - -class RenderInfo { - public icons: Map = new Map(); - public allianceIconRefs: AllianceProgressIconRefs | null = null; - - constructor( - public player: PlayerView, - public lastRenderCalc: number, - public location: Cell | null, - public fontSize: number, - public fontColor: string, - public element: HTMLElement, - public nameDiv: HTMLDivElement, - public nameSpan: HTMLSpanElement, - public troopsDiv: HTMLDivElement, - public flagImg: HTMLImageElement, - public iconsDiv: HTMLDivElement, - public lastTransform: string = "", - ) {} -} - -export class NameLayer implements Layer { - private config: Config; - private lastChecked = 0; - private renderCheckRate = 100; - private renderRefreshRate = 500; - private rand = new PseudoRandom(10); - private renders: RenderInfo[] = []; - private seenPlayers: Set = new Set(); - private container: HTMLDivElement; - private theme: Theme; - private userSettings: UserSettings = new UserSettings(); - private isVisible: boolean = true; - private firstPlace: PlayerView | null = null; - private allianceDuration: number; - private alliancesDisabled: boolean = false; - private myPlayer: PlayerView | null = null; - private lastContainerTransform: string = ""; - private basePlayerTemplate: HTMLDivElement; - private iconTemplate: HTMLImageElement; - private iconCenterTemplate: HTMLImageElement; - private emojiTemplate: HTMLDivElement; - - constructor( - private game: GameView, - private transformHandler: TransformHandler, - private eventBus: EventBus, - ) {} - - shouldTransform(): boolean { - return false; - } - - redraw() {} // not affected by Canvas/WebGL context loss as this layer is DOM-based - - public init() { - this.container = document.createElement("div"); - this.container.style.position = "fixed"; - this.container.style.left = "50%"; - this.container.style.top = "50%"; - this.container.style.pointerEvents = "none"; - this.container.style.zIndex = "2"; - document.body.appendChild(this.container); - - // Add CSS keyframes for traitor icon flashing animation - // Append to container instead of document.head to keep styles scoped to this component - const style = document.createElement("style"); - style.textContent = ` - @keyframes traitorFlash { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.3; - } - } - `; - this.container.appendChild(style); - - this.myPlayer = this.game.myPlayer(); - this.config = this.game.config(); - this.theme = this.config.theme(); - - this.alliancesDisabled = this.config.disableAlliances(); - this.allianceDuration = Math.max(1, this.config.allianceDuration()); - - this.basePlayerTemplate = this.createBasePlayerElement(); - - this.iconTemplate = document.createElement("img"); - - this.iconCenterTemplate = document.createElement("img"); - this.iconCenterTemplate.style.position = "absolute"; - this.iconCenterTemplate.style.top = "50%"; - this.iconCenterTemplate.style.transform = "translateY(-50%)"; - - this.emojiTemplate = document.createElement("div"); - this.emojiTemplate.style.position = "absolute"; - this.emojiTemplate.style.top = "50%"; - this.emojiTemplate.style.transform = "translateY(-50%)"; - - this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); - } - - private onAlternateViewChange(event: AlternateViewEvent) { - this.isVisible = !event.alternateView; - // Update visibility of all name elements immediately - for (const render of this.renders) { - this.updateElementVisibility(render); - } - } - - private updateElementVisibility(render: RenderInfo, baseSize?: number) { - if (!render.player.nameLocation() || !render.player.isAlive()) { - return; - } - - baseSize = - baseSize ?? Math.max(1, Math.floor(render.player.nameLocation().size)); - const size = this.transformHandler.scale * baseSize; - const isOnScreen = render.location - ? this.transformHandler.isOnScreen(render.location) - : false; - const maxZoomScale = 17; - - const display = - !this.isVisible || - size < 7 || - (this.transformHandler.scale > maxZoomScale && size > 100) || - !isOnScreen - ? "none" - : "flex"; - if (render.element.style.display !== display) { - render.element.style.display = display; - } - } - - getTickIntervalMs() { - return 1000; - } - - public tick() { - // Precompute the first-place player for performance - this.firstPlace = getFirstPlacePlayer(this.game); - - for (const player of this.game.playerViews()) { - if (player.isAlive()) { - if (!this.seenPlayers.has(player)) { - this.seenPlayers.add(player); - this.renders.push(this.createPlayerElement(player)); - } - } - } - } - - public renderLayer() { - const screenPosOld = this.transformHandler.worldToScreenCoordinates( - new Cell(0, 0), - ); - const screenPos = new Cell( - screenPosOld.x - window.innerWidth / 2, - screenPosOld.y - window.innerHeight / 2, - ); - const newTransform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`; - if (this.lastContainerTransform !== newTransform) { - this.container.style.transform = newTransform; - this.lastContainerTransform = newTransform; - } - - const now = Date.now(); - if (now > this.lastChecked + this.renderCheckRate) { - this.lastChecked = now; - - this.myPlayer ??= this.game.myPlayer(); - const transitiveTargets = this.myPlayer?.transitiveTargets() ?? []; - - for (const render of this.renders) { - this.renderPlayerInfo(render, transitiveTargets); - } - } - } - - private createBasePlayerElement(): HTMLDivElement { - const element = document.createElement("div"); - element.style.position = "absolute"; - element.style.flexDirection = "column"; - element.style.alignItems = "center"; - element.style.gap = "0px"; - // Start off invisible so it doesn't flash at 0,0 - element.style.display = "none"; - - const iconsDiv = document.createElement("div"); - iconsDiv.classList.add(PLAYER_ICONS); - iconsDiv.style.display = "flex"; - iconsDiv.style.gap = "4px"; - iconsDiv.style.justifyContent = "center"; - iconsDiv.style.alignItems = "center"; - iconsDiv.style.zIndex = "2"; - iconsDiv.style.opacity = "0.8"; - element.appendChild(iconsDiv); - - const nameDiv = document.createElement("div"); - nameDiv.classList.add(PLAYER_NAME); - nameDiv.style.whiteSpace = "nowrap"; - nameDiv.style.textOverflow = "ellipsis"; - nameDiv.style.zIndex = "3"; - nameDiv.style.display = "flex"; - nameDiv.style.justifyContent = "flex-end"; - nameDiv.style.alignItems = "center"; - - const flagImg = document.createElement("img"); - flagImg.classList.add(PLAYER_FLAG); - flagImg.style.opacity = "0.8"; - flagImg.style.zIndex = "1"; - flagImg.style.objectFit = "contain"; - flagImg.style.display = "none"; - nameDiv.appendChild(flagImg); - - const nameSpan = document.createElement("span"); - nameSpan.classList.add(PLAYER_NAME_SPAN); - nameDiv.appendChild(nameSpan); - element.appendChild(nameDiv); - - const troopsDiv = document.createElement("div"); - troopsDiv.classList.add(PLAYER_TROOPS); - troopsDiv.setAttribute("translate", "no"); - troopsDiv.style.zIndex = "3"; - troopsDiv.style.marginTop = "-5%"; - element.appendChild(troopsDiv); - - return element; - } - - private createPlayerElement(player: PlayerView): RenderInfo { - const element = this.basePlayerTemplate.cloneNode(true) as HTMLDivElement; - - // Queryselector expensive but this runs only once per player and better maintainable - const nameDiv = element.querySelector(`.${PLAYER_NAME}`) as HTMLDivElement; - const nameSpan = element.querySelector( - `.${PLAYER_NAME_SPAN}`, - ) as HTMLSpanElement; - const troopsDiv = element.querySelector( - `.${PLAYER_TROOPS}`, - ) as HTMLDivElement; - const flagImg = element.querySelector( - `.${PLAYER_FLAG}`, - ) as HTMLImageElement; - const iconsDiv = element.querySelector( - `.${PLAYER_ICONS}`, - ) as HTMLDivElement; - - const font = this.theme.font(); - nameDiv.style.fontFamily = font; - - const flag = player.cosmetics.flag; - if (flag) { - flagImg.src = assetUrl(flag); - flagImg.style.display = "block"; - } - - const renderInfo = new RenderInfo( - player, - 0, - null, - 0, - "", - element, - nameDiv, - nameSpan, - troopsDiv, - flagImg, - iconsDiv, - ); - - this.container.appendChild(element); - return renderInfo; - } - - renderPlayerInfo(render: RenderInfo, transitiveTargets: PlayerView[]) { - if (!render.player.nameLocation()) { - return; - } - if (!render.player.isAlive()) { - this.renders = this.renders.filter((r) => r !== render); - render.element.remove(); - return; - } - - // Update location and size, show or hide dependent on those - const nameLocation = render.player.nameLocation(); - const newX = nameLocation.x; - const newY = nameLocation.y; - - if ( - !render.location || - render.location.x !== newX || - render.location.y !== newY - ) { - render.location = new Cell(newX, newY); - } - - const baseSize = Math.max(1, Math.floor(nameLocation.size)); - this.updateElementVisibility(render, baseSize); - - if (render.element.style.display === "none") { - return; - } - - // Throttle further updates - const now = Date.now(); - if (now - render.lastRenderCalc <= this.renderRefreshRate) { - return; - } - render.lastRenderCalc = now + this.rand.nextInt(0, 100); - - // Update text sizes, content and color - render.fontSize = Math.max(4, Math.floor(baseSize * 0.4)); - render.nameDiv.style.fontSize = `${render.fontSize}px`; - render.nameDiv.style.lineHeight = `${render.fontSize}px`; - render.flagImg.style.height = `${render.fontSize}px`; - render.troopsDiv.style.fontSize = `${render.fontSize}px`; - - render.nameSpan.textContent = render.player.displayName(); - render.troopsDiv.textContent = renderTroops(render.player.troops()); - - const fontColor = this.theme.textColor(render.player); - if (render.fontColor !== fontColor) { - render.fontColor = fontColor; - render.nameDiv.style.color = fontColor; - render.troopsDiv.style.color = fontColor; - } - - // Handle icons - const iconSize = Math.min(render.fontSize * 1.5, 48); - const darkMode = this.userSettings.darkMode(); - const darkModeStr = darkMode.toString(); - - // Compute which icons should be shown for this player using shared logic - const icons = getPlayerIcons({ - game: this.game, - player: render.player, - includeAllianceIcon: true, - firstPlace: this.firstPlace, - darkMode: darkMode, - alliancesDisabled: this.alliancesDisabled, - transitiveTargets: transitiveTargets, - }); - - // Build a set of desired icon IDs - const desiredIconIds = new Set(icons.map((icon) => icon.id)); - - // Remove any icons that are no longer needed - for (const [id, element] of render.icons) { - if (!desiredIconIds.has(id)) { - if (id === ALLIANCE_ICON_ID) { - render.allianceIconRefs?.wrapper.remove(); - render.allianceIconRefs = null; - render.icons.delete(ALLIANCE_ICON_ID); - } else { - element.remove(); - render.icons.delete(id); - } - } - } - - // Add or update icons that should be shown - for (const icon of icons) { - if (icon.kind === EMOJI_ICON_KIND && icon.text) { - this.handleEmojiIcon(render, icon, iconSize); - continue; - } else if (!(icon.kind === IMAGE_ICON_KIND && icon.src)) { - continue; - } - // Special handling for alliance icon with progress indicator - if (icon.id === ALLIANCE_ICON_ID) { - this.handleAllianceIcons(render, iconSize, darkModeStr); - continue; // Skip regular image handling - } - - const imgElement = this.handleOtherIcons( - render, - icon, - iconSize, - darkModeStr, - ); - - // Traitor flashing - smooth speed increase starting at 15s - if (icon.id === TRAITOR_ICON_ID) { - this.handleTraitorIconFlashing(render.player, imgElement); - } - } - - // Position element with scale - // Don't require nameLocation to be changed: Scale update otherwise sometimes only happens after seconds which looks buggy. - // Because of sometimes overlapping delays of 20 ticks for nameLocation() (largestClusterBoundingBox in PlayerExecution) - // and the 500ms renderRefreshRate in here. - const scale = Math.min(baseSize * 0.25, 3); - const transform = `translate(${newX}px, ${newY}px) translate(-50%, -50%) scale(${scale})`; - if (render.lastTransform !== transform) { - render.element.style.transform = transform; - render.lastTransform = transform; - } - } - - private handleEmojiIcon( - render: RenderInfo, - icon: PlayerIconDescriptor, - size: number, - ) { - let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined; - - if (!emojiDiv) { - emojiDiv = this.emojiTemplate.cloneNode(true) as HTMLDivElement; - render.iconsDiv.appendChild(emojiDiv); - render.icons.set(icon.id, emojiDiv); - } - - emojiDiv.textContent = icon.text ?? ""; - emojiDiv.style.fontSize = `${size}px`; - } - - private handleAllianceIcons( - render: RenderInfo, - size: number, - darkMode: string, - ) { - this.myPlayer ??= this.game.myPlayer(); - const allianceView = this.myPlayer - ?.alliances() - .find((a) => a.other === render.player.id()); - - let fraction = 0; - let hasExtensionRequest = false; - if (allianceView) { - const remaining = Math.max(0, allianceView.expiresAt - this.game.ticks()); - fraction = Math.max(0, Math.min(1, remaining / this.allianceDuration)); - hasExtensionRequest = allianceView.hasExtensionRequest; - } - - if (!render.allianceIconRefs) { - render.allianceIconRefs = createAllianceProgressIconRefs( - size, - fraction, - hasExtensionRequest, - darkMode, - ); - - render.iconsDiv.appendChild(render.allianceIconRefs.wrapper); - render.icons.set(ALLIANCE_ICON_ID, render.allianceIconRefs.wrapper); - } else { - updateAllianceProgressIconRefs( - render.allianceIconRefs, - size, - fraction, - hasExtensionRequest, - darkMode, - ); - } - return; - } - - private handleOtherIcons( - render: RenderInfo, - icon: PlayerIconDescriptor, - size: number, - darkMode: string, - ): HTMLImageElement { - let imgElement = render.icons.get(icon.id) as HTMLImageElement | undefined; - - if (!imgElement) { - imgElement = icon.center - ? (this.iconCenterTemplate.cloneNode(true) as HTMLImageElement) - : (this.iconTemplate.cloneNode(true) as HTMLImageElement); - - imgElement.src = icon.src ?? ""; - imgElement.style.width = `${size}px`; - imgElement.style.height = `${size}px`; - imgElement.setAttribute("dark-mode", darkMode); - render.iconsDiv.appendChild(imgElement); - render.icons.set(icon.id, imgElement); - } else { - // Update src if it changed (e.g., nuke red/white or dark-mode icons) - if (imgElement.src !== icon.src) { - imgElement.src = icon.src ?? ""; - } - - imgElement.style.width = `${size}px`; - imgElement.style.height = `${size}px`; - imgElement.setAttribute("dark-mode", darkMode); - } - return imgElement; - } - - private handleTraitorIconFlashing( - player: PlayerView, - icon: HTMLImageElement, - ) { - const remainingTicks = player.getTraitorRemainingTicks(); - // Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals - const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2; - - if (remainingSeconds <= 15) { - // Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds - // Using cubic ease-out for slower, more gradual acceleration - const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds)); - const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining) - - // Cubic ease-out: slower acceleration, smoother transition - const easedProgress = 1 - Math.pow(1 - normalizedTime, 3); - const maxDuration = 1.0; // Slow flash at 15 seconds - const minDuration = 0.2; // Fast flash at 0 seconds - const duration = - minDuration + (maxDuration - minDuration) * easedProgress; - const animationDuration = `${duration.toFixed(2)}s`; - - icon.style.animation = `traitorFlash ${animationDuration} infinite`; - icon.style.animationTimingFunction = "ease-in-out"; - } else { - // Don't flash if more than 15 seconds remaining - icon.style.animation = "none"; - } - } -} diff --git a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts b/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts deleted file mode 100644 index 447f92d873..0000000000 --- a/src/client/graphics/layers/NukeTrajectoryPreviewLayer.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { EventBus } from "../../../core/EventBus"; -import { listNukeBreakAlliance } from "../../../core/execution/Util"; -import { UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView } from "../../../core/game/GameView"; -import { UniversalPathFinding } from "../../../core/pathfinding/PathFinder"; -import { - GhostStructureChangedEvent, - MouseMoveEvent, - SwapRocketDirectionEvent, -} from "../../InputHandler"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; - -/** - * Layer responsible for rendering the nuke trajectory preview line - * when a nuke type (AtomBomb or HydrogenBomb) is selected and the user hovers over potential targets. - */ -export class NukeTrajectoryPreviewLayer implements Layer { - // Trajectory preview state - private mousePos = { x: 0, y: 0 }; - private trajectoryPoints: TileRef[] = []; - private untargetableSegmentBounds: [number, number] = [-1, -1]; - private targetedIndex = -1; - private lastTrajectoryUpdate: number = 0; - private lastTargetTile: TileRef | null = null; - private currentGhostStructure: UnitType | null = null; - // Cache spawn tile to avoid expensive player.buildables() calls - private cachedSpawnTile: TileRef | null = null; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - private uiState: UIState, - ) {} - - shouldTransform(): boolean { - return true; - } - - init() { - this.eventBus.on(MouseMoveEvent, (e) => { - this.mousePos.x = e.x; - this.mousePos.y = e.y; - }); - this.eventBus.on(GhostStructureChangedEvent, (e) => { - this.currentGhostStructure = e.ghostStructure; - // Clear trajectory if ghost structure changed - if ( - e.ghostStructure !== UnitType.AtomBomb && - e.ghostStructure !== UnitType.HydrogenBomb - ) { - this.trajectoryPoints = []; - this.lastTargetTile = null; - this.cachedSpawnTile = null; - } - }); - this.eventBus.on(SwapRocketDirectionEvent, (event) => { - this.uiState.rocketDirectionUp = event.rocketDirectionUp; - // Force trajectory recalculation - this.lastTargetTile = null; - }); - } - - tick() { - this.updateTrajectoryPreview(); - } - - renderLayer(context: CanvasRenderingContext2D) { - // Update trajectory path each frame for smooth responsiveness - this.updateTrajectoryPath(); - this.drawTrajectoryPreview(context); - } - - /** - * Update trajectory preview - called from tick() to cache spawn tile via expensive player.buildables() call - * This only runs when target tile changes, minimizing worker thread communication - */ - private updateTrajectoryPreview() { - const ghostStructure = this.currentGhostStructure; - const isNukeType = - ghostStructure === UnitType.AtomBomb || - ghostStructure === UnitType.HydrogenBomb; - - // Clear trajectory if not a nuke type - if (!isNukeType) { - this.cachedSpawnTile = null; - return; - } - - // Throttle updates (similar to StructureIconsLayer.renderGhost) - const now = performance.now(); - if (now - this.lastTrajectoryUpdate < 50) { - return; - } - this.lastTrajectoryUpdate = now; - - const player = this.game.myPlayer(); - if (!player) { - this.trajectoryPoints = []; - this.lastTargetTile = null; - this.cachedSpawnTile = null; - return; - } - - // Convert mouse position to world coordinates - const worldCoords = this.transformHandler.screenToWorldCoordinates( - this.mousePos.x, - this.mousePos.y, - ); - - if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { - this.trajectoryPoints = []; - this.lastTargetTile = null; - this.cachedSpawnTile = null; - return; - } - - const targetTile = this.game.ref(worldCoords.x, worldCoords.y); - - // Only recalculate if target tile changed - if (this.lastTargetTile === targetTile) { - return; - } - - this.lastTargetTile = targetTile; - - // Get buildable units to find spawn tile (expensive call - only on tick when tile changes) - player - .buildables(targetTile, [ghostStructure]) - .then((buildables) => { - // Ignore stale results if target changed - if (this.lastTargetTile !== targetTile) { - return; - } - - const buildableUnit = buildables.find( - (bu) => bu.type === ghostStructure, - ); - - if (!buildableUnit || buildableUnit.canBuild === false) { - this.cachedSpawnTile = null; - return; - } - - const spawnTile = buildableUnit.canBuild; - if (!spawnTile) { - this.cachedSpawnTile = null; - return; - } - - // Cache the spawn tile for use in updateTrajectoryPath() - this.cachedSpawnTile = spawnTile; - }) - .catch(() => { - // Handle errors silently - this.cachedSpawnTile = null; - }); - } - - /** - * Update trajectory path - called from renderLayer() each frame for smooth visual feedback - * Uses cached spawn tile to avoid expensive player.buildables() calls - */ - private updateTrajectoryPath() { - const ghostStructure = this.currentGhostStructure; - const isNukeType = - ghostStructure === UnitType.AtomBomb || - ghostStructure === UnitType.HydrogenBomb; - - // Clear trajectory if not a nuke type or no cached spawn tile - if (!isNukeType || !this.cachedSpawnTile) { - this.trajectoryPoints = []; - return; - } - - const player = this.game.myPlayer(); - if (!player) { - this.trajectoryPoints = []; - return; - } - - // Convert mouse position to world coordinates - const worldCoords = this.transformHandler.screenToWorldCoordinates( - this.mousePos.x, - this.mousePos.y, - ); - - if (!this.game.isValidCoord(worldCoords.x, worldCoords.y)) { - this.trajectoryPoints = []; - return; - } - - const targetTile = this.game.ref(worldCoords.x, worldCoords.y); - - // Calculate trajectory using ParabolaUniversalPathFinder with cached spawn tile - const speed = this.game.config().defaultNukeSpeed(); - const pathFinder = UniversalPathFinding.Parabola(this.game, { - increment: speed, - distanceBasedHeight: true, // AtomBomb/HydrogenBomb use distance-based height - directionUp: this.uiState.rocketDirectionUp, - }); - - this.trajectoryPoints = - pathFinder.findPath(this.cachedSpawnTile, targetTile) ?? []; - - // NOTE: This is a lot to do in the rendering method, naive - // But trajectory is already calculated here and needed for prediction. - // From testing, does not seem to have much effect, so I keep it this way. - - // Calculate points when bomb targetability switches - const targetRangeSquared = - this.game.config().defaultNukeTargetableRange() ** 2; - - // Find two switch points where bomb transitions: - // [0]: leaves spawn range, enters untargetable zone - // [1]: enters target range, becomes targetable again - this.untargetableSegmentBounds = [-1, -1]; - for (let i = 0; i < this.trajectoryPoints.length; i++) { - const tile = this.trajectoryPoints[i]; - if (this.untargetableSegmentBounds[0] === -1) { - if ( - this.game.euclideanDistSquared(tile, this.cachedSpawnTile) > - targetRangeSquared - ) { - if ( - this.game.euclideanDistSquared(tile, targetTile) < - targetRangeSquared - ) { - // overlapping spawn & target range - break; - } else { - this.untargetableSegmentBounds[0] = i; - } - } - } else if ( - this.game.euclideanDistSquared(tile, targetTile) < targetRangeSquared - ) { - this.untargetableSegmentBounds[1] = i; - break; - } - } - const playersToBreakAllianceWith = listNukeBreakAlliance({ - game: this.game, - targetTile, - magnitude: this.game.config().nukeMagnitudes(ghostStructure), - threshold: this.game.config().nukeAllianceBreakThreshold(), - }); - // Find the point where SAM can intercept - this.targetedIndex = this.trajectoryPoints.length; - // Check trajectory - for (let i = 0; i < this.trajectoryPoints.length; i++) { - const tile = this.trajectoryPoints[i]; - for (const sam of this.game.nearbyUnits( - tile, - this.game.config().maxSamRange(), - UnitType.SAMLauncher, - )) { - if ( - sam.unit.owner().isMe() || - (this.game.myPlayer()?.isFriendly(sam.unit.owner()) && - !playersToBreakAllianceWith.has(sam.unit.owner().smallID())) - ) { - continue; - } - if ( - sam.distSquared <= - this.game.config().samRange(sam.unit.level()) ** 2 - ) { - this.targetedIndex = i; - break; - } - } - if (this.targetedIndex !== this.trajectoryPoints.length) break; - // Jump over untargetable segment - if (i === this.untargetableSegmentBounds[0]) - i = this.untargetableSegmentBounds[1] - 1; - } - } - - /** - * Draw trajectory preview line on the canvas - */ - private drawTrajectoryPreview(context: CanvasRenderingContext2D) { - const ghostStructure = this.currentGhostStructure; - const isNukeType = - ghostStructure === UnitType.AtomBomb || - ghostStructure === UnitType.HydrogenBomb; - - if (!isNukeType || this.trajectoryPoints.length === 0) { - return; - } - - const player = this.game.myPlayer(); - if (!player) { - return; - } - - // Set of line colors, targeted is after SAM intercept is detected. - const untargetedOutlineColor = "rgba(140, 140, 140, 1)"; - const targetedOutlineColor = "rgba(150, 90, 90, 1)"; - const symbolOutlineColor = "rgba(0, 0, 0, 1)"; - const targetedLocationColor = "rgba(255, 0, 0, 1)"; - const untargetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; - const targetableAndUntargetedLineColor = "rgba(255, 255, 255, 1)"; - const untargetableAndTargetedLineColor = "rgba(255, 80, 80, 1)"; - const targetableAndTargetedLineColor = "rgba(255, 80, 80, 1)"; - - // Set of line widths - const outlineExtraWidth = 1.5; // adds onto below - const lineWidth = 1.25; - const XLineWidth = 2; - const XSize = 6; - - // Set of line dashes - // Outline dashes calculated automatically - const untargetableAndUntargetedLineDash = [2, 6]; - const targetableAndUntargetedLineDash = [8, 4]; - const untargetableAndTargetedLineDash = [2, 6]; - const targetableAndTargetedLineDash = [8, 4]; - - const outlineDash = (dash: number[], extra: number) => { - return [dash[0] + extra, Math.max(dash[1] - extra, 0)]; - }; - - // Tracks the change of color and dash length throughout - let currentOutlineColor = untargetedOutlineColor; - let currentLineColor = targetableAndUntargetedLineColor; - let currentLineDash = targetableAndUntargetedLineDash; - let currentLineWidth = lineWidth; - - // Take in set of "current" parameters and draw both outline and line. - const outlineAndStroke = () => { - context.lineWidth = currentLineWidth + outlineExtraWidth; - context.setLineDash(outlineDash(currentLineDash, outlineExtraWidth)); - context.lineDashOffset = outlineExtraWidth / 2; - context.strokeStyle = currentOutlineColor; - context.stroke(); - context.lineWidth = currentLineWidth; - context.setLineDash(currentLineDash); - context.lineDashOffset = 0; - context.strokeStyle = currentLineColor; - context.stroke(); - }; - const drawUntargetableCircle = (x: number, y: number) => { - context.beginPath(); - context.arc(x, y, 4, 0, 2 * Math.PI, false); - currentOutlineColor = untargetedOutlineColor; - currentLineColor = targetableAndUntargetedLineColor; - currentLineDash = [1, 0]; - outlineAndStroke(); - }; - const drawTargetedX = (x: number, y: number) => { - context.beginPath(); - context.moveTo(x - XSize, y - XSize); - context.lineTo(x + XSize, y + XSize); - context.moveTo(x - XSize, y + XSize); - context.lineTo(x + XSize, y - XSize); - currentOutlineColor = symbolOutlineColor; - currentLineColor = targetedLocationColor; - currentLineDash = [1, 0]; - currentLineWidth = XLineWidth; - outlineAndStroke(); - }; - - // Calculate offset to center coordinates (same as canvas drawing) - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - - context.save(); - context.beginPath(); - - // Draw line connecting trajectory points - for (let i = 0; i < this.trajectoryPoints.length; i++) { - const tile = this.trajectoryPoints[i]; - const x = this.game.x(tile) + offsetX; - const y = this.game.y(tile) + offsetY; - - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - if (i === this.untargetableSegmentBounds[0]) { - outlineAndStroke(); - drawUntargetableCircle(x, y); - context.beginPath(); - if (i >= this.targetedIndex) { - currentOutlineColor = targetedOutlineColor; - currentLineColor = untargetableAndTargetedLineColor; - currentLineDash = untargetableAndTargetedLineDash; - } else { - currentOutlineColor = untargetedOutlineColor; - currentLineColor = untargetableAndUntargetedLineColor; - currentLineDash = untargetableAndUntargetedLineDash; - } - } else if (i === this.untargetableSegmentBounds[1]) { - outlineAndStroke(); - drawUntargetableCircle(x, y); - context.beginPath(); - if (i >= this.targetedIndex) { - currentOutlineColor = targetedOutlineColor; - currentLineColor = targetableAndTargetedLineColor; - currentLineDash = targetableAndTargetedLineDash; - } else { - currentOutlineColor = untargetedOutlineColor; - currentLineColor = targetableAndUntargetedLineColor; - currentLineDash = targetableAndUntargetedLineDash; - } - } - if (i === this.targetedIndex) { - outlineAndStroke(); - drawTargetedX(x, y); - context.beginPath(); - // Always in the targetable zone by definition. - currentOutlineColor = targetedOutlineColor; - currentLineColor = targetableAndTargetedLineColor; - currentLineDash = targetableAndTargetedLineDash; - currentLineWidth = lineWidth; - } - } - - outlineAndStroke(); - context.restore(); - } -} diff --git a/src/client/graphics/layers/PerformanceOverlay.ts b/src/client/graphics/layers/PerformanceOverlay.ts index 9f7fdbe5f4..78871dde53 100644 --- a/src/client/graphics/layers/PerformanceOverlay.ts +++ b/src/client/graphics/layers/PerformanceOverlay.ts @@ -6,6 +6,7 @@ import { USER_SETTINGS_CHANGED_EVENT, UserSettings, } from "../../../core/game/UserSettings"; +import { Controller } from "../../Controller"; import { TickMetricsEvent, TogglePerformanceOverlayEvent, @@ -13,10 +14,9 @@ import { import type { LangSelector } from "../../LangSelector"; import { translateText } from "../../Utils"; import { FrameProfiler } from "../FrameProfiler"; -import { Layer } from "./Layer"; @customElement("performance-overlay") -export class PerformanceOverlay extends LitElement implements Layer { +export class PerformanceOverlay extends LitElement implements Controller { @property({ type: Object }) public eventBus!: EventBus; @@ -82,6 +82,7 @@ export class PerformanceOverlay extends LitElement implements Layer { private fpsHistorySum: number = 0; private lastSecondTime: number = 0; private framesThisSecond: number = 0; + private fpsRafId: number | null = null; private tickExecutionTimes: number[] = []; private tickExecutionTimesSum: number = 0; private tickDelayTimes: number[] = []; @@ -519,6 +520,8 @@ export class PerformanceOverlay extends LitElement implements Layer { disconnectedCallback(): void { super.disconnectedCallback(); + this.stopFpsLoop(); + if (this.isUserSettingsListenerAttached) { globalThis.removeEventListener( `${USER_SETTINGS_CHANGED_EVENT}:${PERFORMANCE_OVERLAY_KEY}`, @@ -561,6 +564,12 @@ export class PerformanceOverlay extends LitElement implements Layer { this.isVisible = visible; FrameProfiler.setEnabled(visible); + if (visible) { + this.startFpsLoop(); + } else { + this.stopFpsLoop(); + } + if (!visible && this.resizeState) { globalThis.removeEventListener("pointermove", this.onResizePointerMove); globalThis.removeEventListener("pointerup", this.onResizePointerUp); @@ -583,6 +592,27 @@ export class PerformanceOverlay extends LitElement implements Layer { this.userSettings.setPerformanceOverlay(nextVisible); } + // FPS measurement runs on its own RAF — the WebGL renderer doesn't expose a + // per-frame hook for the overlay, and starting/stopping with visibility + // keeps the RAF cost off the hot path when the overlay is hidden. + private startFpsLoop(): void { + if (this.fpsRafId !== null) return; + const tick = () => { + this.updateFrameMetrics(0); + this.fpsRafId = requestAnimationFrame(tick); + }; + this.fpsRafId = requestAnimationFrame(tick); + } + + private stopFpsLoop(): void { + if (this.fpsRafId === null) return; + cancelAnimationFrame(this.fpsRafId); + this.fpsRafId = null; + this.lastTime = 0; + this.lastSecondTime = 0; + this.framesThisSecond = 0; + } + private onDragPointerMove = (e: PointerEvent) => { if (!this.dragState || e.pointerId !== this.dragState.pointerId) return; @@ -969,10 +999,6 @@ export class PerformanceOverlay extends LitElement implements Layer { } } - shouldTransform(): boolean { - return false; - } - private getPerformanceColor(fps: number): string { if (fps >= 55) return "performance-good"; if (fps >= 30) return "performance-warning"; diff --git a/src/client/graphics/layers/PlayerActionHandler.ts b/src/client/graphics/layers/PlayerActionHandler.ts index 6e876579cb..085e4d1ce1 100644 --- a/src/client/graphics/layers/PlayerActionHandler.ts +++ b/src/client/graphics/layers/PlayerActionHandler.ts @@ -15,7 +15,7 @@ import { SendSpawnIntentEvent, SendTargetPlayerIntentEvent, } from "../../Transport"; -import { UIState } from "../UIState"; +import { UIState } from "../../UIState"; export class PlayerActionHandler { constructor( diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index e720738f4b..fc2d1e1c50 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -12,11 +12,13 @@ import { import { TileRef } from "../../../core/game/GameMap"; import { AllianceView } from "../../../core/game/GameUpdates"; import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { ContextMenuEvent, MouseMoveEvent, TouchEvent, } from "../../InputHandler"; +import { TransformHandler } from "../../TransformHandler"; import { getTranslatedPlayerTeamLabel, renderDuration, @@ -30,9 +32,7 @@ import { getPlayerIcons, IMAGE_ICON_KIND, } from "../PlayerIcons"; -import { TransformHandler } from "../TransformHandler"; import { ImmunityBarVisibleEvent } from "./ImmunityTimer"; -import { Layer } from "./Layer"; import { CloseRadialMenuEvent } from "./RadialMenu"; import "./RelationSmiley"; import { SpawnBarVisibleEvent } from "./SpawnTimer"; @@ -68,7 +68,7 @@ function distSortUnitWorld(coord: { x: number; y: number }, game: GameView) { } @customElement("player-info-overlay") -export class PlayerInfoOverlay extends LitElement implements Layer { +export class PlayerInfoOverlay extends LitElement implements Controller { @property({ type: Object }) public game!: GameView; @@ -171,14 +171,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer { this.requestUpdate(); } - renderLayer(context: CanvasRenderingContext2D) { - // Implementation for Layer interface - } - - shouldTransform(): boolean { - return false; - } - setVisible(visible: boolean) { this._isInfoVisible = visible; this.requestUpdate(); diff --git a/src/client/graphics/layers/PlayerPanel.ts b/src/client/graphics/layers/PlayerPanel.ts index 6a60675d20..f872f81405 100644 --- a/src/client/graphics/layers/PlayerPanel.ts +++ b/src/client/graphics/layers/PlayerPanel.ts @@ -15,6 +15,7 @@ import { GameView, PlayerView } from "../../../core/game/GameView"; import { Emoji, flattenedEmojiTable } from "../../../core/Util"; import { actionButton } from "../../components/ui/ActionButton"; import "../../components/ui/Divider"; +import { Controller } from "../../Controller"; import { CloseViewEvent, MouseUpEvent, @@ -28,16 +29,15 @@ import { SendEmojiIntentEvent, SendTargetPlayerIntentEvent, } from "../../Transport"; +import { UIState } from "../../UIState"; import { renderDuration, renderNumber, renderTroops, translateText, } from "../../Utils"; -import { UIState } from "../UIState"; import { ChatModal } from "./ChatModal"; import { EmojiTable } from "./EmojiTable"; -import { Layer } from "./Layer"; import "./PlayerModerationModal"; import "./SendResourceModal"; const allianceIcon = assetUrl("images/AllianceIconWhite.svg"); @@ -53,7 +53,7 @@ const traitorIcon = assetUrl("images/TraitorIconLightRed.svg"); const breakAllianceIcon = assetUrl("images/TraitorIconWhite.svg"); @customElement("player-panel") -export class PlayerPanel extends LitElement implements Layer { +export class PlayerPanel extends LitElement implements Controller { public g: GameView; public eventBus: EventBus; public emojiTable: EmojiTable; @@ -410,7 +410,7 @@ export class PlayerPanel extends LitElement implements Layer { } private getTraitorRemainingSeconds(player: PlayerView): number | null { - const ticksLeft = player.data.traitorRemainingTicks ?? 0; + const ticksLeft = player.getTraitorRemainingTicks(); if (!player.isTraitor() || ticksLeft <= 0) return null; return Math.ceil(ticksLeft / 10); // 10 ticks = 1 second } @@ -608,7 +608,7 @@ export class PlayerPanel extends LitElement implements Layer { ${translateText("player_panel.betrayals")}
- ${other.data.betrayals ?? 0} + ${other.betrayals()}
diff --git a/src/client/graphics/layers/RadialMenu.ts b/src/client/graphics/layers/RadialMenu.ts index 8e65357e6d..740a0b90e4 100644 --- a/src/client/graphics/layers/RadialMenu.ts +++ b/src/client/graphics/layers/RadialMenu.ts @@ -1,10 +1,10 @@ import * as d3 from "d3"; import { assetUrl } from "../../../core/AssetUrls"; import { EventBus, GameEvent } from "../../../core/EventBus"; +import { Controller } from "../../Controller"; import { CloseViewEvent } from "../../InputHandler"; import { PlaySoundEffectEvent } from "../../sound/Sounds"; import { getSvgAspectRatio, translateText } from "../../Utils"; -import { Layer } from "./Layer"; import { CenterButtonElement, MenuElement, @@ -51,7 +51,7 @@ type CenterButtonState = "default" | "back"; type RequiredRadialMenuConfig = Required; -export class RadialMenu implements Layer { +export class RadialMenu implements Controller { private menuElement: d3.Selection; private tooltipElement: HTMLDivElement | null = null; private isVisible: boolean = false; @@ -1341,14 +1341,6 @@ export class RadialMenu implements Layer { }); } - renderLayer(context: CanvasRenderingContext2D) { - // No need to render anything on the canvas - } - - shouldTransform(): boolean { - return false; - } - private isReopeningAllowed(): boolean { const now = Date.now(); const timeSinceHide = now - this.lastHideTime; diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts index 14e87c5e55..b29ea6791c 100644 --- a/src/client/graphics/layers/RadialMenuElements.ts +++ b/src/client/graphics/layers/RadialMenuElements.ts @@ -11,8 +11,8 @@ import { import { TileRef } from "../../../core/game/GameMap"; import { GameView, PlayerView } from "../../../core/game/GameView"; import { Emoji, findClosestBy, flattenedEmojiTable } from "../../../core/Util"; +import { UIState } from "../../UIState"; import { renderNumber, translateText } from "../../Utils"; -import { UIState } from "../UIState"; import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu"; import { ChatIntegration } from "./ChatIntegration"; import { EmojiTable } from "./EmojiTable"; diff --git a/src/client/graphics/layers/RailroadLayer.ts b/src/client/graphics/layers/RailroadLayer.ts deleted file mode 100644 index 367c77a27d..0000000000 --- a/src/client/graphics/layers/RailroadLayer.ts +++ /dev/null @@ -1,501 +0,0 @@ -import { colord } from "colord"; -import { EventBus, GameEvent } from "../../../core/EventBus"; -import { PlayerID, UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { - GameUpdateType, - RailroadConstructionUpdate, - RailroadDestructionUpdate, - RailroadSnapUpdate, -} from "../../../core/game/GameUpdates"; -import { GameView } from "../../../core/game/GameView"; -import { AlternateViewEvent } from "../../InputHandler"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; -import { getBridgeRects, getRailroadRects } from "./RailroadSprites"; -import { - computeRailTiles, - RailroadView, - RailTile, - RailType, -} from "./RailroadView"; - -type RailRef = { - tile: RailTile; - numOccurence: number; - lastOwnerId: PlayerID | null; -}; -const SNAPPABLE_STRUCTURES: UnitType[] = [ - UnitType.Port, - UnitType.City, - UnitType.Factory, -]; -export class RailTileChangedEvent implements GameEvent { - constructor(public tile: TileRef) {} -} - -export class RailroadLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private alternativeView = false; - // Save the number of railroads per tiles. Delete when it reaches 0 - private existingRailroads = new Map(); - private railroads = new Map(); - // Railroads under construction - private pendingRailroads = new Set(); - private nextRailIndexToCheck = 0; - private railTileList: TileRef[] = []; - private railTileIndex = new Map(); - private lastRailColorUpdate = 0; - private readonly railColorIntervalMs = 50; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - private uiState: UIState, - ) {} - - shouldTransform(): boolean { - return true; - } - - tick() { - this.updatePendingRailroads(); - const updates = this.game.updatesSinceLastTick(); - if (!updates) return; - // The event has to be handled in this specific order: construction / snap / destruction - // Otherwise some ID may not be available yet/anymore - updates[GameUpdateType.RailroadConstructionEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadConstruction(update); - }); - updates[GameUpdateType.RailroadSnapEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadSnapEvent(update); - }); - updates[GameUpdateType.RailroadDestructionEvent]?.forEach((update) => { - if (update === undefined) return; - this.onRailroadDestruction(update); - }); - } - - updatePendingRailroads() { - for (const id of this.pendingRailroads) { - const pending = this.railroads.get(id); - if (pending === undefined) { - // Rail deleted or snapped before the end of the animation - this.pendingRailroads.delete(id); - continue; - } - const newTiles = pending.tick(); - if (newTiles.length === 0) { - // Animation complete - this.pendingRailroads.delete(id); - continue; - } - - for (const railTile of newTiles) { - this.paintRailTile(railTile); - this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); - } - } - } - - updateRailColors() { - if (this.railTileList.length === 0) { - return; - } - // Throttle color checks so we do not re-evaluate on every frame - const now = performance.now(); - if (now - this.lastRailColorUpdate < this.railColorIntervalMs) { - return; - } - this.lastRailColorUpdate = now; - - // Spread work over multiple frames to avoid large bursts when many rails exist - const maxTilesPerFrame = Math.max( - 1, - Math.ceil(this.railTileList.length / 120), - ); - let checked = 0; - - while (checked < maxTilesPerFrame && this.railTileList.length > 0) { - const tile = this.railTileList[this.nextRailIndexToCheck]; - const railRef = this.existingRailroads.get(tile); - if (railRef) { - const currentOwner = this.game.owner(tile)?.id() ?? null; - if (railRef.lastOwnerId !== currentOwner) { - // Repaint only when the owner changed to keep colors in sync - railRef.lastOwnerId = currentOwner; - this.paintRail(railRef.tile); - } - } - - this.nextRailIndexToCheck = - (this.nextRailIndexToCheck + 1) % this.railTileList.length; - checked++; - } - } - - init() { - this.eventBus.on(AlternateViewEvent, (e) => { - this.alternativeView = e.alternateView; - for (const { tile } of this.existingRailroads.values()) { - this.paintRail(tile); - } - }); - this.redraw(); - } - - redraw() { - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d", { alpha: true }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - - // Firefox's GPU limit is 8192, only known browser issue - const maxTextureSize = 8192; - const scaleX = maxTextureSize / this.game.width(); - const scaleY = maxTextureSize / this.game.height(); - const targetScale = Math.min(2, scaleX, scaleY); - - this.canvas.width = Math.max( - 1, - Math.floor(this.game.width() * targetScale), - ); - this.canvas.height = Math.max( - 1, - Math.floor(this.game.height() * targetScale), - ); - - // Enable smooth scaling - this.context.imageSmoothingEnabled = true; - this.context.imageSmoothingQuality = "high"; - - // Scale context so existing *2 rendering math continues to work automatically - this.context.scale( - this.canvas.width / (this.game.width() * 2), - this.canvas.height / (this.game.height() * 2), - ); - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for (const [_, rail] of this.existingRailroads) { - this.paintRail(rail.tile); - } - } - - private highlightOverlappingRailroads(context: CanvasRenderingContext2D) { - if ( - this.uiState.ghostStructure === null || - !SNAPPABLE_STRUCTURES.includes(this.uiState.ghostStructure) - ) - return; - if ( - this.uiState.overlappingRailroads === undefined || - this.uiState.overlappingRailroads.length === 0 - ) - return; - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - context.fillStyle = "rgba(0, 255, 0, 0.4)"; - for (const id of this.uiState.overlappingRailroads) { - const rail = this.railroads.get(id); - if (rail) { - for (const railTile of rail.drawnTiles()) { - const x = this.game.x(railTile.tile); - const y = this.game.y(railTile.tile); - context.fillRect(x + offsetX - 1, y + offsetY - 1, 2.5, 2.5); - } - } - } - } - - renderLayer(context: CanvasRenderingContext2D) { - const scale = this.transformHandler.scale; - if (scale <= 1) { - return; - } - this.updateRailColors(); - const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1 - const alpha = Math.max(0, Math.min(1, rawAlpha)); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const padding = 2; // small margin so edges do not pop - const visLeft = Math.max(0, topLeft.x - padding); - const visTop = Math.max(0, topLeft.y - padding); - const visRight = Math.min(this.game.width(), bottomRight.x + padding); - const visBottom = Math.min(this.game.height(), bottomRight.y + padding); - const visWidth = Math.max(0, visRight - visLeft); - const visHeight = Math.max(0, visBottom - visTop); - if (visWidth === 0 || visHeight === 0) { - return; - } - - const actualScaleX = this.canvas.width / this.game.width(); - const actualScaleY = this.canvas.height / this.game.height(); - - const srcX = visLeft * actualScaleX; - const srcY = visTop * actualScaleY; - const srcW = visWidth * actualScaleX; - const srcH = visHeight * actualScaleY; - - const dstX = -this.game.width() / 2 + visLeft; - const dstY = -this.game.height() / 2 + visTop; - - context.save(); - context.globalAlpha = alpha; - - this.renderGhostRailroads(context); - - if (this.existingRailroads.size > 0) { - this.highlightOverlappingRailroads(context); - - context.drawImage( - this.canvas, - srcX, - srcY, - srcW, - srcH, - dstX, - dstY, - visWidth, - visHeight, - ); - } - - context.restore(); - } - - private renderGhostRailroads(context: CanvasRenderingContext2D) { - if ( - this.uiState.ghostStructure !== UnitType.City && - this.uiState.ghostStructure !== UnitType.Port - ) - return; - if (this.uiState.ghostRailPaths.length === 0) return; - - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - context.fillStyle = "rgba(0, 0, 0, 0.4)"; - - for (const path of this.uiState.ghostRailPaths) { - const railTiles = computeRailTiles(this.game, path); - for (const railTile of railTiles) { - const x = this.game.x(railTile.tile); - const y = this.game.y(railTile.tile); - - if (this.game.isWater(railTile.tile)) { - context.save(); - context.fillStyle = "rgba(197, 69, 72, 0.4)"; - const bridgeRects = getBridgeRects(railTile.type); - for (const [dx, dy, w, h] of bridgeRects) { - context.fillRect( - x + offsetX + dx / 2, - y + offsetY + dy / 2, - w / 2, - h / 2, - ); - } - context.restore(); - } - - const railRects = getRailroadRects(railTile.type); - for (const [dx, dy, w, h] of railRects) { - context.fillRect( - x + offsetX + dx / 2, - y + offsetY + dy / 2, - w / 2, - h / 2, - ); - } - } - } - } - - private onRailroadSnapEvent(update: RailroadSnapUpdate) { - const original = this.railroads.get(update.originalId); - if (!original) { - console.warn("Could not snap railroad: ", update.originalId); - return; - } - if (!original.isComplete()) { - // The animation is not complete but we don't want to compute where the animation should resume - // Just draw every remaining rails at once - this.drawRemainingTiles(original); - } - - // No need to compute the directions here, the rails are already painted - const directions1: RailTile[] = update.tiles1.map((tile) => ({ - tile, - type: RailType.HORIZONTAL, - })); - const directions2: RailTile[] = update.tiles2.map((tile) => ({ - tile, - type: RailType.HORIZONTAL, - })); - // The rails are already painted, consider them complete - this.railroads.set( - update.newId1, - new RailroadView(update.newId1, directions1, true), - ); - this.railroads.set( - update.newId2, - new RailroadView(update.newId2, directions2, true), - ); - - this.railroads.delete(update.originalId); - } - - private drawRemainingTiles(railroad: RailroadView) { - for (const tile of railroad.remainingTiles()) { - this.paintRail(tile); - } - this.pendingRailroads.delete(railroad.id); - } - - private onRailroadConstruction(railUpdate: RailroadConstructionUpdate) { - const railTiles = computeRailTiles(this.game, railUpdate.tiles); - const rail = new RailroadView(railUpdate.id, railTiles); - this.addRailroad(rail); - } - - private onRailroadDestruction(railUpdate: RailroadDestructionUpdate) { - const railroad = this.railroads.get(railUpdate.id); - if (!railroad) { - console.warn("Can't remove unexisting railroad: ", railUpdate.id); - return; - } - this.removeRailroad(railroad); - } - - private addRailroad(railroad: RailroadView) { - this.railroads.set(railroad.id, railroad); - this.pendingRailroads.add(railroad.id); - } - - private removeRailroad(railroad: RailroadView) { - this.pendingRailroads.delete(railroad.id); - for (const railTile of railroad.drawnTiles()) { - this.clearRailroad(railTile.tile); - this.eventBus.emit(new RailTileChangedEvent(railTile.tile)); - } - this.railroads.delete(railroad.id); - } - - private paintRailTile(railTile: RailTile) { - const currentOwner = this.game.owner(railTile.tile)?.id() ?? null; - const railRef = this.existingRailroads.get(railTile.tile); - - if (railRef) { - railRef.numOccurence++; - railRef.tile = railTile; - railRef.lastOwnerId = currentOwner; - } else { - this.existingRailroads.set(railTile.tile, { - tile: railTile, - numOccurence: 1, - lastOwnerId: currentOwner, - }); - this.railTileIndex.set(railTile.tile, this.railTileList.length); - this.railTileList.push(railTile.tile); - this.paintRail(railTile); - } - } - - private clearRailroad(railroad: TileRef) { - const ref = this.existingRailroads.get(railroad); - if (ref) ref.numOccurence--; - - if (!ref || ref.numOccurence <= 0) { - this.existingRailroads.delete(railroad); - this.removeRailTile(railroad); - if (this.context === undefined) throw new Error("Not initialized"); - if (this.game.isWater(railroad)) { - this.context.clearRect( - this.game.x(railroad) * 2 - 2, - this.game.y(railroad) * 2 - 2, - 5, - 6, - ); - } else { - this.context.clearRect( - this.game.x(railroad) * 2 - 1, - this.game.y(railroad) * 2 - 1, - 3, - 3, - ); - } - } - } - - private removeRailTile(tile: TileRef) { - const idx = this.railTileIndex.get(tile); - if (idx === undefined) return; - - const lastIndex = this.railTileList.length - 1; - const lastTile = this.railTileList[lastIndex]; - - this.railTileList[idx] = lastTile; - this.railTileIndex.set(lastTile, idx); - - this.railTileList.pop(); - this.railTileIndex.delete(tile); - - if (this.nextRailIndexToCheck >= this.railTileList.length) { - this.nextRailIndexToCheck = 0; - } - } - - paintRail(railTile: RailTile) { - if (this.context === undefined) throw new Error("Not initialized"); - const { tile } = railTile; - const { type } = railTile; - const x = this.game.x(tile); - const y = this.game.y(tile); - // If rail tile is over water, paint a bridge underlay first - if (this.game.isWater(tile)) { - this.paintBridge(this.context, x, y, type); - } - const owner = this.game.owner(tile); - const recipient = owner.isPlayer() ? owner : null; - let color = recipient - ? recipient.borderColor() - : colord("rgba(255,255,255,1)"); - - if (this.alternativeView && recipient?.isMe()) { - color = colord("#00ff00"); - } - - this.context.fillStyle = color.toRgbString(); - this.paintRailRects(this.context, x, y, type); - } - - private paintRailRects( - context: CanvasRenderingContext2D, - x: number, - y: number, - direction: RailType, - ) { - const railRects = getRailroadRects(direction); - for (const [dx, dy, w, h] of railRects) { - context.fillRect(x * 2 + dx, y * 2 + dy, w, h); - } - } - - private paintBridge( - context: CanvasRenderingContext2D, - x: number, - y: number, - direction: RailType, - ) { - context.save(); - context.fillStyle = "rgb(197,69,72)"; - const bridgeRects = getBridgeRects(direction); - for (const [dx, dy, w, h] of bridgeRects) { - context.fillRect(x * 2 + dx, y * 2 + dy, w, h); - } - context.restore(); - } -} diff --git a/src/client/graphics/layers/RailroadSprites.ts b/src/client/graphics/layers/RailroadSprites.ts deleted file mode 100644 index 8572ae68fe..0000000000 --- a/src/client/graphics/layers/RailroadSprites.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { RailType } from "./RailroadView"; - -const railTypeToFunctionMap: Record number[][]> = { - [RailType.TOP_RIGHT]: topRightRailroadCornerRects, - [RailType.BOTTOM_LEFT]: bottomLeftRailroadCornerRects, - [RailType.TOP_LEFT]: topLeftRailroadCornerRects, - [RailType.BOTTOM_RIGHT]: bottomRightRailroadCornerRects, - [RailType.HORIZONTAL]: horizontalRailroadRects, - [RailType.VERTICAL]: verticalRailroadRects, -}; - -const railTypeToBridgeFunctionMap: Record number[][]> = { - [RailType.TOP_RIGHT]: topRightBridgeCornerRects, - [RailType.BOTTOM_LEFT]: bottomLeftBridgeCornerRects, - [RailType.TOP_LEFT]: topLeftBridgeCornerRects, - [RailType.BOTTOM_RIGHT]: bottomRightBridgeCornerRects, - [RailType.HORIZONTAL]: horizontalBridge, - [RailType.VERTICAL]: verticalBridge, -}; - -export function getRailroadRects(type: RailType): number[][] { - const railRects = railTypeToFunctionMap[type]; - if (!railRects) { - // Should never happen - throw new Error(`Unsupported RailType: ${type}`); - } - return railRects(); -} - -function horizontalRailroadRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 2, 1], - [-1, 1, 2, 1], - [-1, 0, 1, 1], - ]; - return rects; -} - -function verticalRailroadRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 2], - [1, -1, 1, 2], - [0, 0, 1, 1], - ]; - return rects; -} - -function topRightRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 1], - [0, -1, 1, 2], - [1, -1, 1, 3], - ]; - return rects; -} - -function topLeftRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 3], - [0, -1, 1, 2], - [1, -1, 1, 1], - ]; - return rects; -} - -function bottomRightRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, 1, 1, 1], - [0, 0, 1, 2], - [1, -1, 1, 3], - ]; - return rects; -} - -function bottomLeftRailroadCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-1, -1, 1, 3], - [0, 0, 1, 2], - [1, 1, 1, 1], - ]; - return rects; -} - -export function getBridgeRects(type: RailType): number[][] { - const bridgeRects = railTypeToBridgeFunctionMap[type]; - if (!bridgeRects) { - // Should never happen - throw new Error(`Unsupported RailType: ${type}`); - } - return bridgeRects(); -} - -function horizontalBridge(): number[][] { - // x/y/w/h - return [ - [-1, -2, 3, 1], - [-1, 2, 3, 1], - [-1, 3, 1, 1], - [1, 3, 1, 1], - ]; -} - -function verticalBridge(): number[][] { - // x/y/w/h - return [ - [-2, -1, 1, 3], - [2, -1, 1, 3], - ]; -} -// ⌞ -function topRightBridgeCornerRects(): number[][] { - return [ - [-2, -2, 1, 2], - [-1, 0, 1, 1], - [0, 1, 1, 1], - [1, 2, 2, 1], - [2, -2, 1, 1], - ]; -} -// ⌝ -function bottomLeftBridgeCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-2, -2, 2, 1], - [0, -1, 1, 1], - [1, 0, 1, 1], - [2, 1, 1, 2], - [-2, 2, 1, 1], - ]; - return rects; -} -// ⌟ -function topLeftBridgeCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-2, -2, 1, 1], - [-2, 2, 2, 1], - [0, 1, 1, 1], - [1, 0, 1, 1], - [2, -2, 1, 2], - ]; - return rects; -} -// ⌜ -function bottomRightBridgeCornerRects(): number[][] { - // x/y/w/h - const rects = [ - [-2, 1, 1, 2], - [-1, 0, 1, 1], - [0, -1, 1, 1], - [1, -2, 2, 1], - [2, 2, 1, 1], - ]; - return rects; -} diff --git a/src/client/graphics/layers/RailroadView.ts b/src/client/graphics/layers/RailroadView.ts deleted file mode 100644 index 2f690965dd..0000000000 --- a/src/client/graphics/layers/RailroadView.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { TileRef } from "../../../core/game/GameMap"; -import { GameView } from "../../../core/game/GameView"; - -export enum RailType { - VERTICAL, - HORIZONTAL, - TOP_LEFT, - TOP_RIGHT, - BOTTOM_LEFT, - BOTTOM_RIGHT, -} - -export type RailTile = { - tile: TileRef; - type: RailType; -}; - -export function computeRailTiles(game: GameView, tiles: TileRef[]): RailTile[] { - if (tiles.length === 0) return []; - if (tiles.length === 1) { - return [{ tile: tiles[0], type: RailType.VERTICAL }]; - } - const railTypes: RailTile[] = []; - // Inverse direction computation for the first tile - railTypes.push({ - tile: tiles[0], - type: computeExtremityDirection(game, tiles[0], tiles[1]), - }); - for (let i = 1; i < tiles.length - 1; i++) { - const direction = computeDirection( - game, - tiles[i - 1], - tiles[i], - tiles[i + 1], - ); - railTypes.push({ tile: tiles[i], type: direction }); - } - railTypes.push({ - tile: tiles[tiles.length - 1], - type: computeExtremityDirection( - game, - tiles[tiles.length - 1], - tiles[tiles.length - 2], - ), - }); - return railTypes; -} - -function computeExtremityDirection( - game: GameView, - tile: TileRef, - next: TileRef, -): RailType { - const x = game.x(tile); - const y = game.y(tile); - const nextX = game.x(next); - const nextY = game.y(next); - - const dx = nextX - x; - const dy = nextY - y; - - if (dx === 0 && dy === 0) return RailType.VERTICAL; // No movement - - if (dx === 0) { - return RailType.VERTICAL; - } else if (dy === 0) { - return RailType.HORIZONTAL; - } - return RailType.VERTICAL; -} - -export function computeDirection( - game: GameView, - prev: TileRef, - current: TileRef, - next: TileRef, -): RailType { - const x1 = game.x(prev); - const y1 = game.y(prev); - const x2 = game.x(current); - const y2 = game.y(current); - const x3 = game.x(next); - const y3 = game.y(next); - - const dx1 = x2 - x1; - const dy1 = y2 - y1; - const dx2 = x3 - x2; - const dy2 = y3 - y2; - - // Straight line - if (dx1 === dx2 && dy1 === dy2) { - if (dx1 !== 0) return RailType.HORIZONTAL; - if (dy1 !== 0) return RailType.VERTICAL; - } - - // Turn (corner) cases - if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) { - // Now figure out which type of corner - if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT; - if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT; - if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT; - if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT; - - if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT; - if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT; - if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT; - if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT; - } - return RailType.VERTICAL; -} - -/** - * A list of tile that can be incrementally painted each tick - */ -export class RailroadView { - private headIndex: number = 0; - private tailIndex: number; - private increment: number = 3; - constructor( - public id: number, - private railTiles: RailTile[], - complete: boolean = false, - ) { - // If the railroad is considered complete, no drawing or animation is required - this.tailIndex = complete ? 0 : railTiles.length; - } - - isComplete(): boolean { - return this.headIndex >= this.tailIndex; - } - - tiles(): RailTile[] { - return this.railTiles; - } - - remainingTiles(): RailTile[] { - if (this.isComplete()) { - // Animation complete, no tiles need to be painted - return []; - } - return this.railTiles.slice(this.headIndex, this.tailIndex); - } - - drawnTiles(): RailTile[] { - if (this.isComplete()) { - // Animation complete, every tiles have been painted - return this.tiles(); - } - let drawnTiles = this.railTiles.slice(0, this.headIndex); - drawnTiles = drawnTiles.concat(this.railTiles.slice(this.tailIndex)); - return drawnTiles; - } - - tick(): RailTile[] { - if (this.isComplete()) return []; - let updatedRailTiles: RailTile[]; - // Check if remaining tiles can be done all at once - if (this.tailIndex - this.headIndex <= 2 * this.increment) { - updatedRailTiles = this.railTiles.slice(this.headIndex, this.tailIndex); - } else { - updatedRailTiles = [ - ...this.railTiles.slice( - this.headIndex, - this.headIndex + this.increment, - ), - ...this.railTiles.slice( - this.tailIndex - this.increment, - this.tailIndex, - ), - ]; - } - this.headIndex = Math.min(this.headIndex + this.increment, this.tailIndex); - this.tailIndex = Math.max(this.tailIndex - this.increment, this.headIndex); - return updatedRailTiles; - } -} diff --git a/src/client/graphics/layers/ReplayPanel.ts b/src/client/graphics/layers/ReplayPanel.ts index ea48bbb609..390e570923 100644 --- a/src/client/graphics/layers/ReplayPanel.ts +++ b/src/client/graphics/layers/ReplayPanel.ts @@ -2,13 +2,13 @@ import { html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { ReplaySpeedChangeEvent } from "../../InputHandler"; import { defaultReplaySpeedMultiplier, ReplaySpeedMultiplier, } from "../../utilities/ReplaySpeedMultiplier"; import { translateText } from "../../Utils"; -import { Layer } from "./Layer"; export class ShowReplayPanelEvent { constructor( @@ -18,7 +18,7 @@ export class ShowReplayPanelEvent { } @customElement("replay-panel") -export class ReplayPanel extends LitElement implements Layer { +export class ReplayPanel extends LitElement implements Controller { public game: GameView | undefined; public eventBus: EventBus | undefined; @@ -65,11 +65,6 @@ export class ReplayPanel extends LitElement implements Layer { this.eventBus?.emit(new ReplaySpeedChangeEvent(value)); } - renderLayer(_ctx: CanvasRenderingContext2D) {} - shouldTransform() { - return false; - } - render() { if (!this.visible) return html``; diff --git a/src/client/graphics/layers/SAMRadiusLayer.ts b/src/client/graphics/layers/SAMRadiusLayer.ts deleted file mode 100644 index a7f0da170e..0000000000 --- a/src/client/graphics/layers/SAMRadiusLayer.ts +++ /dev/null @@ -1,334 +0,0 @@ -import type { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import type { - GameView, - PlayerView, - UnitView, -} from "../../../core/game/GameView"; -import { ToggleStructureEvent } from "../../InputHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; - -type Interval = [number, number]; -interface SAMRadius { - x: number; - y: number; - r: number; - owner: PlayerView; - arcs: Interval[]; -} - -interface SamInfo { - ownerId: number; - level: number; -} -/** - * Layer responsible for rendering SAM launcher defense radii - */ -export class SAMRadiusLayer implements Layer { - private readonly samLaunchers: Map = new Map(); // Track SAM launcher IDs -> SAM info - // track whether the stroke should be shown due to hover or due to an active build ghost - private hoveredShow: boolean = false; - private ghostShow: boolean = false; - private visible: boolean = false; - private samRanges: SAMRadius[] = []; - private dashOffset = 0; - private rotationSpeed = 14; // px per second - private lastRefresh = Date.now(); - private needsRedraw = false; - - private handleToggleStructure(e: ToggleStructureEvent) { - const types = e.structureTypes; - this.hoveredShow = - !!types && - (types.indexOf(UnitType.SAMLauncher) !== -1 || - types.indexOf(UnitType.City) !== -1); - this.updateVisibility(); - } - - constructor( - private readonly game: GameView, - private readonly eventBus: EventBus, - private readonly uiState: UIState, - ) {} - - init() { - // Listen for game updates to detect SAM launcher changes - // Also listen for UI toggle structure events so we can show borders when - // the user is hovering the Atom/Hydrogen option (UnitDisplay emits - // ToggleStructureEvent with SAMLauncher included in the list). - this.eventBus.on(ToggleStructureEvent, (e) => - this.handleToggleStructure(e), - ); - } - - shouldTransform(): boolean { - return true; - } - - tick() { - // Check for updates to SAM launchers - const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]; - if (unitUpdates) { - for (const update of unitUpdates) { - const unit = this.game.unit(update.id); - if (unit && unit.type() === UnitType.SAMLauncher) { - if (this.hasChanged(unit)) { - this.needsRedraw = true; // A SAM changed: radiuses shall be recomputed when necessary - break; - } - } - } - } - - // show when in ghost mode for silo/sam/atom/hydrogen - this.ghostShow = - this.uiState.ghostStructure === UnitType.MissileSilo || - this.uiState.ghostStructure === UnitType.SAMLauncher || - this.uiState.ghostStructure === UnitType.City || - this.uiState.ghostStructure === UnitType.AtomBomb || - this.uiState.ghostStructure === UnitType.HydrogenBomb; - this.updateVisibility(); - } - - renderLayer(context: CanvasRenderingContext2D) { - if (this.visible) { - if (this.needsRedraw) { - // SAM changed: the radiuses needs to be updated - this.computeCircleUnions(); - this.needsRedraw = false; - } - this.updateDashAnimation(); - this.drawCirclesUnion(context); - } - } - - private updateDashAnimation() { - const now = Date.now(); - const dt = now - this.lastRefresh; - this.lastRefresh = now; - this.dashOffset += (this.rotationSpeed * dt) / 1000; - if (this.dashOffset > 1e6) this.dashOffset = this.dashOffset % 1000000; - } - - private updateVisibility() { - const next = this.hoveredShow || this.ghostShow; - if (next !== this.visible) { - this.visible = next; - } - } - - private hasChanged(unit: UnitView): boolean { - const samInfos = this.samLaunchers.get(unit.id()); - const isNew = samInfos === undefined; - const active = unit.isActive(); - const ownerId = unit.owner().smallID(); - let hasChanges = isNew || !active; // was built or destroyed - hasChanges ||= !isNew && samInfos.ownerId !== ownerId; // Sam owner changed - hasChanges ||= !isNew && samInfos.level !== unit.level(); // Sam leveled up - return hasChanges; - } - - private getAllSamRanges(): SAMRadius[] { - // Get all active SAM launchers - const samLaunchers = this.game - .units(UnitType.SAMLauncher) - .filter((unit) => unit.isActive()); - - // Update our tracking set - this.samLaunchers.clear(); - samLaunchers.forEach((sam) => - this.samLaunchers.set(sam.id(), { - ownerId: sam.owner().smallID(), - level: sam.level(), - }), - ); - - // Collect radius data - const radiuses = samLaunchers.map((sam) => { - const tile = sam.tile(); - return { - x: this.game.x(tile), - y: this.game.y(tile), - r: this.game.config().samRange(sam.level()), - owner: sam.owner(), - arcs: [], - }; - }); - return radiuses; - } - - private computeUncoveredArcIntervals(a: SAMRadius, circles: SAMRadius[]) { - a.arcs = []; - const TWO_PI = Math.PI * 2; - const EPS = 1e-9; - // helper functions - const normalize = (a: number) => { - while (a < 0) a += TWO_PI; - while (a >= TWO_PI) a -= TWO_PI; - return a; - }; - // merge a list of intervals [s,e] (both between 0..2pi), taking wraparound into account - const mergeIntervals = ( - intervals: Array<[number, number]>, - ): Array<[number, number]> => { - if (intervals.length === 0) return []; - // normalize to non-wrap intervals - const flat: Array<[number, number]> = []; - for (const [s, e] of intervals) { - const ns = normalize(s); - const ne = normalize(e); - if (ne < ns) { - // wraps, split - flat.push([ns, TWO_PI]); - flat.push([0, ne]); - } else { - flat.push([ns, ne]); - } - } - flat.sort((a, b) => a[0] - b[0]); - const merged: Array<[number, number]> = []; - let cur = flat[0].slice() as [number, number]; - for (let i = 1; i < flat.length; i++) { - const it = flat[i]; - if (it[0] <= cur[1] + EPS) { - cur[1] = Math.max(cur[1], it[1]); - } else { - merged.push([cur[0], cur[1]]); - cur = it.slice() as [number, number]; - } - } - merged.push([cur[0], cur[1]]); - return merged; - }; - const covered: Interval[] = []; - let fullyCovered = false; - - for (const b of circles) { - if (a === b) continue; - - // Only same-owner coverage - if (a.owner.smallID() !== b.owner.smallID()) continue; - - const dx = b.x - a.x; - const dy = b.y - a.y; - const d = Math.hypot(dx, dy); - - // a fully inside b - if (d + a.r <= b.r + EPS) { - fullyCovered = true; - break; - } - - // no overlap - if (d >= a.r + b.r - EPS) continue; - - // coincident centers - if (d <= EPS) { - if (b.r >= a.r) { - fullyCovered = true; - break; - } - continue; - } - - // angular span on a covered by b - const theta = Math.atan2(dy, dx); - const cosPhi = (a.r * a.r + d * d - b.r * b.r) / (2 * a.r * d); - const phi = Math.acos(Math.max(-1, Math.min(1, cosPhi))); - - covered.push([theta - phi, theta + phi]); - } - - if (fullyCovered) return; - - const merged = mergeIntervals(covered); - - // subtract from [0, 2π) - const uncovered: Interval[] = []; - if (merged.length === 0) { - uncovered.push([0, TWO_PI]); - } else { - let cursor = 0; - for (const [s, e] of merged) { - if (s > cursor + EPS) { - uncovered.push([cursor, s]); - } - cursor = Math.max(cursor, e); - } - if (cursor < TWO_PI - EPS) { - uncovered.push([cursor, TWO_PI]); - } - } - a.arcs = uncovered; - } - - private drawArcSegments(ctx: CanvasRenderingContext2D, a: SAMRadius) { - const outlineColor = "rgba(0, 0, 0, 1)"; - const lineColorSelf = "rgba(0, 255, 0, 1)"; - const lineColorEnemy = "rgba(255, 0, 0, 1)"; - const lineColorFriend = "rgba(255, 255, 0, 1)"; - const extraOutlineWidth = 1; // adds onto below - const lineWidth = 3; - const lineDash = [12, 6]; - - const offsetX = -this.game.width() / 2; - const offsetY = -this.game.height() / 2; - for (const [s, e] of a.arcs) { - // skip tiny arcs - if (e - s < 1e-3) continue; - ctx.beginPath(); - ctx.arc(a.x + offsetX, a.y + offsetY, a.r, s, e); - - // Outline - ctx.strokeStyle = outlineColor; - ctx.lineWidth = lineWidth + extraOutlineWidth; - ctx.setLineDash([ - lineDash[0] + extraOutlineWidth, - Math.max(lineDash[1] - extraOutlineWidth, 0), - ]); - ctx.lineDashOffset = this.dashOffset + extraOutlineWidth / 2; - ctx.stroke(); - - // Inline - if (a.owner.isMe()) { - ctx.strokeStyle = lineColorSelf; - } else if (this.game.myPlayer()?.isFriendly(a.owner)) { - ctx.strokeStyle = lineColorFriend; - } else { - ctx.strokeStyle = lineColorEnemy; - } - - ctx.lineWidth = lineWidth; - ctx.setLineDash(lineDash); - ctx.lineDashOffset = this.dashOffset; - ctx.stroke(); - } - } - - /** - * Compute for each circle which angular segments are NOT covered by any other circle - */ - private computeCircleUnions() { - this.samRanges = this.getAllSamRanges(); - for (let i = 0; i < this.samRanges.length; i++) { - const a = this.samRanges[i]; - this.computeUncoveredArcIntervals(a, this.samRanges); - } - } - - /** - * Draw union of multiple circles: stroke only the outer arcs so overlapping circles appear as one combined shape. - */ - private drawCirclesUnion(context: CanvasRenderingContext2D) { - const circles = this.samRanges; - if (circles.length === 0 || !this.visible) return; - // Only draw the stroke when UI toggle indicates SAM launchers are focused (e.g. hovering Atom/Hydrogen option). - context.save(); - for (let i = 0; i < circles.length; i++) { - this.drawArcSegments(context, circles[i]); - } - context.restore(); - } -} diff --git a/src/client/graphics/layers/SendResourceModal.ts b/src/client/graphics/layers/SendResourceModal.ts index 38495a6a17..0ce7db8d19 100644 --- a/src/client/graphics/layers/SendResourceModal.ts +++ b/src/client/graphics/layers/SendResourceModal.ts @@ -7,8 +7,8 @@ import { SendDonateGoldIntentEvent, SendDonateTroopsIntentEvent, } from "../../Transport"; +import { UIState } from "../../UIState"; import { renderTroops, translateText } from "../../Utils"; -import { UIState } from "../UIState"; @customElement("send-resource-modal") export class SendResourceModal extends LitElement { diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 5f747c6baf..e1d19199d0 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -5,13 +5,13 @@ import { PauseGameIntentEvent } from "src/client/Transport"; import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; +import { Controller } from "../../Controller"; import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler"; import { translateText } from "../../Utils"; import { SetBackgroundMusicVolumeEvent, SetSoundEffectsVolumeEvent, } from "../../sound/Sounds"; -import { Layer } from "./Layer"; const structureIcon = assetUrl("images/CityIconWhite.svg"); const cursorPriceIcon = assetUrl("images/CursorPriceIconWhite.svg"); const darkModeIcon = assetUrl("images/DarkModeIconWhite.svg"); @@ -35,7 +35,7 @@ export class ShowSettingsModalEvent { } @customElement("settings-modal") -export class SettingsModal extends LitElement implements Layer { +export class SettingsModal extends LitElement implements Controller { public eventBus: EventBus; public userSettings: UserSettings; diff --git a/src/client/graphics/layers/SpawnTimer.ts b/src/client/graphics/layers/SpawnTimer.ts index ada1932017..f4399362d9 100644 --- a/src/client/graphics/layers/SpawnTimer.ts +++ b/src/client/graphics/layers/SpawnTimer.ts @@ -3,15 +3,15 @@ import { customElement } from "lit/decorators.js"; import { EventBus, GameEvent } from "../../../core/EventBus"; import { GameMode, GameType, Team } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; +import { Controller } from "../../Controller"; +import { TransformHandler } from "../../TransformHandler"; export class SpawnBarVisibleEvent implements GameEvent { constructor(public readonly visible: boolean) {} } @customElement("spawn-timer") -export class SpawnTimer extends LitElement implements Layer { +export class SpawnTimer extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; public transformHandler: TransformHandler; @@ -95,10 +95,6 @@ export class SpawnTimer extends LitElement implements Layer { } } - shouldTransform(): boolean { - return false; - } - render() { if (!this.isVisible) { return html``; diff --git a/src/client/graphics/layers/StructureDrawingUtils.ts b/src/client/graphics/layers/StructureDrawingUtils.ts deleted file mode 100644 index bffd1f4d24..0000000000 --- a/src/client/graphics/layers/StructureDrawingUtils.ts +++ /dev/null @@ -1,549 +0,0 @@ -import * as PIXI from "pixi.js"; -import { Theme } from "src/core/configuration/Theme"; -import { assetUrl } from "../../../core/AssetUrls"; -import { - Cell, - PlayerBuildableUnitType, - UnitType, -} from "../../../core/game/Game"; -import { GameView, PlayerView, UnitView } from "../../../core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -const anchorIcon = assetUrl("images/AnchorIcon.v1.png"); -const cityIcon = assetUrl("images/CityIcon.v1.png"); -const factoryIcon = assetUrl("images/FactoryUnit.v1.png"); -const missileSiloIcon = assetUrl("images/MissileSiloUnit.v1.png"); -const SAMMissileIcon = assetUrl("images/SamLauncherUnit.v1.png"); -const shieldIcon = assetUrl("images/ShieldIcon.v1.png"); - -export const STRUCTURE_SHAPES: Partial> = { - [UnitType.City]: "circle", - [UnitType.Port]: "pentagon", - [UnitType.Factory]: "circle", - [UnitType.DefensePost]: "octagon", - [UnitType.SAMLauncher]: "square", - [UnitType.MissileSilo]: "triangle", - [UnitType.Warship]: "cross", - [UnitType.AtomBomb]: "cross", - [UnitType.HydrogenBomb]: "cross", - [UnitType.MIRV]: "cross", -}; -export const LEVEL_SCALE_FACTOR = 3; -export const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5; -export const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4; -export const DOTS_ZOOM_THRESHOLD = 0.5; -export const ZOOM_THRESHOLD = 4.3; -export const ICON_SIZE = { - circle: 28, - octagon: 28, - pentagon: 30, - square: 28, - triangle: 28, - cross: 20, -}; -export const OFFSET_ZOOM_Y = 4; - -export type ShapeType = - | "triangle" - | "square" - | "pentagon" - | "octagon" - | "circle" - | "cross"; - -export class SpriteFactory { - private theme: Theme; - private game: GameView; - private transformHandler: TransformHandler; - private renderSprites: boolean; - private readonly textureCache: Map = new Map(); - private colorCanvas: HTMLCanvasElement | null = null; - private colorCtx: CanvasRenderingContext2D | null = null; - - private readonly structuresInfos: Map< - UnitType, - { iconPath: string; image: HTMLImageElement | null } - > = new Map([ - [UnitType.City, { iconPath: cityIcon, image: null }], - [UnitType.Factory, { iconPath: factoryIcon, image: null }], - [UnitType.DefensePost, { iconPath: shieldIcon, image: null }], - [UnitType.Port, { iconPath: anchorIcon, image: null }], - [UnitType.MissileSilo, { iconPath: missileSiloIcon, image: null }], - [UnitType.SAMLauncher, { iconPath: SAMMissileIcon, image: null }], - ]); - constructor( - theme: Theme, - game: GameView, - transformHandler: TransformHandler, - renderSprites: boolean, - ) { - this.theme = theme; - this.game = game; - this.transformHandler = transformHandler; - this.renderSprites = renderSprites; - this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType)); - } - - public clearCache() { - for (const texture of this.textureCache.values()) { - if (texture && texture !== PIXI.Texture.EMPTY) { - try { - texture.destroy(true); - } catch (e) { - console.error("Error clearing texture cache:", e); - } - } - } - this.textureCache.clear(); - this.colorCanvas = null; - this.colorCtx = null; - } - - private loadIcon( - unitInfo: { - iconPath: string; - image: HTMLImageElement | null; - }, - unitType: UnitType, - ) { - const image = new Image(); - // crossOrigin must be set before src so the fetch is CORS-checked. - // Without this, an icon served from CDN_BASE taints structureCanvas - // and PIXI.Texture.from rejects the upload to WebGL. - image.crossOrigin = "anonymous"; - image.src = unitInfo.iconPath; - image.onload = () => { - unitInfo.image = image; - this.invalidateTextureCache(unitType); - }; - image.onerror = () => { - console.error( - `Failed to load icon for ${unitType}: ${unitInfo.iconPath}`, - ); - }; - } - - private invalidateTextureCache(unitType: UnitType) { - for (const key of Array.from(this.textureCache.keys())) { - if (key.includes(`-${unitType}`)) { - const tex = this.textureCache.get(key); - if (tex && tex !== PIXI.Texture.EMPTY) { - tex.destroy(true); - } - this.textureCache.delete(key); - } - } - } - - createGhostContainer( - player: PlayerView, - ghostStage: PIXI.Container, - pos: { x: number; y: number }, - structureType: PlayerBuildableUnitType, - ): { - container: PIXI.Container; - priceText: PIXI.BitmapText; - priceBg: PIXI.Graphics; - priceGroup: PIXI.Container; - priceBox: { height: number; y: number; paddingX: number; minWidth: number }; - } { - const parentContainer = new PIXI.Container(); - const texture = this.createTexture( - structureType, - player, - false, - false, - true, - ); - const sprite = new PIXI.Sprite(texture); - sprite.anchor.set(0.5); - sprite.alpha = 0.5; - parentContainer.addChild(sprite); - - const priceText = new PIXI.BitmapText({ - text: "125K", - style: { fontFamily: "round_6x6_modified", fontSize: 12 }, - }); - priceText.anchor.set(0.5); - const priceGroup = new PIXI.Container(); - const boxHeight = 18; - const boxY = - (sprite.height > 0 ? sprite.height / 2 : 16) + boxHeight / 2 + 4; - - // a way to resize the pill horizontally based on the text width - const paddingX = 8; - const minWidth = 32; - const textWidth = priceText.width; - const boxWidth = Math.max(minWidth, textWidth + paddingX * 2); - - const priceBg = new PIXI.Graphics(); - priceBg - .roundRect(-boxWidth / 2, boxY - boxHeight / 2, boxWidth, boxHeight, 4) - .fill({ color: 0x000000, alpha: 0.65 }); - - priceText.position.set(0, boxY); - - priceGroup.addChild(priceBg); - priceGroup.addChild(priceText); - parentContainer.addChild(priceGroup); - - parentContainer.position.set(pos.x, pos.y); - parentContainer.scale.set( - Math.min(1, this.transformHandler.scale / ICON_SCALE_FACTOR_ZOOMED_OUT), - ); - ghostStage.addChild(parentContainer); - return { - container: parentContainer, - priceText, - priceBg, - priceGroup, - priceBox: { height: boxHeight, y: boxY, paddingX, minWidth }, - }; - } - - // --- internal helpers --- - - public createUnitContainer( - unit: UnitView, - options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container }, - ): PIXI.Container { - const parentContainer = new PIXI.Container(); - const tile = unit.tile(); - const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); - const screenPos = this.transformHandler.worldToCanvasCoordinates(worldPos); - - const isMarkedForDeletion = unit.markedForDeletion() !== false; - const isConstruction = unit.isUnderConstruction(); - const structureType = unit.type(); - const { type, stage } = options; - const { scale } = this.transformHandler; - - this.renderSprites = - this.game.config().userSettings()?.structureSprites() ?? true; - - if (type === "icon" || type === "dot") { - const texture = this.createTexture( - structureType, - unit.owner(), - isConstruction, - isMarkedForDeletion, - type === "icon", - ); - const sprite = new PIXI.Sprite(texture); - sprite.anchor.set(0.5); - parentContainer.addChild(sprite); - } - - if ((type === "icon" || type === "level") && unit.level() > 1) { - const text = new PIXI.BitmapText({ - text: unit.level().toString(), - style: { fontFamily: "round_6x6_modified", fontSize: 14 }, - }); - text.anchor.set(0.5); - - const shape = STRUCTURE_SHAPES[structureType]; - if (shape !== undefined) { - text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2); - } - parentContainer.addChild(text); - } - - const posX = Math.round(screenPos.x); - let posY = Math.round(screenPos.y); - if (type === "level" && scale >= ZOOM_THRESHOLD && this.renderSprites) { - posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y); - } - parentContainer.position.set(posX, posY); - - if (type === "icon") { - const s = - scale >= ZOOM_THRESHOLD && !this.renderSprites - ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) - : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); - parentContainer.scale.set(s); - } else if (type === "level") { - parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR)); - } - - stage.addChild(parentContainer); - return parentContainer; - } - - private createTexture( - type: UnitType, - owner: PlayerView, - isConstruction: boolean, - isMarkedForDeletion: boolean, - renderIcon: boolean, - ): PIXI.Texture { - const cacheKeyBase = isConstruction - ? `construction-${type}` - : `${this.theme.territoryColor(owner).toRgbString()}-${type}`; - const cacheKey = - cacheKeyBase + - (renderIcon ? "-icon" : "") + - (isMarkedForDeletion ? "-deleted" : ""); - - if (this.textureCache.has(cacheKey)) { - return this.textureCache.get(cacheKey)!; - } - const shape = STRUCTURE_SHAPES[type]; - const texture = shape - ? this.createIcon( - owner, - type, - isConstruction, - isMarkedForDeletion, - shape, - renderIcon, - ) - : PIXI.Texture.EMPTY; - this.textureCache.set(cacheKey, texture); - return texture; - } - - private createIcon( - owner: PlayerView, - structureType: UnitType, - isConstruction: boolean, - isMarkedForDeletion: boolean, - shape: keyof typeof ICON_SIZE, - renderIcon: boolean, - ): PIXI.Texture { - const structureCanvas = document.createElement("canvas"); - let iconSize = ICON_SIZE[shape]; - if (!renderIcon) { - iconSize /= 2.5; - } - structureCanvas.width = Math.ceil(iconSize); - structureCanvas.height = Math.ceil(iconSize); - const context = structureCanvas.getContext("2d")!; - - // Use structureColors defined from the PlayerView. - context.fillStyle = isConstruction - ? "rgb(198,198,198)" - : owner.structureColors().light.toRgbString(); - context.strokeStyle = isConstruction - ? "rgb(127,127, 127)" - : owner.structureColors().dark.toRgbString(); - context.lineWidth = 1; - const halfIconSize = iconSize / 2; - - switch (shape) { - case "triangle": - context.beginPath(); - context.moveTo(halfIconSize, 1); // Top - context.lineTo(iconSize - 1, iconSize - 1); // Bottom right - context.lineTo(0, iconSize - 1); // Bottom left - context.closePath(); - context.fill(); - context.stroke(); - break; - - case "square": - context.fillRect(1, 1, iconSize - 2, iconSize - 2); - context.strokeRect(1, 1, iconSize - 3, iconSize - 3); - break; - - case "octagon": - { - const cx = halfIconSize; - const cy = halfIconSize; - const r = halfIconSize - 1; - const step = (Math.PI * 2) / 8; - - context.beginPath(); - for (let i = 0; i < 8; i++) { - const angle = step * i - Math.PI / 8; // slight rotation for flat top - const x = cx + r * Math.cos(angle); - const y = cy + r * Math.sin(angle); - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - } - context.closePath(); - context.fill(); - context.stroke(); - } - break; - case "pentagon": - { - const cx = halfIconSize; - const cy = halfIconSize; - const r = halfIconSize - 1; - const step = (Math.PI * 2) / 5; - - context.beginPath(); - for (let i = 0; i < 5; i++) { - const angle = step * i - Math.PI / 2; // rotate to have flat base or point up - const x = cx + r * Math.cos(angle); - const y = cy + r * Math.sin(angle); - if (i === 0) { - context.moveTo(x, y); - } else { - context.lineTo(x, y); - } - } - context.closePath(); - context.fill(); - context.stroke(); - } - break; - case "cross": { - context.strokeStyle = "rgba(0, 0, 0, 1)"; - context.fillStyle = "rgba(0, 0, 0, 1)"; - - const gap = iconSize * 0.18; // gap at center - const lineLen = iconSize / 2; - context.save(); - context.translate(halfIconSize, halfIconSize); - // Up - context.beginPath(); - context.moveTo(0, -gap); - context.lineTo(0, -lineLen); - context.stroke(); - // Down - context.beginPath(); - context.moveTo(0, gap); - context.lineTo(0, lineLen); - context.stroke(); - // Left - context.beginPath(); - context.moveTo(-gap, 0); - context.lineTo(-lineLen, 0); - context.stroke(); - // Right - context.beginPath(); - context.moveTo(gap, 0); - context.lineTo(lineLen, 0); - context.stroke(); - context.restore(); - break; - } - - case "circle": - context.beginPath(); - context.arc( - halfIconSize, - halfIconSize, - halfIconSize - 1, - 0, - Math.PI * 2, - ); - context.fill(); - context.stroke(); - break; - - default: - throw new Error(`Unknown shape: ${shape}`); - } - - const structureInfo = this.structuresInfos.get(structureType); - - if (structureInfo?.image && renderIcon) { - const SHAPE_OFFSETS = { - triangle: [6, 11], - square: [5, 5], - octagon: [6, 6], - pentagon: [7, 7], - circle: [6, 6], - cross: [0, 0], - }; - const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0]; - context.drawImage( - this.getImageColored( - structureInfo.image, - owner.structureColors().dark.toRgbString(), - ), - offsetX, - offsetY, - ); - } - - if (isMarkedForDeletion) { - context.save(); - context.strokeStyle = "rgba(255, 64, 64, 0.95)"; - context.lineWidth = Math.max(2, Math.round(iconSize * 0.12)); - context.lineCap = "round"; - const padding = Math.max(2, iconSize * 0.12); - context.beginPath(); - context.moveTo(padding, padding); - context.lineTo(iconSize - padding, iconSize - padding); - context.moveTo(iconSize - padding, padding); - context.lineTo(padding, iconSize - padding); - context.stroke(); - context.restore(); - } - - return PIXI.Texture.from(structureCanvas, true); - } - - public createRange( - type: UnitType, - stage: PIXI.Container, - pos: { x: number; y: number }, - level?: number, - targetingAlly: boolean = false, - ): PIXI.Container | null { - if (stage === undefined) throw new Error("Not initialized"); - const parentContainer = new PIXI.Container(); - const circle = new PIXI.Graphics(); - let radius: number; - switch (type) { - case UnitType.SAMLauncher: - radius = this.game.config().samRange(level ?? 1); - break; - case UnitType.Factory: - radius = this.game.config().trainStationMaxRange(); - break; - case UnitType.DefensePost: - radius = this.game.config().defensePostRange(); - break; - case UnitType.AtomBomb: - radius = this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer; - break; - case UnitType.HydrogenBomb: - radius = this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer; - break; - default: - return null; - } - // Add warning colors (red/orange) when targeting an ally to indicate alliance will break - const isNuke = type === UnitType.AtomBomb || type === UnitType.HydrogenBomb; - const fillColor = targetingAlly && isNuke ? 0xff6b35 : 0xffffff; - const fillAlpha = targetingAlly && isNuke ? 0.35 : 0.2; - const strokeColor = targetingAlly && isNuke ? 0xff4444 : 0xffffff; - const strokeAlpha = targetingAlly && isNuke ? 0.8 : 0.5; - const strokeWidth = targetingAlly && isNuke ? 2 : 1; - - circle - .circle(0, 0, radius) - .fill({ color: fillColor, alpha: fillAlpha }) - .stroke({ width: strokeWidth, color: strokeColor, alpha: strokeAlpha }); - parentContainer.addChild(circle); - parentContainer.position.set(pos.x, pos.y); - parentContainer.scale.set(this.transformHandler.scale); - stage.addChild(parentContainer); - return parentContainer; - } - - private getImageColored( - image: HTMLImageElement, - color: string, - ): HTMLCanvasElement { - if (!this.colorCanvas || !this.colorCtx) { - this.colorCanvas = document.createElement("canvas"); - this.colorCtx = this.colorCanvas.getContext("2d")!; - } - const { colorCanvas, colorCtx: ctx } = this; - if (colorCanvas.width !== image.width) colorCanvas.width = image.width; - if (colorCanvas.height !== image.height) colorCanvas.height = image.height; - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = color; - ctx.fillRect(0, 0, colorCanvas.width, colorCanvas.height); - ctx.globalCompositeOperation = "destination-in"; - ctx.drawImage(image, 0, 0); - return colorCanvas; - } -} diff --git a/src/client/graphics/layers/StructureIconsLayer.ts b/src/client/graphics/layers/StructureIconsLayer.ts deleted file mode 100644 index 760b17d2fd..0000000000 --- a/src/client/graphics/layers/StructureIconsLayer.ts +++ /dev/null @@ -1,932 +0,0 @@ -import { extend } from "colord"; -import a11yPlugin from "colord/plugins/a11y"; -import { OutlineFilter } from "pixi-filters"; -import * as PIXI from "pixi.js"; -import { Theme } from "src/core/configuration/Theme"; -import { assetUrl } from "../../../core/AssetUrls"; -import { EventBus } from "../../../core/EventBus"; -import { wouldNukeBreakAlliance } from "../../../core/execution/Util"; -import { - BuildableUnit, - Cell, - PlayerBuildableUnitType, - PlayerID, - Structures, - UnitType, -} from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, UnitView } from "../../../core/game/GameView"; -import { - ConfirmGhostStructureEvent, - GhostStructureChangedEvent, - MouseMoveEvent, - MouseUpEvent, - ToggleStructureEvent as ToggleStructuresEvent, -} from "../../InputHandler"; -import { - BuildUnitIntentEvent, - SendUpgradeStructureIntentEvent, -} from "../../Transport"; -import { renderNumber } from "../../Utils"; -import { TransformHandler } from "../TransformHandler"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; -import { - DOTS_ZOOM_THRESHOLD, - ICON_SCALE_FACTOR_ZOOMED_IN, - ICON_SCALE_FACTOR_ZOOMED_OUT, - ICON_SIZE, - LEVEL_SCALE_FACTOR, - OFFSET_ZOOM_Y, - SpriteFactory, - STRUCTURE_SHAPES, - ZOOM_THRESHOLD, -} from "./StructureDrawingUtils"; -const bitmapFont = assetUrl("fonts/round_6x6_modified.xml"); - -/** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */ -export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean { - return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb; -} - -extend([a11yPlugin]); - -class StructureRenderInfo { - public isOnScreen: boolean = false; - constructor( - public unit: UnitView, - public owner: PlayerID, - public iconContainer: PIXI.Container, - public levelContainer: PIXI.Container, - public dotContainer: PIXI.Container, - public level: number = 0, - public underConstruction: boolean = true, - ) {} -} - -export class StructureIconsLayer implements Layer { - private ghostUnit: { - container: PIXI.Container; - priceText: PIXI.BitmapText; - priceBg: PIXI.Graphics; - priceGroup: PIXI.Container; - priceBox: { height: number; y: number; paddingX: number; minWidth: number }; - range: PIXI.Container | null; - rangeLevel?: number; - targetingAlly?: boolean; - buildableUnit: BuildableUnit; - } | null = null; - private pixicanvas: HTMLCanvasElement; - private iconsStage: PIXI.Container; - private ghostStage: PIXI.Container; - private levelsStage: PIXI.Container; - private rootStage: PIXI.Container = new PIXI.Container(); - private dotsStage: PIXI.Container; - private readonly theme: Theme; - private renderer: PIXI.Renderer | null = null; - private rendererInitialized: boolean = false; - private readonly rendersByUnitId: Map = - new Map(); - private readonly seenUnitIds: Set = new Set(); - private readonly connectedAllySmallIds: Set = new Set(); - private readonly mousePos = { x: 0, y: 0 }; - private renderSprites = true; - private factory: SpriteFactory; - private readonly structures: Map< - PlayerBuildableUnitType, - { visible: boolean } - > = new Map(Structures.types.map((type) => [type, { visible: true }])); - private lastGhostQueryAt: number; - private visibilityStateDirty = true; - private pendingConfirm: MouseUpEvent | null = null; - private hasHiddenStructure = false; - private rebuildPending = false; - potentialUpgrade: StructureRenderInfo | undefined; - private filterRedArray: OutlineFilter[] = []; - private filterGreenArray: OutlineFilter[] = []; - private filterWhiteArray: OutlineFilter[] = []; - - constructor( - private game: GameView, - private eventBus: EventBus, - public uiState: UIState, - private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - this.factory = new SpriteFactory( - this.theme, - game, - transformHandler, - this.renderSprites, - ); - } - - async setupRenderer() { - if (this.renderer) { - this.renderer.destroy(true); - this.rootStage.removeChildren(); - } - - try { - await PIXI.Assets.load(bitmapFont); - } catch (error) { - console.error("Failed to load bitmap font:", error); - } - - this.pixicanvas = document.createElement("canvas"); - this.pixicanvas.width = window.innerWidth; - this.pixicanvas.height = window.innerHeight; - - // This will prefer WebGL, eventually WebGPU, and fallback to Canvas - // Restrict using 'preferences: ["WebGPU", "WebGL"]' or - // 'preferences: "WebGPU"' later if needed - const renderer = await PIXI.autoDetectRenderer({ - canvas: this.pixicanvas, - resolution: 1, - width: this.pixicanvas.width, - height: this.pixicanvas.height, - antialias: false, - clearBeforeRender: true, - backgroundAlpha: 0, - backgroundColor: 0x00000000, - }); - - console.info(`Using ${renderer.name} for structure icons layer`); - - this.iconsStage = new PIXI.Container(); - this.iconsStage.position.set(0, 0); - this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - - this.ghostStage = new PIXI.Container(); - this.ghostStage.position.set(0, 0); - this.ghostStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - - this.levelsStage = new PIXI.Container(); - this.levelsStage.position.set(0, 0); - this.levelsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - - this.dotsStage = new PIXI.Container(); - this.dotsStage.position.set(0, 0); - this.dotsStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - - this.rootStage.addChild( - this.dotsStage, - this.iconsStage, - this.levelsStage, - this.ghostStage, - ); - this.rootStage.position.set(0, 0); - this.rootStage.setSize(this.pixicanvas.width, this.pixicanvas.height); - - this.filterRedArray = [ - new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }), - ]; - this.filterGreenArray = [ - new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }), - ]; - this.filterWhiteArray = [ - new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }), - ]; - - this.renderer = renderer; - - if (this.renderer.name === "webgpu") { - // Listen to device loss as PixiJS doesn't handle WebGPU context loss itself - const gpuRenderer = this.renderer as PIXI.WebGPURenderer; - gpuRenderer.gpu.device.lost.then(() => { - this.redraw(); - }); - } - - if (this.renderer.name === "webgl") { - this.renderer.runners.contextChange.add({ - // Listen to contextChange as PixiJS handles WebGL context loss and restores itself. - // Don't listen to "webglcontextrestored" event directly as it can fire before PixiJS is ready. - contextChange: () => { - requestAnimationFrame(() => { - this.redraw(); - }); - }, - }); - } - - this.rendererInitialized = true; - } - - private rebuildAllIcons() { - this.clearGhostStructure(); - this.factory.clearCache(); - const allUnitIds = Array.from(this.seenUnitIds); - this.seenUnitIds.clear(); - for (const unitId of allUnitIds) { - const render = this.rendersByUnitId.get(unitId); - if (render) { - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.levelContainer?.destroy({ children: true }); - } - const unitView = this.game.unit(unitId); - if (unitView && unitView.isActive()) { - this.handleActiveUnit(unitView); - } else { - this.rendersByUnitId.delete(unitId); - } - } - } - - shouldTransform(): boolean { - return false; - } - - async redraw() { - if (this.rebuildPending) { - return; - } - if (this.rendererOrGLContextLost()) { - return; - } - this.rebuildPending = true; - - try { - if (this.renderer?.name === "webgpu") { - this.rendererInitialized = false; - await this.setupRenderer(); - } - this.resizeCanvas(); - this.rebuildAllIcons(); - } finally { - this.rebuildPending = false; - } - } - - async init() { - this.eventBus.on(ToggleStructuresEvent, (e) => - this.toggleStructures(e.structureTypes), - ); - this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e)); - - this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e)); - this.eventBus.on(ConfirmGhostStructureEvent, () => - this.requestConfirmStructure( - new MouseUpEvent(this.mousePos.x, this.mousePos.y), - ), - ); - - window.addEventListener("resize", () => this.resizeCanvas()); - await this.setupRenderer(); - this.resizeCanvas(); - } - - private rendererOrGLContextLost(): boolean { - if (!this.renderer || !this.rendererInitialized) return true; - if (this.renderer.name === "webgl") { - // For WebGL, check isLost to prevent ungraceful handling by PixiJS: - // its GL > logPrettyShaderError throws, when getShaderSource returns null - // Needs to be fixed in PixiJS, in meantime prevent it from here - return (this.renderer as PIXI.WebGLRenderer).context?.isLost === true; - } - return false; - } - - resizeCanvas() { - if (this.rendererOrGLContextLost()) { - return; - } - this.pixicanvas.width = window.innerWidth; - this.pixicanvas.height = window.innerHeight; - this.renderer?.resize(innerWidth, innerHeight, 1); - } - - tick() { - const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit]; - if (unitUpdates) { - for (let i = 0, len = unitUpdates.length; i < len; i++) { - const unitView = this.game.unit(unitUpdates[i].id); - if (unitView === undefined) { - continue; - } - - const unitId = unitView.id(); - if (unitView.isActive()) { - this.handleActiveUnit(unitView); - } else if (this.seenUnitIds.has(unitId)) { - this.handleInactiveUnit(unitView); - } - } - } - this.renderSprites = - this.game.config().userSettings()?.structureSprites() ?? true; - } - - renderLayer(mainContext: CanvasRenderingContext2D) { - if (this.rendererOrGLContextLost()) { - return; - } - - if (this.ghostUnit) { - if (this.uiState.ghostStructure === null) { - this.removeGhostStructure(); - } else if ( - this.uiState.ghostStructure !== this.ghostUnit.buildableUnit.type - ) { - this.clearGhostStructure(); - } - } else if (this.uiState.ghostStructure !== null) { - this.createGhostStructure(this.uiState.ghostStructure); - } - this.renderGhost(); - - if (this.transformHandler.hasChanged()) { - for (const render of this.rendersByUnitId.values()) { - this.computeNewLocation(render); - } - } - const scale = this.transformHandler.scale; - - this.dotsStage!.visible = scale <= DOTS_ZOOM_THRESHOLD; - this.iconsStage!.visible = - scale > DOTS_ZOOM_THRESHOLD && - (scale <= ZOOM_THRESHOLD || !this.renderSprites); - this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites; - if (this.renderer) { - this.renderer.render(this.rootStage); - mainContext.drawImage(this.renderer.canvas, 0, 0); - } - } - - renderGhost() { - if (!this.ghostUnit) return; - - const now = performance.now(); - if (now - this.lastGhostQueryAt < 50) { - return; - } - this.lastGhostQueryAt = now; - let tileRef: TileRef | undefined; - const tile = this.transformHandler.screenToWorldCoordinates( - this.mousePos.x, - this.mousePos.y, - ); - if (this.game.isValidCoord(tile.x, tile.y)) { - tileRef = this.game.ref(tile.x, tile.y); - } - - // Check if targeting an ally (for nuke warning visual) - // Uses shared logic with NukeExecution.maybeBreakAlliances() - let targetingAlly = false; - const myPlayer = this.game.myPlayer(); - const nukeType = this.ghostUnit.buildableUnit.type; - if ( - tileRef && - myPlayer && - (nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb) - ) { - // Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff - this.connectedAllySmallIds.clear(); - const allies = myPlayer.allies(); - for (let i = 0; i < allies.length; i++) { - const ally = allies[i]; - if (!ally.isDisconnected()) { - this.connectedAllySmallIds.add(ally.smallID()); - } - } - - if (this.connectedAllySmallIds.size > 0) { - targetingAlly = wouldNukeBreakAlliance({ - game: this.game, - targetTile: tileRef, - magnitude: this.game.config().nukeMagnitudes(nukeType), - allySmallIds: this.connectedAllySmallIds, - threshold: this.game.config().nukeAllianceBreakThreshold(), - }); - } - } - - this.game - ?.myPlayer() - ?.buildables(tileRef, [this.ghostUnit?.buildableUnit.type]) - .then((buildables) => { - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = []; - this.potentialUpgrade.dotContainer.filters = []; - } - if (this.ghostUnit?.container) { - this.ghostUnit.container.filters = []; - } - - if (!this.ghostUnit) { - this.pendingConfirm = null; - return; - } - - const unit = buildables.find( - (u) => u.type === this.ghostUnit!.buildableUnit.type, - ); - const showPrice = this.game.config().userSettings().cursorCostLabel(); - if (!unit) { - Object.assign(this.ghostUnit.buildableUnit, { - canBuild: false, - canUpgrade: false, - }); - this.updateGhostPrice(0, showPrice); - this.ghostUnit.container.filters = this.filterRedArray; - this.pendingConfirm = null; - return; - } - - this.ghostUnit.buildableUnit = unit; - this.updateGhostPrice(unit.cost ?? 0, showPrice); - - const targetLevel = this.resolveGhostRangeLevel(unit); - this.updateGhostRange(targetLevel, targetingAlly); - - if (unit.canUpgrade) { - this.potentialUpgrade = this.rendersByUnitId.get(unit.canUpgrade); - if ( - this.potentialUpgrade && - this.potentialUpgrade.unit.owner().id() !== - this.game.myPlayer()?.id() - ) { - this.potentialUpgrade = undefined; - } - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = this.filterGreenArray; - this.potentialUpgrade.dotContainer.filters = this.filterGreenArray; - } - // No overlapping when a structure is upgradable - this.uiState.overlappingRailroads = []; - this.uiState.ghostRailPaths = []; - } else if (unit.canBuild === false) { - this.ghostUnit.container.filters = this.filterRedArray; - this.uiState.overlappingRailroads = []; - this.uiState.ghostRailPaths = []; - } else { - this.uiState.overlappingRailroads = unit.overlappingRailroads; - this.uiState.ghostRailPaths = unit.ghostRailPaths; - } - - const scale = this.transformHandler.scale; - const s = - scale >= ZOOM_THRESHOLD - ? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN) - : Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT); - this.ghostUnit.container.scale.set(s); - this.ghostUnit.range?.scale.set(this.transformHandler.scale); - - if (this.pendingConfirm !== null) { - const ev = this.pendingConfirm; - this.pendingConfirm = null; - if (this.isGhostReadyForConfirm()) { - this.createStructure(ev); - } - } - }); - } - - private updateGhostPrice(cost: bigint | number, showPrice: boolean) { - if (!this.ghostUnit) return; - const { priceText, priceBg, priceBox, priceGroup } = this.ghostUnit; - priceGroup.visible = showPrice; - if (!showPrice) return; - - priceText.text = renderNumber(cost); - priceText.position.set(0, priceBox.y); - - const textWidth = priceText.width; - const boxWidth = Math.max( - priceBox.minWidth, - textWidth + priceBox.paddingX * 2, - ); - - priceBg.clear(); - priceBg - .roundRect( - -boxWidth / 2, - priceBox.y - priceBox.height / 2, - boxWidth, - priceBox.height, - 4, - ) - .fill({ color: 0x000000, alpha: 0.65 }); - } - - /** - * True when the ghost exists and buildableUnit has been refreshed (canBuild or canUpgrade set). - * Used to avoid running createStructure before renderGhost's async buildables() has updated the ghost. - */ - private isGhostReadyForConfirm(): boolean { - if (!this.ghostUnit) return false; - const bu = this.ghostUnit.buildableUnit; - return bu.canBuild !== false || bu.canUpgrade !== false; - } - - /** - * Request confirm (place/upgrade): run createStructure now if ghost is ready, otherwise defer until - * renderGhost's buildables() callback has updated the ghost. Shared by Enter (ConfirmGhostStructureEvent) - * and mouse click (MouseUpEvent) so numpad-select-then-confirm works. - */ - private requestConfirmStructure(e: MouseUpEvent): void { - if (!this.ghostUnit && !this.uiState.ghostStructure) return; - if (this.isGhostReadyForConfirm()) { - this.createStructure(e); - } else { - this.pendingConfirm = e; - } - } - - private createStructure(e: MouseUpEvent) { - if (!this.ghostUnit) return; - if ( - this.ghostUnit.buildableUnit.canBuild === false && - this.ghostUnit.buildableUnit.canUpgrade === false - ) { - this.removeGhostStructure(); - return; - } - const tile = this.transformHandler.screenToWorldCoordinates(e.x, e.y); - if (this.ghostUnit.buildableUnit.canUpgrade !== false) { - this.eventBus.emit( - new SendUpgradeStructureIntentEvent( - this.ghostUnit.buildableUnit.canUpgrade, - this.ghostUnit.buildableUnit.type, - ), - ); - this.removeGhostStructure(); - } else if (this.ghostUnit.buildableUnit.canBuild) { - const unitType = this.ghostUnit.buildableUnit.type; - const rocketDirectionUp = - unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb - ? this.uiState.rocketDirectionUp - : undefined; - this.eventBus.emit( - new BuildUnitIntentEvent( - unitType, - this.game.ref(tile.x, tile.y), - rocketDirectionUp, - ), - ); - if (!shouldPreserveGhostAfterBuild(unitType)) { - this.removeGhostStructure(); - } - } else { - this.removeGhostStructure(); - } - } - - private moveGhost(e: MouseMoveEvent) { - this.mousePos.x = e.x; - this.mousePos.y = e.y; - - if (!this.ghostUnit) return; - const local = this.transformHandler.screenToCanvasCoordinates(e.x, e.y); - this.ghostUnit.container.position.set(local.x, local.y); - this.ghostUnit.range?.position.set(local.x, local.y); - } - - private createGhostStructure(type: PlayerBuildableUnitType | null) { - const player = this.game.myPlayer(); - if (!player) return; - if (type === null) { - return; - } - const local = this.transformHandler.screenToCanvasCoordinates( - this.mousePos.x, - this.mousePos.y, - ); - const ghost = this.factory.createGhostContainer( - player, - this.ghostStage, - { x: local.x, y: local.y }, - type, - ); - this.ghostUnit = { - container: ghost.container, - priceText: ghost.priceText, - priceBg: ghost.priceBg, - priceGroup: ghost.priceGroup, - priceBox: ghost.priceBox, - range: null, - buildableUnit: { - type, - canBuild: false, - canUpgrade: false, - cost: 0n, - overlappingRailroads: [], - ghostRailPaths: [], - }, - }; - const showPrice = this.game.config().userSettings().cursorCostLabel(); - this.updateGhostPrice(0, showPrice); - const baseLevel = this.resolveGhostRangeLevel(this.ghostUnit.buildableUnit); - this.updateGhostRange(baseLevel); - } - - private clearGhostStructure() { - this.pendingConfirm = null; - if (this.ghostUnit) { - this.ghostUnit.container.destroy({ children: true }); - this.ghostUnit.range?.destroy({ children: true }); - this.ghostUnit = null; - } - if (this.potentialUpgrade) { - this.potentialUpgrade.iconContainer.filters = []; - this.potentialUpgrade.dotContainer.filters = []; - this.potentialUpgrade = undefined; - } - this.uiState.ghostRailPaths = []; - } - - private removeGhostStructure() { - this.clearGhostStructure(); - this.uiState.ghostStructure = null; - this.eventBus.emit(new GhostStructureChangedEvent(null)); - } - - private resolveGhostRangeLevel( - buildableUnit: BuildableUnit, - ): number | undefined { - if (buildableUnit.type !== UnitType.SAMLauncher) { - return undefined; - } - if (buildableUnit.canUpgrade !== false) { - const existing = this.game.unit(buildableUnit.canUpgrade); - if (existing) { - return existing.level() + 1; - } else { - console.error("Failed to find existing SAMLauncher for upgrade"); - } - } - - return 1; - } - - private updateGhostRange(level?: number, targetingAlly: boolean = false) { - if (!this.ghostUnit) { - return; - } - - if ( - this.ghostUnit.range && - this.ghostUnit.rangeLevel === level && - this.ghostUnit.targetingAlly === targetingAlly - ) { - return; - } - - this.ghostUnit.range?.destroy({ children: true }); - this.ghostUnit.range = null; - this.ghostUnit.rangeLevel = level; - this.ghostUnit.targetingAlly = targetingAlly; - - const position = this.ghostUnit.container.position; - const range = this.factory.createRange( - this.ghostUnit.buildableUnit.type, - this.ghostStage, - { x: position.x, y: position.y }, - level, - targetingAlly, - ); - if (range) { - this.ghostUnit.range = range; - } - } - - private toggleStructures( - toggleStructureType: PlayerBuildableUnitType[] | null, - ): void { - for (const [structureType, infos] of this.structures) { - infos.visible = - toggleStructureType?.indexOf(structureType) !== -1 || - toggleStructureType === null; - } - this.visibilityStateDirty = true; - for (const render of this.rendersByUnitId.values()) { - this.modifyVisibility(render); - } - } - - private refreshVisibilityStateCache() { - if (!this.visibilityStateDirty) { - return; - } - - this.hasHiddenStructure = false; - for (const infos of this.structures.values()) { - if (infos.visible === false) { - this.hasHiddenStructure = true; - break; - } - } - - this.visibilityStateDirty = false; - } - - private findRenderByUnit( - unitView: UnitView, - ): StructureRenderInfo | undefined { - return this.rendersByUnitId.get(unitView.id()); - } - - private handleActiveUnit(unitView: UnitView) { - if (this.seenUnitIds.has(unitView.id())) { - const render = this.findRenderByUnit(unitView); - if (render) { - this.checkForConstructionState(render, unitView); - this.checkForDeletionState(render, unitView); - this.checkForOwnershipChange(render, unitView); - this.checkForLevelChange(render, unitView); - } - } else if ( - this.structures.has(unitView.type() as PlayerBuildableUnitType) - ) { - this.addNewStructure(unitView); - } - } - - private handleInactiveUnit(unitView: UnitView) { - if (!this.seenUnitIds.has(unitView.id())) { - return; - } - - const render = this.findRenderByUnit(unitView); - if (render) { - this.deleteStructure(render); - } - } - - private modifyVisibility(render: StructureRenderInfo) { - this.refreshVisibilityStateCache(); - - const structureType = render.unit.type() as PlayerBuildableUnitType; - const structureInfos = this.structures.get(structureType); - - if (structureInfos) { - render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3; - render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3; - if (structureInfos.visible && this.hasHiddenStructure) { - render.iconContainer.filters = this.filterWhiteArray; - render.dotContainer.filters = this.filterWhiteArray; - } else { - render.iconContainer.filters = []; - render.dotContainer.filters = []; - } - } - } - - private checkForDeletionState(render: StructureRenderInfo, unit: UnitView) { - if (unit.markedForDeletion() !== false) { - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private checkForConstructionState( - render: StructureRenderInfo, - unit: UnitView, - ) { - if (render.underConstruction && !unit.isUnderConstruction()) { - render.underConstruction = false; - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private checkForOwnershipChange(render: StructureRenderInfo, unit: UnitView) { - if (render.owner !== unit.owner().id()) { - render.owner = unit.owner().id(); - render.iconContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private checkForLevelChange(render: StructureRenderInfo, unit: UnitView) { - if (render.level !== unit.level()) { - render.level = unit.level(); - render.iconContainer?.destroy({ children: true }); - render.levelContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - render.iconContainer = this.createIconSprite(unit); - render.levelContainer = this.createLevelSprite(unit); - render.dotContainer = this.createDotSprite(unit); - this.modifyVisibility(render); - } - } - - private computeNewLocation(render: StructureRenderInfo) { - const tile = render.unit.tile(); - const worldPos = new Cell(this.game.x(tile), this.game.y(tile)); - const screenPos = this.transformHandler.worldToCanvasCoordinates(worldPos); - screenPos.x = Math.round(screenPos.x); - - const scale = this.transformHandler.scale; - screenPos.y = Math.round( - scale >= ZOOM_THRESHOLD && - this.game.config().userSettings()?.structureSprites() - ? screenPos.y - scale * OFFSET_ZOOM_Y - : screenPos.y, - ); - - const type = render.unit.type(); - const margin = - type !== undefined && STRUCTURE_SHAPES[type] !== undefined - ? ICON_SIZE[STRUCTURE_SHAPES[type]] - : 28; - - const onScreen = - screenPos.x + margin > 0 && - screenPos.x - margin < this.pixicanvas.width && - screenPos.y + margin > 0 && - screenPos.y - margin < this.pixicanvas.height; - - if (onScreen) { - if (scale > ZOOM_THRESHOLD) { - const target = this.game.config().userSettings()?.structureSprites() - ? render.levelContainer - : render.iconContainer; - target.position.set(screenPos.x, screenPos.y); - target.scale.set( - Math.max( - 1, - scale / - (target === render.levelContainer - ? LEVEL_SCALE_FACTOR - : ICON_SCALE_FACTOR_ZOOMED_IN), - ), - ); - } else if (scale > DOTS_ZOOM_THRESHOLD) { - render.iconContainer.position.set(screenPos.x, screenPos.y); - render.iconContainer.scale.set( - Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT), - ); - } else { - render.dotContainer.position.set(screenPos.x, screenPos.y); - } - } - - if (render.isOnScreen !== onScreen) { - render.isOnScreen = onScreen; - render.iconContainer.visible = onScreen; - render.dotContainer.visible = onScreen; - render.levelContainer.visible = onScreen; - } - } - - private addNewStructure(unitView: UnitView) { - this.seenUnitIds.add(unitView.id()); - const render = new StructureRenderInfo( - unitView, - unitView.owner().id(), - this.createIconSprite(unitView), - this.createLevelSprite(unitView), - this.createDotSprite(unitView), - unitView.level(), - unitView.isUnderConstruction(), - ); - this.rendersByUnitId.set(unitView.id(), render); - this.computeNewLocation(render); - this.modifyVisibility(render); - } - - private createLevelSprite(unit: UnitView): PIXI.Container { - return this.factory.createUnitContainer(unit, { - type: "level", - stage: this.levelsStage, - }); - } - - private createDotSprite(unit: UnitView): PIXI.Container { - return this.factory.createUnitContainer(unit, { - type: "dot", - stage: this.dotsStage, - }); - } - - private createIconSprite(unit: UnitView): PIXI.Container { - return this.factory.createUnitContainer(unit, { - type: "icon", - stage: this.iconsStage, - }); - } - - private deleteStructure(render: StructureRenderInfo) { - render.iconContainer?.destroy({ children: true }); - render.levelContainer?.destroy({ children: true }); - render.dotContainer?.destroy({ children: true }); - const unitId = render.unit.id(); - this.rendersByUnitId.delete(unitId); - this.seenUnitIds.delete(unitId); - if (this.potentialUpgrade?.unit.id() === unitId) { - this.potentialUpgrade = undefined; - } - } -} diff --git a/src/client/graphics/layers/StructureLayer.ts b/src/client/graphics/layers/StructureLayer.ts deleted file mode 100644 index 9ba8e3b2a8..0000000000 --- a/src/client/graphics/layers/StructureLayer.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { colord, Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; -import { assetUrl } from "../../../core/AssetUrls"; -import { EventBus } from "../../../core/EventBus"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -import { Cell, UnitType } from "../../../core/game/Game"; -import { euclDistFN, isometricDistFN } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, UnitView } from "../../../core/game/GameView"; -const cityIcon = assetUrl("images/buildings/cityAlt1.png"); -const factoryIcon = assetUrl("images/buildings/factoryAlt1.png"); -const shieldIcon = assetUrl("images/buildings/fortAlt3.png"); -const anchorIcon = assetUrl("images/buildings/port1.png"); -const missileSiloIcon = assetUrl("images/buildings/silo1.png"); -const SAMMissileIcon = assetUrl("images/buildings/silo4.png"); - -const underConstructionColor = colord("rgb(150,150,150)"); - -// Base radius values and scaling factor for unit borders and territories -const BASE_BORDER_RADIUS = 16.5; -const BASE_TERRITORY_RADIUS = 13.5; -const RADIUS_SCALE_FACTOR = 0.5; -const ZOOM_THRESHOLD = 4.3; // below this zoom level, structures are not rendered - -interface UnitRenderConfig { - icon: string; - borderRadius: number; - territoryRadius: number; -} - -export class StructureLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private unitIcons: Map = new Map(); - private theme: Theme; - private tempCanvas: HTMLCanvasElement; - private tempContext: CanvasRenderingContext2D; - - // Configuration for supported unit types only - private readonly unitConfigs: Partial> = { - [UnitType.Port]: { - icon: anchorIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.City]: { - icon: cityIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.Factory]: { - icon: factoryIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.MissileSilo]: { - icon: missileSiloIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.DefensePost]: { - icon: shieldIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - [UnitType.SAMLauncher]: { - icon: SAMMissileIcon, - borderRadius: BASE_BORDER_RADIUS * RADIUS_SCALE_FACTOR, - territoryRadius: BASE_TERRITORY_RADIUS * RADIUS_SCALE_FACTOR, - }, - }; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - this.tempCanvas = document.createElement("canvas"); - const tempContext = this.tempCanvas.getContext("2d"); - if (tempContext === null) throw new Error("2d context not supported"); - this.tempContext = tempContext; - this.loadIconData(); - } - - private loadIcon(unitType: string, config: UnitRenderConfig) { - const image = new Image(); - // crossOrigin must be set before src so the fetch is CORS-checked. - // Without this, an icon served from CDN_BASE taints any canvas/texture - // it's drawn into, and WebGL refuses to upload it via texImage2D. - image.crossOrigin = "anonymous"; - image.src = config.icon; - image.onload = () => { - this.unitIcons.set(unitType, image); - console.log( - `icon loaded: ${unitType}, size: ${image.width}x${image.height}`, - ); - }; - image.onerror = () => { - console.error(`Failed to load icon for ${unitType}: ${config.icon}`); - }; - } - - private loadIconData() { - Object.entries(this.unitConfigs).forEach(([unitType, config]) => { - this.loadIcon(unitType, config); - }); - } - - shouldTransform(): boolean { - return true; - } - - tick() { - const updates = this.game.updatesSinceLastTick(); - const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; - for (const u of unitUpdates) { - const unit = this.game.unit(u.id); - if (unit === undefined) continue; - this.handleUnitRendering(unit); - } - } - - init() { - this.redraw(); - } - - redraw() { - console.log("structure layer redrawing"); - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d", { alpha: true }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - - // Firefox's GPU limit is 8192, only known browser issue - const maxTextureSize = 8192; - const scaleX = maxTextureSize / this.game.width(); - const scaleY = maxTextureSize / this.game.height(); - const targetScale = Math.min(2, scaleX, scaleY); - this.canvas.width = Math.max( - 1, - Math.floor(this.game.width() * targetScale), - ); - this.canvas.height = Math.max( - 1, - Math.floor(this.game.height() * targetScale), - ); - - // Enable smooth scaling - this.context.imageSmoothingEnabled = true; - this.context.imageSmoothingQuality = "high"; - this.context.scale( - this.canvas.width / (this.game.width() * 2), - this.canvas.height / (this.game.height() * 2), - ); - - Promise.all( - Array.from(this.unitIcons.values()).map((img) => - img.decode?.().catch((err) => { - console.warn("Failed to decode unit icon image:", err); - }), - ), - ).finally(() => { - this.game.units().forEach((u) => this.handleUnitRendering(u)); - }); - } - - renderLayer(context: CanvasRenderingContext2D) { - if ( - this.transformHandler.scale <= ZOOM_THRESHOLD || - !this.game.config().userSettings()?.structureSprites() - ) { - return; - } - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } - - private isUnitTypeSupported(unitType: UnitType): boolean { - return unitType in this.unitConfigs; - } - - private drawBorder( - unit: UnitView, - borderColor: Colord, - config: UnitRenderConfig, - ) { - // Draw border and territory - for (const tile of this.game.bfs( - unit.tile(), - isometricDistFN(unit.tile(), config.borderRadius, true), - )) { - this.paintCell( - new Cell(this.game.x(tile), this.game.y(tile)), - borderColor, - 255, - ); - } - - for (const tile of this.game.bfs( - unit.tile(), - isometricDistFN(unit.tile(), config.territoryRadius, true), - )) { - this.paintCell( - new Cell(this.game.x(tile), this.game.y(tile)), - unit.isUnderConstruction() - ? underConstructionColor - : unit.owner().territoryColor(), - 130, - ); - } - } - - private handleUnitRendering(unit: UnitView) { - const unitType = unit.type(); - const iconType = unitType; - if (!this.isUnitTypeSupported(unitType)) return; - - const config = this.unitConfigs[unitType]; - let icon: HTMLImageElement | undefined; - let borderColor = unit.owner().borderColor(); - - // Handle cooldown states and special icons - if (unit.isUnderConstruction()) { - icon = this.unitIcons.get(iconType); - borderColor = underConstructionColor; - } else { - icon = this.unitIcons.get(iconType); - } - - if (!config || !icon) return; - - // Clear previous rendering - for (const tile of this.game.bfs( - unit.tile(), - euclDistFN(unit.tile(), config.borderRadius + 1, true), - )) { - this.clearCell(new Cell(this.game.x(tile), this.game.y(tile))); - } - - if (!unit.isActive()) return; - - this.drawBorder(unit, borderColor, config); - - // Render icon at 1/2 scale for better quality - const scaledWidth = icon.width >> 1; - const scaledHeight = icon.height >> 1; - const startX = this.game.x(unit.tile()) - (scaledWidth >> 1); - const startY = this.game.y(unit.tile()) - (scaledHeight >> 1); - - this.renderIcon(icon, startX, startY - 4, scaledWidth, scaledHeight, unit); - } - - private renderIcon( - image: HTMLImageElement, - startX: number, - startY: number, - width: number, - height: number, - unit: UnitView, - ) { - let color = unit.owner().borderColor(); - if (unit.isUnderConstruction()) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - color = underConstructionColor; - } - - // Make temp canvas at the final render size (2x scale) - this.tempCanvas.width = width * 2; - this.tempCanvas.height = height * 2; - - // Enable smooth scaling - this.tempContext.imageSmoothingEnabled = true; - this.tempContext.imageSmoothingQuality = "high"; - - // Draw the image at final size with high quality scaling - this.tempContext.drawImage(image, 0, 0, width * 2, height * 2); - - // Restore the alpha channel - this.tempContext.globalCompositeOperation = "destination-in"; - this.tempContext.drawImage(image, 0, 0, width * 2, height * 2); - - // Draw the final result to the main canvas - this.context.drawImage(this.tempCanvas, startX * 2, startY * 2); - } - - paintCell(cell: Cell, color: Colord, alpha: number) { - this.clearCell(cell); - this.context.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.context.fillRect(cell.x * 2, cell.y * 2, 2, 2); - } - - clearCell(cell: Cell) { - this.context.clearRect(cell.x * 2, cell.y * 2, 2, 2); - } -} diff --git a/src/client/graphics/layers/TeamStats.ts b/src/client/graphics/layers/TeamStats.ts index 6e846bcdf6..3df5500938 100644 --- a/src/client/graphics/layers/TeamStats.ts +++ b/src/client/graphics/layers/TeamStats.ts @@ -3,13 +3,13 @@ import { customElement, property } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameMode, Team, UnitType } from "../../../core/game/Game"; import { GameView, PlayerView } from "../../../core/game/GameView"; +import { Controller } from "../../Controller"; import { formatPercentage, renderNumber, renderTroops, translateText, } from "../../Utils"; -import { Layer } from "./Layer"; interface TeamEntry { teamName: string; @@ -26,7 +26,7 @@ interface TeamEntry { } @customElement("team-stats") -export class TeamStats extends LitElement implements Layer { +export class TeamStats extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; @@ -125,12 +125,6 @@ export class TeamStats extends LitElement implements Layer { this.requestUpdate(); } - renderLayer(context: CanvasRenderingContext2D) {} - - shouldTransform(): boolean { - return false; - } - render() { if (!this.visible) return html``; diff --git a/src/client/graphics/layers/TerrainLayer.ts b/src/client/graphics/layers/TerrainLayer.ts deleted file mode 100644 index 492c970289..0000000000 --- a/src/client/graphics/layers/TerrainLayer.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { Theme } from "src/core/configuration/Theme"; -import { Config } from "../../../core/configuration/Config"; -import { GameView } from "../../../core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -export class TerrainLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private theme: Theme; - private config: Config; - - constructor( - private game: GameView, - private transformHandler: TransformHandler, - ) { - this.config = this.game.config(); - } - shouldTransform(): boolean { - return true; - } - tick() { - if (this.config.theme() !== this.theme) { - this.redraw(); - return; - } - // Repaint terrain for tiles whose terrain changed (e.g. nuke - // turning land to water). - const updatedTiles = this.game.recentlyUpdatedTerrainTiles(); - if (updatedTiles.length > 0) { - let dirty = false; - for (const tile of updatedTiles) { - const terrainColor = this.theme.terrainColor(this.game, tile); - const offset = tile * 4; - const r = terrainColor.rgba.r; - const g = terrainColor.rgba.g; - const b = terrainColor.rgba.b; - if ( - this.imageData.data[offset] !== r || - this.imageData.data[offset + 1] !== g || - this.imageData.data[offset + 2] !== b - ) { - this.imageData.data[offset] = r; - this.imageData.data[offset + 1] = g; - this.imageData.data[offset + 2] = b; - dirty = true; - } - } - if (dirty) { - this.context.putImageData(this.imageData, 0, 0); - } - } - } - - init() { - console.log("redrew terrain layer"); - this.redraw(); - } - - redraw(): void { - this.canvas = document.createElement("canvas"); - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - const context = this.canvas.getContext("2d", { alpha: false }); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - - this.imageData = this.context.createImageData( - this.canvas.width, - this.canvas.height, - ); - - this.initImageData(); - this.context.putImageData(this.imageData, 0, 0); - } - - initImageData() { - this.theme = this.config.theme(); - this.game.forEachTile((tile) => { - const terrainColor = this.theme.terrainColor(this.game, tile); - // TODO: isn't tileref and index the same? - const index = this.game.y(tile) * this.game.width() + this.game.x(tile); - const offset = index * 4; - this.imageData.data[offset] = terrainColor.rgba.r; - this.imageData.data[offset + 1] = terrainColor.rgba.g; - this.imageData.data[offset + 2] = terrainColor.rgba.b; - this.imageData.data[offset + 3] = 255; - }); - } - - renderLayer(context: CanvasRenderingContext2D) { - if (this.transformHandler.scale < 1) { - context.imageSmoothingEnabled = true; - context.imageSmoothingQuality = "low"; - } else { - context.imageSmoothingEnabled = false; - } - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } -} diff --git a/src/client/graphics/layers/TerritoryLayer.ts b/src/client/graphics/layers/TerritoryLayer.ts deleted file mode 100644 index 3cc3d34e69..0000000000 --- a/src/client/graphics/layers/TerritoryLayer.ts +++ /dev/null @@ -1,709 +0,0 @@ -import { PriorityQueue } from "@datastructures-js/priority-queue"; -import { Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { - Cell, - ColoredTeams, - PlayerType, - Team, - UnitType, -} from "../../../core/game/Game"; -import { euclDistFN, TileRef } from "../../../core/game/GameMap"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, PlayerView } from "../../../core/game/GameView"; -import { PseudoRandom } from "../../../core/PseudoRandom"; -import { - AlternateViewEvent, - DragEvent, - MouseOverEvent, -} from "../../InputHandler"; -import { FrameProfiler } from "../FrameProfiler"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -export class TerritoryLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private imageData: ImageData; - private alternativeImageData: ImageData; - private borderAnimTime = 0; - - private cachedTerritoryPatternsEnabled: boolean | undefined; - - private tileToRenderQueue: PriorityQueue<{ - tile: TileRef; - lastUpdate: number; - }> = new PriorityQueue((a, b) => { - return a.lastUpdate - b.lastUpdate; - }); - private random = new PseudoRandom(123); - private theme: Theme; - - // Used for spawn highlighting - private highlightCanvas: HTMLCanvasElement; - private highlightContext: CanvasRenderingContext2D; - - private highlightedTerritory: PlayerView | null = null; - - private alternativeView = false; - private lastDragTime = 0; - private nodrawDragDuration = 200; - private lastMousePosition: { x: number; y: number } | null = null; - - private refreshRate = 10; //refresh every 10ms - private lastRefresh = 0; - - private lastFocusedPlayer: PlayerView | null = null; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - this.cachedTerritoryPatternsEnabled = undefined; - } - - shouldTransform(): boolean { - return true; - } - - async paintPlayerBorder(player: PlayerView) { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); // Immediately paint the tile instead of enqueueing - }); - } - - tick() { - if (this.game.inSpawnPhase()) { - this.spawnHighlight(); - } - - this.game.recentlyUpdatedTiles().forEach((t) => { - this.enqueueTile(t); - // Immediately clear territory overlay for water tiles so old - // borders/territory don't persist visually (e.g. after nuke turns land to water) - if (this.game.isWater(t)) { - this.clearTile(t); - } - }); - const updates = this.game.updatesSinceLastTick(); - const unitUpdates = updates !== null ? updates[GameUpdateType.Unit] : []; - unitUpdates.forEach((update) => { - if (update.unitType === UnitType.DefensePost) { - // Only update borders if the defense post is not under construction - if (update.underConstruction) { - return; // Skip barrier creation while under construction - } - - const tile = update.pos; - this.game - .bfs(tile, euclDistFN(tile, this.game.config().defensePostRange())) - .forEach((t) => { - if ( - this.game.isBorder(t) && - (this.game.ownerID(t) === update.ownerID || - this.game.ownerID(t) === update.lastOwnerID) - ) { - this.enqueueTile(t); - } - }); - } - }); - - // Detect alliance mutations - const myPlayer = this.game.myPlayer(); - if (myPlayer) { - updates?.[GameUpdateType.BrokeAlliance]?.forEach((update) => { - const territory = this.game.playerBySmallID(update.betrayedID); - if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); - } - }); - - updates?.[GameUpdateType.AllianceRequestReply]?.forEach((update) => { - if ( - update.accepted && - (update.request.requestorID === myPlayer.smallID() || - update.request.recipientID === myPlayer.smallID()) - ) { - const territoryId = - update.request.requestorID === myPlayer.smallID() - ? update.request.recipientID - : update.request.requestorID; - const territory = this.game.playerBySmallID(territoryId); - if (territory && territory instanceof PlayerView) { - this.redrawBorder(territory); - } - } - }); - updates?.[GameUpdateType.EmbargoEvent]?.forEach((update) => { - const player = this.game.playerBySmallID(update.playerID) as PlayerView; - const embargoed = this.game.playerBySmallID( - update.embargoedID, - ) as PlayerView; - - if ( - player.id() === myPlayer?.id() || - embargoed.id() === myPlayer?.id() - ) { - this.redrawBorder(player, embargoed); - } - }); - } - - const focusedPlayer = this.game.focusedPlayer(); - if (focusedPlayer !== this.lastFocusedPlayer) { - if (this.lastFocusedPlayer) { - this.paintPlayerBorder(this.lastFocusedPlayer); - } - if (focusedPlayer) { - this.paintPlayerBorder(focusedPlayer); - } - this.lastFocusedPlayer = focusedPlayer; - } - } - - private spawnHighlight() { - this.highlightContext.clearRect( - 0, - 0, - this.game.width(), - this.game.height(), - ); - - this.drawFocusedPlayerHighlight(); - - const humans = this.game - .playerViews() - .filter((p) => p.type() === PlayerType.Human); - - const focusedPlayer = this.game.focusedPlayer(); - const teamColors = Object.values(ColoredTeams); - for (const human of humans) { - if (human === focusedPlayer) { - continue; - } - const center = human.nameLocation(); - if (!center) { - continue; - } - const centerTile = this.game.ref(center.x, center.y); - if (!centerTile) { - continue; - } - let color = this.theme.spawnHighlightColor(); - const myPlayer = this.game.myPlayer(); - if (myPlayer !== null && myPlayer !== human && myPlayer.team() === null) { - // In FFA games (when team === null), use default yellow spawn highlight color - color = this.theme.spawnHighlightColor(); - } else if (myPlayer !== null && myPlayer !== human) { - // In Team games, the spawn highlight color becomes that player's team color - // Optionally, this could be broken down to teammate or enemy and simplified to green and red, respectively - const team = human.team(); - if (team !== null && teamColors.includes(team)) { - color = this.theme.teamColor(team); - } else { - if (myPlayer.isFriendly(human)) { - color = this.theme.spawnHighlightTeamColor(); - } else { - color = this.theme.spawnHighlightColor(); - } - } - } - - for (const tile of this.game.bfs( - centerTile, - euclDistFN(centerTile, 9, true), - )) { - if (!this.game.hasOwner(tile)) { - this.paintHighlightTile(tile, color, 255); - } - } - } - } - - private drawFocusedPlayerHighlight() { - const focusedPlayer = this.game.focusedPlayer(); - - if (!focusedPlayer) { - return; - } - const center = focusedPlayer.nameLocation(); - if (!center) { - return; - } - // Breathing border animation - this.borderAnimTime += 0.5; - const minRad = 8; - const maxRad = 24; - // Range: [minPadding..maxPadding] - const radius = - minRad + (maxRad - minRad) * (0.5 + 0.5 * Math.sin(this.borderAnimTime)); - - const baseColor = this.theme.spawnHighlightSelfColor(); //white - let teamColor: Colord; - - const team: Team | null = focusedPlayer.team(); - if (team !== null && Object.values(ColoredTeams).includes(team)) { - teamColor = this.theme.teamColor(team).alpha(0.5); - } else { - teamColor = baseColor; - } - - this.drawBreathingRing( - center.x, - center.y, - minRad, - maxRad, - radius, - baseColor, // Always draw white static semi-transparent ring - teamColor, // Pass the breathing ring color. White for FFA, Duos, Trios, Quads. Transparent team color for TEAM games. - ); - - // Draw breathing rings for teammates in team games (helps colorblind players identify teammates) - this.drawTeammateHighlights(minRad, maxRad, radius); - } - - private drawTeammateHighlights( - minRad: number, - maxRad: number, - radius: number, - ) { - const myPlayer = this.game.myPlayer(); - if (myPlayer === null || myPlayer.team() === null) { - return; - } - - const teammates = this.game - .playerViews() - .filter((p) => p !== myPlayer && myPlayer.isOnSameTeam(p)); - - // Smaller radius for teammates (more subtle than self highlight) - const teammateMinRad = 5; - const teammateMaxRad = 14; - const teammateRadius = - teammateMinRad + - (teammateMaxRad - teammateMinRad) * - ((radius - minRad) / (maxRad - minRad)); - - const teamColors = Object.values(ColoredTeams); - for (const teammate of teammates) { - const center = teammate.nameLocation(); - if (!center) { - continue; - } - - const team = teammate.team(); - let baseColor: Colord; - let breathingColor: Colord; - - if (team !== null && teamColors.includes(team)) { - baseColor = this.theme.teamColor(team).alpha(0.5); - breathingColor = this.theme.teamColor(team).alpha(0.5); - } else { - baseColor = this.theme.spawnHighlightTeamColor(); - breathingColor = this.theme.spawnHighlightTeamColor(); - } - - this.drawBreathingRing( - center.x, - center.y, - teammateMinRad, - teammateMaxRad, - teammateRadius, - baseColor, - breathingColor, - ); - } - } - - init() { - this.eventBus.on(MouseOverEvent, (e) => this.onMouseOver(e)); - this.eventBus.on(AlternateViewEvent, (e) => { - this.alternativeView = e.alternateView; - }); - this.eventBus.on(DragEvent, (e) => { - // TODO: consider re-enabling this on mobile or low end devices for smoother dragging. - // this.lastDragTime = Date.now(); - }); - this.redraw(); - } - - onMouseOver(event: MouseOverEvent) { - this.lastMousePosition = { x: event.x, y: event.y }; - this.updateHighlightedTerritory(); - } - - private updateHighlightedTerritory() { - if (!this.alternativeView) { - return; - } - - if (!this.lastMousePosition) { - return; - } - - const cell = this.transformHandler.screenToWorldCoordinates( - this.lastMousePosition.x, - this.lastMousePosition.y, - ); - if (!this.game.isValidCoord(cell.x, cell.y)) { - return; - } - - const previousTerritory = this.highlightedTerritory; - const territory = this.getTerritoryAtCell(cell); - - if (territory) { - this.highlightedTerritory = territory; - } else { - this.highlightedTerritory = null; - } - - if (previousTerritory?.id() !== this.highlightedTerritory?.id()) { - const territories: PlayerView[] = []; - if (previousTerritory) { - territories.push(previousTerritory); - } - if (this.highlightedTerritory) { - territories.push(this.highlightedTerritory); - } - this.redrawBorder(...territories); - } - } - - private getTerritoryAtCell(cell: { x: number; y: number }) { - const tile = this.game.ref(cell.x, cell.y); - if (!tile) { - return null; - } - // If the tile has no owner, it is either a fallout tile or a terra nullius tile. - if (!this.game.hasOwner(tile)) { - return null; - } - const owner = this.game.owner(tile); - return owner instanceof PlayerView ? owner : null; - } - - redraw() { - console.log("redrew territory layer"); - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - this.imageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.alternativeImageData = this.context.getImageData( - 0, - 0, - this.canvas.width, - this.canvas.height, - ); - this.initImageData(); - - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - ); - - // Add a second canvas for highlights - this.highlightCanvas = document.createElement("canvas"); - const highlightContext = this.highlightCanvas.getContext("2d", { - alpha: true, - }); - if (highlightContext === null) throw new Error("2d context not supported"); - this.highlightContext = highlightContext; - this.highlightCanvas.width = this.game.width(); - this.highlightCanvas.height = this.game.height(); - - this.game.forEachTile((t) => { - this.paintTerritory(t); - }); - } - - redrawBorder(...players: PlayerView[]) { - return Promise.all( - players.map(async (player) => { - const tiles = await player.borderTiles(); - tiles.borderTiles.forEach((tile: TileRef) => { - this.paintTerritory(tile, true); - }); - }), - ); - } - - initImageData() { - this.game.forEachTile((tile) => { - const cell = new Cell(this.game.x(tile), this.game.y(tile)); - const index = cell.y * this.game.width() + cell.x; - const offset = index * 4; - this.imageData.data[offset + 3] = 0; - this.alternativeImageData.data[offset + 3] = 0; - }); - } - - renderLayer(context: CanvasRenderingContext2D) { - const now = Date.now(); - if ( - now > this.lastDragTime + this.nodrawDragDuration && - now > this.lastRefresh + this.refreshRate - ) { - this.lastRefresh = now; - const renderTerritoryStart = FrameProfiler.start(); - this.renderTerritory(); - FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart); - - const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect(); - const vx0 = Math.max(0, topLeft.x); - const vy0 = Math.max(0, topLeft.y); - const vx1 = Math.min(this.game.width() - 1, bottomRight.x); - const vy1 = Math.min(this.game.height() - 1, bottomRight.y); - - const w = vx1 - vx0 + 1; - const h = vy1 - vy0 + 1; - - if (w > 0 && h > 0) { - const putImageStart = FrameProfiler.start(); - this.context.putImageData( - this.alternativeView ? this.alternativeImageData : this.imageData, - 0, - 0, - vx0, - vy0, - w, - h, - ); - FrameProfiler.end("TerritoryLayer:putImageData", putImageStart); - } - } - - const drawCanvasStart = FrameProfiler.start(); - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart); - if (this.game.inSpawnPhase()) { - const highlightDrawStart = FrameProfiler.start(); - context.drawImage( - this.highlightCanvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - FrameProfiler.end( - "TerritoryLayer:drawHighlightCanvas", - highlightDrawStart, - ); - } - } - - renderTerritory() { - let numToRender = Math.floor(this.tileToRenderQueue.size() / 10); - if (numToRender === 0 || this.game.inSpawnPhase()) { - numToRender = this.tileToRenderQueue.size(); - } - - while (numToRender > 0) { - numToRender--; - - const entry = this.tileToRenderQueue.pop(); - if (!entry) { - break; - } - - const tile = entry.tile; - this.paintTerritory(tile); - for (const neighbor of this.game.neighbors(tile)) { - this.paintTerritory(neighbor, true); - } - } - } - - paintTerritory(tile: TileRef, isBorder: boolean = false) { - if (isBorder && !this.game.hasOwner(tile)) { - return; - } - - if (!this.game.hasOwner(tile)) { - if (this.game.hasFallout(tile)) { - this.paintTile(this.imageData, tile, this.theme.falloutColor(), 150); - this.paintTile( - this.alternativeImageData, - tile, - this.theme.falloutColor(), - 150, - ); - return; - } - this.clearTile(tile); - return; - } - const owner = this.game.owner(tile) as PlayerView; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const isHighlighted = - this.highlightedTerritory && - this.highlightedTerritory.id() === owner.id(); - const myPlayer = this.game.myPlayer(); - - if (this.game.isBorder(tile)) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const playerIsFocused = owner && this.game.focusedPlayer() === owner; - if (myPlayer) { - const alternativeColor = this.alternateViewColor(owner); - this.paintTile(this.alternativeImageData, tile, alternativeColor, 255); - } - const isDefended = this.game.hasUnitNearby( - tile, - this.game.config().defensePostRange(), - UnitType.DefensePost, - owner.id(), - ); - - this.paintTile( - this.imageData, - tile, - owner.borderColor(tile, isDefended), - 255, - ); - } else { - // Alternative view only shows borders. - this.clearAlternativeTile(tile); - - this.paintTile(this.imageData, tile, owner.territoryColor(tile), 150); - } - } - - alternateViewColor(other: PlayerView): Colord { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) { - return this.theme.neutralColor(); - } - if (other.smallID() === myPlayer.smallID()) { - return this.theme.selfColor(); - } - if (other.isFriendly(myPlayer)) { - return this.theme.allyColor(); - } - if (!other.hasEmbargo(myPlayer)) { - return this.theme.neutralColor(); - } - return this.theme.enemyColor(); - } - - paintAlternateViewTile(tile: TileRef, other: PlayerView) { - const color = this.alternateViewColor(other); - this.paintTile(this.alternativeImageData, tile, color, 255); - } - - paintTile(imageData: ImageData, tile: TileRef, color: Colord, alpha: number) { - const offset = tile * 4; - imageData.data[offset] = color.rgba.r; - imageData.data[offset + 1] = color.rgba.g; - imageData.data[offset + 2] = color.rgba.b; - imageData.data[offset + 3] = alpha; - } - - clearTile(tile: TileRef) { - const offset = tile * 4; - this.imageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - clearAlternativeTile(tile: TileRef) { - const offset = tile * 4; - this.alternativeImageData.data[offset + 3] = 0; // Set alpha to 0 (fully transparent) - } - - enqueueTile(tile: TileRef) { - this.tileToRenderQueue.push({ - tile: tile, - lastUpdate: this.game.ticks() + this.random.nextFloat(0, 0.5), - }); - } - - async enqueuePlayerBorder(player: PlayerView) { - const playerBorderTiles = await player.borderTiles(); - playerBorderTiles.borderTiles.forEach((tile: TileRef) => { - this.enqueueTile(tile); - }); - } - - paintHighlightTile(tile: TileRef, color: Colord, alpha: number) { - this.clearTile(tile); - const x = this.game.x(tile); - const y = this.game.y(tile); - this.highlightContext.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.highlightContext.fillRect(x, y, 1, 1); - } - - clearHighlightTile(tile: TileRef) { - const x = this.game.x(tile); - const y = this.game.y(tile); - this.highlightContext.clearRect(x, y, 1, 1); - } - - private drawBreathingRing( - cx: number, - cy: number, - minRad: number, - maxRad: number, - radius: number, - transparentColor: Colord, - breathingColor: Colord, - ) { - const ctx = this.highlightContext; - if (!ctx) return; - - // Draw a semi-transparent ring around the starting location - ctx.beginPath(); - // Transparency matches the highlight color provided - const transparent = transparentColor.alpha(0); - const radGrad = ctx.createRadialGradient(cx, cy, minRad, cx, cy, maxRad); - - // Pixels with radius < minRad are transparent - radGrad.addColorStop(0, transparent.toRgbString()); - // The ring then starts with solid highlight color - radGrad.addColorStop(0.01, transparentColor.toRgbString()); - radGrad.addColorStop(0.1, transparentColor.toRgbString()); - // The outer edge of the ring is transparent - radGrad.addColorStop(1, transparent.toRgbString()); - - // Draw an arc at the max radius and fill with the created radial gradient - ctx.arc(cx, cy, maxRad, 0, Math.PI * 2); - ctx.fillStyle = radGrad; - ctx.closePath(); - ctx.fill(); - - const breatheInner = breathingColor.alpha(0); - // Draw a solid ring around the starting location with outer radius = the breathing radius - ctx.beginPath(); - const radGrad2 = ctx.createRadialGradient(cx, cy, minRad, cx, cy, radius); - // Pixels with radius < minRad are transparent - radGrad2.addColorStop(0, breatheInner.toRgbString()); - // The ring then starts with solid highlight color - radGrad2.addColorStop(0.01, breathingColor.toRgbString()); - // The ring is solid throughout - radGrad2.addColorStop(1, breathingColor.toRgbString()); - - // Draw an arc at the current breathing radius and fill with the created "gradient" - ctx.arc(cx, cy, radius, 0, Math.PI * 2); - ctx.fillStyle = radGrad2; - ctx.fill(); - } -} diff --git a/src/client/graphics/layers/UILayer.ts b/src/client/graphics/layers/UILayer.ts deleted file mode 100644 index 4c4ff15bb6..0000000000 --- a/src/client/graphics/layers/UILayer.ts +++ /dev/null @@ -1,583 +0,0 @@ -import { Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { UnitType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView, UnitView } from "../../../core/game/GameView"; -import { - CloseViewEvent, - UnitSelectionEvent, - WarshipSelectionBoxCancelEvent, - WarshipSelectionBoxCompleteEvent, - WarshipSelectionBoxUpdateEvent, -} from "../../InputHandler"; -import { ProgressBar } from "../ProgressBar"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -const COLOR_PROGRESSION = [ - "rgb(232, 25, 25)", - "rgb(240, 122, 25)", - "rgb(202, 231, 15)", - "rgb(44, 239, 18)", -]; -const HEALTHBAR_WIDTH = 11; // Width of the health bar -const LOADINGBAR_WIDTH = 14; // Width of the loading bar -const PROGRESSBAR_HEIGHT = 3; // Height of a bar - -/** - * Layer responsible for drawing UI elements that overlay the game - * such as selection boxes, health bars, etc. - */ -export class UILayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D | null; - private theme: Theme | null = null; - private selectionAnimTime = 0; - private allProgressBars: Map< - number, - { unit: UnitView; progressBar: ProgressBar } - > = new Map(); - private allHealthBars: Map = new Map(); - // Keep track of currently selected unit - private selectedUnit: UnitView | null = null; - - // Keep track of multi-selected warships (box selection) - private multiSelectedWarships: UnitView[] = []; - - // Per-unit last selection box position for multi-select cleanup - private multiSelectionBoxCenters: Map< - number, - { x: number; y: number; size: number } - > = new Map(); - - // Keep track of previous selection box position for cleanup - private lastSelectionBoxCenter: { - x: number; - y: number; - size: number; - } | null = null; - - // Visual settings for selection - private readonly SELECTION_BOX_SIZE = 6; // Size of the selection box (should be larger than the warship) - - // Selection box (drag rectangle) state - private selectionBoxActive = false; - private selectionBoxStartX = 0; - private selectionBoxStartY = 0; - private selectionBoxEndX = 0; - private selectionBoxEndY = 0; - private selectionBoxCanvas: HTMLCanvasElement = - document.createElement("canvas"); - private selectionBoxCtx: CanvasRenderingContext2D | null = null; - - constructor( - private game: GameView, - private eventBus: EventBus, - private transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - } - - shouldTransform(): boolean { - return true; - } - - tick() { - // Update the selection animation time - this.selectionAnimTime = (this.selectionAnimTime + 1) % 60; - - // If there's a selected warship, redraw to update the selection box animation - if (this.selectedUnit && this.selectedUnit.type() === UnitType.Warship) { - this.drawSelectionBox(this.selectedUnit); - } - - // Animate multi-selected warships - for (const unit of this.multiSelectedWarships) { - if (unit.isActive()) { - this.drawSelectionBoxMulti(unit); - } else { - // Unit was destroyed — clean up its box - const prev = this.multiSelectionBoxCenters.get(unit.id()); - if (prev) { - this.clearSelectionBox(prev.x, prev.y, prev.size); - this.multiSelectionBoxCenters.delete(unit.id()); - } - } - } - // Remove destroyed units from the list - this.multiSelectedWarships = this.multiSelectedWarships.filter((u) => - u.isActive(), - ); - - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id)) - ?.forEach((unitView) => { - if (unitView === undefined) return; - this.onUnitEvent(unitView); - }); - this.updateProgressBars(); - } - - init() { - this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelection(e)); - this.eventBus.on(WarshipSelectionBoxUpdateEvent, (e) => { - this.selectionBoxActive = true; - this.selectionBoxStartX = e.startX; - this.selectionBoxStartY = e.startY; - this.selectionBoxEndX = e.endX; - this.selectionBoxEndY = e.endY; - }); - const clearBox = () => { - this.selectionBoxActive = false; - this.selectionBoxCtx?.clearRect( - 0, - 0, - this.selectionBoxCanvas.width, - this.selectionBoxCanvas.height, - ); - }; - this.eventBus.on(WarshipSelectionBoxCompleteEvent, clearBox); - this.eventBus.on(WarshipSelectionBoxCancelEvent, clearBox); - this.eventBus.on(CloseViewEvent, clearBox); - this.redraw(); - } - - renderLayer(context: CanvasRenderingContext2D) { - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - if (this.selectionBoxActive) { - this.renderSelectionBox(context); - } - } - - private renderSelectionBox(context: CanvasRenderingContext2D) { - if (!this.selectionBoxCtx) return; - - const topLeft = this.transformHandler.screenToWorldCoordinates( - Math.min(this.selectionBoxStartX, this.selectionBoxEndX), - Math.min(this.selectionBoxStartY, this.selectionBoxEndY), - ); - const bottomRight = this.transformHandler.screenToWorldCoordinates( - Math.max(this.selectionBoxStartX, this.selectionBoxEndX), - Math.max(this.selectionBoxStartY, this.selectionBoxEndY), - ); - - const cx1 = Math.max(0, Math.floor(topLeft.x)); - const cy1 = Math.max(0, Math.floor(topLeft.y)); - const cx2 = Math.min( - this.selectionBoxCanvas.width - 1, - Math.floor(bottomRight.x), - ); - const cy2 = Math.min( - this.selectionBoxCanvas.height - 1, - Math.floor(bottomRight.y), - ); - - if (cx2 <= cx1 || cy2 <= cy1) return; - - const myPlayer = this.game.myPlayer(); - const baseColor = myPlayer ? myPlayer.territoryColor().lighten(0.2) : null; - const colorStr = baseColor - ? baseColor.alpha(0.85).toRgbString() - : "rgba(100,200,255,0.85)"; - - this.selectionBoxCtx.clearRect( - 0, - 0, - this.selectionBoxCanvas.width, - this.selectionBoxCanvas.height, - ); - this.selectionBoxCtx.fillStyle = colorStr; - this.drawDashedLine(this.selectionBoxCtx, cx1, cy1, cx2, cy1); - this.drawDashedLine(this.selectionBoxCtx, cx1, cy2, cx2, cy2); - this.drawDashedLine(this.selectionBoxCtx, cx1, cy1, cx1, cy2); - this.drawDashedLine(this.selectionBoxCtx, cx2, cy1, cx2, cy2); - - this.selectionBoxCtx.fillStyle = baseColor - ? baseColor.alpha(0.06).toRgbString() - : "rgba(100,200,255,0.06)"; - this.selectionBoxCtx.fillRect( - cx1 + 1, - cy1 + 1, - cx2 - cx1 - 1, - cy2 - cy1 - 1, - ); - - context.drawImage( - this.selectionBoxCanvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } - - private drawDashedLine( - ctx: CanvasRenderingContext2D, - x1: number, - y1: number, - x2: number, - y2: number, - ) { - if (x1 === x2) { - for (let y = y1; y <= y2; y++) { - if ((x1 + y) % 2 === 0) ctx.fillRect(x1, y, 1, 1); - } - } else { - for (let x = x1; x <= x2; x++) { - if ((x + y1) % 2 === 0) ctx.fillRect(x, y1, 1, 1); - } - } - } - - redraw() { - this.canvas = document.createElement("canvas"); - this.context = this.canvas.getContext("2d"); - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - - this.selectionBoxCanvas = document.createElement("canvas"); - this.selectionBoxCanvas.width = this.game.width(); - this.selectionBoxCanvas.height = this.game.height(); - this.selectionBoxCtx = this.selectionBoxCanvas.getContext("2d"); - } - - onUnitEvent(unit: UnitView) { - const underConst = unit.isUnderConstruction(); - if (underConst) { - this.createLoadingBar(unit); - return; - } - switch (unit.type()) { - case UnitType.Warship: { - this.drawHealthBar(unit); - break; - } - case UnitType.City: - case UnitType.Factory: - case UnitType.DefensePost: - case UnitType.Port: - case UnitType.MissileSilo: - case UnitType.SAMLauncher: - if ( - unit.markedForDeletion() !== false || - unit.missileReadinesss() < 1 - ) { - this.createLoadingBar(unit); - } - break; - default: - return; - } - } - - private clearIcon(icon: HTMLImageElement, startX: number, startY: number) { - if (this.context !== null) { - this.context.clearRect(startX, startY, icon.width, icon.height); - } - } - - private drawIcon( - icon: HTMLImageElement, - unit: UnitView, - startX: number, - startY: number, - ) { - if (this.context === null || this.theme === null) { - return; - } - const color = unit.owner().borderColor(); - this.context.fillStyle = color.toRgbString(); - this.context.fillRect(startX, startY, icon.width, icon.height); - this.context.drawImage(icon, startX, startY); - } - - /** - * Handle the unit selection event (single or multi). - * When event.units.length > 0 it's a multi-selection from box/select-all. - * When event.unit is set it's a single warship selection. - * When event.isSelected is false it clears all selection state. - */ - private onUnitSelection(event: UnitSelectionEvent) { - if (event.isSelected) { - // Always clear single-selection outline first - if (this.lastSelectionBoxCenter) { - const { x, y, size } = this.lastSelectionBoxCenter; - this.clearSelectionBox(x, y, size); - this.lastSelectionBoxCenter = null; - } - // selectedUnit is always reset regardless of lastSelectionBoxCenter - this.selectedUnit = null; - // Always clear previous multi-selection boxes - for (const [, center] of this.multiSelectionBoxCenters) { - this.clearSelectionBox(center.x, center.y, center.size); - } - this.multiSelectionBoxCenters.clear(); - this.multiSelectedWarships = []; - - if ((event.units ?? []).length > 0) { - // Multi-selection - this.multiSelectedWarships = event.units; - for (const unit of this.multiSelectedWarships) { - if (unit.isActive()) { - this.drawSelectionBoxMulti(unit); - } - } - } else { - // Single selection - this.selectedUnit = event.unit; - if (event.unit && event.unit.type() === UnitType.Warship) { - this.drawSelectionBox(event.unit); - } - } - } else { - // Deselect everything - if (this.lastSelectionBoxCenter) { - const { x, y, size } = this.lastSelectionBoxCenter; - this.clearSelectionBox(x, y, size); - this.lastSelectionBoxCenter = null; - } - this.selectedUnit = null; - for (const [, center] of this.multiSelectionBoxCenters) { - this.clearSelectionBox(center.x, center.y, center.size); - } - this.multiSelectionBoxCenters.clear(); - this.multiSelectedWarships = []; - } - } - - /** - * Draw selection box for a multi-selected warship, tracking position per unit id. - */ - private drawSelectionBoxMulti(unit: UnitView) { - if (!unit || !unit.isActive()) return; - - if (this.theme === null) throw new Error("missing theme"); - const selectionColor = unit.owner().territoryColor().lighten(0.2); - const centerX = this.game.x(unit.tile()); - const centerY = this.game.y(unit.tile()); - - const prev = this.multiSelectionBoxCenters.get(unit.id()); - if (prev && (prev.x !== centerX || prev.y !== centerY)) { - this.clearSelectionBox(prev.x, prev.y, prev.size); - } - - this.paintSelectionBoxAt(centerX, centerY, selectionColor); - - this.multiSelectionBoxCenters.set(unit.id(), { - x: centerX, - y: centerY, - size: this.SELECTION_BOX_SIZE, - }); - } - - /** - * Shared helper: paint the dashed pulsing border pixels for a selection box. - */ - private paintSelectionBoxAt( - centerX: number, - centerY: number, - selectionColor: Colord, - ) { - const size = this.SELECTION_BOX_SIZE; - const opacity = 200 + Math.sin(this.selectionAnimTime * 0.1) * 55; - - for (let x = centerX - size; x <= centerX + size; x++) { - for (let y = centerY - size; y <= centerY + size; y++) { - if ( - x === centerX - size || - x === centerX + size || - y === centerY - size || - y === centerY + size - ) { - if ((x + y) % 2 === 0) { - this.paintCell(x, y, selectionColor, opacity); - } - } - } - } - } - - /** - * Clear the selection box at a specific position - */ - private clearSelectionBox(x: number, y: number, size: number) { - for (let px = x - size; px <= x + size; px++) { - for (let py = y - size; py <= y + size; py++) { - if ( - px === x - size || - px === x + size || - py === y - size || - py === y + size - ) { - this.clearCell(px, py); - } - } - } - } - - /** - * Draw a selection box around the given unit - */ - public drawSelectionBox(unit: UnitView) { - if (!unit || !unit.isActive()) { - return; - } - - if (this.theme === null) throw new Error("missing theme"); - const selectionColor = unit.owner().territoryColor().lighten(0.2); - const centerX = this.game.x(unit.tile()); - const centerY = this.game.y(unit.tile()); - - // Clear previous box if unit moved - if ( - this.lastSelectionBoxCenter && - (this.lastSelectionBoxCenter.x !== centerX || - this.lastSelectionBoxCenter.y !== centerY) - ) { - this.clearSelectionBox( - this.lastSelectionBoxCenter.x, - this.lastSelectionBoxCenter.y, - this.lastSelectionBoxCenter.size, - ); - } - - this.paintSelectionBoxAt(centerX, centerY, selectionColor); - - this.lastSelectionBoxCenter = { - x: centerX, - y: centerY, - size: this.SELECTION_BOX_SIZE, - }; - } - - /** - * Draw health bar for a unit - */ - public drawHealthBar(unit: UnitView) { - const maxHealth = this.game.unitInfo(unit.type()).maxHealth; - if (maxHealth === undefined || this.context === null) { - return; - } - if ( - this.allHealthBars.has(unit.id()) && - (unit.health() >= maxHealth || unit.health() <= 0 || !unit.isActive()) - ) { - // full hp/dead warships dont need a hp bar - this.allHealthBars.get(unit.id())?.clear(); - this.allHealthBars.delete(unit.id()); - } else if ( - unit.isActive() && - unit.health() < maxHealth && - unit.health() > 0 - ) { - this.allHealthBars.get(unit.id())?.clear(); - const healthBar = new ProgressBar( - COLOR_PROGRESSION, - this.context, - this.game.x(unit.tile()) - 4, - this.game.y(unit.tile()) - 6, - HEALTHBAR_WIDTH, - PROGRESSBAR_HEIGHT, - unit.health() / maxHealth, - ); - // keep track of units that have health bars for clearing purposes - this.allHealthBars.set(unit.id(), healthBar); - } - } - - private updateProgressBars() { - this.allProgressBars.forEach((progressBarInfo, unitId) => { - const progress = this.getProgress(progressBarInfo.unit); - if (progress >= 1) { - this.allProgressBars.get(unitId)?.progressBar.clear(); - this.allProgressBars.delete(unitId); - return; - } else { - progressBarInfo.progressBar.setProgress(progress); - } - }); - } - - private getProgress(unit: UnitView): number { - if (!unit.isActive()) { - return 1; - } - const underConst = unit.isUnderConstruction(); - if (underConst) { - const constDuration = this.game.unitInfo( - unit.type(), - ).constructionDuration; - if (constDuration === undefined) { - throw new Error("unit does not have constructionTime"); - } - return ( - (this.game.ticks() - unit.createdAt()) / - (constDuration === 0 ? 1 : constDuration) - ); - } - switch (unit.type()) { - case UnitType.MissileSilo: - case UnitType.SAMLauncher: - return !unit.markedForDeletion() - ? unit.missileReadinesss() - : this.deletionProgress(this.game, unit); - case UnitType.City: - case UnitType.Factory: - case UnitType.Port: - case UnitType.DefensePost: - return this.deletionProgress(this.game, unit); - default: - return 1; - } - } - - private deletionProgress(game: GameView, unit: UnitView): number { - const deleteAt = unit.markedForDeletion(); - if (deleteAt === false) return 1; - return Math.max( - 0, - (deleteAt - game.ticks()) / game.config().deletionMarkDuration(), - ); - } - - public createLoadingBar(unit: UnitView) { - if (!this.context) { - return; - } - if (!this.allProgressBars.has(unit.id())) { - const progressBar = new ProgressBar( - COLOR_PROGRESSION, - this.context, - this.game.x(unit.tile()) - 6, - this.game.y(unit.tile()) + 6, - LOADINGBAR_WIDTH, - PROGRESSBAR_HEIGHT, - 0, - ); - this.allProgressBars.set(unit.id(), { - unit, - progressBar, - }); - } - } - - paintCell(x: number, y: number, color: Colord, alpha: number) { - if (this.context === null) throw new Error("null context"); - this.clearCell(x, y); - this.context.fillStyle = color.alpha(alpha / 255).toRgbString(); - this.context.fillRect(x, y, 1, 1); - } - - clearCell(x: number, y: number) { - if (this.context === null) throw new Error("null context"); - this.context.clearRect(x, y, 1, 1); - } -} diff --git a/src/client/graphics/layers/UnitDisplay.ts b/src/client/graphics/layers/UnitDisplay.ts index 69c504cb50..8714d51b65 100644 --- a/src/client/graphics/layers/UnitDisplay.ts +++ b/src/client/graphics/layers/UnitDisplay.ts @@ -11,13 +11,10 @@ import { } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { UserSettings } from "../../../core/game/UserSettings"; -import { - GhostStructureChangedEvent, - ToggleStructureEvent, -} from "../../InputHandler"; +import { Controller } from "../../Controller"; +import { ToggleStructureEvent } from "../../InputHandler"; +import { UIState } from "../../UIState"; import { renderNumber, translateText } from "../../Utils"; -import { UIState } from "../UIState"; -import { Layer } from "./Layer"; const warshipIcon = assetUrl("images/BattleshipIconWhite.svg"); const cityIcon = assetUrl("images/CityIconWhite.svg"); const factoryIcon = assetUrl("images/FactoryIconWhite.svg"); @@ -31,7 +28,7 @@ const samLauncherIcon = assetUrl("images/SamLauncherIconWhite.svg"); const defensePostIcon = assetUrl("images/ShieldIconWhite.svg"); @customElement("unit-display") -export class UnitDisplay extends LitElement implements Layer { +export class UnitDisplay extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; public uiState: UIState; @@ -268,10 +265,8 @@ export class UnitDisplay extends LitElement implements Layer { @click=${() => { if (selected) { this.uiState.ghostStructure = null; - this.eventBus?.emit(new GhostStructureChangedEvent(null)); } else if (this.canBuild(unitType)) { this.uiState.ghostStructure = unitType; - this.eventBus?.emit(new GhostStructureChangedEvent(unitType)); } this.requestUpdate(); }} diff --git a/src/client/graphics/layers/UnitLayer.ts b/src/client/graphics/layers/UnitLayer.ts deleted file mode 100644 index e40fb4d0ec..0000000000 --- a/src/client/graphics/layers/UnitLayer.ts +++ /dev/null @@ -1,768 +0,0 @@ -import { colord, Colord } from "colord"; -import { Theme } from "src/core/configuration/Theme"; -import { EventBus } from "../../../core/EventBus"; -import { Cell, UnitType } from "../../../core/game/Game"; -import { TileRef } from "../../../core/game/GameMap"; -import { GameView, UnitView } from "../../../core/game/GameView"; -import { BezenhamLine } from "../../../core/utilities/Line"; -import { - AlternateViewEvent, - CloseViewEvent, - ContextMenuEvent, - MouseUpEvent, - SelectAllWarshipsEvent, - TouchEvent, - UnitSelectionEvent, - WarshipSelectionBoxCancelEvent, - WarshipSelectionBoxCompleteEvent, -} from "../../InputHandler"; -import { MoveWarshipIntentEvent } from "../../Transport"; -import { TransformHandler } from "../TransformHandler"; -import { Layer } from "./Layer"; - -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { - getColoredSprite, - isSpriteReady, - loadAllSprites, -} from "../SpriteLoader"; - -enum Relationship { - Self, - Ally, - Enemy, -} - -export class UnitLayer implements Layer { - private canvas: HTMLCanvasElement; - private context: CanvasRenderingContext2D; - private transportShipTrailCanvas: HTMLCanvasElement; - private unitTrailContext: CanvasRenderingContext2D; - - private unitToTrail = new Map(); - private pendingTrailClears: UnitView[] = []; - - private theme: Theme; - - private alternateView = false; - - private oldShellTile = new Map(); - - private transformHandler: TransformHandler; - - // Selected unit property as suggested in the review comment - private selectedUnit: UnitView | null = null; - - // Multi-selected warships (from selection box) - private selectedWarships: UnitView[] = []; - - // Configuration for unit selection - private readonly WARSHIP_SELECTION_RADIUS = 10; // Radius in game cells for warship selection hit zone - - constructor( - private game: GameView, - private eventBus: EventBus, - transformHandler: TransformHandler, - ) { - this.theme = game.config().theme(); - this.transformHandler = transformHandler; - } - - shouldTransform(): boolean { - return true; - } - - tick() { - const updatedUnitIds = - this.game - .updatesSinceLastTick() - ?.[GameUpdateType.Unit]?.map((unit) => unit.id) ?? []; - - const motionPlanUnitIds = this.game.motionPlannedUnitIds(); - - if (updatedUnitIds.length === 0) { - this.updateUnitsSprites(motionPlanUnitIds); - return; - } - if (motionPlanUnitIds.length === 0) { - this.updateUnitsSprites(updatedUnitIds); - return; - } - - const unitIds = new Set(updatedUnitIds); - for (const id of motionPlanUnitIds) { - unitIds.add(id); - } - this.updateUnitsSprites(Array.from(unitIds)); - } - - init() { - this.eventBus.on(AlternateViewEvent, (e) => this.onAlternativeViewEvent(e)); - this.eventBus.on(MouseUpEvent, (e) => this.onMouseUp(e)); - this.eventBus.on(TouchEvent, (e) => this.onTouch(e)); - this.eventBus.on(UnitSelectionEvent, (e) => this.onUnitSelectionChange(e)); - this.eventBus.on(WarshipSelectionBoxCompleteEvent, (e) => - this.onSelectionBoxComplete(e), - ); - this.eventBus.on(WarshipSelectionBoxCancelEvent, () => - this.onSelectionBoxCancel(), - ); - this.eventBus.on(CloseViewEvent, () => this.onSelectionBoxCancel()); - this.eventBus.on(SelectAllWarshipsEvent, () => this.onSelectAllWarships()); - this.redraw(); - - loadAllSprites(); - } - - /** - * Find player-owned warships near the given cell within a configurable radius - * @param clickRef The tile to check - * @returns Array of player's warships in range, sorted by distance (closest first) - */ - private findWarshipsNearCell(clickRef: TileRef): UnitView[] { - // Only select warships owned by the player - return this.game - .units(UnitType.Warship) - .filter( - (unit) => - unit.isActive() && - unit.owner() === this.game.myPlayer() && // Only allow selecting own warships - this.game.manhattanDist(unit.tile(), clickRef) <= - this.WARSHIP_SELECTION_RADIUS, - ) - .sort((a, b) => { - // Sort by distance (closest first) - const distA = this.game.manhattanDist(a.tile(), clickRef); - const distB = this.game.manhattanDist(b.tile(), clickRef); - return distA - distB; - }); - } - - private onMouseUp( - event: MouseUpEvent, - clickRef?: TileRef, - nearbyWarships?: UnitView[], - ) { - if (clickRef === undefined) { - // Convert screen coordinates to world coordinates - const cell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - if (!this.game.isValidCoord(cell.x, cell.y)) return; - - clickRef = this.game.ref(cell.x, cell.y); - } - if (!this.game.isWater(clickRef)) return; - - // If we have multi-selected warships, send them all to this tile - if (this.selectedWarships.length > 0) { - const myPlayer = this.game.myPlayer(); - const activeIds = this.selectedWarships - .filter((u) => u.isActive() && u.owner() === myPlayer) - .map((u) => u.id()); - - if (activeIds.length > 0) { - this.eventBus.emit(new MoveWarshipIntentEvent(activeIds, clickRef)); - } - this.selectedWarships = []; - this.eventBus.emit(new UnitSelectionEvent(null, false)); - return; - } - - if (this.selectedUnit) { - this.eventBus.emit( - new MoveWarshipIntentEvent([this.selectedUnit.id()], clickRef), - ); - // Deselect - this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); - return; - } - - // Find warships near this tile, sorted by distance - nearbyWarships ??= this.findWarshipsNearCell(clickRef); - if (nearbyWarships.length > 0) { - // Toggle selection of the closest warship - this.eventBus.emit(new UnitSelectionEvent(nearbyWarships[0], true)); - } - } - - private onTouch(event: TouchEvent) { - const cell = this.transformHandler.screenToWorldCoordinates( - event.x, - event.y, - ); - - if (!this.game.isValidCoord(cell.x, cell.y)) { - return; - } - - const clickRef = this.game.ref(cell.x, cell.y); - if (this.game.inSpawnPhase()) { - // No Radial Menu during spawn phase, only spawn point selection - if (!this.game.isWater(clickRef)) { - this.eventBus.emit(new MouseUpEvent(event.x, event.y)); - } - return; - } - - if (!this.game.isWater(clickRef)) { - // No warship to find because no Ocean tile, open Radial Menu - this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); - return; - } - - if (this.selectedUnit) { - // Reuse the mouse logic, send clickRef to avoid fetching it again - this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef); - return; - } - - // Also delegate if we have multi-selected warships - if (this.selectedWarships.length > 0) { - this.onMouseUp(new MouseUpEvent(event.x, event.y), clickRef); - return; - } - - const nearbyWarships = this.findWarshipsNearCell(clickRef); - - if (nearbyWarships.length > 0) { - this.onMouseUp( - new MouseUpEvent(event.x, event.y), - clickRef, - nearbyWarships, - ); - } else { - // No warships selected or nearby, open Radial Menu - this.eventBus.emit(new ContextMenuEvent(event.x, event.y)); - } - } - - /** - * Handle unit selection changes - */ - private onUnitSelectionChange(event: UnitSelectionEvent) { - if (event.isSelected) { - this.selectedUnit = event.unit; - } else if (this.selectedUnit === event.unit) { - this.selectedUnit = null; - } - } - - /** - * Handle completion of shift+drag selection box. - * Finds all player-owned warships within the screen rectangle. - */ - private onSelectionBoxComplete(event: WarshipSelectionBoxCompleteEvent) { - const x1 = Math.min(event.startX, event.endX); - const y1 = Math.min(event.startY, event.endY); - const x2 = Math.max(event.startX, event.endX); - const y2 = Math.max(event.startY, event.endY); - - const myPlayer = this.game.myPlayer(); - if (!myPlayer) return; - - this.selectedWarships = this.game.units(UnitType.Warship).filter((unit) => { - if (!unit.isActive() || unit.owner() !== myPlayer) return false; - const screen = this.transformHandler.worldToScreenCoordinates( - new Cell(this.game.x(unit.tile()), this.game.y(unit.tile())), - ); - return ( - screen.x >= x1 && screen.x <= x2 && screen.y >= y1 && screen.y <= y2 - ); - }); - - // Clear single selection if we got a box selection - if (this.selectedWarships.length > 0 && this.selectedUnit) { - this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); - } - - // Notify UILayer to draw selection boxes for all selected warships - this.eventBus.emit( - new UnitSelectionEvent(null, true, this.selectedWarships), - ); - } - - private onSelectionBoxCancel() { - this.selectedWarships = []; - this.eventBus.emit(new UnitSelectionEvent(null, false)); - } - - private onSelectAllWarships() { - const myPlayer = this.game.myPlayer(); - if (!myPlayer) return; - - const allWarships = this.game - .units(UnitType.Warship) - .filter((u) => u.isActive() && u.owner() === myPlayer); - - if (allWarships.length === 0) return; - - // Clear single selection if active - if (this.selectedUnit) { - this.eventBus.emit(new UnitSelectionEvent(this.selectedUnit, false)); - } - - this.selectedWarships = allWarships; - this.eventBus.emit( - new UnitSelectionEvent(null, true, this.selectedWarships), - ); - } - - /** - * Handle unit deactivation or destruction - * If the selected unit is removed from the game, deselect it - */ - private handleUnitDeactivation(unit: UnitView) { - if (this.selectedUnit === unit && !unit.isActive()) { - this.eventBus.emit(new UnitSelectionEvent(unit, false)); - } - } - - renderLayer(context: CanvasRenderingContext2D) { - context.drawImage( - this.transportShipTrailCanvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - context.drawImage( - this.canvas, - -this.game.width() / 2, - -this.game.height() / 2, - this.game.width(), - this.game.height(), - ); - } - - onAlternativeViewEvent(event: AlternateViewEvent) { - this.alternateView = event.alternateView; - this.redraw(); - } - - redraw() { - this.canvas = document.createElement("canvas"); - const context = this.canvas.getContext("2d"); - if (context === null) throw new Error("2d context not supported"); - this.context = context; - this.transportShipTrailCanvas = document.createElement("canvas"); - const trailContext = this.transportShipTrailCanvas.getContext("2d"); - if (trailContext === null) throw new Error("2d context not supported"); - this.unitTrailContext = trailContext; - - this.canvas.width = this.game.width(); - this.canvas.height = this.game.height(); - this.transportShipTrailCanvas.width = this.game.width(); - this.transportShipTrailCanvas.height = this.game.height(); - - this.updateUnitsSprites(this.game.units().map((unit) => unit.id())); - - this.unitToTrail.forEach((trail, unit) => { - for (const t of trail) { - this.paintCell( - this.game.x(t), - this.game.y(t), - this.relationship(unit), - unit.owner().territoryColor(), - 150, - this.unitTrailContext, - ); - } - }); - } - - private updateUnitsSprites(unitIds: number[]) { - const unitsToUpdate = unitIds - ?.map((id) => this.game.unit(id)) - .filter((unit) => unit !== undefined); - - if (unitsToUpdate) { - // the clearing and drawing of unit sprites need to be done in 2 passes - // otherwise the sprite of a unit can be drawn on top of another unit - this.clearUnitsCells(unitsToUpdate); - this.drawUnitsCells(unitsToUpdate); - this.flushTrailClears(); - } - } - - private clearUnitsCells(unitViews: UnitView[]) { - unitViews - .filter((unitView) => isSpriteReady(unitView)) - .forEach((unitView) => { - const sprite = getColoredSprite(unitView, this.theme); - const clearsize = sprite.width + 1; - const lastX = this.game.x(unitView.lastTile()); - const lastY = this.game.y(unitView.lastTile()); - this.context.clearRect( - lastX - clearsize / 2, - lastY - clearsize / 2, - clearsize, - clearsize, - ); - }); - } - - private drawUnitsCells(unitViews: UnitView[]) { - unitViews.forEach((unitView) => this.onUnitEvent(unitView)); - } - - private relationship(unit: UnitView): Relationship { - const myPlayer = this.game.myPlayer(); - if (myPlayer === null) { - return Relationship.Enemy; - } - if (myPlayer === unit.owner()) { - return Relationship.Self; - } - if (myPlayer.isFriendly(unit.owner())) { - return Relationship.Ally; - } - return Relationship.Enemy; - } - - onUnitEvent(unit: UnitView) { - // Check if unit was deactivated - if (!unit.isActive()) { - this.handleUnitDeactivation(unit); - } - - switch (unit.type()) { - case UnitType.TransportShip: - this.handleBoatEvent(unit); - break; - case UnitType.Warship: - this.handleWarShipEvent(unit); - break; - case UnitType.Shell: - this.handleShellEvent(unit); - break; - case UnitType.SAMMissile: - this.handleMissileEvent(unit); - break; - case UnitType.TradeShip: - this.handleTradeShipEvent(unit); - break; - case UnitType.Train: - this.handleTrainEvent(unit); - break; - case UnitType.MIRVWarhead: - this.handleMIRVWarhead(unit); - break; - case UnitType.AtomBomb: - case UnitType.HydrogenBomb: - case UnitType.MIRV: - this.handleNuke(unit); - break; - } - } - - private handleWarShipEvent(unit: UnitView) { - if (unit.warshipState().state !== "patrolling" && unit.isActive()) { - if (unit.warshipState().isInCombat) { - this.drawSprite(unit, colord("rgb(200,0,0)")); - } else { - this.drawSprite(unit); - } - this.drawRetreatCross(unit); - return; - } - - if (unit.warshipState().isInCombat) { - this.drawSprite(unit, colord("rgb(200,0,0)")); - return; - } - - this.drawSprite(unit); - } - - private drawRetreatCross(unit: UnitView) { - // Blink: 500ms on, 500ms off - if (Math.floor(Date.now() / 500) % 2 === 0) return; - const x = this.game.x(unit.tile()); - const y = this.game.y(unit.tile()); - const ctx = this.context; - ctx.save(); - const cx = x + 0.5; - const cy = y + 0.5; - ctx.lineCap = "square"; - ctx.strokeStyle = "rgb(36,36,36)"; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(cx, cy - 1.5); - ctx.lineTo(cx, cy + 1.5); - ctx.moveTo(cx - 1.5, cy); - ctx.lineTo(cx + 1.5, cy); - ctx.stroke(); - ctx.restore(); - } - - private handleShellEvent(unit: UnitView) { - const rel = this.relationship(unit); - - // Clear current and previous positions - this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); - const oldTile = this.oldShellTile.get(unit); - if (oldTile !== undefined) { - this.clearCell(this.game.x(oldTile), this.game.y(oldTile)); - } - - this.oldShellTile.set(unit, unit.lastTile()); - if (!unit.isActive()) { - return; - } - - // Paint current and previous positions - this.paintCell( - this.game.x(unit.tile()), - this.game.y(unit.tile()), - rel, - unit.owner().borderColor(), - 255, - ); - this.paintCell( - this.game.x(unit.lastTile()), - this.game.y(unit.lastTile()), - rel, - unit.owner().borderColor(), - 255, - ); - } - - // interception missile from SAM - private handleMissileEvent(unit: UnitView) { - this.drawSprite(unit); - } - - private drawTrail(trail: number[], color: Colord, rel: Relationship) { - // Paint new trail - for (const t of trail) { - this.paintCell( - this.game.x(t), - this.game.y(t), - rel, - color, - 150, - this.unitTrailContext, - ); - } - } - - private flushTrailClears() { - if (this.pendingTrailClears.length === 0) return; - - const clearedTiles = new Set(); - for (const unit of this.pendingTrailClears) { - const trail = this.unitToTrail.get(unit); - if (trail) { - for (const t of trail) { - if (!clearedTiles.has(t)) { - this.clearCell( - this.game.x(t), - this.game.y(t), - this.unitTrailContext, - ); - clearedTiles.add(t); - } - } - this.unitToTrail.delete(unit); - } - } - this.pendingTrailClears = []; - - // Single repaint pass for all remaining units - for (const [other, trail] of this.unitToTrail) { - const rel = this.relationship(other); - for (const t of trail) { - if (clearedTiles.has(t)) { - this.paintCell( - this.game.x(t), - this.game.y(t), - rel, - other.owner().territoryColor(), - 150, - this.unitTrailContext, - ); - } - } - } - } - - private handleNuke(unit: UnitView) { - const rel = this.relationship(unit); - - if (!this.unitToTrail.has(unit)) { - this.unitToTrail.set(unit, []); - } - - let newTrailSize = 1; - const trail = this.unitToTrail.get(unit) ?? []; - // It can move faster than 1 pixel, draw a line for the trail or else it will be dotted - if (trail.length >= 1) { - const cur = { - x: this.game.x(unit.lastTile()), - y: this.game.y(unit.lastTile()), - }; - const prev = { - x: this.game.x(trail[trail.length - 1]), - y: this.game.y(trail[trail.length - 1]), - }; - const line = new BezenhamLine(prev, cur); - let point = line.increment(); - while (point !== true) { - trail.push(this.game.ref(point.x, point.y)); - point = line.increment(); - } - newTrailSize = line.size(); - } else { - trail.push(unit.lastTile()); - } - - this.drawTrail( - trail.slice(-newTrailSize), - unit.owner().territoryColor(), - rel, - ); - this.drawSprite(unit); - if (!unit.isActive()) { - this.pendingTrailClears.push(unit); - } - } - - private handleMIRVWarhead(unit: UnitView) { - const rel = this.relationship(unit); - - this.clearCell(this.game.x(unit.lastTile()), this.game.y(unit.lastTile())); - - if (unit.isActive()) { - // Paint area - this.paintCell( - this.game.x(unit.tile()), - this.game.y(unit.tile()), - rel, - unit.owner().borderColor(), - 255, - ); - } - } - - private handleTradeShipEvent(unit: UnitView) { - this.drawSprite(unit); - } - - private handleTrainEvent(unit: UnitView) { - this.drawSprite(unit); - } - - private handleBoatEvent(unit: UnitView) { - const rel = this.relationship(unit); - - if (!this.unitToTrail.has(unit)) { - this.unitToTrail.set(unit, []); - } - const trail = this.unitToTrail.get(unit) ?? []; - trail.push(unit.lastTile()); - - // Paint trail - this.drawTrail(trail.slice(-1), unit.owner().territoryColor(), rel); - this.drawSprite(unit); - - if (!unit.isActive()) { - this.pendingTrailClears.push(unit); - } - } - - paintCell( - x: number, - y: number, - relationship: Relationship, - color: Colord, - alpha: number, - context: CanvasRenderingContext2D = this.context, - ) { - this.clearCell(x, y, context); - if (this.alternateView) { - switch (relationship) { - case Relationship.Self: - context.fillStyle = this.theme.selfColor().toRgbString(); - break; - case Relationship.Ally: - context.fillStyle = this.theme.allyColor().toRgbString(); - break; - case Relationship.Enemy: - context.fillStyle = this.theme.enemyColor().toRgbString(); - break; - } - } else { - context.fillStyle = color.alpha(alpha / 255).toRgbString(); - } - context.fillRect(x, y, 1, 1); - } - - clearCell( - x: number, - y: number, - context: CanvasRenderingContext2D = this.context, - ) { - context.clearRect(x, y, 1, 1); - } - - drawSprite(unit: UnitView, customTerritoryColor?: Colord) { - const x = this.game.x(unit.tile()); - const y = this.game.y(unit.tile()); - - let alternateViewColor: Colord | null = null; - - if (this.alternateView) { - let rel = this.relationship(unit); - const dstPortId = unit.targetUnitId(); - if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) { - const target = this.game.unit(dstPortId)?.owner(); - const myPlayer = this.game.myPlayer(); - if (myPlayer !== null && target !== undefined) { - if (myPlayer === target) { - rel = Relationship.Self; - } else if (myPlayer.isFriendly(target)) { - rel = Relationship.Ally; - } - } - } - switch (rel) { - case Relationship.Self: - alternateViewColor = this.theme.selfColor(); - break; - case Relationship.Ally: - alternateViewColor = this.theme.allyColor(); - break; - case Relationship.Enemy: - alternateViewColor = this.theme.enemyColor(); - break; - } - } - - const sprite = getColoredSprite( - unit, - this.theme, - alternateViewColor ?? customTerritoryColor, - alternateViewColor ?? undefined, - ); - - if (unit.isActive()) { - const targetable = unit.targetable(); - if (!targetable) { - this.context.save(); - this.context.globalAlpha = 0.5; - } - this.context.drawImage( - sprite, - Math.round(x - sprite.width / 2), - Math.round(y - sprite.height / 2), - sprite.width, - sprite.width, - ); - if (!targetable) { - this.context.restore(); - } - } - } -} diff --git a/src/client/graphics/layers/WinModal.ts b/src/client/graphics/layers/WinModal.ts index 2c42b5679d..7c7b8fdd66 100644 --- a/src/client/graphics/layers/WinModal.ts +++ b/src/client/graphics/layers/WinModal.ts @@ -12,6 +12,7 @@ import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { getUserMe } from "../../Api"; import "../../components/CosmeticButton"; +import { Controller } from "../../Controller"; import { fetchCosmetics, purchaseCosmetic, @@ -20,10 +21,9 @@ import { import { crazyGamesSDK } from "../../CrazyGamesSDK"; import { Platform } from "../../Platform"; import { SendWinnerEvent } from "../../Transport"; -import { Layer } from "./Layer"; @customElement("win-modal") -export class WinModal extends LitElement implements Layer { +export class WinModal extends LitElement implements Controller { public game: GameView; public eventBus: EventBus; @@ -321,10 +321,4 @@ export class WinModal extends LitElement implements Layer { } }); } - - renderLayer(/* context: CanvasRenderingContext2D */) {} - - shouldTransform(): boolean { - return false; - } } diff --git a/src/client/graphics/ui/MoveIndicatorUI.ts b/src/client/graphics/ui/MoveIndicatorUI.ts deleted file mode 100644 index 8da36b7d4a..0000000000 --- a/src/client/graphics/ui/MoveIndicatorUI.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Cell } from "src/core/game/Game"; -import { TransformHandler } from "../TransformHandler"; -import { UIElement } from "./UIElement"; - -/** - * move indicator fx for warship, similar to moba games. - */ -export class MoveIndicatorUI implements UIElement { - private lifeTime = 0; - private readonly duration = 800; // ms - private readonly startRadius = 13; // starting distance from center (screen pixels) - private readonly chevronSize = 5; // size in screen pixels - private readonly cell: Cell; - - constructor( - private transformHandler: TransformHandler, - public x: number, - public y: number, - ) { - this.cell = new Cell(this.x + 0.5, this.y + 0.5); - } - - render(ctx: CanvasRenderingContext2D, delta: number): boolean { - this.lifeTime += delta; - if (this.lifeTime >= this.duration) return false; - - const t = this.lifeTime / this.duration; - const alpha = 1 - t; // fade out - - // Scale with zoom level (same pattern as NavalTarget) - const transformScale = this.transformHandler.scale; - const scale = transformScale > 10 ? 1 + (transformScale - 10) / 10 : 1; - - const radius = this.startRadius * scale * (1 - t * 0.7); // converge inward - const chevronSize = this.chevronSize * scale; - - // Get screen coordinates - const screenPos = this.transformHandler.worldToCanvasCoordinates(this.cell); - const centerX = screenPos.x; - const centerY = screenPos.y; - - ctx.save(); - ctx.globalAlpha = alpha; - ctx.strokeStyle = "#ff0000"; - ctx.lineWidth = 2 * scale; - ctx.lineCap = "round"; - ctx.lineJoin = "round"; - - // pre calculation of offsets - const tipOffset = chevronSize * 0.4; - const wingOffset = chevronSize * 0.6; - const width = chevronSize; - - ctx.beginPath(); - - // Top (pointing down) - ctx.moveTo(centerX - width, centerY - radius - wingOffset); - ctx.lineTo(centerX, centerY - radius + tipOffset); - ctx.lineTo(centerX + width, centerY - radius - wingOffset); - - // Bottom (pointing up) - ctx.moveTo(centerX - width, centerY + radius + wingOffset); - ctx.lineTo(centerX, centerY + radius - tipOffset); - ctx.lineTo(centerX + width, centerY + radius + wingOffset); - - // Left (pointing right) - ctx.moveTo(centerX - radius - wingOffset, centerY - width); - ctx.lineTo(centerX - radius + tipOffset, centerY); - ctx.lineTo(centerX - radius - wingOffset, centerY + width); - - // Right (pointing left) - ctx.moveTo(centerX + radius + wingOffset, centerY - width); - ctx.lineTo(centerX + radius - tipOffset, centerY); - ctx.lineTo(centerX + radius + wingOffset, centerY + width); - - ctx.stroke(); - - ctx.restore(); - return true; - } -} diff --git a/src/client/graphics/ui/NavalTarget.ts b/src/client/graphics/ui/NavalTarget.ts deleted file mode 100644 index 0e88487df5..0000000000 --- a/src/client/graphics/ui/NavalTarget.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Cell, UnitType } from "src/core/game/Game"; -import { GameView, UnitView } from "src/core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -import { UIElement } from "./UIElement"; - -const BASE_ALPHA = 0.9; -const SHADOW_OFFSET_Y = 2; - -/** - * Draw a simple zoom-aware target - */ -export class Target implements UIElement { - private offset = 0; - private readonly rotationSpeed = 20; - private readonly dashSize: number; - private readonly outerRadius: number; - private readonly cell: Cell; - private readonly animationDuration = 150; - private animationElapsedTime = 0; - protected ended: boolean = false; - protected lifeTime: number = 0; - - constructor( - private transformHandler: TransformHandler, - public x: number, - public y: number, - private radius: number, - ) { - this.outerRadius = radius * 2 - 4; - // 2 dashes per circle, with a 10 pixel gap - this.dashSize = Math.PI * this.outerRadius - 10; - this.cell = new Cell(this.x + 0.5, this.y + 0.5); - } - render(ctx: CanvasRenderingContext2D, delta: number): boolean { - this.lifeTime += delta; - - if (this.ended) { - this.animationElapsedTime += delta; - if (this.animationElapsedTime >= this.animationDuration) return false; - } - - let t: number; - if (this.ended) { - // end animation - t = Math.max(0, 1 - this.animationElapsedTime / this.animationDuration); - } else { - t = 1; // No start fade feels more reactive - } - const alpha = Math.max(0, Math.min(1, BASE_ALPHA * t)); - - const screenPos = this.transformHandler.worldToCanvasCoordinates(this.cell); - screenPos.x = Math.round(screenPos.x); - screenPos.y = Math.round(screenPos.y); - const transformScale = this.transformHandler.scale; - const scale = transformScale > 10 ? 1 + (transformScale - 10) / 10 : 1; - this.offset += this.rotationSpeed * (delta / 1000); - - ctx.save(); - ctx.globalAlpha = alpha; - ctx.lineWidth = 1; - ctx.strokeStyle = `rgba(255,0,0,${alpha})`; - - this.drawInnerRing(ctx, screenPos.x, screenPos.y, scale); - this.drawOuterRing(ctx, screenPos.x, screenPos.y, scale); - - ctx.restore(); - return true; - } - - private drawInnerRing( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - scale: number, - ) { - ctx.beginPath(); - ctx.lineWidth = 2; - ctx.lineDashOffset = this.offset * scale; - ctx.setLineDash([8 * scale, 8 * scale]); - ctx.arc(x, y, this.radius * scale, 0, Math.PI * 2); - ctx.stroke(); - } - - private drawOuterRing( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - scale: number, - ) { - ctx.beginPath(); - ctx.lineWidth = 4 * scale; - ctx.lineDashOffset = (-this.offset / 2) * scale; - ctx.setLineDash([this.dashSize * scale, 10 * scale]); - ctx.arc(x, y, this.outerRadius * scale, 0, Math.PI * 2); - ctx.stroke(); - - // Small shadow under the outer circle - ctx.beginPath(); - ctx.strokeStyle = `rgba(0,0,0,0.2)`; - ctx.arc(x, y + SHADOW_OFFSET_Y, this.outerRadius * scale, 0, Math.PI * 2); - ctx.stroke(); - } -} - -/** - * Bind a target to a naval invasion - */ -export class NavalTarget extends Target { - constructor( - transformHandler: TransformHandler, - readonly game: GameView, - private unit: UnitView, - ) { - const tile = unit.targetTile(); - if (tile === undefined) { - throw new Error("NavalTarget requires a target tile"); - } - super(transformHandler, game.x(tile), game.y(tile), 10); - } - - render(ctx: CanvasRenderingContext2D, delta: number): boolean { - if ( - !this.ended && - (!this.unit.isActive() || - (this.unit.type() === UnitType.TransportShip && - this.unit.transportShipState().isRetreating)) - ) { - this.ended = true; - } - return super.render(ctx, delta); - } -} diff --git a/src/client/graphics/ui/NukeTelegraph.ts b/src/client/graphics/ui/NukeTelegraph.ts deleted file mode 100644 index 1d54a85670..0000000000 --- a/src/client/graphics/ui/NukeTelegraph.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { Cell } from "src/core/game/Game"; -import { GameView, UnitView } from "src/core/game/GameView"; -import { TransformHandler } from "../TransformHandler"; -import { UIElement } from "./UIElement"; - -const OUTER_EXPAND = 20; -const FILL_ALPHA_OFFSET = 0.6; - -/** - * Draw an area with two disks - */ -export class CircleArea implements UIElement { - private offset = 0; - private readonly dashSize: number; - private readonly rotationSpeed = 20; - private readonly baseAlpha = 0.9; - private readonly cell: Cell; - private readonly animationDuration = 150; - protected ended: boolean = false; - protected lifeTime: number = 0; - - constructor( - private transformHandler: TransformHandler, - public x: number, - public y: number, - private innerDiameter: number, - private outerDiameter: number, - ) { - this.cell = new Cell(this.x + 0.5, this.y + 0.5); - // Compute a dash length that produces N dashes around the circle - const numDash = Math.max(1, Math.floor(this.outerDiameter / 3)); - this.dashSize = (Math.PI / numDash) * this.outerDiameter; - } - render(ctx: CanvasRenderingContext2D, delta: number): boolean { - this.lifeTime += delta; - - if (this.ended && this.lifeTime >= this.animationDuration) return false; - let t: number; - if (this.ended) { - t = Math.max(0, 1 - this.lifeTime / this.animationDuration); - } else { - t = Math.min(1, this.lifeTime / this.animationDuration); - } - const alpha = Math.max(0, Math.min(1, this.baseAlpha * t)); - const scale = this.transformHandler.scale; - - const innerDiameter = - (this.innerDiameter / 2) * (1 - t) + this.innerDiameter * t; - const screenPos = this.transformHandler.worldToCanvasCoordinates(this.cell); - screenPos.x = Math.round(screenPos.x); - screenPos.y = Math.round(screenPos.y); - - ctx.save(); - ctx.globalAlpha = alpha; - ctx.lineWidth = 2; - ctx.strokeStyle = `rgba(255,0,0,${alpha})`; - ctx.fillStyle = `rgba(255,0,0,${Math.max(0, alpha - FILL_ALPHA_OFFSET)})`; - - // Inner circle - ctx.beginPath(); - ctx.lineWidth = 1; - ctx.arc(screenPos.x, screenPos.y, innerDiameter * scale, 0, Math.PI * 2); - ctx.stroke(); - ctx.fill(); - - // Outer circle - this.offset += this.rotationSpeed * (delta / 1000); - ctx.beginPath(); - ctx.strokeStyle = `rgba(255,0,0,${alpha})`; - ctx.lineWidth = Math.max(2, 1 * scale); - ctx.lineDashOffset = this.offset * scale; - ctx.setLineDash([this.dashSize * scale]); - const outerDiameter = - (this.outerDiameter + OUTER_EXPAND) * (1 - t) + this.outerDiameter * t; - ctx.arc(screenPos.x, screenPos.y, outerDiameter * scale, 0, Math.PI * 2); - ctx.stroke(); - - ctx.restore(); - return true; - } -} - -/** - * Bind a nuke destination to an area - */ -export class NukeTelegraph extends CircleArea { - constructor( - transformHandler: TransformHandler, - private readonly game: GameView, - private nuke: UnitView, - ) { - const tile = nuke.targetTile(); - if (tile === undefined) { - throw new Error("NukeArea requires a target tile"); - } - const magnitude = game.config().nukeMagnitudes(nuke.type()); - super( - transformHandler, - game.x(tile), - game.y(tile), - magnitude.inner, - magnitude.outer, - ); - } - - render(ctx: CanvasRenderingContext2D, delta: number): boolean { - if (!this.ended && !this.nuke.isActive()) { - this.ended = true; - this.lifeTime = 0; // reset lifetime to reuse animation logic - } - return super.render(ctx, delta); - } -} diff --git a/src/client/graphics/ui/TextIndicator.ts b/src/client/graphics/ui/TextIndicator.ts index 1c49006a1d..1d68f7b0c6 100644 --- a/src/client/graphics/ui/TextIndicator.ts +++ b/src/client/graphics/ui/TextIndicator.ts @@ -1,5 +1,5 @@ import { Cell } from "src/core/game/Game"; -import { TransformHandler } from "../TransformHandler"; +import { TransformHandler } from "../../TransformHandler"; import { UIElement } from "./UIElement"; const MIN_TEXT_ZOOM = 1.1; diff --git a/src/client/render/GameConstants.ts b/src/client/render/GameConstants.ts new file mode 100644 index 0000000000..51f5e65e30 --- /dev/null +++ b/src/client/render/GameConstants.ts @@ -0,0 +1,163 @@ +/** + * game-constants.ts — Upstream game facts replicated in the renderer/shim. + * + * All values here are sourced from upstream game code. When upstream changes, + * audit this file first. + * + * Primary sources: + * - vendor/openfront/src/core/configuration/DefaultConfig.ts (DefaultConfig, DefaultServerConfig) + * - vendor/openfront/src/client/graphics/layers/FxLayer.ts (visual-only constants) + */ + +import { + UT_ATOM_BOMB, + UT_CITY, + UT_DEFENSE_POST, + UT_FACTORY, + UT_HYDROGEN_BOMB, + UT_MIRV_WARHEAD, + UT_MISSILE_SILO, + UT_PORT, + UT_SAM_LAUNCHER, +} from "./types"; + +// --------------------------------------------------------------------------- +// Tick timing +// --------------------------------------------------------------------------- + +/** + * Milliseconds per game tick. + * Source: DefaultServerConfig.turnIntervalMs() → return 100 + */ +export const MS_PER_TICK = 100; + +// --------------------------------------------------------------------------- +// Unit health +// --------------------------------------------------------------------------- + +/** + * Maximum health for a Warship unit. + * Source: DefaultConfig.unitInfo(UnitType.Warship) → { maxHealth: 1000 } + */ +export const WARSHIP_MAX_HEALTH = 1000; + +// --------------------------------------------------------------------------- +// Construction durations (ticks) +// --------------------------------------------------------------------------- + +/** + * How many ticks each structure type takes to finish construction. + * Source: DefaultConfig.unitInfo(type).constructionDuration (non-instantBuild path): + * case UnitType.City: constructionDuration: 2 * 10 + * case UnitType.Port: constructionDuration: 2 * 10 + * case UnitType.Factory: constructionDuration: 2 * 10 + * case UnitType.DefensePost: constructionDuration: 5 * 10 + * case UnitType.MissileSilo: constructionDuration: 10 * 10 + * case UnitType.SAMLauncher: constructionDuration: 30 * 10 + */ +export const CONSTRUCTION_DURATIONS: Readonly> = { + [UT_CITY]: 2 * 10, + [UT_PORT]: 2 * 10, + [UT_FACTORY]: 2 * 10, + [UT_DEFENSE_POST]: 5 * 10, + [UT_MISSILE_SILO]: 10 * 10, + [UT_SAM_LAUNCHER]: 30 * 10, +}; + +// --------------------------------------------------------------------------- +// Missile cooldowns (ticks) +// --------------------------------------------------------------------------- + +/** + * Ticks for a SAM Launcher to reload one missile. + * Source: DefaultConfig.SAMCooldown() → return 120 + * NOTE: different from SiloCooldown — do not conflate. + */ +export const SAM_COOLDOWN_TICKS = 120; + +/** + * Ticks for a Missile Silo to reload one missile. + * Source: DefaultConfig.SiloCooldown() → return 75 + */ +export const SILO_COOLDOWN_TICKS = 75; + +// --------------------------------------------------------------------------- +// Deletion mark duration (ticks) +// --------------------------------------------------------------------------- + +/** + * How many ticks a structure remains in the "marked for deletion" state. + * Source: DefaultConfig.deletionMarkDuration() → return 30 * 10 + */ +export const DELETION_MARK_DURATION = 30 * 10; + +// --------------------------------------------------------------------------- +// Nuke explosion visual radii (tiles) +// --------------------------------------------------------------------------- + +/** + * Visual explosion radius (tiles) for each nuke type, used for shockwave and + * debris scatter sizing. + * + * Source: FxLayer.ts, inside the unit-death event handler: + * case UnitType.AtomBomb: this.onNukeEvent(unit, 70) + * case UnitType.MIRVWarhead: this.onNukeEvent(unit, 70) + * case UnitType.HydrogenBomb: this.onNukeEvent(unit, 160) + * + * Note: these are visual-only radii. The gameplay damage radii are separate + * and come from DefaultConfig.nukeMagnitudes() → { inner, outer }. + */ +export const NUKE_EXPLOSION_RADII: Readonly> = { + [UT_ATOM_BOMB]: 70, + [UT_HYDROGEN_BOMB]: 160, + [UT_MIRV_WARHEAD]: 70, +}; + +// --------------------------------------------------------------------------- +// SAM range formula +// --------------------------------------------------------------------------- + +/** + * SAM Launcher coverage radius in tiles at a given upgrade level. + * Source: DefaultConfig.samRange(level): + * return this.maxSamRange() - 480 / (level + 5) + * where maxSamRange() → return 150 + */ +export function samRange(level: number): number { + return 150 - 480 / (level + 5); +} + +// --------------------------------------------------------------------------- +// Missile readiness formula +// --------------------------------------------------------------------------- + +/** + * Fractional missile readiness [0, 1] for a Silo or SAM Launcher. + * Returns 1.0 when fully loaded, 0.0 when completely empty with no partial reload. + * + * Source: adapted from upstream readiness display logic (UILayer / FxLayer). + * Uses per-type cooldown: SAMCooldown() = 120, SiloCooldown() = 75. + */ +export function missileReadiness( + unitType: string, + level: number, + missileTimerQueue: number[], + gameTick: number, +): number { + const cooldown = + unitType === UT_SAM_LAUNCHER ? SAM_COOLDOWN_TICKS : SILO_COOLDOWN_TICKS; + const maxMissiles = level; + const reloading = missileTimerQueue.length; + if (reloading === 0) return 1; + + const ready = maxMissiles - reloading; + if (ready === 0 && maxMissiles > 1) return 0; + + let readiness = ready / maxMissiles; + for (const timer of missileTimerQueue) { + const progress = gameTick - timer; + const ratio = progress / cooldown; + readiness += ratio / maxMissiles; + } + return Math.max(0, Math.min(1, readiness)); +} diff --git a/src/client/render/frame/RailroadCache.ts b/src/client/render/frame/RailroadCache.ts new file mode 100644 index 0000000000..803201eaf2 --- /dev/null +++ b/src/client/render/frame/RailroadCache.ts @@ -0,0 +1,273 @@ +/** + * RailroadCache — always-on accumulator for railroad events. + * + * The game doesn't expose current railroad state via any API — it only sends + * construction/destruction/snap delta events. This cache accumulates them + * every tick so consumers that start later can reconstruct the full set. + * + * Includes orientation computation, construction animation, and a per-tile + * Uint8Array ready for GPU upload. + * + * Ported verbatim from openfront-workspace/packages/shim/src/railroad-cache.ts; + * only imports changed (types come from src/core/game/GameUpdates instead of + * the shim's local types module). + */ + +import { + GameUpdateType, + GameUpdateViewData, + RailroadConstructionUpdate, + RailroadDestructionUpdate, + RailroadSnapUpdate, +} from "../../../core/game/GameUpdates"; + +// Regular enum (not const enum) for cross-package use. +export enum RailType { + VERTICAL, + HORIZONTAL, + TOP_LEFT, + TOP_RIGHT, + BOTTOM_LEFT, + BOTTOM_RIGHT, +} + +interface RailTile { + ref: number; + type: RailType; +} + +interface RailroadAnim { + tiles: RailTile[]; + headIndex: number; + tailIndex: number; + complete: boolean; +} + +const RAIL_INCREMENT = 3; + +// --------------------------------------------------------------------------- +// Orientation helpers +// --------------------------------------------------------------------------- + +function railExtremity(tile: number, next: number, w: number): RailType { + const dx = (next % w) - (tile % w); + const dy = (next - (next % w)) / w - (tile - (tile % w)) / w; + if (dx === 0) return RailType.VERTICAL; + if (dy === 0) return RailType.HORIZONTAL; + return RailType.VERTICAL; +} + +function railDirection( + prev: number, + cur: number, + next: number, + w: number, +): RailType { + const x1 = prev % w, + y1 = (prev - x1) / w; + const x2 = cur % w, + y2 = (cur - x2) / w; + const x3 = next % w, + y3 = (next - x3) / w; + const dx1 = x2 - x1, + dy1 = y2 - y1; + const dx2 = x3 - x2, + dy2 = y3 - y2; + if (dx1 === dx2 && dy1 === dy2) { + return dx1 !== 0 ? RailType.HORIZONTAL : RailType.VERTICAL; + } + if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) { + if (dx1 === 0 && dx2 === 1 && dy1 === -1) return RailType.BOTTOM_RIGHT; + if (dx1 === 0 && dx2 === -1 && dy1 === -1) return RailType.BOTTOM_LEFT; + if (dx1 === 0 && dx2 === 1 && dy1 === 1) return RailType.TOP_RIGHT; + if (dx1 === 0 && dx2 === -1 && dy1 === 1) return RailType.TOP_LEFT; + if (dx1 === 1 && dx2 === 0 && dy2 === -1) return RailType.TOP_LEFT; + if (dx1 === -1 && dx2 === 0 && dy2 === -1) return RailType.TOP_RIGHT; + if (dx1 === 1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_LEFT; + if (dx1 === -1 && dx2 === 0 && dy2 === 1) return RailType.BOTTOM_RIGHT; + } + return RailType.VERTICAL; +} + +function computeRailTiles(tileRefs: number[], w: number): RailTile[] { + if (tileRefs.length === 0) return []; + if (tileRefs.length === 1) + return [{ ref: tileRefs[0]!, type: RailType.VERTICAL }]; + const result: RailTile[] = []; + result.push({ + ref: tileRefs[0]!, + type: railExtremity(tileRefs[0]!, tileRefs[1]!, w), + }); + for (let i = 1; i < tileRefs.length - 1; i++) { + result.push({ + ref: tileRefs[i]!, + type: railDirection(tileRefs[i - 1]!, tileRefs[i]!, tileRefs[i + 1]!, w), + }); + } + const last = tileRefs.length - 1; + result.push({ + ref: tileRefs[last]!, + type: railExtremity(tileRefs[last]!, tileRefs[last - 1]!, w), + }); + return result; +} + +export class RailroadCache { + private mapW: number; + private anims = new Map(); + + /** + * Per-tile reference count. Multiple railroads can share tiles at junctions + * (near stations). A tile is only cleared from railroadState when its ref + * count drops to zero. + */ + private tileRefCount = new Map(); + + /** Per-tile railroad state (0=none, 1-6 = RailType+1). Ready for GPU upload. */ + readonly railroadState: Uint8Array; + + /** True if railroadState changed this tick. */ + railroadDirty = false; + + /** Tile refs revealed by animation this tick (for dust FX). */ + readonly revealedRailTiles: number[] = []; + + constructor(mapW: number, mapH: number) { + this.mapW = mapW; + this.railroadState = new Uint8Array(mapW * mapH); + } + + /** + * Process this tick's railroad events and advance animations. + * Event order matches the upstream game client: Construction → Snap → Destruction. + */ + apply(gu: GameUpdateViewData): void { + const constructs = (gu.updates[GameUpdateType.RailroadConstructionEvent] ?? + []) as RailroadConstructionUpdate[]; + for (const evt of constructs) this.addRailroad(evt.id, evt.tiles, false); + + const snaps = (gu.updates[GameUpdateType.RailroadSnapEvent] ?? + []) as RailroadSnapUpdate[]; + for (const evt of snaps) { + this.removeRailroad(evt.originalId); + this.addRailroad(evt.newId1, evt.tiles1, true); + this.addRailroad(evt.newId2, evt.tiles2, true); + } + + const destructs = (gu.updates[GameUpdateType.RailroadDestructionEvent] ?? + []) as RailroadDestructionUpdate[]; + for (const evt of destructs) this.removeRailroad(evt.id); + + this.tickAnimations(); + } + + /** Clear the dirty flag after the consumer has uploaded the state. */ + clearDirty(): void { + this.railroadDirty = false; + } + + /** Get raw tile refs for the given railroad IDs (for ghost manager overlap resolution). */ + getRailroadTileRefs(ids: number[]): number[] { + const tiles: number[] = []; + for (const id of ids) { + const anim = this.anims.get(id); + if (anim) for (const t of anim.tiles) tiles.push(t.ref); + } + return tiles; + } + + /** Read-only view of current railroads: id → raw tile refs. */ + getRailroads(): ReadonlyMap { + const result = new Map(); + for (const [id, anim] of this.anims) { + result.set( + id, + anim.tiles.map((t) => t.ref), + ); + } + return result; + } + + reset(): void { + this.anims.clear(); + this.tileRefCount.clear(); + this.railroadState.fill(0); + this.railroadDirty = false; + } + + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + private addRailroad(id: number, tileRefs: number[], complete: boolean): void { + const tiles = computeRailTiles(tileRefs, this.mapW); + const anim: RailroadAnim = { + tiles, + headIndex: complete ? tiles.length : 0, + tailIndex: complete ? 0 : tiles.length, + complete, + }; + this.anims.set(id, anim); + + // Increment ref counts for all tiles in this railroad + for (const rt of tiles) { + this.tileRefCount.set(rt.ref, (this.tileRefCount.get(rt.ref) ?? 0) + 1); + } + + if (complete) { + for (const rt of tiles) this.railroadState[rt.ref] = rt.type + 1; + this.railroadDirty = true; + } + } + + private removeRailroad(id: number): void { + const anim = this.anims.get(id); + if (!anim) return; + + // Decrement ref counts; only clear tiles whose count drops to zero + for (const rt of anim.tiles) { + const count = (this.tileRefCount.get(rt.ref) ?? 1) - 1; + if (count <= 0) { + this.tileRefCount.delete(rt.ref); + this.railroadState[rt.ref] = 0; + } else { + this.tileRefCount.set(rt.ref, count); + } + } + + this.anims.delete(id); + this.railroadDirty = true; + } + + private tickAnimations(): void { + this.revealedRailTiles.length = 0; + for (const anim of this.anims.values()) { + if (anim.complete) continue; + if (anim.tailIndex - anim.headIndex <= 2 * RAIL_INCREMENT) { + for (let i = anim.headIndex; i < anim.tailIndex; i++) { + const t = anim.tiles[i]!; + this.railroadState[t.ref] = t.type + 1; + this.revealedRailTiles.push(t.ref); + } + anim.headIndex = anim.tailIndex; + anim.complete = true; + this.railroadDirty = true; + } else { + for (let i = anim.headIndex; i < anim.headIndex + RAIL_INCREMENT; i++) { + const t = anim.tiles[i]!; + this.railroadState[t.ref] = t.type + 1; + this.revealedRailTiles.push(t.ref); + } + for (let i = anim.tailIndex - RAIL_INCREMENT; i < anim.tailIndex; i++) { + const t = anim.tiles[i]!; + this.railroadState[t.ref] = t.type + 1; + this.revealedRailTiles.push(t.ref); + } + anim.headIndex += RAIL_INCREMENT; + anim.tailIndex -= RAIL_INCREMENT; + if (anim.headIndex >= anim.tailIndex) anim.complete = true; + this.railroadDirty = true; + } + } + } +} diff --git a/src/client/render/frame/TrailManager.ts b/src/client/render/frame/TrailManager.ts new file mode 100644 index 0000000000..c9fd32d019 --- /dev/null +++ b/src/client/render/frame/TrailManager.ts @@ -0,0 +1,133 @@ +/** + * TrailManager — per-tile "last owner" stamp for trail rendering. + * + * Each tick, for each tracked unit, stamps tiles between lastPos and pos + * (bresenham) with the owner's smallID. When a unit dies its tiles are cleared, + * with overlapping tiles repainted from any surviving unit. + * + * Simpler than the original openfront-workspace TrailManager (no MotionPlanStore + * dependency). Since we run in the main thread reading GameView directly, we + * don't need plan-based reconstruction. + */ + +import type { UnitState } from "../types"; + +interface UnitTrail { + ownerID: number; + tiles: Set; + lastPosStamped: number; // tile ref of the last position we stamped +} + +export class TrailManager { + private readonly trailState: Uint8Array; + private readonly unitTrails = new Map(); + private readonly mapW: number; + + private _dirtyRowMin = Infinity; + private _dirtyRowMax = -1; + + constructor(mapW: number, mapH: number) { + this.mapW = mapW; + this.trailState = new Uint8Array(mapW * mapH); + } + + getTrailState(): Uint8Array { + return this.trailState; + } + + get dirtyRowMin(): number { + return this._dirtyRowMin; + } + get dirtyRowMax(): number { + return this._dirtyRowMax; + } + + clearDirtyRows(): void { + this._dirtyRowMin = Infinity; + this._dirtyRowMax = -1; + } + + reset(): void { + this.unitTrails.clear(); + this.trailState.fill(0); + this._dirtyRowMin = Infinity; + this._dirtyRowMax = -1; + } + + /** + * Update trails from the current unit set. Stamps tiles between lastPos and + * pos (bresenham) for each tracked unit, and clears tiles for units that + * have disappeared (overlapping tiles get repainted from survivors). + */ + update(units: Map, trackedIds: number[]): void { + this.clearDeadUnits(units); + for (const id of trackedIds) { + const unit = units.get(id); + if (!unit) continue; + let trail = this.unitTrails.get(id); + if (!trail) { + trail = { ownerID: unit.ownerID, tiles: new Set(), lastPosStamped: -1 }; + this.unitTrails.set(id, trail); + } + if (trail.lastPosStamped === -1) { + // First sighting — just stamp current pos + this.stamp(unit.pos, trail.ownerID); + trail.tiles.add(unit.pos); + trail.lastPosStamped = unit.pos; + } else if (trail.lastPosStamped !== unit.pos) { + this.bresenham(trail.lastPosStamped, unit.pos, trail); + trail.lastPosStamped = unit.pos; + } + } + } + + private clearDeadUnits(units: Map): void { + for (const [id, trail] of this.unitTrails) { + if (units.has(id)) continue; + const deadTiles = trail.tiles; + for (const ref of deadTiles) this.stamp(ref, 0); + this.unitTrails.delete(id); + // Repaint any tiles that overlap surviving trails + for (const other of this.unitTrails.values()) { + for (const ref of deadTiles) { + if (other.tiles.has(ref)) this.stamp(ref, other.ownerID); + } + } + } + } + + private stamp(ref: number, ownerID: number): void { + this.trailState[ref] = ownerID; + const row = (ref / this.mapW) | 0; + if (row < this._dirtyRowMin) this._dirtyRowMin = row; + if (row > this._dirtyRowMax) this._dirtyRowMax = row; + } + + private bresenham(from: number, to: number, trail: UnitTrail): void { + const w = this.mapW; + let x0 = from % w; + let y0 = (from - x0) / w; + const x1 = to % w; + const y1 = (to - x1) / w; + const dx = Math.abs(x1 - x0); + const dy = -Math.abs(y1 - y0); + const sx = x0 < x1 ? 1 : -1; + const sy = y0 < y1 ? 1 : -1; + let err = dx + dy; + for (;;) { + const ref = y0 * w + x0; + trail.tiles.add(ref); + this.stamp(ref, trail.ownerID); + if (x0 === x1 && y0 === y1) break; + const e2 = 2 * err; + if (e2 >= dy) { + err += dy; + x0 += sx; + } + if (e2 <= dx) { + err += dx; + y0 += sy; + } + } + } +} diff --git a/src/client/render/frame/Upload.ts b/src/client/render/frame/Upload.ts new file mode 100644 index 0000000000..cc0774bebb --- /dev/null +++ b/src/client/render/frame/Upload.ts @@ -0,0 +1,135 @@ +import type { + AttackRingInput, + BonusEvent, + ConquestFx, + DeadUnitFx, + FrameData, + NameEntry, + NukeTelegraphData, + PlayerState, + PlayerStatusData, + TilePair, + UnitState, +} from "../types"; + +/** + * Structural interface for the GPU view target. + * Satisfied by GameView through TypeScript structural typing. + */ +export interface FrameUploadTarget { + uploadTileAndTrailState(tileState: Uint16Array, trailState: Uint8Array): void; + uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void; + uploadLiveTrailDelta( + trailState: Uint8Array, + dirtyRowMin: number, + dirtyRowMax: number, + ): void; + applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void; + applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void; + uploadRailroadState(data: Uint8Array): void; + applyRailroadDust(tileRefs: number[]): void; + updateUnits(units: ReadonlyMap, gameTick: number): void; + updateStructures(units: ReadonlyMap): void; + applyDeadUnits(deadUnits: DeadUnitFx[]): void; + applyConquestEvents(events: ConquestFx[]): void; + applyBonusEvents(events: BonusEvent[]): void; + updateAttackRings(rings: AttackRingInput[]): void; + updateNukeTelegraphs(data: NukeTelegraphData[]): void; + updateNames( + names: ReadonlyMap, + players: ReadonlyMap, + snap: boolean, + statusData?: ReadonlyMap, + ): void; + updateRelations(data: Uint8Array, size: number): void; + setSAMAllianceClusters(clusters: ReadonlyMap): void; +} + +export interface UploadOptions { + /** Snap name positions instantly (seek mode). Default: false. */ + snap?: boolean; + /** Skip tile upload — caller already handled tiles (e.g. seek with bloom reset). */ + skipTileUpload?: boolean; +} + +/** + * Upload a FrameData snapshot to the GPU view. + * + * Handles tile upload mode switching, all view update calls, and conditional + * railroad/ephemeral uploads. The FrameData itself carries semantic differences + * (seek sets deadUnits=[], conquestEvents=[] etc.) — this function is a + * straightforward dispatch loop. + */ +export function uploadFrameData( + view: FrameUploadTarget, + frame: FrameData, + opts?: UploadOptions, +): void { + const snap = opts?.snap ?? false; + const skipTileUpload = opts?.skipTileUpload ?? false; + + // --- Tiles + Trails --- + // Live mode: changedTiles[] means "only these tiles changed" (empty = nothing changed, skip upload). + // changedTiles null/undefined means "no delta info" (first tick — full upload needed). + // Copy mode: changedTiles[] = delta playback, null = full seek. + if (!skipTileUpload) { + if (frame.tileMode === "live" && frame.changedTiles) { + // Live delta path — tiles and trails uploaded independently + if (frame.changedTiles.length > 0) { + view.uploadLiveDelta(frame.tileState, frame.changedTiles); + } + // Trail dirty rows come from TrailManager, independent of tile deltas + if (frame.trailDirtyRowMax >= 0) { + view.uploadLiveTrailDelta( + frame.trailState, + frame.trailDirtyRowMin, + frame.trailDirtyRowMax, + ); + } + } else if (frame.tileMode === "live") { + view.uploadTileAndTrailState(frame.tileState, frame.trailState); + } else if (!frame.changedTiles) { + view.applyFullTiles(frame.tileState, frame.trailState); + } else { + view.applyDelta(frame.changedTiles, frame.trailState); + } + } + + // --- Railroads --- + if (frame.railroadDirty) { + view.uploadRailroadState(frame.railroadState); + if (frame.revealedRailTiles.length > 0) { + view.applyRailroadDust(frame.revealedRailTiles); + } + } + + // --- Units + structures --- + view.updateUnits(frame.units, frame.tick); + if (frame.structuresDirty) { + view.updateStructures(frame.units); + } + + // --- Ephemeral effects --- + if (frame.events.deadUnits.length > 0) { + view.applyDeadUnits(frame.events.deadUnits); + } + if (frame.events.conquestEvents.length > 0) { + view.applyConquestEvents(frame.events.conquestEvents); + } + if (frame.events.bonusEvents.length > 0) { + view.applyBonusEvents(frame.events.bonusEvents); + } + + // --- Attack rings + nuke telegraphs --- + view.updateAttackRings(frame.attackRings); + view.updateNukeTelegraphs(frame.nukeTelegraphs); + + // --- Names + player status --- + view.updateNames(frame.names, frame.players, snap, frame.playerStatus); + + // --- Relations --- + view.updateRelations(frame.relationMatrix, frame.relationSize); + + // --- Alliance clusters (SAM pass) --- + view.setSAMAllianceClusters(frame.allianceClusters); +} diff --git a/src/client/render/frame/derive/AllianceClusters.ts b/src/client/render/frame/derive/AllianceClusters.ts new file mode 100644 index 0000000000..3c895ebcaa --- /dev/null +++ b/src/client/render/frame/derive/AllianceClusters.ts @@ -0,0 +1,44 @@ +import type { PlayerState } from "../../types"; + +/** + * Compute alliance clusters via union-find. + * Returns a map of `playerSmallID → clusterRootID`. + * Used by SAM radius pass to color allies as a group. + */ +export function computeAllianceClusters( + players: ReadonlyMap, +): Map { + const parent = new Map(); + + function find(x: number): number { + while (parent.get(x) !== x) { + const p = parent.get(x)!; + parent.set(x, parent.get(p)!); + x = p; + } + return x; + } + + function union(a: number, b: number): void { + const ra = find(a); + const rb = find(b); + if (ra !== rb) parent.set(rb, ra); + } + + for (const ps of players.values()) { + if (ps.smallID > 0) parent.set(ps.smallID, ps.smallID); + } + + for (const ps of players.values()) { + if (!ps.allies || ps.smallID <= 0) continue; + for (const allyID of ps.allies) { + if (parent.has(allyID)) union(ps.smallID, allyID); + } + } + + const result = new Map(); + for (const id of parent.keys()) { + result.set(id, find(id)); + } + return result; +} diff --git a/src/client/render/frame/derive/AttackRings.ts b/src/client/render/frame/derive/AttackRings.ts new file mode 100644 index 0000000000..fbe025839a --- /dev/null +++ b/src/client/render/frame/derive/AttackRings.ts @@ -0,0 +1,43 @@ +import type { AttackRingInput, UnitState } from "../../types"; +import { UT_TRANSPORT } from "../../types"; + +/** + * Extract attack ring indicators for transport ships with active targets. + * Optionally filter to a specific owner (live path filters to local player). + */ +export function extractAttackRings( + units: ReadonlyMap, + mapW: number, + ownerFilter?: number, +): AttackRingInput[] { + const rings: AttackRingInput[] = []; + for (const u of units.values()) { + if (u.unitType !== UT_TRANSPORT) continue; + if (u.targetTile === null || !u.isActive || u.retreating) continue; + if (ownerFilter !== undefined && u.ownerID !== ownerFilter) continue; + const t = u.targetTile; + rings.push({ x: t % mapW, y: (t - (t % mapW)) / mapW, unitId: u.id }); + } + return rings; +} + +/** + * Targeted variant — iterates only pre-classified transport IDs instead of all units. + * Used by the live path where UnitClassifier maintains the transport ID set. + */ +export function extractAttackRingsFromIds( + transportIds: readonly number[], + units: ReadonlyMap, + mapW: number, + ownerFilter?: number, +): AttackRingInput[] { + const rings: AttackRingInput[] = []; + for (const id of transportIds) { + const u = units.get(id); + if (!u || u.targetTile === null || !u.isActive || u.retreating) continue; + if (ownerFilter !== undefined && u.ownerID !== ownerFilter) continue; + const t = u.targetTile; + rings.push({ x: t % mapW, y: (t - (t % mapW)) / mapW, unitId: u.id }); + } + return rings; +} diff --git a/src/client/render/frame/derive/NukeTelegraphs.ts b/src/client/render/frame/derive/NukeTelegraphs.ts new file mode 100644 index 0000000000..5523efb16a --- /dev/null +++ b/src/client/render/frame/derive/NukeTelegraphs.ts @@ -0,0 +1,57 @@ +import type { NukeTelegraphData, UnitState } from "../../types"; +import { NUKE_MAGNITUDES } from "../../types"; + +/** + * Extract nuke telegraph circles for active nukes with targets. + * + * When `friendlyIDs` is provided, only nukes owned by those players are + * included (live game — you see your own + teammates' telegraphs). + * When omitted, all nukes are included (replay / spectator). + */ +export function extractNukeTelegraphs( + units: ReadonlyMap, + mapW: number, + friendlyIDs?: ReadonlySet, +): NukeTelegraphData[] { + const telegraphs: NukeTelegraphData[] = []; + for (const u of units.values()) { + if (u.targetTile === null || !u.isActive) continue; + if (friendlyIDs && !friendlyIDs.has(u.ownerID)) continue; + const mag = NUKE_MAGNITUDES[u.unitType]; + if (!mag) continue; + telegraphs.push({ + x: u.targetTile % mapW, + y: (u.targetTile - (u.targetTile % mapW)) / mapW, + innerRadius: mag.inner, + outerRadius: mag.outer, + }); + } + return telegraphs; +} + +/** + * Targeted variant — iterates only pre-classified nuke IDs instead of all units. + * Used by the live path where UnitClassifier maintains the nuke ID set. + */ +export function extractNukeTelegraphsFromIds( + nukeIds: readonly number[], + units: ReadonlyMap, + mapW: number, + friendlyIDs?: ReadonlySet, +): NukeTelegraphData[] { + const telegraphs: NukeTelegraphData[] = []; + for (const id of nukeIds) { + const u = units.get(id); + if (!u || u.targetTile === null || !u.isActive) continue; + if (friendlyIDs && !friendlyIDs.has(u.ownerID)) continue; + const mag = NUKE_MAGNITUDES[u.unitType]; + if (!mag) continue; + telegraphs.push({ + x: u.targetTile % mapW, + y: (u.targetTile - (u.targetTile % mapW)) / mapW, + innerRadius: mag.inner, + outerRadius: mag.outer, + }); + } + return telegraphs; +} diff --git a/src/client/render/frame/derive/PlayerStatus.ts b/src/client/render/frame/derive/PlayerStatus.ts new file mode 100644 index 0000000000..ec98808bcc --- /dev/null +++ b/src/client/render/frame/derive/PlayerStatus.ts @@ -0,0 +1,141 @@ +import type { PlayerState, PlayerStatusData, UnitState } from "../../types"; +import { NUKE_TYPES, UT_MIRV_WARHEAD } from "../../types"; + +/** Unit types that indicate an active nuke is in flight. */ +const NUKE_ACTIVE_TYPES: ReadonlySet = new Set([ + ...NUKE_TYPES, + UT_MIRV_WARHEAD, +]); + +const OWNER_MASK = 0xfff; + +export interface ComputePlayerStatusOptions { + /** + * Local player smallID for computing relative flags. Omit (or set to 0) + * for replay mode — relative flags will all be false. + */ + localPlayerID?: number; + /** + * Tile state buffer (the same Uint16Array exposed via FrameData.tileState). + * Used to determine if a nuke's target tile is owned by the local player + * for the `nukeTargetsMe` flag. If omitted, `nukeTargetsMe` stays false. + */ + tileState?: Uint16Array; +} + +/** + * Compute per-player status flags for the name/status-icon pass. + * + * Without `opts.localPlayerID`: replay-path mode. Crown/traitor/disconnected/ + * nukeActive are populated; relative flags (alliance/target/embargo/ + * nukeTargetsMe) are all false. + * + * With `opts.localPlayerID`: live mode. Relative flags compare each player + * against the local player's state to determine alliance/target/embargo; + * if `opts.tileState` is also given, `nukeTargetsMe` is set for players + * whose in-flight nuke is targeting one of the local player's tiles. + * + * `allianceReq` and `allianceFraction` are not computed yet — they need + * additional context (the local player's PlayerID string for outgoing + * requests, and the current tick for fraction). Left as `false`/`0` until + * those use cases need them. + */ +export function computePlayerStatus( + players: ReadonlyMap, + units: ReadonlyMap, + opts: ComputePlayerStatusOptions = {}, +): Map { + const result = new Map(); + const localPlayerID = opts.localPlayerID ?? 0; + const tileState = opts.tileState; + const localPlayer = + localPlayerID > 0 ? players.get(localPlayerID) : undefined; + + // Nuke owners: players who have an active nuke in flight. + // Also collect which of those nukes target a tile owned by the local player. + const nukeOwners = new Set(); + const nukeAimedAtMe = new Set(); + for (const u of units.values()) { + if (!u.isActive || !NUKE_ACTIVE_TYPES.has(u.unitType)) continue; + nukeOwners.add(u.ownerID); + if ( + localPlayer !== undefined && + tileState !== undefined && + u.targetTile !== null + ) { + const tileOwner = tileState[u.targetTile] & OWNER_MASK; + if (tileOwner === localPlayerID) { + nukeAimedAtMe.add(u.ownerID); + } + } + } + + // Crown: alive player with most tiles owned. + let crownSmallID = -1; + let maxTiles = 0; + for (const ps of players.values()) { + if (!ps.isAlive) continue; + if (ps.tilesOwned > maxTiles) { + maxTiles = ps.tilesOwned; + crownSmallID = ps.smallID; + } + } + + // Relative-flag sets seeded from the local player's state. Looking them + // up once outside the per-player loop is O(1) per player rather than O(n) + // per .includes(); doesn't matter at small scale but keeps the loop tidy. + const allySet = localPlayer ? new Set(localPlayer.allies) : null; + const targetSet = localPlayer ? new Set(localPlayer.targets) : null; + const myEmbargoes = localPlayer ? new Set(localPlayer.embargoes) : null; + + for (const ps of players.values()) { + if (!ps.isAlive) continue; + const sid = ps.smallID; + const crown = sid === crownSmallID; + const traitor = ps.isTraitor; + const disconnected = ps.isDisconnected; + const traitorRemainingTicks = ps.traitorRemainingTicks; + const nukeActive = nukeOwners.has(sid); + + // Relative flags — only meaningful when there's a local player AND we're + // not looking at the local player itself. + let alliance = false; + let target = false; + let embargo = false; + let nukeTargetsMe = false; + if (localPlayer !== undefined && sid !== localPlayerID) { + alliance = allySet!.has(sid); + target = targetSet!.has(sid); + // Embargo is bilateral: either side embargoes the other. + embargo = myEmbargoes!.has(sid) || ps.embargoes.includes(localPlayerID); + nukeTargetsMe = nukeAimedAtMe.has(sid); + } + + if ( + crown || + traitor || + disconnected || + traitorRemainingTicks > 0 || + nukeActive || + alliance || + target || + embargo || + nukeTargetsMe + ) { + result.set(sid, { + crown, + traitor, + disconnected, + alliance, + allianceReq: false, + target, + embargo, + nukeActive, + nukeTargetsMe, + traitorRemainingTicks, + allianceFraction: 0, + }); + } + } + return result; +} diff --git a/src/client/render/frame/derive/RelationMatrix.ts b/src/client/render/frame/derive/RelationMatrix.ts new file mode 100644 index 0000000000..ca3b7dcb8c --- /dev/null +++ b/src/client/render/frame/derive/RelationMatrix.ts @@ -0,0 +1,91 @@ +import type { PlayerState, PlayerStatic } from "../../types"; + +const RELATION_SIZE = 1024; +const RELATION_NEUTRAL = 0; +const RELATION_FRIENDLY = 1; +const RELATION_EMBARGO = 2; + +/** Reusable matrix buffer — one allocation, rewritten each frame. */ +const matrix = new Uint8Array(RELATION_SIZE * RELATION_SIZE); + +export interface RelationMatrixResult { + matrix: Uint8Array; + size: number; +} + +/** + * Build a relationship matrix from player alliance, embargo, and team data. + * Indexed by `[ownerA * size + ownerB]` → 0=neutral, 1=friendly, 2=embargo. + * Embargo overrides friendly (matching game priority). + * + * @param teams Optional smallID→team map. Same-team players are marked friendly. + */ +export function buildRelationMatrix( + players: ReadonlyMap, + teams?: ReadonlyMap, +): RelationMatrixResult { + matrix.fill(RELATION_NEUTRAL); + + // Teammates — mark same-team pairs as friendly (before embargoes, which override) + if (teams && teams.size > 0) { + const byTeam = new Map(); + for (const [sid, team] of teams) { + if (sid <= 0 || sid >= RELATION_SIZE) continue; + let bucket = byTeam.get(team); + if (!bucket) { + bucket = []; + byTeam.set(team, bucket); + } + bucket.push(sid); + } + for (const members of byTeam.values()) { + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) { + const a = members[i]!, + b = members[j]!; + matrix[a * RELATION_SIZE + b] = RELATION_FRIENDLY; + matrix[b * RELATION_SIZE + a] = RELATION_FRIENDLY; + } + } + } + } + + // Alliances + for (const ps of players.values()) { + const sid = ps.smallID; + if (sid <= 0 || sid >= RELATION_SIZE) continue; + + if (ps.allies) { + for (const allyID of ps.allies) { + if (allyID > 0 && allyID < RELATION_SIZE) { + const ab = sid * RELATION_SIZE + allyID; + const ba = allyID * RELATION_SIZE + sid; + if (matrix[ab]! < RELATION_FRIENDLY) matrix[ab] = RELATION_FRIENDLY; + if (matrix[ba]! < RELATION_FRIENDLY) matrix[ba] = RELATION_FRIENDLY; + } + } + } + + if (ps.embargoes) { + for (const eID of ps.embargoes) { + if (eID > 0 && eID < RELATION_SIZE) { + matrix[sid * RELATION_SIZE + eID] = RELATION_EMBARGO; + matrix[eID * RELATION_SIZE + sid] = RELATION_EMBARGO; + } + } + } + } + + return { matrix, size: RELATION_SIZE }; +} + +/** Build a smallID→team map from a player list. Skips players with no team. */ +export function buildTeamMap( + players: readonly PlayerStatic[], +): ReadonlyMap { + const m = new Map(); + for (const p of players) { + if (p.team !== null) m.set(p.smallID, p.team); + } + return m; +} diff --git a/src/client/render/frame/index.ts b/src/client/render/frame/index.ts new file mode 100644 index 0000000000..ddcb4fa55b --- /dev/null +++ b/src/client/render/frame/index.ts @@ -0,0 +1,20 @@ +// Re-export the boundary contract type +export type { FrameData } from "../types"; + +// Shared derive functions +export { computeAllianceClusters } from "./derive/AllianceClusters"; +export { + extractAttackRings, + extractAttackRingsFromIds, +} from "./derive/AttackRings"; +export { + extractNukeTelegraphs, + extractNukeTelegraphsFromIds, +} from "./derive/NukeTelegraphs"; +export { computePlayerStatus } from "./derive/PlayerStatus"; +export { buildRelationMatrix, buildTeamMap } from "./derive/RelationMatrix"; + +// Upload +export type { RelationMatrixResult } from "./derive/RelationMatrix"; +export { uploadFrameData } from "./Upload"; +export type { FrameUploadTarget, UploadOptions } from "./Upload"; diff --git a/src/client/render/gl/Camera.ts b/src/client/render/gl/Camera.ts new file mode 100644 index 0000000000..1bd4cbf8df --- /dev/null +++ b/src/client/render/gl/Camera.ts @@ -0,0 +1,198 @@ +/** + * 2D camera: pan/zoom → column-major mat3 for WebGL2 vertex shaders. + * + * Pure viewport math — no DOM event listeners. Input handling lives + * in GameView, which calls panBy / zoomAtScreen / etc. + * + * Coordinate system: + * World: (0,0) top-left, (mapWidth, mapHeight) bottom-right, +Y down. + * Clip: (-1,-1) bottom-left, (1,1) top-right. + * + * The mat3 maps world → clip: + * sx = zoom * 2 / canvasWidth + * sy = zoom * -2 / canvasHeight (Y flip) + * tx = -offsetX * sx + * ty = -offsetY * sy + */ + +const MIN_ZOOM = 0.2; +const MAX_ZOOM = 20; +const DBLCLICK_MIN_ZOOM = 0.7; +const DBLCLICK_MAX_ZOOM = 3; + +export class Camera { + offsetX: number; + offsetY: number; + zoom: number; + + private mapW: number; + private mapH: number; + private canvasW = 1; + private canvasH = 1; + private mat = new Float32Array(9); + private dirty = true; + /** True until fitMap() has been called with valid canvas dimensions. */ + private needsInitialFit = true; + + constructor(mapWidth: number, mapHeight: number) { + this.mapW = mapWidth; + this.mapH = mapHeight; + this.offsetX = mapWidth / 2; + this.offsetY = mapHeight / 2; + this.zoom = 1; + } + + /** Update canvas pixel dimensions. Triggers initial fitMap on first call. */ + resize(cssWidth: number, cssHeight: number): void { + const dpr = window.devicePixelRatio || 1; + this.canvasW = Math.round(cssWidth * dpr); + this.canvasH = Math.round(cssHeight * dpr); + if (this.needsInitialFit) { + this.fitMap(); + } + this.dirty = true; + } + + /** Fit the map into the viewport (~90% fill). */ + fitMap(): void { + this.offsetX = this.mapW / 2; + this.offsetY = this.mapH / 2; + const sx = this.canvasW / this.mapW; + const sy = this.canvasH / this.mapH; + this.zoom = Math.min(sx, sy) * 0.9; + this.dirty = true; + this.needsInitialFit = false; + } + + /** Center the camera on a bounding box with padding (1.4 ≈ 71% fill). */ + focusBBox( + minX: number, + minY: number, + maxX: number, + maxY: number, + padding = 1.4, + ): void { + this.offsetX = (minX + maxX + 1) / 2; + this.offsetY = (minY + maxY + 1) / 2; + const bboxW = maxX - minX + 1; + const bboxH = maxY - minY + 1; + const sx = this.canvasW / bboxW; + const sy = this.canvasH / bboxH; + this.zoom = Math.max( + DBLCLICK_MIN_ZOOM, + Math.min(DBLCLICK_MAX_ZOOM, Math.min(sx, sy) / padding), + ); + this.clampOffset(); + this.dirty = true; + } + + /** Set the camera center to a world position. */ + panTo(worldX: number, worldY: number): void { + this.offsetX = worldX; + this.offsetY = worldY; + this.clampOffset(); + this.dirty = true; + } + + /** Shift the camera center by a world-space delta (used for drag panning). */ + panBy(dx: number, dy: number): void { + this.offsetX += dx; + this.offsetY += dy; + this.clampOffset(); + this.dirty = true; + } + + /** Restore camera state, skipping the initial fitMap. */ + setCameraState(x: number, y: number, z: number): void { + this.offsetX = x; + this.offsetY = y; + this.zoom = z; + this.needsInitialFit = false; + this.dirty = true; + } + + /** Multiply zoom by a factor (centered on current view). */ + zoomBy(factor: number): void { + this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor)); + this.clampOffset(); + this.dirty = true; + } + + /** Set absolute zoom level. */ + zoomTo(level: number): void { + this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, level)); + this.clampOffset(); + this.dirty = true; + } + + /** + * Zoom by a factor while keeping a screen point fixed in world space. + * Used for wheel-zoom: the world position under the cursor stays put. + */ + zoomAtScreen(factor: number, screenX: number, screenY: number): void { + const worldBefore = this.screenToWorld(screenX, screenY); + this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, this.zoom * factor)); + const worldAfter = this.screenToWorld(screenX, screenY); + this.offsetX += worldBefore.x - worldAfter.x; + this.offsetY += worldBefore.y - worldAfter.y; + this.clampOffset(); + this.dirty = true; + } + + /** Return the column-major mat3 camera matrix (world → clip). */ + getMatrix(): Float32Array { + if (this.dirty) { + const sx = (this.zoom * 2) / this.canvasW; + const sy = (this.zoom * -2) / this.canvasH; // Y flip + const tx = -this.offsetX * sx; + const ty = -this.offsetY * sy; + const m = this.mat; + m[0] = sx; + m[1] = 0; + m[2] = 0; + m[3] = 0; + m[4] = sy; + m[5] = 0; + m[6] = tx; + m[7] = ty; + m[8] = 1; + this.dirty = false; + } + return this.mat; + } + + /** Convert screen pixel position to world coordinates. */ + screenToWorld(screenX: number, screenY: number): { x: number; y: number } { + const dpr = window.devicePixelRatio || 1; + const ndcX = ((screenX * dpr) / this.canvasW) * 2 - 1; + const ndcY = -(((screenY * dpr) / this.canvasH) * 2 - 1); + const sx = (this.zoom * 2) / this.canvasW; + const sy = (this.zoom * -2) / this.canvasH; + return { + x: (ndcX - -this.offsetX * sx) / sx, + y: (ndcY - -this.offsetY * sy) / sy, + }; + } + + /** Convert world coordinates to screen pixel position (CSS pixels). */ + worldToScreen(worldX: number, worldY: number): { x: number; y: number } { + const dpr = window.devicePixelRatio || 1; + return { + x: (this.zoom * (worldX - this.offsetX)) / dpr + this.canvasW / (2 * dpr), + y: (this.zoom * (worldY - this.offsetY)) / dpr + this.canvasH / (2 * dpr), + }; + } + + private clampOffset(): void { + const halfVpW = this.canvasW / (2 * this.zoom); + const halfVpH = this.canvasH / (2 * this.zoom); + this.offsetX = Math.max( + -halfVpW, + Math.min(this.mapW + halfVpW, this.offsetX), + ); + this.offsetY = Math.max( + -halfVpH, + Math.min(this.mapH + halfVpH, this.offsetY), + ); + } +} diff --git a/src/client/render/gl/DynamicBuffer.ts b/src/client/render/gl/DynamicBuffer.ts new file mode 100644 index 0000000000..86ce6f89fe --- /dev/null +++ b/src/client/render/gl/DynamicBuffer.ts @@ -0,0 +1,55 @@ +/** + * DynamicInstanceBuffer — manages grow-on-demand instance buffers. + * + * Encapsulates the pattern of doubling capacity when needed, allocating new + * Float32Array, copying old data, and rebinding the GL buffer. + */ + +export class DynamicInstanceBuffer { + private data: Float32Array; + private bytes: Uint8Array; + private capacity: number; + + constructor( + private gl: WebGL2RenderingContext, + private buf: WebGLBuffer, + initialCapacity: number, + private floatsPerInstance: number, + ) { + this.capacity = initialCapacity; + this.data = new Float32Array(initialCapacity * floatsPerInstance); + this.bytes = new Uint8Array(this.data.buffer); + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW); + } + + ensureCapacity(needed: number): void { + if (needed <= this.capacity) return; + while (this.capacity < needed) this.capacity *= 2; + const newData = new Float32Array(this.capacity * this.floatsPerInstance); + newData.set(this.data); + this.data = newData; + this.bytes = new Uint8Array(newData.buffer); + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.buf); + gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW); + } + + get float32(): Float32Array { + return this.data; + } + + get uint8(): Uint8Array { + return this.bytes; + } + + get buffer(): WebGLBuffer { + return this.buf; + } + + dispose(): void { + if (this.buf !== null && this.buf !== undefined) { + this.gl.deleteBuffer(this.buf); + } + } +} diff --git a/src/client/render/gl/Events.ts b/src/client/render/gl/Events.ts new file mode 100644 index 0000000000..a4a691105a --- /dev/null +++ b/src/client/render/gl/Events.ts @@ -0,0 +1,96 @@ +import type { UnitState } from "../types"; + +/** Event data emitted by GameView for map interactions. */ +export interface MapPointerEvent { + /** CSS pixel X relative to viewport (clientX). */ + screenX: number; + /** CSS pixel Y relative to viewport (clientY). */ + screenY: number; + /** World-space X (fractional; floor for tile column). */ + worldX: number; + /** World-space Y (fractional; floor for tile row). */ + worldY: number; + /** Tile column (integer, -1 if out of bounds). */ + tileX: number; + /** Tile row (integer, -1 if out of bounds). */ + tileY: number; + /** Territory owner at this tile (0 = unowned/OOB). */ + ownerID: number; + /** Nearest mobile unit under cursor, or null. */ + unit: UnitState | null; + /** Nearest structure under cursor, or null. */ + structure: UnitState | null; + /** Mouse button: 0 = left, 1 = middle, 2 = right. */ + button: number; + /** Shift key held. */ + shiftKey: boolean; + /** Ctrl/Meta key held. */ + ctrlKey: boolean; + /** Alt key held. */ + altKey: boolean; +} + +/** Scroll event data emitted by GameView. */ +export interface MapScrollEvent { + deltaX: number; + deltaY: number; + shiftKey: boolean; + ctrlKey: boolean; + altKey: boolean; +} + +/** Alt-view temporarily peeked (space hold — enables altview + gridview). */ +export interface AltViewPeekEvent { + active: boolean; +} + +/** Grid-view default toggled (persistent resting state changed via 'M'). */ +export interface GridViewToggleEvent { + active: boolean; +} + +/** Map of event names to their payload types. */ +export interface GameViewEventMap { + /** Left-click (pointerdown + pointerup with < 10px movement). */ + click: MapPointerEvent; + /** Double-click. */ + dblclick: MapPointerEvent; + /** Middle-click (auxclick with button 1). */ + middleclick: MapPointerEvent; + /** Right-click / context menu. */ + contextmenu: MapPointerEvent; + /** Hovered entity changed (owner, unit, or structure differs from previous). */ + hover: MapPointerEvent; + /** Scroll with modifier keys (unmodified scroll is consumed by zoom). */ + scroll: MapScrollEvent; + /** User selected a radial menu item. */ + menuselect: RadialMenuSelectEvent; + /** Alt-view temporarily peeked (space hold — enables altview + gridview). */ + altviewpeek: AltViewPeekEvent; + /** Grid-view default toggled (M key). */ + gridviewtoggle: GridViewToggleEvent; +} + +/** A single item in the radial context menu. */ +export interface RadialMenuItem { + /** Unique identifier for this action. */ + id: string; + /** Emoji key into the atlas (e.g. "⚔️"), or empty string for no icon. */ + icon: string; + /** RGB color [0–1]. */ + color: [number, number, number]; + /** Whether this action is currently available. */ + enabled: boolean; + /** If present, clicking this item opens a submenu with these items. */ + subItems?: RadialMenuItem[]; +} + +/** Emitted when the user selects a radial menu item. */ +export interface RadialMenuSelectEvent { + /** Index of the selected segment. */ + index: number; + /** The item's id. */ + id: string; +} + +export type GameViewEventType = keyof GameViewEventMap; diff --git a/src/client/render/gl/GameView.ts b/src/client/render/gl/GameView.ts new file mode 100644 index 0000000000..8108514e58 --- /dev/null +++ b/src/client/render/gl/GameView.ts @@ -0,0 +1,354 @@ +/** + * GameView — public facade for the openfront-gl renderer. + * + * Wraps GPURenderer (rendering) and Camera (viewport math) as private + * implementation details. Handles all user interaction: drag-to-pan, + * wheel-to-zoom, click detection, hover tracking, and hit-testing. + * + * Consumers only touch GameView — they never import GPURenderer or Camera. + */ + +import type { + AttackRingInput, + BonusEvent, + ConquestFx, + DeadUnitFx, + GhostPreviewData, + NameEntry, + NukeTelegraphData, + NukeTrajectoryData, + PlayerState, + PlayerStatic, + PlayerStatusData, + RendererConfig, + TilePair, + UnitState, +} from "../types"; +import type { + GameViewEventMap, + GameViewEventType, + RadialMenuItem, +} from "./Events"; +import type { SpawnCenter } from "./passes/SpawnOverlayPass"; +import { GPURenderer } from "./Renderer"; +import type { RenderSettings } from "./RenderSettings"; + +export class GameView { + private renderer: GPURenderer; + private resizeObs: ResizeObserver | null = null; + + private listeners = new Map void>>(); + + constructor( + canvas: HTMLCanvasElement, + header: RendererConfig, + terrainBytes: Uint8Array, + paletteData: Float32Array, + raf?: typeof requestAnimationFrame, + caf?: typeof cancelAnimationFrame, + ) { + this.renderer = new GPURenderer( + canvas, + header, + terrainBytes, + paletteData, + raf, + caf, + ); + + this.resizeObs = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) this.renderer.resize(width, height); + } + }); + this.resizeObs.observe(canvas); + + const rect = canvas.getBoundingClientRect(); + if (rect.width > 0) this.renderer.resize(rect.width, rect.height); + } + + // ---- Event system ---- + + on( + event: K, + handler: (e: GameViewEventMap[K]) => void, + ): void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(handler as (e: unknown) => void); + } + + off( + event: K, + handler: (e: GameViewEventMap[K]) => void, + ): void { + this.listeners.get(event)?.delete(handler as (e: unknown) => void); + } + + private emit( + event: K, + data: GameViewEventMap[K], + ): void { + const set = this.listeners.get(event); + if (set) + for (const fn of set) (fn as (e: GameViewEventMap[K]) => void)(data); + } + + // ---- Radial menu ---- + + showRadialMenu( + screenX: number, + screenY: number, + items: RadialMenuItem[], + centerItem?: RadialMenuItem, + ): void { + this.renderer.showRadialMenu(screenX, screenY, items, centerItem); + } + + hideRadialMenu(): void { + this.renderer.hideRadialMenu(); + } + + openRadialSubMenu(subItems: RadialMenuItem[]): void { + this.renderer.openRadialSubMenu(subItems); + } + + goBackRadialMenu(): void { + this.renderer.goBackRadialMenu(); + } + + get radialMenuVisible(): boolean { + return this.renderer.radialMenuVisible; + } + registerRadialMenuIcons( + icons: { key: string; img: CanvasImageSource }[], + ): void { + this.renderer.registerRadialMenuIcons(icons); + } + + // ---- Camera ---- + + screenToWorld(screenX: number, screenY: number): { x: number; y: number } { + return this.renderer.screenToWorld(screenX, screenY); + } + + worldToScreen(worldX: number, worldY: number): { x: number; y: number } { + return this.renderer.worldToScreen(worldX, worldY); + } + + panTo(worldX: number, worldY: number): void { + this.renderer.panTo(worldX, worldY); + } + zoomTo(level: number): void { + this.renderer.zoomTo(level); + } + fitMap(): void { + this.renderer.fitMap(); + } + focusOwner(ownerID: number): void { + this.renderer.focusOwner(ownerID); + } + + focusBBox( + minX: number, + minY: number, + maxX: number, + maxY: number, + padding?: number, + ): void { + this.renderer.focusBBox(minX, minY, maxX, maxY, padding); + } + + getCameraState(): { x: number; y: number; z: number } { + return this.renderer.getCameraState(); + } + + setCameraState(x: number, y: number, z: number): void { + this.renderer.setCameraState(x, y, z); + } + + getOwnerAtWorld(worldX: number, worldY: number): number { + return this.renderer.getOwnerAtWorld(worldX, worldY); + } + + // ---- Data upload ---- + + applyFullFrame( + tileState: Uint16Array, + trailState: Uint8Array, + nukeEvents?: Array<{ tick: number; tiles: number[] }>, + currentTick?: number, + ): void { + this.renderer.applyFullFrame( + tileState, + trailState, + nukeEvents, + currentTick, + ); + } + + applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void { + this.renderer.applyFullTiles(tileState, trailState); + } + applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void { + this.renderer.applyDelta(changedTiles, trailState); + } + uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void { + this.renderer.uploadLiveDelta(tileState, changedTiles); + } + uploadLiveTrailDelta( + trailState: Uint8Array, + dirtyRowMin: number, + dirtyRowMax: number, + ): void { + this.renderer.uploadLiveTrailDelta(trailState, dirtyRowMin, dirtyRowMax); + } + /** Upload full tile + trail state without resetting bloom (for live play). */ + uploadTileAndTrailState( + tileState: Uint16Array, + trailState: Uint8Array, + ): void { + this.renderer.uploadTileAndTrailState(tileState, trailState); + } + updatePalette(paletteData: Float32Array): void { + this.renderer.updatePalette(paletteData); + } + addPlayers(players: PlayerStatic[], paletteData: Float32Array): void { + this.renderer.addPlayers(players, paletteData); + } + uploadRailroadState(data: Uint8Array): void { + this.renderer.uploadRailroadState(data); + } + updateUnits(units: Map, gameTick: number): void { + this.renderer.updateUnits(units, gameTick); + } + updateNames( + names: Map, + players: Map, + snap: boolean, + statusData?: Map, + ): void { + this.renderer.updateNames(names, players, snap, statusData); + } + updateRelations(data: Uint8Array, size: number): void { + this.renderer.updateRelations(data, size); + } + updateStructures(units: Map): void { + this.renderer.updateStructures(units); + } + applyDeadUnits(deadUnits: DeadUnitFx[]): void { + this.renderer.applyDeadUnits(deadUnits); + } + applyConquestEvents(events: ConquestFx[]): void { + this.renderer.applyConquestEvents(events); + } + applyBonusEvents(events: BonusEvent[]): void { + this.renderer.applyBonusEvents(events); + } + applyRailroadDust(tileRefs: number[]): void { + this.renderer.applyRailroadDust(tileRefs); + } + updateAttackRings(rings: AttackRingInput[]): void { + this.renderer.updateAttackRings(rings); + } + clearFx(): void { + this.renderer.clearFx(); + } + setFxTimeFn(fn: () => number): void { + this.renderer.setFxTimeFn(fn); + } + + /** Update ghost structure preview (build-mode visualization). null = clear. */ + updateGhostPreview(data: GhostPreviewData | null): void { + this.renderer.updateGhostPreview(data); + } + + // ---- Nuke UI ---- + + /** Update nuke trajectory preview arc. null = hide. */ + updateNukeTrajectory(data: NukeTrajectoryData | null): void { + this.renderer.updateNukeTrajectory(data); + } + + /** Update in-flight nuke target telegraph circles. */ + updateNukeTelegraphs(data: NukeTelegraphData[]): void { + this.renderer.updateNukeTelegraphs(data); + } + + /** Update spawn phase overlay (tile highlights + breathing rings). */ + updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void { + this.renderer.updateSpawnOverlay(inSpawnPhase, centers); + } + + // ---- Selection box ---- + + /** Show/hide the stippled selection box around a unit (warship selection). */ + setSelectedUnit(unitId: number | null): void { + this.renderer.setSelectedUnit(unitId); + } + + /** Set multiple selected units (multi-select). Pass [] to clear. */ + setSelectedUnits(unitIds: readonly number[]): void { + this.renderer.setSelectedUnits(unitIds); + } + + /** Flash converging-chevron animation at a warship move target. */ + showMoveIndicator(tileX: number, tileY: number, ownerID: number): void { + this.renderer.showMoveIndicator(tileX, tileY, ownerID); + } + + // ---- SAM radius (replay) ---- + + setSAMRadiusVisible(visible: boolean): void { + this.renderer.setSAMRadiusVisible(visible); + } + setSAMPerspective(playerID: number, allies: Set): void { + this.renderer.setSAMPerspective(playerID, allies); + } + setSAMColorMode(mode: "perspective" | "owner"): void { + this.renderer.setSAMColorMode(mode); + } + setSAMAllianceClusters(clusters: Map): void { + this.renderer.setSAMAllianceClusters(clusters); + } + + // ---- Other ---- + + setLocalPlayerID(id: number): void { + this.renderer.setLocalPlayerID(id); + } + setAltView(active: boolean): void { + this.renderer.setAltView(active); + } + setHighlightOwner(ownerID: number): void { + this.renderer.setHighlightOwner(ownerID); + } + setHighlightStructureTypes(unitTypes: string[] | null): void { + this.renderer.setHighlightStructureTypes(unitTypes); + } + getSettings(): RenderSettings { + return this.renderer.getSettings(); + } + get fps(): number { + return this.renderer.fps; + } + set onFrame(cb: ((ms: number) => void) | null) { + this.renderer.onFrame = cb; + } + set afterRender(cb: ((canvas: HTMLCanvasElement) => void) | null) { + this.renderer.afterRender = cb; + } + + // ---- Lifecycle ---- + + dispose(): void { + this.resizeObs?.disconnect(); + this.resizeObs = null; + this.listeners.clear(); + this.renderer.dispose(); + } +} diff --git a/src/client/render/gl/RenderSettings.ts b/src/client/render/gl/RenderSettings.ts new file mode 100644 index 0000000000..2aa9cfc601 --- /dev/null +++ b/src/client/render/gl/RenderSettings.ts @@ -0,0 +1,258 @@ +import defaults from "./render-settings.json"; + +export interface RenderSettings { + passEnabled: { + terrain: boolean; + mapOverlay: boolean; + structure: boolean; + unit: boolean; + name: boolean; + falloutBloom: boolean; + railroad: boolean; + fx: boolean; + bar: boolean; + nameDebug: boolean; + }; + falloutBloom: { + broilSpeedCold: number; + broilSpeedHot: number; + noiseFreq1: number; + noiseFreq2: number; + contrastLoCold: number; + contrastLoHot: number; + contrastHiCold: number; + contrastHiHot: number; + metaFreq: number; + intensityCold: number; + intensityHot: number; + metaInfluenceCold: number; + metaInfluenceHot: number; + opacityFadeEnd: number; + bloomR: number; + bloomG: number; + bloomB: number; + bloomCoverage: number; + heatDecayPerTick: number; + }; + dayNight: { + mode: "light" | "dark"; + nightAmbient: number; + dayAmbient: number; + falloffPower: number; + falloutLightR: number; + falloutLightG: number; + falloutLightB: number; + falloutLightIntensity: number; + falloutLightThreshold: number; + emberLightR: number; + emberLightG: number; + emberLightB: number; + emberLightIntensity: number; + blurZoomDivisor: number; + lightRadiusMultiplier: number; + }; + mapOverlay: { + trailAlpha: number; + defenseCheckerDarken: number; + charcoalBase: number; + charcoalVariation: number; + charcoalAlpha: number; + emberThresholdUnowned: number; + emberThresholdOwned: number; + emberFlickerSpeed: number; + emberColorDarkR: number; + emberColorDarkG: number; + emberColorDarkB: number; + emberColorBrightR: number; + emberColorBrightG: number; + emberColorBrightB: number; + emberStrengthUnowned: number; + highlightBrighten: number; + highlightFillBrighten: number; + highlightThicken: number; + defensePostRange: number; + embargoTintRatio: number; + friendlyTintRatio: number; + }; + railroad: { + railMinZoom: number; + railDetailZoom: number; + railAlpha: number; + }; + structure: { + iconSize: number; + dotsZoomThreshold: number; + /** Icon size multiplier when zoomed out past dotsZoomThreshold. */ + dotScale: number; + iconScaleFactorZoomedOut: number; + /** + * Zoom level at which structures begin growing with the canvas. + * Below this zoom, structures stay at a fixed screen size (capped). + * Above this zoom, they grow proportionally to zoom — i.e. world-anchored, + * so they cover a fixed area of the map. + */ + iconGrowZoom: number; + shapes: Record; + highlightOutlineWidth: number; + highlightDimAlpha: number; + }; + structureLevel: { + scale: number; + outlineWidth: number; + }; + bar: { + healthBarW: number; + healthBarH: number; + healthBarOffsetY: number; + progressBarW: number; + progressBarH: number; + progressBarOffsetY: number; + borderWidth: number; + threshold1: number; + threshold2: number; + threshold3: number; + colorRedR: number; + colorRedG: number; + colorRedB: number; + colorOrangeR: number; + colorOrangeG: number; + colorOrangeB: number; + colorYellowR: number; + colorYellowG: number; + colorYellowB: number; + colorGreenR: number; + colorGreenG: number; + colorGreenB: number; + }; + unit: { + unitSize: number; + flickerSpeed: number; + angryR: number; + angryG: number; + angryB: number; + }; + name: { + lerpSpeed: number; + cullThreshold: number; + nameScaleFactor: number; + nameScaleCap: number; + troopSizeMultiplier: number; + outlineWidth: number; + outlineR: number; + outlineG: number; + outlineB: number; + outlineUsePlayerColor: boolean; + fillUsePlayerColor: boolean; + emojiRowOffset: number; + statusRowOffset: number; + }; + fx: { + shockwaveRingWidth: number; + nukeShockwaveDurationMs: number; + nukeShockwaveRadiusFactor: number; + samShockwaveDurationMs: number; + samShockwaveRadius: number; + debrisLifetimeMs: number; + debrisFadeIn: number; // 0–1 fraction of lifetime + debrisFadeOut: number; // 0–1 fraction of lifetime (start of fade) + conquestLifetimeMs: number; + conquestFadeIn: number; + conquestFadeOut: number; + }; + nukeTrajectory: { + lineWidth: number; // px — main line stroke width + outlineWidth: number; // px — extra width for outline behind line + dashTargetable: number; // px — dash length in targetable zone + gapTargetable: number; // px — gap length in targetable zone + dashUntargetable: number; // px — dash length in untargetable zone + gapUntargetable: number; // px — gap length in untargetable zone + lineR: number; // normal line color + lineG: number; + lineB: number; + interceptR: number; // line color after SAM intercept + interceptG: number; + interceptB: number; + outlineR: number; // outline color (normal) + outlineG: number; + outlineB: number; + interceptOutlineR: number; // outline color (after intercept) + interceptOutlineG: number; + interceptOutlineB: number; + markerCircleRadius: number; // px — zone boundary circle size + markerXRadius: number; // px — SAM intercept X size + }; + nukeTelegraph: { + strokeWidth: number; // world units — circle ring width + dashLen: number; // world units — outer ring dash length + gapLen: number; // world units — outer ring gap length + rotationSpeed: number; // outer ring rotation speed + baseAlpha: number; // base opacity (0–1) + pulseAmplitude: number; // alpha pulse ± + pulseSpeed: number; // pulse frequency (radians/sec) + fillAlphaOffset: number; // inner fill is baseAlpha minus this + colorR: number; // circle color + colorG: number; + colorB: number; + }; + moveIndicator: { + startRadius: number; // screen px — initial distance from center + chevronSize: number; // screen px — wing span + lineWidth: number; // screen px — stroke width + duration: number; // ms — total animation lifetime + converge: number; // 0–1 — fraction of radius consumed during animation + }; + samRadius: { + strokeWidth: number; // ring half-width in world units + dashLen: number; // dash length in world units + gapLen: number; // gap length in world units + rotationSpeed: number; // world units per second + alpha: number; // base opacity (0–1) + outlineWidth: number; // outline border width in world units + outlineSoftness: number; // smoothstep range (0 = hard, higher = softer) + }; + bonusPopup: { + scale: number; + lifetimeMs: number; + riseSpeed: number; + yOffset: number; + outlineWidth: number; + colorR: number; + colorG: number; + colorB: number; + minScreenScale: number; // minimum world-scale when zoomed out (prevents vanishing) + cullZoom: number; // popups hidden below this zoom level + }; + spawnOverlay: { + highlightRadius: number; // tile highlight radius (squared internally) + highlightAlpha: number; // tile highlight opacity (0–1) + selfMinRad: number; // self ring inner radius + selfMaxRad: number; // self ring outer radius + mateMinRad: number; // teammate ring inner radius + mateMaxRad: number; // teammate ring outer radius + animSpeed: number; // breathing animation speed + gradientInnerEdge: number; // static gradient inner ramp end (0–1) + gradientSolidEnd: number; // static gradient solid band end (0–1) + }; + altView: { + gridFontSize: number; + recolorStructures: boolean; + }; + lightConfigs: Record; +} + +/** Create a fresh settings object with defaults from render-settings.json. */ +export function createRenderSettings(): RenderSettings { + return JSON.parse(JSON.stringify(defaults)) as RenderSettings; +} + +/** Dump current settings to a downloadable JSON file. */ +export function dumpSettings(settings: RenderSettings): void { + const json = JSON.stringify(settings, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "render-settings.json"; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts new file mode 100644 index 0000000000..c174aa32da --- /dev/null +++ b/src/client/render/gl/Renderer.ts @@ -0,0 +1,1143 @@ +/** + * GPURenderer v2 — normalized render pipeline. + * + * Draw order: + * DATA SYNC: tile flush → heat update → border compute + * BASE PASS (darkened by night): terrain → territory fill + fallout charcoal + * NIGHT COMPOSITE (optional): lightmap → scene × (ambient + lightmap) + * FULL BRIGHTNESS (always): borders → railroads → ground units → structures → + * structure levels → bars → bloom → trails → missiles → fx → conquest → names + */ + +import type { + AttackRingInput, + BonusEvent, + ConquestFx, + DeadUnitFx, + GhostPreviewData, + NameEntry, + NukeTelegraphData, + NukeTrajectoryData, + PlayerState, + PlayerStatic, + PlayerStatusData, + RendererConfig, + TilePair, + UnitState, +} from "../types"; +import { Camera } from "./Camera"; +import type { RadialMenuItem } from "./Events"; +import { BarPass } from "./passes/BarPass"; +import { BorderComputePass } from "./passes/BorderComputePass"; +import { BorderStampPass } from "./passes/BorderStampPass"; +import { ConquestPopupPass } from "./passes/ConquestPopupPass"; +import { CoordinateGridPass } from "./passes/CoordinateGridPass"; +import { CrosshairPass } from "./passes/CrosshairPass"; +import { FalloutBloomPass } from "./passes/FalloutBloomPass"; +import { FalloutLightPass } from "./passes/FalloutLightPass"; +import { FxPass } from "./passes/fx-pass"; +import { LightmapPass } from "./passes/LightmapPass"; +import { MoveIndicatorPass } from "./passes/MoveIndicatorPass"; +import { NamePass } from "./passes/name-pass"; +import { NightCompositePass } from "./passes/NightCompositePass"; +import { NukeTelegraphPass } from "./passes/NukeTelegraphPass"; +import { NukeTrajectoryPass } from "./passes/NukeTrajectoryPass"; +import { PointLightPass } from "./passes/PointLightPass"; +import { RadialMenuPass } from "./passes/RadialMenuPass"; +import { RailroadPass } from "./passes/RailroadPass"; +import { RangeCirclePass } from "./passes/RangeCirclePass"; +import { SAMRadiusPass } from "./passes/SamRadiusPass"; +import { SelectionBoxPass } from "./passes/SelectionBoxPass"; +import type { SpawnCenter } from "./passes/SpawnOverlayPass"; +import { SpawnOverlayPass } from "./passes/SpawnOverlayPass"; +import { StructureLevelPass } from "./passes/StructureLevelPass"; +import { StructurePass } from "./passes/StructurePass"; +import { TerrainPass } from "./passes/TerrainPass"; +import { TerritoryPass } from "./passes/TerritoryPass"; +import { TrailPass } from "./passes/TrailPass"; +import { UnitPass } from "./passes/UnitPass"; +import { createRenderSettings, type RenderSettings } from "./RenderSettings"; +import { AffiliationPalette } from "./utils/Affiliation"; +import { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; +import { + createTexture2D, + toScreen, + toTarget, + type RenderTarget, +} from "./utils/GlUtils"; +import { + createGPUResources, + disposeGPUResources, + type GPUResources, +} from "./utils/GpuResources"; +import { HeatManager } from "./utils/HeatManager"; + +/** Ghost types that trigger SAM radius overlay (matches upstream SAMRadiusLayer). */ +const SAM_RADIUS_GHOST_TYPES = new Set([ + "Missile Silo", + "SAM Launcher", + "City", + "Atom Bomb", + "Hydrogen Bomb", +]); + +/** Subset for build-button hover — excludes City/Silo (SAM radii irrelevant). */ +const SAM_RADIUS_HIGHLIGHT_TYPES = new Set([ + "SAM Launcher", + "Atom Bomb", + "Hydrogen Bomb", +]); + +export class GPURenderer { + private gl: WebGL2RenderingContext; + private camera: Camera; + private res: GPUResources; + + // Passes + private terrainPass: TerrainPass; + private territoryPass: TerritoryPass; + private trailPass: TrailPass; + private borderStampPass: BorderStampPass; + private borderPass: BorderComputePass; + private bloomPass: FalloutBloomPass; + private pointLightPass: PointLightPass; + private falloutLightPass: FalloutLightPass; + private lightmapPass: LightmapPass; + private nightCompositePass: NightCompositePass; + private structurePass: StructurePass; + private structureLevelPass: StructureLevelPass; + private unitPass: UnitPass; + private namePass: NamePass; + private fxPass: FxPass; + private rangeCirclePass: RangeCirclePass; + private samRadiusPass: SAMRadiusPass; + private crosshairPass: CrosshairPass; + private railroadPass: RailroadPass; + private barPass: BarPass; + private conquestPopupPass: ConquestPopupPass; + private radialMenuPass: RadialMenuPass; + private selectionBoxPass: SelectionBoxPass; + private moveIndicatorPass: MoveIndicatorPass; + private nukeTrajectoryPass: NukeTrajectoryPass; + private nukeTelegraphPass: NukeTelegraphPass; + private heatManager: HeatManager; + private affiliationPalette: AffiliationPalette; + private coordinateGridPass: CoordinateGridPass; + private spawnOverlayPass: SpawnOverlayPass; + + private paletteTex: WebGLTexture; + private paletteData: Float32Array; + private canvas: HTMLCanvasElement; + private settings: RenderSettings; + private sceneTarget: RenderTarget; + private raf: typeof requestAnimationFrame; + private caf: typeof cancelAnimationFrame; + + private animId: number | null = null; + private frameTick = 0; + private mapW = 0; + private mapH = 0; + + // FPS tracking + private frameTimes: Float64Array = new Float64Array(60); + private frameIdx = 0; + private frameCount = 0; + fps = 0; + onFrame: ((ms: number) => void) | null = null; + afterRender: ((canvas: HTMLCanvasElement) => void) | null = null; + + // Hit-testing references + private lastUnits: Map = new Map(); + private lastStructures: Map = new Map(); + + // Local player relationship data (for SAM radius coloring) + private localPlayerID = 0; + private playerTeams = new Map(); // smallID → team + + // Alt-view: affiliation recoloring (space hold) + private altView = false; + // Grid-view: coordinate grid overlay (M toggle) + private gridView = false; + + // SAM radius visibility tracking (show if either source is true) + private samGhostVisible = false; + private samHighlightVisible = false; + + // Warship selection — supports any number of selections. + private selectedUnitIds: number[] = []; + /** Reusable scratch buffer of {x,y,r,g,b} for the selection-box pass. */ + private readonly selectionBoxEntries: import("./passes/SelectionBoxPass").SelectionEntry[] = + []; + + constructor( + canvas: HTMLCanvasElement, + header: RendererConfig, + terrainBytes: Uint8Array, + paletteData: Float32Array, + raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window), + caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window), + ) { + this.canvas = canvas; + this.settings = createRenderSettings(); + this.raf = raf; + this.caf = caf; + + const gl = canvas.getContext("webgl2", { + alpha: false, + antialias: false, + powerPreference: "high-performance", + }); + if (!gl) throw new Error("WebGL2 not supported"); + this.gl = gl; + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + + const floatExt = gl.getExtension("EXT_color_buffer_float"); + if (!floatExt) + console.warn("EXT_color_buffer_float not available — palette may fail"); + + const mapW = header.mapWidth; + const mapH = header.mapHeight; + this.mapW = mapW; + this.mapH = mapH; + + this.camera = new Camera(mapW, mapH); + + // --- Terrain (static) --- + const terrainRGBA = buildTerrainRGBA(terrainBytes, mapW, mapH); + this.terrainPass = new TerrainPass(gl, terrainRGBA, mapW, mapH); + + // --- Shared palette texture (RGBA32F, 4096×2) --- + this.paletteData = paletteData; + const palW = getPaletteSize(); + this.paletteTex = createTexture2D(gl, { + width: palW, + height: 2, + internalFormat: gl.RGBA32F, + format: gl.RGBA, + type: gl.FLOAT, + data: paletteData, + filter: gl.NEAREST, + }); + + // --- Border compute (creates its own borderTex) --- + // Need a temporary tileTex reference for border compute — we'll create + // GPUResources first, then wire everything. + // But borderPass creates its own borderTex internally, so we need to + // create GPUResources with it. Let's sequence carefully: + + // 1. Create GPUResources (creates tileTex, trailTex, heatTexA/B) + // borderTex placeholder — we'll get it from borderPass + // First create a dummy, then replace after borderPass is created. + + // Actually: borderPass creates its own internal borderTex (RGBA8). + // We need tileTex to exist before borderPass. So: + // a) Create shared resources (tileTex, trailTex, heatA/B) + // b) Create borderPass with tileTex → gives us borderTex + // c) Store borderTex in res + + // Create shared textures except borderTex + this.res = createGPUResources(gl, mapW, mapH, this.paletteTex, null!); + + // --- Border compute (needs tileTex) --- + this.borderPass = new BorderComputePass( + gl, + mapW, + mapH, + this.res.tileTex, + this.settings, + ); + this.res.borderTex = this.borderPass.getBorderTex(); + + // --- Heat manager (needs tileTex, heatTexA/B) --- + this.heatManager = new HeatManager( + gl, + mapW, + mapH, + this.res.tileTex, + this.res.heatTexA, + this.res.heatTexB, + this.settings, + ); + + // --- Territory (needs tileTex, trailTex, paletteTex) --- + this.territoryPass = new TerritoryPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.res.trailTex, + this.paletteTex, + this.settings, + ); + + // --- Spawn overlay (needs tileTex) --- + this.spawnOverlayPass = new SpawnOverlayPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.settings.spawnOverlay, + ); + + // --- Trail (needs trailTex, paletteTex) --- + this.trailPass = new TrailPass( + gl, + mapW, + mapH, + this.res.trailTex, + this.paletteTex, + this.settings, + ); + + // --- Border stamp (needs tileTex, paletteTex, borderTex) --- + this.borderStampPass = new BorderStampPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.paletteTex, + this.res.borderTex, + this.settings, + ); + + // --- Fallout bloom (needs tileTex, heatManager) --- + this.bloomPass = new FalloutBloomPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.heatManager, + this.settings, + ); + + // --- Point lights --- + this.pointLightPass = new PointLightPass( + gl, + header, + paletteData, + this.settings, + ); + + // --- Fallout light (needs tileTex, borderTex, heatManager) --- + this.falloutLightPass = new FalloutLightPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.res.borderTex, + this.heatManager, + this.settings, + ); + + // --- Lightmap orchestrator --- + this.lightmapPass = new LightmapPass( + gl, + mapW, + mapH, + this.pointLightPass, + this.falloutLightPass, + this.settings, + ); + + // --- Night composite --- + this.nightCompositePass = new NightCompositePass(gl, this.settings); + + // --- Railroad (needs tileTex) --- + this.railroadPass = new RailroadPass( + gl, + mapW, + mapH, + this.res.tileTex, + this.paletteTex, + terrainBytes, + this.settings, + ); + + // --- Range circle (ghost preview radius) --- + this.rangeCirclePass = new RangeCirclePass(gl); + + // --- SAM radius overlay (dashed green circles during build mode) --- + this.samRadiusPass = new SAMRadiusPass(gl, mapW, this.settings); + this.samRadiusPass.setPaletteData(paletteData); + + // --- Crosshair (warship placement) --- + this.crosshairPass = new CrosshairPass(gl); + + // --- Remaining passes (unchanged from v1) --- + this.structurePass = new StructurePass( + gl, + header, + this.paletteTex, + this.settings, + ); + this.structureLevelPass = new StructureLevelPass(gl, header, this.settings); + this.unitPass = new UnitPass(gl, header, this.paletteTex, this.settings); + this.namePass = new NamePass(gl, header, paletteData, this.settings); + this.fxPass = new FxPass(gl, header, this.settings); + this.barPass = new BarPass(gl, header, this.settings); + this.conquestPopupPass = new ConquestPopupPass(gl, this.settings); + this.conquestPopupPass.setMapWidth(this.mapW); + this.radialMenuPass = new RadialMenuPass(gl); + this.selectionBoxPass = new SelectionBoxPass(gl); + this.moveIndicatorPass = new MoveIndicatorPass(gl, this.settings); + this.nukeTrajectoryPass = new NukeTrajectoryPass(gl, this.settings); + this.nukeTelegraphPass = new NukeTelegraphPass(gl, this.settings); + + // --- Scene capture target (for night composite) --- + const sceneTex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, sceneTex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + const sceneFbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + sceneTex, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + this.sceneTarget = { fbo: sceneFbo, tex: sceneTex, w: 1, h: 1 }; + + // --- Alt-view passes --- + this.affiliationPalette = new AffiliationPalette(gl); + const affTex = this.affiliationPalette.getTexture(); + this.borderStampPass.setAffiliationTex(affTex); + this.unitPass.setAffiliationTex(affTex); + this.structurePass.setAffiliationTex(affTex); + this.trailPass.setAffiliationTex(affTex); + this.coordinateGridPass = new CoordinateGridPass( + gl, + mapW, + mapH, + this.settings, + ); + + for (const p of header.players) { + if (p.team !== null) this.playerTeams.set(p.smallID, p.team); + } + + this.startLoop(); + } + + private renderLoop = (): void => { + this.draw(); + this.animId = this.raf(this.renderLoop); + }; + + private startLoop(): void { + this.animId ??= this.raf(this.renderLoop); + } + + private stopLoop(): void { + if (this.animId !== null) { + this.caf(this.animId); + this.animId = null; + } + } + + // --------------------------------------------------------------------------- + // Canvas / Camera + // --------------------------------------------------------------------------- + + resize(cssWidth: number, cssHeight: number): void { + const dpr = window.devicePixelRatio || 1; + this.canvas.width = Math.round(cssWidth * dpr); + this.canvas.height = Math.round(cssHeight * dpr); + this.camera.resize(cssWidth, cssHeight); + } + + screenToWorld(screenX: number, screenY: number): { x: number; y: number } { + return this.camera.screenToWorld(screenX, screenY); + } + + worldToScreen(worldX: number, worldY: number): { x: number; y: number } { + return this.camera.worldToScreen(worldX, worldY); + } + + panTo(worldX: number, worldY: number): void { + this.camera.panTo(worldX, worldY); + } + panBy(dx: number, dy: number): void { + this.camera.panBy(dx, dy); + } + zoomTo(level: number): void { + this.camera.zoomTo(level); + } + zoomBy(factor: number): void { + this.camera.zoomBy(factor); + } + zoomAtScreen(factor: number, screenX: number, screenY: number): void { + this.camera.zoomAtScreen(factor, screenX, screenY); + } + fitMap(): void { + this.camera.fitMap(); + } + focusBBox( + minX: number, + minY: number, + maxX: number, + maxY: number, + padding?: number, + ): void { + this.camera.focusBBox(minX, minY, maxX, maxY, padding); + } + getCameraState(): { x: number; y: number; z: number } { + return { + x: this.camera.offsetX, + y: this.camera.offsetY, + z: this.camera.zoom, + }; + } + setCameraState(x: number, y: number, z: number): void { + this.camera.setCameraState(x, y, z); + } + get zoom(): number { + return this.camera.zoom; + } + + // --------------------------------------------------------------------------- + // Data upload + // --------------------------------------------------------------------------- + + applyFullFrame( + tileState: Uint16Array, + trailState: Uint8Array, + nukeEvents?: Array<{ tick: number; tiles: number[] }>, + currentTick?: number, + ): void { + this.territoryPass.uploadFullTileState(tileState); + this.territoryPass.uploadFullTrailState(trailState); + this.heatManager.resetForSeek(tileState, nukeEvents, currentTick); + } + + applyFullTiles(tileState: Uint16Array, trailState: Uint8Array): void { + this.territoryPass.uploadFullTileState(tileState); + this.territoryPass.uploadFullTrailState(trailState); + } + + applyDelta(changedTiles: TilePair[], trailState: Uint8Array): void { + this.territoryPass.uploadDeltaTiles(changedTiles); + this.territoryPass.uploadFullTrailState(trailState); + } + + uploadTileAndTrailState( + tileState: Uint16Array, + trailState: Uint8Array, + ): void { + this.territoryPass.setLiveRefs(tileState, trailState); + } + + uploadLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void { + this.territoryPass.applyLiveDelta(tileState, changedTiles); + } + + uploadLiveTrailDelta( + trailState: Uint8Array, + dirtyRowMin: number, + dirtyRowMax: number, + ): void { + this.territoryPass.applyLiveTrailDelta( + trailState, + dirtyRowMin, + dirtyRowMax, + ); + } + + /** Re-upload palette data to the GPU texture (e.g. when players appear after initial startup). */ + updatePalette(paletteData: Float32Array): void { + const gl = this.gl; + // Mutate the stored array in-place so all passes sharing the reference see the update. + this.paletteData.set(paletteData); + // Re-upload to the GPU texture + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + getPaletteSize(), + 2, + gl.RGBA, + gl.FLOAT, + this.paletteData, + ); + // SAM radius pass stores its own copy + this.samRadiusPass.setPaletteData(this.paletteData); + } + + /** Register late-arriving players (updates palette + NamePass lookup maps). */ + addPlayers(players: PlayerStatic[], paletteData: Float32Array): void { + this.updatePalette(paletteData); + this.namePass.addPlayers(players, this.paletteData); + for (const p of players) { + if (p.team !== null) this.playerTeams.set(p.smallID, p.team); + } + } + + uploadRailroadState(data: Uint8Array): void { + this.railroadPass.uploadRailroadState(data); + } + + updateUnits(units: Map, gameTick: number): void { + this.lastUnits = units; + this.frameTick++; + this.unitPass.updateUnits(units, this.frameTick); + this.barPass.updateBars(units, this.lastStructures, gameTick); + this.pointLightPass.updateLights(units); + this.heatManager.decayHeat(); + } + + updateNames( + names: Map, + players: Map, + snap: boolean, + statusData?: Map, + ): void { + this.namePass.updateNames(names, players, snap, statusData); + + // Extract local player's allies + teammates for SAM radius coloring + if (this.localPlayerID > 0) { + const localPS = players.get(this.localPlayerID); + const friendly = new Set(localPS?.allies ?? []); + const myTeam = this.playerTeams.get(this.localPlayerID); + if (myTeam !== undefined) { + for (const [sid, team] of this.playerTeams) { + if (team === myTeam && sid !== this.localPlayerID) friendly.add(sid); + } + } + this.samRadiusPass.setAllies(friendly); + this.unitPass.setAllies(friendly); + } + } + + updateRelations(data: Uint8Array, size: number): void { + this.borderPass.updateRelations(data, size); + this.affiliationPalette.updateRelations(data, size); + } + + updateStructures(units: Map): void { + this.lastStructures = units; + this.structurePass.updateStructures(units); + this.structureLevelPass.updateStructures(units); + this.samRadiusPass.updateStructures(units); + this.unitPass.setStructures(units); + const posts: { x: number; y: number; ownerID: number }[] = []; + const w = this.mapW; + for (const u of units.values()) { + if (u.unitType === "Defense Post" && !u.underConstruction) { + posts.push({ + x: u.pos % w, + y: (u.pos - (u.pos % w)) / w, + ownerID: u.ownerID, + }); + } + } + this.borderPass.updateDefensePosts(posts); + } + + applyDeadUnits(deadUnits: DeadUnitFx[]): void { + if (deadUnits.length > 0) this.fxPass.applyDeadUnits(deadUnits); + } + + applyRailroadDust(tileRefs: number[]): void { + if (tileRefs.length > 0) this.fxPass.applyRailroadDust(tileRefs); + } + + applyConquestEvents(events: ConquestFx[]): void { + if (events.length > 0) { + this.fxPass.applyConquestEvents(events); + this.conquestPopupPass.applyConquestEvents(events); + } + } + + applyBonusEvents(events: BonusEvent[]): void { + if (events.length === 0) return; + // In live game, filter to local player only. In replay (localPlayerID=0), show all. + const filtered = + this.localPlayerID > 0 + ? events.filter((e) => e.smallID === this.localPlayerID) + : events; + if (filtered.length > 0) this.conquestPopupPass.applyBonusEvents(filtered); + } + + updateAttackRings(rings: AttackRingInput[]): void { + this.fxPass.updateAttackRings(rings); + } + + clearFx(): void { + this.fxPass.clear(); + this.conquestPopupPass.clear(); + } + setFxTimeFn(fn: () => number): void { + this.fxPass.setTimeFn(fn); + this.conquestPopupPass.setTimeFn(fn); + } + + updateGhostPreview(data: GhostPreviewData | null): void { + this.structurePass.updateGhostPreview(data); + this.railroadPass.updateGhostPreview(data); + this.rangeCirclePass.updateGhostPreview(data); + this.crosshairPass.updateGhostPreview(data); + this.samGhostVisible = + data !== null && SAM_RADIUS_GHOST_TYPES.has(data.ghostType); + this.samRadiusPass.setVisible( + this.samGhostVisible || this.samHighlightVisible, + ); + } + + updateNukeTrajectory(data: NukeTrajectoryData | null): void { + this.nukeTrajectoryPass.update(data); + } + + updateNukeTelegraphs(data: NukeTelegraphData[]): void { + this.nukeTelegraphPass.update(data); + } + + updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void { + this.spawnOverlayPass.update(inSpawnPhase, centers); + } + + // --------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------- + + setHighlightOwner(ownerID: number): void { + this.borderPass.setHighlightOwner(ownerID); + this.territoryPass.setHighlightOwner(ownerID); + } + setHighlightStructureTypes(unitTypes: string[] | null): void { + this.structurePass.setHighlightTypes(unitTypes); + this.structureLevelPass.setHighlightTypes(unitTypes); + this.samHighlightVisible = + unitTypes !== null && + unitTypes.some((t) => SAM_RADIUS_HIGHLIGHT_TYPES.has(t)); + this.samRadiusPass.setVisible( + this.samGhostVisible || this.samHighlightVisible, + ); + } + + focusOwner(ownerID: number): void { + if (ownerID !== 0) { + const bbox = this.territoryPass.getBBoxForOwner(ownerID); + if (bbox) { + this.camera.focusBBox(bbox.minX, bbox.minY, bbox.maxX, bbox.maxY); + return; + } + } + this.camera.focusBBox(0, 0, this.mapW - 1, this.mapH - 1); + } + + getOwnerAtWorld(worldX: number, worldY: number): number { + const tx = Math.floor(worldX); + const ty = Math.floor(worldY); + if (tx < 0 || ty < 0 || tx >= this.mapW || ty >= this.mapH) return 0; + return this.territoryPass.getOwnerAt(ty * this.mapW + tx); + } + + getUnitAtWorld( + worldX: number, + worldY: number, + radius: number, + ): UnitState | null { + let best: UnitState | null = null; + let bestDist = radius * radius; + const w = this.mapW; + for (const u of this.lastUnits.values()) { + const dx = (u.pos % w) - worldX; + const dy = Math.floor(u.pos / w) - worldY; + const d2 = dx * dx + dy * dy; + if (d2 < bestDist) { + bestDist = d2; + best = u; + } + } + return best; + } + + getStructureAtWorld( + worldX: number, + worldY: number, + radius: number, + ): UnitState | null { + let best: UnitState | null = null; + let bestDist = radius * radius; + const w = this.mapW; + for (const s of this.lastStructures.values()) { + const dx = (s.pos % w) - worldX; + const dy = Math.floor(s.pos / w) - worldY; + const d2 = dx * dx + dy * dy; + if (d2 < bestDist) { + bestDist = d2; + best = s; + } + } + return best; + } + + setLocalPlayerID(id: number): void { + if (id === this.localPlayerID) return; + this.localPlayerID = id; + this.samRadiusPass.setLocalPlayer(id); + this.affiliationPalette.setLocalPlayer(id); + this.unitPass.setLocalPlayer(id); + } + + setSAMRadiusVisible(visible: boolean): void { + this.samRadiusPass.setVisible(visible); + } + + setSAMPerspective(playerID: number, allies: Set): void { + this.samRadiusPass.setLocalPlayer(playerID); + this.samRadiusPass.setAllies(allies); + this.unitPass.setLocalPlayer(playerID); + this.unitPass.setAllies(allies); + } + + setSAMColorMode(mode: "perspective" | "owner"): void { + this.samRadiusPass.setColorMode(mode); + } + + setSAMAllianceClusters(clusters: Map): void { + this.samRadiusPass.setAllianceClusters(clusters); + } + + setAltView(active: boolean): void { + this.altView = active; + this.territoryPass.setAltView(active); + this.borderStampPass.setAltView(active); + this.unitPass.setAltView(active); + this.structurePass.setAltView(active); + this.trailPass.setAltView(active); + } + + setGridView(active: boolean): void { + this.gridView = active; + } + + getSettings(): RenderSettings { + return this.settings; + } + + // --------------------------------------------------------------------------- + // Radial menu + // --------------------------------------------------------------------------- + + showRadialMenu( + anchorX: number, + anchorY: number, + items: RadialMenuItem[], + centerItem?: RadialMenuItem, + ): void { + this.radialMenuPass.show(anchorX, anchorY, items, centerItem); + } + + hideRadialMenu(): void { + this.radialMenuPass.hide(); + } + openRadialSubMenu(subItems: RadialMenuItem[]): void { + this.radialMenuPass.openSubMenu(subItems); + } + goBackRadialMenu(): void { + this.radialMenuPass.goBack(); + } + setRadialMenuHover(index: number): void { + this.radialMenuPass.setHover(index); + } + radialMenuHitTest(screenX: number, screenY: number): number { + return this.radialMenuPass.hitTest(screenX, screenY); + } + get radialMenuVisible(): boolean { + return this.radialMenuPass.isVisible; + } + getRadialMenuItems(): readonly RadialMenuItem[] { + return this.radialMenuPass.getItems(); + } + getRadialMenuItemAt(index: number): RadialMenuItem | null { + return this.radialMenuPass.getItemAt(index); + } + registerRadialMenuIcons( + icons: { key: string; img: CanvasImageSource }[], + ): void { + this.radialMenuPass.registerIcons(icons); + } + + // --------------------------------------------------------------------------- + // Selection box (warship selection) + // --------------------------------------------------------------------------- + + setSelectedUnit(unitId: number | null): void { + this.setSelectedUnits(unitId === null ? [] : [unitId]); + } + + setSelectedUnits(unitIds: readonly number[]): void { + // Copy in (callers may mutate their array). + this.selectedUnitIds.length = 0; + for (let i = 0; i < unitIds.length; i++) { + this.selectedUnitIds.push(unitIds[i]); + } + if (this.selectedUnitIds.length === 0) { + this.selectionBoxPass.hide(); + } + // Position + color are rebuilt each frame in updateSelectionBox() from + // lastUnits — dead units get dropped automatically. + } + + private updateSelectionBox(): void { + if (this.selectedUnitIds.length === 0) return; + + // Build the entries for this frame and prune dead unit IDs in place. + const entries = this.selectionBoxEntries; + entries.length = 0; + let writeIdx = 0; + for (let i = 0; i < this.selectedUnitIds.length; i++) { + const id = this.selectedUnitIds[i]; + const unit = this.lastUnits.get(id); + if (!unit || !unit.isActive) continue; // dead — drop + this.selectedUnitIds[writeIdx++] = id; + + const centerX = unit.pos % this.mapW; + const centerY = Math.floor(unit.pos / this.mapW); + // Lighten the owner's territory color by ~20% (mix toward white). + const off = unit.ownerID * 4; + const r = Math.min( + 1, + this.paletteData[off] + (1 - this.paletteData[off]) * 0.3, + ); + const g = Math.min( + 1, + this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3, + ); + const b = Math.min( + 1, + this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3, + ); + entries.push({ centerX, centerY, r, g, b }); + } + this.selectedUnitIds.length = writeIdx; + + this.selectionBoxPass.setSelections(entries); + } + + // --------------------------------------------------------------------------- + // Move indicator (warship move-target chevrons) + // --------------------------------------------------------------------------- + + showMoveIndicator(tileX: number, tileY: number, ownerID: number): void { + const off = ownerID * 4; + const r = Math.min( + 1, + this.paletteData[off] + (1 - this.paletteData[off]) * 0.3, + ); + const g = Math.min( + 1, + this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3, + ); + const b = Math.min( + 1, + this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3, + ); + this.moveIndicatorPass.show(tileX, tileY, r, g, b); + } + + // --------------------------------------------------------------------------- + // Render — normalized draw order + // --------------------------------------------------------------------------- + + draw(): void { + const now = performance.now(); + this.trackFps(now); + this.uploadTextures(); + this.computeTextures(); + this.renderFrame(); + if (this.onFrame) this.onFrame(performance.now() - now); + if (this.afterRender) this.afterRender(this.canvas); + } + + private trackFps(now: number): void { + this.frameTimes[this.frameIdx] = now; + this.frameIdx = (this.frameIdx + 1) % this.frameTimes.length; + if (this.frameCount < this.frameTimes.length) this.frameCount++; + if (this.frameCount > 1) { + const oldest = + this.frameTimes[ + (this.frameIdx - this.frameCount + this.frameTimes.length) % + this.frameTimes.length + ]; + this.fps = (this.frameCount - 1) / ((now - oldest) / 1000); + } + } + + private uploadTextures(): void { + if (this.altView) this.affiliationPalette.flush(); + if (this.territoryPass.flushTileTexture()) + this.borderPass.notifyTilesChanged(); + this.territoryPass.flushTrailTexture(); + this.heatManager.updateHeat(); + } + + private computeTextures(): void { + if (this.settings.passEnabled.mapOverlay) + this.borderPass.draw(this.frameTick); + } + + private renderFrame(): void { + const cam = this.camera.getMatrix(); + const zoom = this.camera.zoom; + const cw = this.canvas.width; + const ch = this.canvas.height; + const nightActive = this.isNightActive(); + + if (nightActive) { + this.resizeSceneTargetIfNeeded(cw, ch); + const sceneTex = toTarget(this.gl, this.sceneTarget, () => + this.drawBaseLayer(cam), + ); + const lightTex = this.lightmapPass.draw(cam, cw, ch); + toScreen(this.gl, cw, ch, () => + this.nightCompositePass.draw(sceneTex, lightTex), + ); + } else { + toScreen(this.gl, cw, ch, () => this.drawBaseLayer(cam)); + } + + this.renderOverlays(cam, zoom); + } + + private isNightActive(): boolean { + return this.settings.dayNight.mode === "dark"; + } + + private resizeSceneTargetIfNeeded(cw: number, ch: number): void { + if (this.sceneTarget.w === cw && this.sceneTarget.h === ch) return; + this.sceneTarget.w = cw; + this.sceneTarget.h = ch; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.sceneTarget.tex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + cw, + ch, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + } + + private drawBaseLayer(cam: Float32Array): void { + const gl = this.gl; + const pe = this.settings.passEnabled; + gl.clearColor(0.04, 0.04, 0.06, 1.0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.disable(gl.BLEND); + if (pe.terrain) this.terrainPass.draw(cam); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + if (pe.mapOverlay) this.territoryPass.draw(cam); + } + + private renderOverlays(cam: Float32Array, zoom: number): void { + const gl = this.gl; + const pe = this.settings.passEnabled; + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + + this.spawnOverlayPass.draw(cam); + if (pe.mapOverlay) this.borderStampPass.draw(cam); + if (pe.railroad) this.railroadPass.draw(cam, zoom); + if (pe.unit) this.unitPass.drawGround(cam); + this.samRadiusPass.draw(cam); + this.rangeCirclePass.draw(cam); + this.nukeTrajectoryPass.draw(cam); + this.crosshairPass.draw(cam); + if (pe.structure) this.structurePass.draw(cam, zoom); + if (pe.structure) this.structureLevelPass.draw(cam, zoom); + if (pe.bar) this.barPass.draw(cam); + this.updateSelectionBox(); + this.selectionBoxPass.draw(cam, this.frameTick); + this.moveIndicatorPass.draw(cam, zoom); + this.nukeTelegraphPass.draw(cam); + if (pe.falloutBloom) this.bloomPass.draw(cam, this.frameTick); + if (pe.mapOverlay) this.trailPass.draw(cam); + if (pe.unit) this.unitPass.drawMissiles(cam); + + if (pe.fx) { + this.fxPass.tick(); + this.fxPass.draw(cam, zoom); + } + + this.conquestPopupPass.tick(); + this.conquestPopupPass.draw(cam, zoom); + + if (this.gridView) this.coordinateGridPass.draw(cam, zoom); + if (pe.name && !this.gridView) + this.namePass.draw(cam, this.nightCompositePass.getAmbient()); + + this.radialMenuPass.draw(); + + gl.disable(gl.BLEND); + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + dispose(): void { + this.stopLoop(); + this.terrainPass.dispose(); + this.territoryPass.dispose(); + this.trailPass.dispose(); + this.borderStampPass.dispose(); + this.borderPass.dispose(); + this.bloomPass.dispose(); + this.pointLightPass.dispose(); + this.falloutLightPass.dispose(); + this.lightmapPass.dispose(); + this.nightCompositePass.dispose(); + this.heatManager.dispose(); + this.affiliationPalette.dispose(); + this.coordinateGridPass.dispose(); + this.spawnOverlayPass.dispose(); + this.railroadPass.dispose(); + this.rangeCirclePass.dispose(); + this.samRadiusPass.dispose(); + this.crosshairPass.dispose(); + this.structurePass.dispose(); + this.structureLevelPass.dispose(); + this.unitPass.dispose(); + this.namePass.dispose(); + this.fxPass.dispose(); + this.conquestPopupPass.dispose(); + this.radialMenuPass.dispose(); + this.selectionBoxPass.dispose(); + this.moveIndicatorPass.dispose(); + this.nukeTrajectoryPass.dispose(); + this.nukeTelegraphPass.dispose(); + this.barPass.dispose(); + disposeGPUResources(this.gl, this.res); + this.gl.deleteTexture(this.paletteTex); + this.gl.deleteFramebuffer(this.sceneTarget.fbo); + this.gl.deleteTexture(this.sceneTarget.tex); + this.lastUnits = new Map(); + this.lastStructures = new Map(); + } +} diff --git a/src/client/render/gl/SettingsUtils.ts b/src/client/render/gl/SettingsUtils.ts new file mode 100644 index 0000000000..fa48e0086a --- /dev/null +++ b/src/client/render/gl/SettingsUtils.ts @@ -0,0 +1,49 @@ +/** + * Utilities for RenderSettings persistence — deep-assign, deep-diff. + */ + +type Obj = Record; + +/** Recursively assign source values onto target, preserving target's structure. */ +export function deepAssign(target: Obj, source: Obj): void { + for (const key of Object.keys(source)) { + if ( + typeof source[key] === "object" && + source[key] !== null && + typeof target[key] === "object" && + target[key] !== null + ) { + deepAssign(target[key] as Obj, source[key] as Obj); + } else if (key in target) { + target[key] = source[key]; + } + } +} + +/** + * Compute a sparse deep-partial of values that differ from defaults. + * Returns `undefined` if nothing differs. + */ +export function deepDiff(defaults: Obj, current: Obj): Obj | undefined { + let result: Obj | undefined; + for (const key of Object.keys(defaults)) { + const dv = defaults[key]; + const cv = current[key]; + if ( + typeof dv === "object" && + dv !== null && + typeof cv === "object" && + cv !== null + ) { + const sub = deepDiff(dv as Obj, cv as Obj); + if (sub !== undefined) { + result ??= {}; + result[key] = sub; + } + } else if (dv !== cv) { + result ??= {}; + result[key] = cv; + } + } + return result; +} diff --git a/src/client/render/gl/debug/ConfigProp.ts b/src/client/render/gl/debug/ConfigProp.ts new file mode 100644 index 0000000000..a6b9b585f3 --- /dev/null +++ b/src/client/render/gl/debug/ConfigProp.ts @@ -0,0 +1,12 @@ +import type { Controller } from "lil-gui"; + +/** + * A single configurable property in the debug GUI. + * Each prop knows how to draw itself, report modification, and reset. + */ +export interface ConfigProp { + draw(folder: import("lil-gui").default): Controller; + isModified(): boolean; + resetToDefault(): void; + updateDisplay(): void; +} diff --git a/src/client/render/gl/debug/Folder.ts b/src/client/render/gl/debug/Folder.ts new file mode 100644 index 0000000000..69bb0e44b2 --- /dev/null +++ b/src/client/render/gl/debug/Folder.ts @@ -0,0 +1,18 @@ +import type { ConfigProp } from "./ConfigProp"; + +export interface FolderNode { + kind: "folder"; + label: string; + closed: boolean; + children: DebugNode[]; +} + +export type DebugNode = ConfigProp | FolderNode; + +export function folder( + label: string, + children: DebugNode[], + opts: { closed?: boolean } = {}, +): FolderNode { + return { kind: "folder", label, closed: opts.closed ?? true, children }; +} diff --git a/src/client/render/gl/debug/Layout.ts b/src/client/render/gl/debug/Layout.ts new file mode 100644 index 0000000000..67077228e1 --- /dev/null +++ b/src/client/render/gl/debug/Layout.ts @@ -0,0 +1,737 @@ +import type { RenderSettings } from "../RenderSettings"; +import type { DebugNode } from "./Folder"; +import { folder } from "./Folder"; +import { color } from "./props/Color"; +import { select } from "./props/Select"; +import { slider } from "./props/Slider"; +import { toggle } from "./props/Toggle"; + +export function buildTree(s: RenderSettings, d: RenderSettings): DebugNode[] { + return [ + folder("Pass Enables", [ + toggle(s.passEnabled, "terrain", d.passEnabled), + toggle(s.passEnabled, "mapOverlay", d.passEnabled), + toggle(s.passEnabled, "structure", d.passEnabled), + toggle(s.passEnabled, "unit", d.passEnabled), + toggle(s.passEnabled, "name", d.passEnabled), + toggle(s.passEnabled, "falloutBloom", d.passEnabled), + toggle(s.passEnabled, "railroad", d.passEnabled), + toggle(s.passEnabled, "fx", d.passEnabled), + toggle(s.passEnabled, "bar", d.passEnabled), + toggle(s.passEnabled, "nameDebug", d.passEnabled, "Name Debug Boxes"), + ]), + + folder("Fallout Bloom", [ + slider(s.falloutBloom, "broilSpeedCold", d.falloutBloom, 0, 0.05, 0.0001), + slider(s.falloutBloom, "broilSpeedHot", d.falloutBloom, 0, 0.05, 0.0001), + slider(s.falloutBloom, "noiseFreq1", d.falloutBloom, 0, 0.5, 0.001), + slider(s.falloutBloom, "noiseFreq2", d.falloutBloom, 0, 0.5, 0.001), + slider(s.falloutBloom, "contrastLoCold", d.falloutBloom, 0, 1, 0.01), + slider(s.falloutBloom, "contrastLoHot", d.falloutBloom, 0, 1, 0.01), + slider(s.falloutBloom, "contrastHiCold", d.falloutBloom, 0, 1, 0.01), + slider(s.falloutBloom, "contrastHiHot", d.falloutBloom, 0, 1, 0.01), + slider(s.falloutBloom, "metaFreq", d.falloutBloom, 0, 0.2, 0.001), + slider(s.falloutBloom, "intensityCold", d.falloutBloom, 0, 10, 0.05), + slider(s.falloutBloom, "intensityHot", d.falloutBloom, 0, 20, 0.1), + slider(s.falloutBloom, "metaInfluenceCold", d.falloutBloom, 0, 1, 0.01), + slider(s.falloutBloom, "metaInfluenceHot", d.falloutBloom, 0, 1, 0.01), + slider(s.falloutBloom, "opacityFadeEnd", d.falloutBloom, 0, 1, 0.01), + color( + s.falloutBloom, + "bloomR", + "bloomG", + "bloomB", + d.falloutBloom, + "Bloom Color", + ), + slider(s.falloutBloom, "bloomCoverage", d.falloutBloom, 0, 10, 0.1), + slider(s.falloutBloom, "heatDecayPerTick", d.falloutBloom, 0, 5, 0.01), + ]), + + folder("Day / Night", [ + select(s.dayNight, "mode", d.dayNight, ["light", "dark"], "Mode"), + slider(s.dayNight, "nightAmbient", d.dayNight, 0, 1, 0.01), + slider(s.dayNight, "dayAmbient", d.dayNight, 0, 1, 0.01), + slider(s.dayNight, "falloffPower", d.dayNight, 0.5, 5, 0.1), + slider(s.dayNight, "falloutLightIntensity", d.dayNight, 0, 20, 0.1), + slider(s.dayNight, "falloutLightThreshold", d.dayNight, 0, 0.5, 0.001), + slider(s.dayNight, "blurZoomDivisor", d.dayNight, 1, 20, 0.5), + slider(s.dayNight, "lightRadiusMultiplier", d.dayNight, 0.1, 5, 0.1), + color( + s.dayNight, + "falloutLightR", + "falloutLightG", + "falloutLightB", + d.dayNight, + "Fallout Light Color", + ), + slider(s.dayNight, "emberLightIntensity", d.dayNight, 0, 20, 0.1), + color( + s.dayNight, + "emberLightR", + "emberLightG", + "emberLightB", + d.dayNight, + "Ember Light Color", + ), + ]), + + folder("Map Overlay", [ + slider(s.mapOverlay, "trailAlpha", d.mapOverlay, 0, 1, 0.01), + slider(s.mapOverlay, "defenseCheckerDarken", d.mapOverlay, 0, 1, 0.01), + slider(s.mapOverlay, "charcoalBase", d.mapOverlay, 0, 0.3, 0.005), + slider(s.mapOverlay, "charcoalVariation", d.mapOverlay, 0, 0.3, 0.005), + slider(s.mapOverlay, "charcoalAlpha", d.mapOverlay, 0, 1, 0.01), + slider( + s.mapOverlay, + "emberThresholdUnowned", + d.mapOverlay, + 0.5, + 1, + 0.005, + ), + slider(s.mapOverlay, "emberThresholdOwned", d.mapOverlay, 0.5, 1, 0.005), + slider(s.mapOverlay, "emberFlickerSpeed", d.mapOverlay, 0, 2, 0.01), + color( + s.mapOverlay, + "emberColorDarkR", + "emberColorDarkG", + "emberColorDarkB", + d.mapOverlay, + "Ember Color Dark", + ), + color( + s.mapOverlay, + "emberColorBrightR", + "emberColorBrightG", + "emberColorBrightB", + d.mapOverlay, + "Ember Color Bright", + ), + slider(s.mapOverlay, "emberStrengthUnowned", d.mapOverlay, 0, 2, 0.01), + slider( + s.mapOverlay, + "highlightBrighten", + d.mapOverlay, + 0, + 1, + 0.01, + "Highlight Brighten (border)", + ), + slider( + s.mapOverlay, + "highlightFillBrighten", + d.mapOverlay, + 0, + 1, + 0.01, + "Highlight Brighten (fill)", + ), + slider( + s.mapOverlay, + "highlightThicken", + d.mapOverlay, + 0, + 10, + 1, + "Highlight Thicken (tiles)", + ), + + folder("Railroad", [ + slider(s.railroad, "railMinZoom", d.railroad, 0, 10, 0.1, "Min Zoom"), + slider( + s.railroad, + "railDetailZoom", + d.railroad, + 0, + 20, + 0.1, + "Detail Zoom", + ), + slider(s.railroad, "railAlpha", d.railroad, 0, 1, 0.01, "Alpha"), + ]), + ]), + + folder("Structure", [ + slider(s.structure, "iconSize", d.structure, 10, 60, 1), + slider(s.structure, "dotsZoomThreshold", d.structure, 0.1, 2, 0.05), + slider( + s.structure, + "iconScaleFactorZoomedOut", + d.structure, + 0.5, + 3, + 0.05, + ), + slider( + s.structure, + "highlightOutlineWidth", + d.structure, + 0, + 0.2, + 0.005, + "Highlight Outline W", + ), + slider( + s.structure, + "highlightDimAlpha", + d.structure, + 0, + 1, + 0.01, + "Highlight Dim Alpha", + ), + folder( + "Per-Shape", + Object.entries(s.structure.shapes).map(([name, cfg]) => + folder(name, [ + slider( + cfg, + "scale", + d.structure.shapes[name], + 0.5, + 2, + 0.05, + "Frame Scale", + ), + slider( + cfg, + "iconFill", + d.structure.shapes[name], + 0.2, + 1.5, + 0.05, + "Icon Fill", + ), + ]), + ), + ), + ]), + + folder("Bar", [ + slider(s.bar, "healthBarW", d.bar, 3, 30, 1, "Health Width"), + slider(s.bar, "healthBarH", d.bar, 1, 10, 1, "Health Height"), + slider(s.bar, "healthBarOffsetY", d.bar, -20, 0, 1, "Health Offset Y"), + slider(s.bar, "progressBarW", d.bar, 3, 30, 1, "Progress Width"), + slider(s.bar, "progressBarH", d.bar, 1, 10, 1, "Progress Height"), + slider(s.bar, "progressBarOffsetY", d.bar, 0, 20, 1, "Progress Offset Y"), + slider(s.bar, "borderWidth", d.bar, 0, 3, 0.5, "Border Width"), + slider(s.bar, "threshold1", d.bar, 0, 1, 0.05, "Red→Orange"), + slider(s.bar, "threshold2", d.bar, 0, 1, 0.05, "Orange→Yellow"), + slider(s.bar, "threshold3", d.bar, 0, 1, 0.05, "Yellow→Green"), + color(s.bar, "colorRedR", "colorRedG", "colorRedB", d.bar, "Red"), + color( + s.bar, + "colorOrangeR", + "colorOrangeG", + "colorOrangeB", + d.bar, + "Orange", + ), + color( + s.bar, + "colorYellowR", + "colorYellowG", + "colorYellowB", + d.bar, + "Yellow", + ), + color(s.bar, "colorGreenR", "colorGreenG", "colorGreenB", d.bar, "Green"), + ]), + + folder("Unit", [ + slider(s.unit, "unitSize", d.unit, 4, 64, 1), + slider(s.unit, "flickerSpeed", d.unit, 0, 2, 0.01), + color(s.unit, "angryR", "angryG", "angryB", d.unit, "Angry Color"), + ]), + + folder("Name", [ + slider(s.name, "lerpSpeed", d.name, 1, 30, 0.5), + slider(s.name, "cullThreshold", d.name, 0, 0.05, 0.001), + slider(s.name, "nameScaleFactor", d.name, 0.1, 1, 0.05), + slider(s.name, "nameScaleCap", d.name, 1, 10, 0.5), + slider(s.name, "troopSizeMultiplier", d.name, 0.1, 2, 0.05), + slider(s.name, "outlineWidth", d.name, 0, 10, 0.1, "Outline Width (px)"), + color( + s.name, + "outlineR", + "outlineG", + "outlineB", + d.name, + "Outline Color", + ), + toggle(s.name, "outlineUsePlayerColor", d.name, "Outline = Player Color"), + toggle(s.name, "fillUsePlayerColor", d.name, "Fill = Player Color"), + slider(s.name, "emojiRowOffset", d.name, 0, 5, 0.1, "Emoji Row Offset"), + slider(s.name, "statusRowOffset", d.name, 0, 5, 0.1, "Status Row Offset"), + ]), + + folder("FX", [ + slider(s.fx, "shockwaveRingWidth", d.fx, 0.01, 0.2, 0.005), + slider( + s.fx, + "nukeShockwaveDurationMs", + d.fx, + 200, + 5000, + 100, + "Nuke Shock Duration", + ), + slider( + s.fx, + "nukeShockwaveRadiusFactor", + d.fx, + 0.5, + 3, + 0.1, + "Nuke Shock Radius ×", + ), + slider( + s.fx, + "samShockwaveDurationMs", + d.fx, + 200, + 3000, + 50, + "SAM Shock Duration", + ), + slider(s.fx, "samShockwaveRadius", d.fx, 10, 100, 5, "SAM Shock Radius"), + slider( + s.fx, + "debrisLifetimeMs", + d.fx, + 1000, + 15000, + 500, + "Debris Lifetime", + ), + slider(s.fx, "debrisFadeIn", d.fx, 0, 0.5, 0.01, "Debris Fade In"), + slider(s.fx, "debrisFadeOut", d.fx, 0.3, 1, 0.01, "Debris Fade Out"), + slider( + s.fx, + "conquestLifetimeMs", + d.fx, + 500, + 8000, + 250, + "Conquest Lifetime", + ), + slider(s.fx, "conquestFadeIn", d.fx, 0, 0.5, 0.01, "Conquest Fade In"), + slider(s.fx, "conquestFadeOut", d.fx, 0.3, 1, 0.01, "Conquest Fade Out"), + ]), + + folder("Nuke Trajectory", [ + slider( + s.nukeTrajectory, + "lineWidth", + d.nukeTrajectory, + 0.5, + 5, + 0.25, + "Line Width (px)", + ), + slider( + s.nukeTrajectory, + "outlineWidth", + d.nukeTrajectory, + 0, + 4, + 0.25, + "Outline Width (px)", + ), + slider( + s.nukeTrajectory, + "dashTargetable", + d.nukeTrajectory, + 1, + 30, + 1, + "Dash (targetable)", + ), + slider( + s.nukeTrajectory, + "gapTargetable", + d.nukeTrajectory, + 1, + 20, + 1, + "Gap (targetable)", + ), + slider( + s.nukeTrajectory, + "dashUntargetable", + d.nukeTrajectory, + 1, + 20, + 1, + "Dash (untargetable)", + ), + slider( + s.nukeTrajectory, + "gapUntargetable", + d.nukeTrajectory, + 1, + 20, + 1, + "Gap (untargetable)", + ), + color( + s.nukeTrajectory, + "lineR", + "lineG", + "lineB", + d.nukeTrajectory, + "Line Color", + ), + color( + s.nukeTrajectory, + "interceptR", + "interceptG", + "interceptB", + d.nukeTrajectory, + "Intercept Color", + ), + color( + s.nukeTrajectory, + "outlineR", + "outlineG", + "outlineB", + d.nukeTrajectory, + "Outline Color", + ), + color( + s.nukeTrajectory, + "interceptOutlineR", + "interceptOutlineG", + "interceptOutlineB", + d.nukeTrajectory, + "Intercept Outline", + ), + slider( + s.nukeTrajectory, + "markerCircleRadius", + d.nukeTrajectory, + 2, + 16, + 1, + "Circle Marker (px)", + ), + slider( + s.nukeTrajectory, + "markerXRadius", + d.nukeTrajectory, + 2, + 16, + 1, + "X Marker (px)", + ), + ]), + + folder("Nuke Telegraph", [ + slider( + s.nukeTelegraph, + "strokeWidth", + d.nukeTelegraph, + 0.5, + 5, + 0.25, + "Stroke Width", + ), + slider( + s.nukeTelegraph, + "dashLen", + d.nukeTelegraph, + 2, + 30, + 1, + "Dash Length", + ), + slider( + s.nukeTelegraph, + "gapLen", + d.nukeTelegraph, + 1, + 20, + 1, + "Gap Length", + ), + slider( + s.nukeTelegraph, + "rotationSpeed", + d.nukeTelegraph, + 0, + 60, + 1, + "Rotation Speed", + ), + slider( + s.nukeTelegraph, + "baseAlpha", + d.nukeTelegraph, + 0, + 1, + 0.05, + "Base Alpha", + ), + slider( + s.nukeTelegraph, + "pulseAmplitude", + d.nukeTelegraph, + 0, + 0.5, + 0.01, + "Pulse Amplitude", + ), + slider( + s.nukeTelegraph, + "pulseSpeed", + d.nukeTelegraph, + 0, + 10, + 0.5, + "Pulse Speed", + ), + slider( + s.nukeTelegraph, + "fillAlphaOffset", + d.nukeTelegraph, + 0, + 1, + 0.05, + "Fill Alpha Offset", + ), + color( + s.nukeTelegraph, + "colorR", + "colorG", + "colorB", + d.nukeTelegraph, + "Color", + ), + ]), + + folder("Move Indicator", [ + slider( + s.moveIndicator, + "startRadius", + d.moveIndicator, + 1, + 40, + 1, + "Start Radius (px)", + ), + slider( + s.moveIndicator, + "chevronSize", + d.moveIndicator, + 1, + 20, + 0.5, + "Chevron Size (px)", + ), + slider( + s.moveIndicator, + "lineWidth", + d.moveIndicator, + 0.5, + 6, + 0.25, + "Line Width (px)", + ), + slider( + s.moveIndicator, + "duration", + d.moveIndicator, + 100, + 3000, + 50, + "Duration (ms)", + ), + slider( + s.moveIndicator, + "converge", + d.moveIndicator, + 0, + 1, + 0.05, + "Converge", + ), + ]), + + folder("SAM Radius", [ + slider( + s.samRadius, + "strokeWidth", + d.samRadius, + 0.5, + 5, + 0.1, + "Stroke Width", + ), + slider(s.samRadius, "dashLen", d.samRadius, 2, 30, 1, "Dash Length"), + slider(s.samRadius, "gapLen", d.samRadius, 1, 20, 1, "Gap Length"), + slider( + s.samRadius, + "rotationSpeed", + d.samRadius, + 0, + 40, + 1, + "Rotation Speed", + ), + slider(s.samRadius, "alpha", d.samRadius, 0, 1, 0.05, "Alpha"), + slider( + s.samRadius, + "outlineWidth", + d.samRadius, + 0, + 2, + 0.05, + "Outline Width", + ), + slider( + s.samRadius, + "outlineSoftness", + d.samRadius, + 0, + 1, + 0.05, + "Outline Softness", + ), + ]), + + folder("Bonus Popup", [ + slider(s.bonusPopup, "scale", d.bonusPopup, 1, 12, 0.5, "Scale"), + slider( + s.bonusPopup, + "lifetimeMs", + d.bonusPopup, + 500, + 5000, + 100, + "Lifetime (ms)", + ), + slider(s.bonusPopup, "riseSpeed", d.bonusPopup, 0, 10, 0.5, "Rise Speed"), + slider(s.bonusPopup, "yOffset", d.bonusPopup, -10, 10, 0.5, "Y Offset"), + slider( + s.bonusPopup, + "outlineWidth", + d.bonusPopup, + 0, + 5, + 0.1, + "Outline Width", + ), + color(s.bonusPopup, "colorR", "colorG", "colorB", d.bonusPopup, "Color"), + slider( + s.bonusPopup, + "minScreenScale", + d.bonusPopup, + 0, + 1, + 0.01, + "Min Screen Scale", + ), + slider(s.bonusPopup, "cullZoom", d.bonusPopup, 0, 2, 0.05, "Cull Zoom"), + ]), + + folder("Spawn Overlay", [ + slider( + s.spawnOverlay, + "highlightRadius", + d.spawnOverlay, + 1, + 20, + 1, + "Highlight Radius", + ), + slider( + s.spawnOverlay, + "highlightAlpha", + d.spawnOverlay, + 0, + 1, + 0.05, + "Highlight Alpha", + ), + slider( + s.spawnOverlay, + "selfMinRad", + d.spawnOverlay, + 1, + 30, + 0.5, + "Self Min Radius", + ), + slider( + s.spawnOverlay, + "selfMaxRad", + d.spawnOverlay, + 5, + 50, + 0.5, + "Self Max Radius", + ), + slider( + s.spawnOverlay, + "mateMinRad", + d.spawnOverlay, + 1, + 20, + 0.5, + "Mate Min Radius", + ), + slider( + s.spawnOverlay, + "mateMaxRad", + d.spawnOverlay, + 5, + 30, + 0.5, + "Mate Max Radius", + ), + slider( + s.spawnOverlay, + "animSpeed", + d.spawnOverlay, + 0.001, + 0.02, + 0.001, + "Anim Speed", + ), + slider( + s.spawnOverlay, + "gradientInnerEdge", + d.spawnOverlay, + 0.001, + 0.1, + 0.001, + "Gradient Inner Edge", + ), + slider( + s.spawnOverlay, + "gradientSolidEnd", + d.spawnOverlay, + 0.01, + 0.5, + 0.01, + "Gradient Solid End", + ), + ]), + + folder("Alt View", [ + slider(s.altView, "gridFontSize", d.altView, 6, 32, 1, "Grid Font Size"), + toggle(s.altView, "recolorStructures", d.altView, "Recolor Structures"), + ]), + + folder( + "Light Configs", + Object.entries(s.lightConfigs).map(([name, cfg]) => + folder(name, [ + slider(cfg, "radius", d.lightConfigs[name], 1, 60, 1), + slider(cfg, "intensity", d.lightConfigs[name], 0, 10, 0.1), + ]), + ), + ), + ]; +} diff --git a/src/client/render/gl/debug/Tree.ts b/src/client/render/gl/debug/Tree.ts new file mode 100644 index 0000000000..8b5a06a234 --- /dev/null +++ b/src/client/render/gl/debug/Tree.ts @@ -0,0 +1,23 @@ +import GUI from "lil-gui"; +import type { ConfigProp } from "./ConfigProp"; +import type { DebugNode, FolderNode } from "./Folder"; + +/** Walk the debug tree, drawing each node onto the GUI. Returns all leaf props. */ +export function walkTree(nodes: DebugNode[], parent: GUI): ConfigProp[] { + const props: ConfigProp[] = []; + for (const node of nodes) { + if (isFolderNode(node)) { + const sub = parent.addFolder(node.label); + props.push(...walkTree(node.children, sub)); + if (node.closed) sub.close(); + } else { + node.draw(parent); + props.push(node); + } + } + return props; +} + +function isFolderNode(node: DebugNode): node is FolderNode { + return (node as FolderNode).kind === "folder"; +} diff --git a/src/client/render/gl/debug/Wiring.ts b/src/client/render/gl/debug/Wiring.ts new file mode 100644 index 0000000000..9511ac9c49 --- /dev/null +++ b/src/client/render/gl/debug/Wiring.ts @@ -0,0 +1,215 @@ +import GUI, { FunctionController } from "lil-gui"; +import type { RenderSettings } from "../RenderSettings"; +import { createRenderSettings, dumpSettings } from "../RenderSettings"; +import { deepAssign } from "../SettingsUtils"; +import type { ConfigProp } from "./ConfigProp"; + +// --------------------------------------------------------------------------- +// Draggable title bar +// --------------------------------------------------------------------------- + +export function makeDraggable(gui: GUI): void { + const titleBar = gui.domElement.querySelector( + ".title, .lil-title", + ) as HTMLElement | null; + if (!titleBar) return; + + titleBar.style.cursor = "grab"; + let dragging = false; + let didDrag = false; + let startX = 0, + startY = 0, + startLeft = 0, + startTop = 0; + + titleBar.addEventListener("mousedown", (e) => { + dragging = true; + didDrag = false; + titleBar.style.cursor = "grabbing"; + const rect = gui.domElement.getBoundingClientRect(); + startX = e.clientX; + startY = e.clientY; + startLeft = rect.left; + startTop = rect.top; + gui.domElement.style.left = rect.left + "px"; + gui.domElement.style.right = "auto"; + e.preventDefault(); + }); + + window.addEventListener("mousemove", (e) => { + if (!dragging) return; + didDrag = true; + gui.domElement.style.left = startLeft + e.clientX - startX + "px"; + gui.domElement.style.top = startTop + e.clientY - startY + "px"; + }); + + window.addEventListener("mouseup", () => { + if (!dragging) return; + dragging = false; + titleBar.style.cursor = "grab"; + }); + + titleBar.addEventListener( + "click", + (e) => { + if (didDrag) e.stopPropagation(); + }, + { capture: true }, + ); +} + +// --------------------------------------------------------------------------- +// Actions: Download JSON, Load JSON, Reset to Defaults +// --------------------------------------------------------------------------- + +export function wireActions( + gui: GUI, + settings: RenderSettings, + props: ConfigProp[], + onSettingsChanged?: () => void, +): void { + gui.add({ dump: () => dumpSettings(settings) }, "dump").name("Download JSON"); + + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = ".json"; + fileInput.style.display = "none"; + document.body.appendChild(fileInput); + + fileInput.addEventListener("change", () => { + const file = fileInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + try { + deepAssign(settings, JSON.parse(reader.result as string)); + props.forEach((p) => p.updateDisplay()); + onSettingsChanged?.(); + } catch (e) { + console.error("Failed to load render settings:", e); + } + }; + reader.readAsText(file); + fileInput.value = ""; + }); + + gui.add({ load: () => fileInput.click() }, "load").name("Load JSON"); + + gui + .add( + { + reset: () => { + deepAssign(settings, createRenderSettings()); + props.forEach((p) => p.resetToDefault()); + onSettingsChanged?.(); + }, + }, + "reset", + ) + .name("Reset to Defaults"); +} + +// --------------------------------------------------------------------------- +// Modified indicators: blue label + right-click reset context menu +// --------------------------------------------------------------------------- + +const MODIFIED_CLASS = "lil-modified"; + +let stylesInjected = false; +function injectModifiedStyles(): void { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement("style"); + style.textContent = ` + .${MODIFIED_CLASS} .lil-name { color: #5ba8d6; } + .lil-reset-menu { + position: fixed; + z-index: 10000; + background: #1a1a2e; + border: 1px solid #444; + border-radius: 4px; + padding: 4px 0; + font: 12px sans-serif; + color: #ccc; + box-shadow: 0 2px 8px rgba(0,0,0,0.5); + } + .lil-reset-menu div { + padding: 4px 16px; + cursor: pointer; + white-space: nowrap; + } + .lil-reset-menu div:hover { + background: #2a2a4e; + color: #fff; + } + `; + document.head.appendChild(style); +} + +function createContextMenu(): HTMLDivElement { + const menu = document.createElement("div"); + menu.className = "lil-reset-menu"; + menu.style.display = "none"; + document.body.appendChild(menu); + document.addEventListener("mousedown", (e) => { + if (!menu.contains(e.target as Node)) menu.style.display = "none"; + }); + return menu; +} + +export function wireModifiedIndicators( + gui: GUI, + props: ConfigProp[], + onSettingsChanged?: () => void, +): void { + injectModifiedStyles(); + const contextMenu = createContextMenu(); + + // Map each lil-gui Controller back to its ConfigProp + const allControllers = gui.controllersRecursive(); + // Props were pushed in walk order, controllers are in the same order (minus FunctionControllers) + const propControllers = allControllers.filter( + (c) => !(c instanceof FunctionController), + ); + + propControllers.forEach((ctrl, i) => { + const prop = props[i]; + + const updateClass = () => + ctrl.domElement.classList.toggle(MODIFIED_CLASS, prop.isModified()); + + updateClass(); + + const prev = ctrl._onChange; + ctrl.onChange(function (...args: unknown[]) { + prev?.apply(ctrl, args as any); + updateClass(); + }); + + ctrl.$name.addEventListener("contextmenu", (e) => { + if (!prop.isModified()) return; + e.preventDefault(); + e.stopPropagation(); + + contextMenu.innerHTML = ""; + const item = document.createElement("div"); + item.textContent = "Reset to default"; + item.addEventListener("mousedown", (ev) => { + ev.stopPropagation(); + prop.resetToDefault(); + updateClass(); + onSettingsChanged?.(); + contextMenu.style.display = "none"; + }); + contextMenu.appendChild(item); + contextMenu.style.left = e.clientX + "px"; + contextMenu.style.top = e.clientY + "px"; + contextMenu.style.display = ""; + }); + }); + + // Wire onFinishChange for persistence + if (onSettingsChanged) { + allControllers.forEach((c) => c.onFinishChange(onSettingsChanged)); + } +} diff --git a/src/client/render/gl/debug/index.ts b/src/client/render/gl/debug/index.ts new file mode 100644 index 0000000000..7a414c4641 --- /dev/null +++ b/src/client/render/gl/debug/index.ts @@ -0,0 +1,28 @@ +import GUI from "lil-gui"; +import type { RenderSettings } from "../RenderSettings"; +import { createRenderSettings } from "../RenderSettings"; +import { buildTree } from "./Layout"; +import { walkTree } from "./Tree"; +import { makeDraggable, wireActions, wireModifiedIndicators } from "./Wiring"; + +export function createDebugGui( + settings: RenderSettings, + onSettingsChanged?: () => void, +): GUI { + const gui = new GUI({ title: "Render Settings", width: 320 }); + gui.domElement.style.position = "fixed"; + gui.domElement.style.top = "8px"; + gui.domElement.style.right = "8px"; + gui.domElement.style.zIndex = "100"; + + makeDraggable(gui); + + const defaults = createRenderSettings(); + const props = walkTree(buildTree(settings, defaults), gui); + + wireActions(gui, settings, props, onSettingsChanged); + wireModifiedIndicators(gui, props, onSettingsChanged); + + gui.close(); + return gui; +} diff --git a/src/client/render/gl/debug/props/Color.ts b/src/client/render/gl/debug/props/Color.ts new file mode 100644 index 0000000000..cfcebc4c44 --- /dev/null +++ b/src/client/render/gl/debug/props/Color.ts @@ -0,0 +1,67 @@ +import type GUI from "lil-gui"; +import type { ColorController, Controller } from "lil-gui"; +import type { ConfigProp } from "../ConfigProp"; + +export function color>( + target: T, + rKey: keyof T & string, + gKey: keyof T & string, + bKey: keyof T & string, + defaults: T, + label?: string, +): ConfigProp { + const defaultR = defaults[rKey] as number; + const defaultG = defaults[gKey] as number; + const defaultB = defaults[bKey] as number; + + const proxy = { + color: { + r: target[rKey] as number, + g: target[gKey] as number, + b: target[bKey] as number, + }, + }; + let ctrl: Controller | undefined; + + return { + draw(folder: GUI) { + ctrl = folder + .addColor(proxy, "color") + .onChange((v: { r: number; g: number; b: number }) => { + (target as Record)[rKey] = v.r; + (target as Record)[gKey] = v.g; + (target as Record)[bKey] = v.b; + }); + if (label) ctrl.name(label); + return ctrl; + }, + isModified: () => + (target[rKey] as number) !== defaultR || + (target[gKey] as number) !== defaultG || + (target[bKey] as number) !== defaultB, + resetToDefault() { + (target as Record)[rKey] = defaultR; + (target as Record)[gKey] = defaultG; + (target as Record)[bKey] = defaultB; + proxy.color = { r: defaultR, g: defaultG, b: defaultB }; + (ctrl as ColorController | undefined)?.load( + "#" + + [defaultR, defaultG, defaultB] + .map((v) => + Math.round(v * 255) + .toString(16) + .padStart(2, "0"), + ) + .join(""), + ); + }, + updateDisplay() { + proxy.color = { + r: target[rKey] as number, + g: target[gKey] as number, + b: target[bKey] as number, + }; + ctrl?.updateDisplay(); + }, + }; +} diff --git a/src/client/render/gl/debug/props/Select.ts b/src/client/render/gl/debug/props/Select.ts new file mode 100644 index 0000000000..12f6289ef5 --- /dev/null +++ b/src/client/render/gl/debug/props/Select.ts @@ -0,0 +1,29 @@ +import type GUI from "lil-gui"; +import type { Controller } from "lil-gui"; +import type { ConfigProp } from "../ConfigProp"; + +export function select>( + target: T, + key: keyof T & string, + defaults: T, + options: string[], + label?: string, +): ConfigProp { + const defaultVal = defaults[key] as string; + let ctrl: Controller | undefined; + return { + draw(folder: GUI) { + ctrl = folder.add(target, key, options); + if (label) ctrl.name(label); + return ctrl; + }, + isModified: () => (target[key] as string) !== defaultVal, + resetToDefault() { + (target as Record)[key] = defaultVal; + ctrl?.updateDisplay(); + }, + updateDisplay() { + ctrl?.updateDisplay(); + }, + }; +} diff --git a/src/client/render/gl/debug/props/Slider.ts b/src/client/render/gl/debug/props/Slider.ts new file mode 100644 index 0000000000..d9b164a403 --- /dev/null +++ b/src/client/render/gl/debug/props/Slider.ts @@ -0,0 +1,31 @@ +import type GUI from "lil-gui"; +import type { Controller } from "lil-gui"; +import type { ConfigProp } from "../ConfigProp"; + +export function slider>( + target: T, + key: keyof T & string, + defaults: T, + min: number, + max: number, + step: number, + label?: string, +): ConfigProp { + const defaultVal = defaults[key] as number; + let ctrl: Controller | undefined; + return { + draw(folder: GUI) { + ctrl = folder.add(target, key, min, max, step); + if (label) ctrl.name(label); + return ctrl; + }, + isModified: () => (target[key] as number) !== defaultVal, + resetToDefault() { + (target as Record)[key] = defaultVal; + ctrl?.updateDisplay(); + }, + updateDisplay() { + ctrl?.updateDisplay(); + }, + }; +} diff --git a/src/client/render/gl/debug/props/Toggle.ts b/src/client/render/gl/debug/props/Toggle.ts new file mode 100644 index 0000000000..ec6fb03f77 --- /dev/null +++ b/src/client/render/gl/debug/props/Toggle.ts @@ -0,0 +1,28 @@ +import type GUI from "lil-gui"; +import type { Controller } from "lil-gui"; +import type { ConfigProp } from "../ConfigProp"; + +export function toggle>( + target: T, + key: keyof T & string, + defaults: T, + label?: string, +): ConfigProp { + const defaultVal = defaults[key] as boolean; + let ctrl: Controller | undefined; + return { + draw(folder: GUI) { + ctrl = folder.add(target, key); + if (label) ctrl.name(label); + return ctrl; + }, + isModified: () => (target[key] as boolean) !== defaultVal, + resetToDefault() { + (target as Record)[key] = defaultVal; + ctrl?.updateDisplay(); + }, + updateDisplay() { + ctrl?.updateDisplay(); + }, + }; +} diff --git a/src/client/render/gl/index.ts b/src/client/render/gl/index.ts new file mode 100644 index 0000000000..3f30aab33e --- /dev/null +++ b/src/client/render/gl/index.ts @@ -0,0 +1,28 @@ +export type { AttackRingInput } from "../types"; +export { createDebugGui } from "./debug/index"; +export type { + GameViewEventMap, + GameViewEventType, + MapPointerEvent, + MapScrollEvent, + RadialMenuItem, + RadialMenuSelectEvent, +} from "./Events"; +export { GameView } from "./GameView"; +export type { SpawnCenter } from "./passes/SpawnOverlayPass"; +export { createRenderSettings, dumpSettings } from "./RenderSettings"; +export type { RenderSettings } from "./RenderSettings"; +export { deepAssign, deepDiff } from "./SettingsUtils"; +export { buildTerrainRGBA, getPaletteSize } from "./utils/ColorUtils"; +export { buildNukeTrajectory, samRange } from "./utils/NukeTrajectory"; +export type { SAMInfo } from "./utils/NukeTrajectory"; + +// Re-export shared types used in the public API +export type { + NameEntry, + PlayerState, + PlayerStatic, + RendererConfig, + TilePair, + UnitState, +} from "../types"; diff --git a/src/client/render/gl/passes/BarPass.ts b/src/client/render/gl/passes/BarPass.ts new file mode 100644 index 0000000000..7e1de38001 --- /dev/null +++ b/src/client/render/gl/passes/BarPass.ts @@ -0,0 +1,262 @@ +/** + * BarPass — instanced health/progress bars above units and below structures. + * + * Two draw calls per frame: + * 1. Health bars (11x3 tiles, above warships) + * 2. Progress bars (14x3 tiles, below structures — construction + missile readiness) + * + * Data flow: + * UnitState.health / .missileTimerQueue / .constructionStartTick → CPU progress + * → instance VBO (x, y, progress) → GPU colored rectangle + */ + +import { + CONSTRUCTION_DURATIONS, + DELETION_MARK_DURATION, + missileReadiness, + WARSHIP_MAX_HEALTH, +} from "../../GameConstants"; +import type { RendererConfig, UnitState } from "../../types"; +import { UT_MISSILE_SILO, UT_SAM_LAUNCHER } from "../../types"; +import type { RenderSettings } from "../RenderSettings"; +import { createProgram } from "../utils/GlUtils"; + +import barFragSrc from "../shaders/bar/bar.frag.glsl?raw"; +import barVertSrc from "../shaders/bar/bar.vert.glsl?raw"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const FLOATS_PER_INSTANCE = 3; // x, y, progress +const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; + +// --------------------------------------------------------------------------- +// BarPass +// --------------------------------------------------------------------------- + +export class BarPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private program: WebGLProgram; + private maxBars = 2048; + + private uCamera: WebGLUniformLocation; + private uBarSize: WebGLUniformLocation; + private uBarOffset: WebGLUniformLocation; + private uBorderWidth: WebGLUniformLocation; + private uThresholds: WebGLUniformLocation; + private uColorRed: WebGLUniformLocation; + private uColorOrange: WebGLUniformLocation; + private uColorYellow: WebGLUniformLocation; + private uColorGreen: WebGLUniformLocation; + + private vao: WebGLVertexArrayObject; + private instanceBuf: WebGLBuffer; + + private healthData: Float32Array; + private healthCount = 0; + private progressData: Float32Array; + private progressCount = 0; + + private mapW: number; + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = header.mapWidth; + + // --- Shader program --- + this.program = createProgram(gl, barVertSrc, barFragSrc); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uBarSize = gl.getUniformLocation(this.program, "uBarSize")!; + this.uBarOffset = gl.getUniformLocation(this.program, "uBarOffset")!; + this.uBorderWidth = gl.getUniformLocation(this.program, "uBorderWidth")!; + this.uThresholds = gl.getUniformLocation(this.program, "uThresholds")!; + this.uColorRed = gl.getUniformLocation(this.program, "uColorRed")!; + this.uColorOrange = gl.getUniformLocation(this.program, "uColorOrange")!; + this.uColorYellow = gl.getUniformLocation(this.program, "uColorYellow")!; + this.uColorGreen = gl.getUniformLocation(this.program, "uColorGreen")!; + + // --- Instance data buffers (CPU-side) --- + this.healthData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE); + this.progressData = new Float32Array(this.maxBars * FLOATS_PER_INSTANCE); + + // --- VAO: unit quad + instanced data --- + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + // Quad vertices (2 triangles) + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Instance buffer (dynamic) + this.instanceBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + this.maxBars * BYTES_PER_INSTANCE, + gl.DYNAMIC_DRAW, + ); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); + gl.vertexAttribDivisor(1, 1); + + gl.bindVertexArray(null); + } + + /** Rebuild bar instance data from current unit state. */ + updateBars( + mobileUnits: Map, + structures: Map, + gameTick: number, + ): void { + this.healthCount = 0; + this.progressCount = 0; + + // --- Health bars (warships) --- + for (const unit of mobileUnits.values()) { + if ( + unit.health === null || + unit.health <= 0 || + unit.health >= WARSHIP_MAX_HEALTH + ) + continue; + this.pushHealth(unit, unit.health / WARSHIP_MAX_HEALTH); + } + + // --- Progress bars (structures) --- + for (const unit of structures.values()) { + const progress = this.computeStructureProgress(unit, gameTick); + if (progress !== null) this.pushProgress(unit, progress); + } + } + + /** Render bars. Call once per frame after FX, before names. */ + draw(cameraMat: Float32Array): void { + if (this.healthCount === 0 && this.progressCount === 0) return; + + const gl = this.gl; + const b = this.settings.bar; + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMat); + gl.uniform1f(this.uBorderWidth, b.borderWidth); + gl.uniform3f(this.uThresholds, b.threshold1, b.threshold2, b.threshold3); + gl.uniform3f(this.uColorRed, b.colorRedR, b.colorRedG, b.colorRedB); + gl.uniform3f( + this.uColorOrange, + b.colorOrangeR, + b.colorOrangeG, + b.colorOrangeB, + ); + gl.uniform3f( + this.uColorYellow, + b.colorYellowR, + b.colorYellowG, + b.colorYellowB, + ); + gl.uniform3f(this.uColorGreen, b.colorGreenR, b.colorGreenG, b.colorGreenB); + gl.bindVertexArray(this.vao); + + // Health bars + if (this.healthCount > 0) { + gl.uniform2f(this.uBarSize, b.healthBarW, b.healthBarH); + gl.uniform2f(this.uBarOffset, -b.healthBarW / 2, b.healthBarOffsetY); + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.healthData.subarray(0, this.healthCount * FLOATS_PER_INSTANCE), + ); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.healthCount); + } + + // Progress bars + if (this.progressCount > 0) { + gl.uniform2f(this.uBarSize, b.progressBarW, b.progressBarH); + gl.uniform2f(this.uBarOffset, -b.progressBarW / 2, b.progressBarOffsetY); + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.progressData.subarray(0, this.progressCount * FLOATS_PER_INSTANCE), + ); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.progressCount); + } + + gl.bindVertexArray(null); + } + + dispose(): void { + this.gl.deleteProgram(this.program); + this.gl.deleteBuffer(this.instanceBuf); + this.gl.deleteVertexArray(this.vao); + } + + // ---- Private ---- + + private pushHealth(unit: UnitState, progress: number): void { + if (this.healthCount >= this.maxBars) return; + const off = this.healthCount * FLOATS_PER_INSTANCE; + this.healthData[off] = unit.pos % this.mapW; + this.healthData[off + 1] = (unit.pos - this.healthData[off]) / this.mapW; + this.healthData[off + 2] = progress; + this.healthCount++; + } + + private pushProgress(unit: UnitState, progress: number): void { + if (this.progressCount >= this.maxBars) return; + const off = this.progressCount * FLOATS_PER_INSTANCE; + const x = unit.pos % this.mapW; + this.progressData[off] = x; + this.progressData[off + 1] = (unit.pos - x) / this.mapW; + this.progressData[off + 2] = progress; + this.progressCount++; + } + + private computeStructureProgress( + unit: UnitState, + gameTick: number, + ): number | null { + // Deletion progress (reverse countdown — takes priority over other bars) + if (unit.markedForDeletion !== false) { + const remaining = unit.markedForDeletion - gameTick; + return Math.max(0, Math.min(1, remaining / DELETION_MARK_DURATION)); + } + + // Construction progress + if (unit.underConstruction && unit.constructionStartTick !== null) { + const duration = CONSTRUCTION_DURATIONS[unit.unitType] ?? 50; + const elapsed = gameTick - unit.constructionStartTick; + return Math.min(1, Math.max(0, elapsed / duration)); + } + + // Missile readiness (Silo / SAM) + if ( + unit.unitType === UT_MISSILE_SILO || + unit.unitType === UT_SAM_LAUNCHER + ) { + const readiness = missileReadiness( + unit.unitType, + unit.level, + unit.missileTimerQueue, + gameTick, + ); + if (readiness < 1) return readiness; + } + + return null; + } +} diff --git a/src/client/render/gl/passes/BorderComputePass.ts b/src/client/render/gl/passes/BorderComputePass.ts new file mode 100644 index 0000000000..e6e0910e53 --- /dev/null +++ b/src/client/render/gl/passes/BorderComputePass.ts @@ -0,0 +1,265 @@ +/** + * BorderComputePass — tile-resolution pass that computes per-tile border flags. + * + * Runs a fullscreen quad at tile resolution (mapW × mapH) and writes to an + * RGBA8 texture: + * R = border type: 0 = interior, 0.5 = normal border, 1.0 = highlight border + * G = ember intensity: 0–255 (pre-computed flicker value, 0 = no ember) + * B = defense proximity: 1.0 if border tile is within range of same-owner defense post + * + * Both MapOverlayPass (daytime) and the night stamp overlay read this buffer + * instead of independently computing neighbor checks. Border thickening is + * computed once here via an N-tile Chebyshev radius expansion. + */ + +import type { RenderSettings } from "../RenderSettings"; +import borderComputeFragSrc from "../shaders/border-compute/border-compute.frag.glsl?raw"; +import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw"; +import { + createFullscreenQuad, + createProgram, + createTexture2D, + shaderSrc, +} from "../utils/GlUtils"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +const MAX_DEFENSE_POSTS = 64; + +/** Max player smallID supported by the relationship texture. */ +const RELATION_TEX_SIZE = 1024; + +// --------------------------------------------------------------------------- +// BorderComputePass +// --------------------------------------------------------------------------- + +export class BorderComputePass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + + private borderTex: WebGLTexture; + private borderFbo: WebGLFramebuffer; + private mapW: number; + private mapH: number; + + private relationTex: WebGLTexture; + + private uMapSize: WebGLUniformLocation; + private uHighlightOwner: WebGLUniformLocation; + private uHighlightThicken: WebGLUniformLocation; + private uTick: WebGLUniformLocation; + private uEmberThresholdUnowned: WebGLUniformLocation; + private uEmberThresholdOwned: WebGLUniformLocation; + private uEmberFlickerSpeed: WebGLUniformLocation; + private uDefensePosts: WebGLUniformLocation; + private uDefensePostCount: WebGLUniformLocation; + private uDefensePostRange: WebGLUniformLocation; + + private highlightOwner = 0; + /** True when any input has changed since last draw. Starts true so first frame computes. */ + private dirty = true; + + /** Packed defense post data: [x, y, ownerID, 0, x, y, ownerID, 0, ...] */ + private defensePostData = new Float32Array(MAX_DEFENSE_POSTS * 4); + private defensePostCount = 0; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + + this.program = createProgram( + gl, + fullscreenNoUvVertSrc, + shaderSrc(borderComputeFragSrc, { ...TILE_DEFINES, MAX_DEFENSE_POSTS }), + ); + + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uHighlightOwner = gl.getUniformLocation( + this.program, + "uHighlightOwner", + )!; + this.uHighlightThicken = gl.getUniformLocation( + this.program, + "uHighlightThicken", + )!; + this.uTick = gl.getUniformLocation(this.program, "uTick")!; + this.uEmberThresholdUnowned = gl.getUniformLocation( + this.program, + "uEmberThresholdUnowned", + )!; + this.uEmberThresholdOwned = gl.getUniformLocation( + this.program, + "uEmberThresholdOwned", + )!; + this.uEmberFlickerSpeed = gl.getUniformLocation( + this.program, + "uEmberFlickerSpeed", + )!; + this.uDefensePosts = gl.getUniformLocation(this.program, "uDefensePosts")!; + this.uDefensePostCount = gl.getUniformLocation( + this.program, + "uDefensePostCount", + )!; + this.uDefensePostRange = gl.getUniformLocation( + this.program, + "uDefensePostRange", + )!; + + // Texture unit binding + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uRelationTex"), 1); + + // --- Relationship texture (R8UI, RELATION_TEX_SIZE × RELATION_TEX_SIZE) --- + this.relationTex = createTexture2D(gl, { + width: RELATION_TEX_SIZE, + height: RELATION_TEX_SIZE, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + // --- RGBA8 border buffer at tile resolution --- + // R = border type, G = ember intensity, B = defense proximity flag + this.borderTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.RGBA8, + format: gl.RGBA, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + // FBO + this.borderFbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.borderFbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.borderTex, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // Fullscreen quad VAO [0,1] + this.vao = createFullscreenQuad(gl); + + // Store tileTex reference for binding + this._tileTex = tileTex; + } + + private _tileTex: WebGLTexture; + + /** Set the highlighted player's ownerID (0 = no highlight). */ + setHighlightOwner(ownerID: number): void { + if (ownerID === this.highlightOwner) return; + this.highlightOwner = ownerID; + this.dirty = true; + } + + /** + * Upload a relationship matrix (R8UI, size × size). + * Values: 0 = neutral, 1 = friendly, 2 = embargo. + * Indexed by [ownerA, ownerB]. Size must be ≤ RELATION_TEX_SIZE. + */ + updateRelations(data: Uint8Array, size: number): void { + const gl = this.gl; + const s = Math.min(size, RELATION_TEX_SIZE); + gl.bindTexture(gl.TEXTURE_2D, this.relationTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + s, + s, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + data, + ); + this.dirty = true; + } + + /** Update defense post positions for checkerboard proximity. */ + updateDefensePosts(posts: { x: number; y: number; ownerID: number }[]): void { + const count = Math.min(posts.length, MAX_DEFENSE_POSTS); + const data = this.defensePostData; + for (let i = 0; i < count; i++) { + const p = posts[i]; + const off = i * 4; + data[off] = p.x; + data[off + 1] = p.y; + data[off + 2] = p.ownerID; + data[off + 3] = 0; + } + this.defensePostCount = count; + this.dirty = true; + } + + /** Notify that the tile texture has been updated (ownership may have changed). */ + notifyTilesChanged(): void { + this.dirty = true; + } + + /** The border buffer texture (RG8, tile resolution). */ + getBorderTex(): WebGLTexture { + return this.borderTex; + } + + /** + * Compute border flags for the current frame. Call before MapOverlayPass and stamp overlay. + * Leaves the GL state with its own FBO bound — caller must restore FBO and viewport. + */ + draw(tick: number): void { + if (!this.dirty) return; + this.dirty = false; + + const gl = this.gl; + const mo = this.settings.mapOverlay; + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.borderFbo); + gl.viewport(0, 0, this.mapW, this.mapH); + gl.disable(gl.BLEND); + + gl.useProgram(this.program); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1ui(this.uHighlightOwner, this.highlightOwner); + gl.uniform1i(this.uHighlightThicken, Math.floor(mo.highlightThicken)); + gl.uniform1f(this.uTick, tick); + gl.uniform1f(this.uEmberThresholdUnowned, mo.emberThresholdUnowned); + gl.uniform1f(this.uEmberThresholdOwned, mo.emberThresholdOwned); + gl.uniform1f(this.uEmberFlickerSpeed, mo.emberFlickerSpeed); + gl.uniform4fv(this.uDefensePosts, this.defensePostData); + gl.uniform1i(this.uDefensePostCount, this.defensePostCount); + gl.uniform1f(this.uDefensePostRange, mo.defensePostRange); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this._tileTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.relationTex); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteTexture(this.borderTex); + gl.deleteTexture(this.relationTex); + gl.deleteFramebuffer(this.borderFbo); + } +} diff --git a/src/client/render/gl/passes/BorderStampPass.ts b/src/client/render/gl/passes/BorderStampPass.ts new file mode 100644 index 0000000000..9334a3b920 --- /dev/null +++ b/src/client/render/gl/passes/BorderStampPass.ts @@ -0,0 +1,162 @@ +/** + * BorderStampPass — territory borders + defense checkerboard + embers. + * + * Always draws at full brightness (after the optional night composite). + * Reads pre-computed border flags, ember intensity, and defense proximity + * from the BorderComputePass RGBA8 buffer. + */ + +import type { RenderSettings } from "../RenderSettings"; +import { getPaletteSize } from "../utils/ColorUtils"; +import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +import borderStampFragSrc from "../shaders/day-night/border-stamp.frag.glsl?raw"; +import borderStampVertSrc from "../shaders/day-night/border-stamp.vert.glsl?raw"; + +export class BorderStampPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + + private program: WebGLProgram; + private uCam: WebGLUniformLocation; + private uMapSize: WebGLUniformLocation; + private uHighlightBrighten: WebGLUniformLocation; + private uDefenseCheckerDarken: WebGLUniformLocation; + private uEmbargoTintRatio: WebGLUniformLocation; + private uFriendlyTintRatio: WebGLUniformLocation; + private uEmberColorDark: WebGLUniformLocation; + private uEmberColorBright: WebGLUniformLocation; + private uEmberStrengthUnowned: WebGLUniformLocation; + private uAltView: WebGLUniformLocation; + + private vao: WebGLVertexArrayObject; + private tileTex: WebGLTexture; + private paletteTex: WebGLTexture; + private borderTex: WebGLTexture; + private affiliationTex: WebGLTexture | null = null; + private altView = false; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + paletteTex: WebGLTexture, + borderTex: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.paletteTex = paletteTex; + this.borderTex = borderTex; + + this.program = createProgram( + gl, + borderStampVertSrc, + shaderSrc(borderStampFragSrc, { + PALETTE_SIZE: getPaletteSize(), + ...TILE_DEFINES, + }), + ); + this.uCam = gl.getUniformLocation(this.program, "uCamera")!; + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uHighlightBrighten = gl.getUniformLocation( + this.program, + "uHighlightBrighten", + )!; + this.uDefenseCheckerDarken = gl.getUniformLocation( + this.program, + "uDefenseCheckerDarken", + )!; + this.uEmbargoTintRatio = gl.getUniformLocation( + this.program, + "uEmbargoTintRatio", + )!; + this.uFriendlyTintRatio = gl.getUniformLocation( + this.program, + "uFriendlyTintRatio", + )!; + this.uEmberColorDark = gl.getUniformLocation( + this.program, + "uEmberColorDark", + )!; + this.uEmberColorBright = gl.getUniformLocation( + this.program, + "uEmberColorBright", + )!; + this.uEmberStrengthUnowned = gl.getUniformLocation( + this.program, + "uEmberStrengthUnowned", + )!; + this.uAltView = gl.getUniformLocation(this.program, "uAltView")!; + + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uBorderTex"), 2); + gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 3); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + setAltView(active: boolean): void { + this.altView = active; + } + setAffiliationTex(tex: WebGLTexture): void { + this.affiliationTex = tex; + } + + /** Draw borders + defense checkerboard + embers. Blending must be enabled. */ + draw(cameraMatrix: Float32Array): void { + const gl = this.gl; + const mo = this.settings.mapOverlay; + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCam, false, cameraMatrix); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1f(this.uHighlightBrighten, mo.highlightBrighten); + gl.uniform1f(this.uDefenseCheckerDarken, mo.defenseCheckerDarken); + gl.uniform1f(this.uEmbargoTintRatio, mo.embargoTintRatio); + gl.uniform1f(this.uFriendlyTintRatio, mo.friendlyTintRatio); + gl.uniform3f( + this.uEmberColorDark, + mo.emberColorDarkR, + mo.emberColorDarkG, + mo.emberColorDarkB, + ); + gl.uniform3f( + this.uEmberColorBright, + mo.emberColorBrightR, + mo.emberColorBrightG, + mo.emberColorBrightB, + ); + gl.uniform1f(this.uEmberStrengthUnowned, mo.emberStrengthUnowned); + gl.uniform1i(this.uAltView, this.altView ? 1 : 0); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.borderTex); + if (this.affiliationTex) { + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex); + } + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/ConquestPopupPass.ts b/src/client/render/gl/passes/ConquestPopupPass.ts new file mode 100644 index 0000000000..ad8959bdd2 --- /dev/null +++ b/src/client/render/gl/passes/ConquestPopupPass.ts @@ -0,0 +1,415 @@ +/** + * ConquestPopupPass — MSDF-rendered floating text popups. + * + * Renders two kinds of popups using the same MSDF atlas as NamePass: + * - Conquest popups: "+ 500" gold text at conquered player locations (static position, fade only) + * - Bonus popups: "+ 45K" income text at port tiles (rises upward + fades) + */ + +import type { BonusEvent, ConquestFx } from "../../types"; +import type { RenderSettings } from "../RenderSettings"; +import { createProgram } from "../utils/GlUtils"; +import type { GlyphTables } from "./name-pass/AtlasData"; +import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData"; +import { buildGlyphMetricsTex } from "./name-pass/DataTextures"; +import { layoutString } from "./name-pass/TextLayout"; +import { CHAR_RANGE, MAX_CHARS } from "./name-pass/Types"; + +import { assetUrl } from "src/core/AssetUrls"; +import fragSrc from "../shaders/conquest-popup/conquest-popup.frag.glsl?raw"; +import vertSrc from "../shaders/conquest-popup/conquest-popup.vert.glsl?raw"; + +const atlasUrl = assetUrl("atlases/msdf-atlas.png"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// worldX, worldY, cursorX, charCode, alpha, colorR, colorG, colorB, scale, outlineWidth +const FLOATS_PER_INSTANCE = 10; +const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; +const CONQUEST_LIFETIME_MS = 2500; +/** Nominal game tick rate — 100ms per tick. */ +const MS_PER_TICK = 100; +/** Tiles below conquered name location (matches upstream DynamicUILayer). */ +const CONQUEST_Y_OFFSET = 8; +/** World-space font size for conquest popups. */ +const CONQUEST_SCALE = 6; +const CONQUEST_OUTLINE_WIDTH = 2.0; + +// --------------------------------------------------------------------------- +// Active popup tracking +// --------------------------------------------------------------------------- + +interface ActivePopup { + x: number; + y: number; + text: string; + startMs: number; + lifetimeMs: number; + riseSpeed: number; // world units per second (0 = no rise) + colorR: number; + colorG: number; + colorB: number; + scale: number; + outlineWidth: number; +} + +function formatGold(gold: number): string { + if (gold >= 1_000_000) return (gold / 1_000_000).toFixed(1) + "M"; + if (gold >= 1_000) return (gold / 1_000).toFixed(1) + "K"; + return gold.toString(); +} + +// --------------------------------------------------------------------------- +// ConquestPopupPass +// --------------------------------------------------------------------------- + +export class ConquestPopupPass { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private maxInstances = 512; + + // Uniform locations + private uCamera: WebGLUniformLocation; + private uZoom: WebGLUniformLocation; + private uMinScreenScale: WebGLUniformLocation; + private uDistRange: WebGLUniformLocation; + + private vao: WebGLVertexArrayObject; + private instanceBuf: WebGLBuffer; + private instanceData: Float32Array; + private instanceCount = 0; + + private glyphMetricsTex: WebGLTexture; + private atlasTex: WebGLTexture | null = null; + private atlasReady = false; + + // CPU-side glyph tables for layoutString + private glyph: GlyphTables; + private kernTable: Int8Array; + + // Reusable buffers for layoutString + private charCodes = new Uint8Array(MAX_CHARS); + private cursors = new Float32Array(MAX_CHARS); + + private distanceRange: number; + private fontSize: number; + private atlasScaleH: number; + private base: number; + + // Active popups (both conquest and bonus, unified) + private active: ActivePopup[] = []; + + // Settings reference + private settings: RenderSettings; + + // Map width for tile→x/y conversion + private mapW = 0; + + // Pluggable time source (same pattern as FxPass) + private timeFn: () => number = () => performance.now(); + private now(): number { + return this.timeFn(); + } + + constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + this.gl = gl; + this.settings = settings; + + // Parse atlas data (shared with NamePass/StructureLevelPass) + const atlas = parseAtlasData(); + this.glyph = buildGlyphTables(atlas.chars); + this.kernTable = new Int8Array(CHAR_RANGE * CHAR_RANGE); + this.distanceRange = atlas.distanceRange; + this.fontSize = atlas.fontSize; + this.atlasScaleH = atlas.scaleH; + this.base = atlas.base; + + // Compile shaders + this.program = createProgram(gl, vertSrc, fragSrc); + + // Texture unit bindings + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1); + + // Static uniforms + gl.uniform1f( + gl.getUniformLocation(this.program, "uFontSize")!, + this.fontSize, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uAtlasScaleH")!, + this.atlasScaleH, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, this.base); + + // Dynamic uniform locations + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uZoom = gl.getUniformLocation(this.program, "uZoom")!; + this.uMinScreenScale = gl.getUniformLocation( + this.program, + "uMinScreenScale", + )!; + this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!; + + // Glyph metrics data texture + this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas); + + // Start async MSDF atlas load + this.loadAtlas(); + + // Instance buffer + this.instanceData = new Float32Array( + this.maxInstances * FLOATS_PER_INSTANCE, + ); + this.instanceBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + this.instanceData.byteLength, + gl.DYNAMIC_DRAW, + ); + + // VAO + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + // Attribute 0: unit quad [0,1]² + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Per-instance attributes from instance buffer + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf); + // Attribute 1: vec4 (worldX, worldY, cursorX, charCode) at offset 0 + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); + gl.vertexAttribDivisor(1, 1); + // Attribute 2: vec4 (alpha, colorR, colorG, colorB) at offset 16 + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 16); + gl.vertexAttribDivisor(2, 1); + // Attribute 3: vec2 (scale, outlineWidth) at offset 32 + gl.enableVertexAttribArray(3); + gl.vertexAttribPointer(3, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 32); + gl.vertexAttribDivisor(3, 1); + + gl.bindVertexArray(null); + } + + private loadAtlas(): void { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const gl = this.gl; + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + this.atlasTex = tex; + this.atlasReady = true; + }; + img.src = atlasUrl; + } + + setMapWidth(w: number): void { + this.mapW = w; + } + + // ------------------------------------------------------------------------- + // Event input + // ------------------------------------------------------------------------- + + applyConquestEvents(events: ConquestFx[]): void { + const now = this.now(); + for (const evt of events) { + const startMs = now - (evt.tickAge ?? 0) * MS_PER_TICK; + if (now - startMs >= CONQUEST_LIFETIME_MS) continue; + this.active.push({ + x: evt.x, + y: evt.y + CONQUEST_Y_OFFSET, + text: "+ " + formatGold(evt.gold), + startMs, + lifetimeMs: CONQUEST_LIFETIME_MS, + riseSpeed: 0, + colorR: 1, + colorG: 1, + colorB: 1, + scale: CONQUEST_SCALE, + outlineWidth: CONQUEST_OUTLINE_WIDTH, + }); + } + } + + applyBonusEvents(events: BonusEvent[]): void { + if (this.mapW === 0) return; + const now = this.now(); + const s = this.settings.bonusPopup; + for (const evt of events) { + if (evt.gold === 0) continue; + const x = evt.tile % this.mapW; + const y = Math.floor(evt.tile / this.mapW); + const sign = evt.gold >= 0 ? "+" : "-"; + this.active.push({ + x, + y: y + s.yOffset, + text: sign + " " + formatGold(Math.abs(evt.gold)), + startMs: now, + lifetimeMs: s.lifetimeMs, + riseSpeed: s.riseSpeed, + colorR: s.colorR, + colorG: s.colorG, + colorB: s.colorB, + scale: s.scale, + outlineWidth: s.outlineWidth, + }); + } + } + + // ------------------------------------------------------------------------- + // Tick — cull expired, rebuild instance buffer + // ------------------------------------------------------------------------- + + tick(): void { + if (this.active.length === 0) return; + const now = this.now(); + + // Remove expired popups (swap-remove) + for (let i = this.active.length - 1; i >= 0; i--) { + if (now - this.active[i].startMs >= this.active[i].lifetimeMs) { + this.active[i] = this.active[this.active.length - 1]; + this.active.pop(); + } + } + + this.rebuildInstances(now); + } + + private rebuildInstances(now: number): void { + let count = 0; + + for (const popup of this.active) { + const elapsed = now - popup.startMs; + const alpha = Math.max(0, 1 - elapsed / popup.lifetimeMs); + if (alpha <= 0) continue; + + // Rise animation: move upward over time + const riseY = + popup.riseSpeed > 0 + ? popup.y - (elapsed / 1000) * popup.riseSpeed + : popup.y; + + layoutString( + popup.text, + this.glyph, + this.kernTable, + this.charCodes, + this.cursors, + ); + const len = Math.min(popup.text.length, MAX_CHARS); + + for (let i = 0; i < len; i++) { + if (this.charCodes[i] === 0) continue; + if (count >= this.maxInstances) { + this.growBuffer(); + } + + const off = count * FLOATS_PER_INSTANCE; + this.instanceData[off + 0] = popup.x; + this.instanceData[off + 1] = riseY; + this.instanceData[off + 2] = this.cursors[i]; + this.instanceData[off + 3] = this.charCodes[i]; + this.instanceData[off + 4] = alpha; + this.instanceData[off + 5] = popup.colorR; + this.instanceData[off + 6] = popup.colorG; + this.instanceData[off + 7] = popup.colorB; + this.instanceData[off + 8] = popup.scale; + this.instanceData[off + 9] = popup.outlineWidth; + count++; + } + } + + this.instanceCount = count; + } + + private growBuffer(): void { + this.maxInstances *= 2; + const newData = new Float32Array(this.maxInstances * FLOATS_PER_INSTANCE); + newData.set(this.instanceData); + this.instanceData = newData; + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + this.instanceData.byteLength, + gl.DYNAMIC_DRAW, + ); + } + + // ------------------------------------------------------------------------- + // Draw + // ------------------------------------------------------------------------- + + draw(cameraMatrix: Float32Array, zoom: number): void { + if (!this.atlasReady || this.instanceCount === 0) return; + if (zoom < this.settings.bonusPopup.cullZoom) return; + + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uZoom, zoom); + gl.uniform1f(this.uMinScreenScale, this.settings.bonusPopup.minScreenScale); + gl.uniform1f(this.uDistRange, this.distanceRange); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.glyphMetricsTex); + + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceData, + 0, + this.instanceCount * FLOATS_PER_INSTANCE, + ); + + gl.bindVertexArray(this.vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** Override the time source. Default: performance.now (wall clock). */ + setTimeFn(fn: () => number): void { + this.timeFn = fn; + } + + clear(): void { + this.active.length = 0; + this.instanceCount = 0; + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteBuffer(this.instanceBuf); + gl.deleteVertexArray(this.vao); + gl.deleteTexture(this.glyphMetricsTex); + if (this.atlasTex) gl.deleteTexture(this.atlasTex); + } +} diff --git a/src/client/render/gl/passes/CoordinateGridPass.ts b/src/client/render/gl/passes/CoordinateGridPass.ts new file mode 100644 index 0000000000..5993a02d60 --- /dev/null +++ b/src/client/render/gl/passes/CoordinateGridPass.ts @@ -0,0 +1,135 @@ +/** + * CoordinateGridPass — procedural grid overlay with cell labels. + * + * Draws white grid lines at cell boundaries and alphanumeric labels + * (A1, B2, ...) at the top-left of each cell. Grid computation matches + * the upstream game's CoordinateGridLayer. + */ + +import type { RenderSettings } from "../RenderSettings"; +import { createMapQuad, createProgram } from "../utils/GlUtils"; + +import gridFragSrc from "../shaders/grid/grid.frag.glsl?raw"; +import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw"; + +const BASE_CELL_COUNT = 10; +const MAX_COLUMNS = 50; +const MIN_ROWS = 2; + +const GLYPH_W = 24; +const GLYPH_H = 36; +const CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +export class CoordinateGridPass { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + private glyphTex: WebGLTexture; + + private uCamera: WebGLUniformLocation; + private uMapSize: WebGLUniformLocation; + private uCellSize: WebGLUniformLocation; + private uZoom: WebGLUniformLocation; + private uFontSize: WebGLUniformLocation; + + private mapW: number; + private mapH: number; + private cellSize: number; + private settings: RenderSettings; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + settings: RenderSettings, + ) { + this.gl = gl; + this.mapW = mapW; + this.mapH = mapH; + this.cellSize = computeCellSize(mapW, mapH); + this.settings = settings; + + this.glyphTex = this.createGlyphAtlas(); + + this.program = createProgram(gl, overlayVertSrc, gridFragSrc); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uCellSize = gl.getUniformLocation(this.program, "uCellSize")!; + this.uZoom = gl.getUniformLocation(this.program, "uZoom")!; + this.uFontSize = gl.getUniformLocation(this.program, "uFontSize")!; + + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphTex"), 0); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + draw(cameraMatrix: Float32Array, zoom: number): void { + const gl = this.gl; + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1f(this.uCellSize, this.cellSize); + gl.uniform1f(this.uZoom, zoom); + gl.uniform1f(this.uFontSize, this.settings.altView.gridFontSize); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.glyphTex); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + gl.deleteTexture(this.glyphTex); + } + + /** Render A-Z, 0-9 glyphs into a single-row texture atlas. */ + private createGlyphAtlas(): WebGLTexture { + const canvas = document.createElement("canvas"); + canvas.width = CHARS.length * GLYPH_W; + canvas.height = GLYPH_H; + + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "white"; + ctx.font = `bold ${GLYPH_H - 8}px monospace`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + for (let i = 0; i < CHARS.length; i++) { + ctx.fillText(CHARS[i], i * GLYPH_W + GLYPH_W / 2, GLYPH_H / 2); + } + + const gl = this.gl; + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + return tex; + } +} + +/** Compute cell size matching upstream CoordinateGridLayer.computeGrid(). */ +function computeCellSize(mapW: number, mapH: number): number { + const raw = Math.min(mapW, mapH) / BASE_CELL_COUNT; + let rows = Math.max(1, Math.round(mapH / raw)); + let cols = Math.max(1, Math.round(mapW / raw)); + + if (cols > MAX_COLUMNS) { + const maxRows = Math.floor((MAX_COLUMNS * mapH) / mapW); + rows = Math.max(MIN_ROWS, Math.min(rows, maxRows)); + cols = MAX_COLUMNS; + } + + return Math.min(mapW / cols, mapH / rows); +} diff --git a/src/client/render/gl/passes/CrosshairPass.ts b/src/client/render/gl/passes/CrosshairPass.ts new file mode 100644 index 0000000000..77cef1c2be --- /dev/null +++ b/src/client/render/gl/passes/CrosshairPass.ts @@ -0,0 +1,96 @@ +/** + * CrosshairPass — renders a red crosshair at the cursor position during + * warship or MIRV placement (ghost preview). + * + * Screen-space quad with a crosshair SDF in the fragment shader. + * Darker red when placement is invalid. + */ + +import type { GhostPreviewData } from "../../types"; +import { UT_MIRV, UT_WARSHIP } from "../../types"; +import { createProgram } from "../utils/GlUtils"; + +import fragSrc from "../shaders/crosshair/crosshair.frag.glsl?raw"; +import vertSrc from "../shaders/crosshair/crosshair.vert.glsl?raw"; + +/** Half-size of the crosshair quad in screen pixels. */ +const CROSSHAIR_PX = 20; + +export class CrosshairPass { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + + private uCamera: WebGLUniformLocation; + private uCenter: WebGLUniformLocation; + private uHalfSize: WebGLUniformLocation; + private uViewport: WebGLUniformLocation; + private uColor: WebGLUniformLocation; + + private active = false; + private centerX = 0; + private centerY = 0; + private canBuild = false; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.program = createProgram(gl, vertSrc, fragSrc); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uCenter = gl.getUniformLocation(this.program, "uCenter")!; + this.uHalfSize = gl.getUniformLocation(this.program, "uHalfSize")!; + this.uViewport = gl.getUniformLocation(this.program, "uViewport")!; + this.uColor = gl.getUniformLocation(this.program, "uColor")!; + + // Unit quad [0,1] + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + } + + updateGhostPreview(data: GhostPreviewData | null): void { + if (data && (data.ghostType === UT_WARSHIP || data.ghostType === UT_MIRV)) { + this.active = true; + this.centerX = data.tileX; + this.centerY = data.tileY; + this.canBuild = data.canBuild || data.canUpgrade; + } else { + this.active = false; + } + } + + draw(cameraMatrix: Float32Array): void { + if (!this.active) return; + + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uCenter, this.centerX, this.centerY); + gl.uniform1f(this.uHalfSize, CROSSHAIR_PX); + gl.uniform2f(this.uViewport, gl.drawingBufferWidth, gl.drawingBufferHeight); + + if (this.canBuild) { + gl.uniform3f(this.uColor, 0.9, 0.15, 0.15); // red crosshair + } else { + gl.uniform3f(this.uColor, 0.4, 0.1, 0.1); // dark red = can't build + } + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/FalloutBloomPass.ts b/src/client/render/gl/passes/FalloutBloomPass.ts new file mode 100644 index 0000000000..dbbc5fdbf0 --- /dev/null +++ b/src/client/render/gl/passes/FalloutBloomPass.ts @@ -0,0 +1,320 @@ +/** + * FalloutBloomPass — soft radioactive glow around irradiated tiles. + * + * Tile-space pipeline (camera-independent, zero shimmer): + * 1. Extract — compute per-tile bloom at map resolution (mapW x mapH) + * 2. Blur — two iterations of separable 9-tap Gaussian in tile space + * 3. Composite — camera-projected map quad samples blurred texture (LINEAR) + * + * Heat management is handled by HeatManager (shared with LightmapPass). + */ + +import type { RenderSettings } from "../RenderSettings"; +import { + createFullscreenQuad, + createMapQuad, + createProgram, + shaderSrc, +} from "../utils/GlUtils"; +import type { HeatManager } from "../utils/HeatManager"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +import compositeFragSrc from "../shaders/fallout-bloom/composite.frag.glsl?raw"; +import compositeVertSrc from "../shaders/fallout-bloom/composite.vert.glsl?raw"; +import extractFragSrc from "../shaders/fallout-bloom/extract.frag.glsl?raw"; +import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw"; +import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw"; +import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw"; + +export class FalloutBloomPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + private tileTex: WebGLTexture; + private heatManager: HeatManager; + + // Programs + private extractProg: WebGLProgram; + private blurProg: WebGLProgram; + private compositeProg: WebGLProgram; + + // Uniforms — extract + private uExtractMapSize: WebGLUniformLocation; + private uExtractTick: WebGLUniformLocation; + private uBroilSpeedCold: WebGLUniformLocation; + private uBroilSpeedHot: WebGLUniformLocation; + private uNoiseFreq1: WebGLUniformLocation; + private uNoiseFreq2: WebGLUniformLocation; + private uContrastLoCold: WebGLUniformLocation; + private uContrastLoHot: WebGLUniformLocation; + private uContrastHiCold: WebGLUniformLocation; + private uContrastHiHot: WebGLUniformLocation; + private uMetaFreq: WebGLUniformLocation; + private uIntensityCold: WebGLUniformLocation; + private uIntensityHot: WebGLUniformLocation; + private uMetaInfluenceCold: WebGLUniformLocation; + private uMetaInfluenceHot: WebGLUniformLocation; + private uOpacityFadeEnd: WebGLUniformLocation; + private uBloomColor: WebGLUniformLocation; + + // Uniforms — composite + private uCompositeCam: WebGLUniformLocation; + private uCompositeMapSize: WebGLUniformLocation; + private uBloomCoverage: WebGLUniformLocation; + + // Uniforms — blur + private uBlurDir: WebGLUniformLocation; + + // FBOs (map resolution — fixed size) + private fboA: WebGLFramebuffer; + private fboB: WebGLFramebuffer; + private texA: WebGLTexture; + private texB: WebGLTexture; + + // Geometry + private mapVao: WebGLVertexArrayObject; + private quadVao: WebGLVertexArrayObject; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + heatManager: HeatManager, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.heatManager = heatManager; + + // --- Extract program (tile-space, no camera) --- + this.extractProg = createProgram( + gl, + fullscreenNoUvVertSrc, + shaderSrc(extractFragSrc, TILE_DEFINES), + ); + this.uExtractMapSize = gl.getUniformLocation(this.extractProg, "uMapSize")!; + this.uExtractTick = gl.getUniformLocation(this.extractProg, "uTick")!; + this.uBroilSpeedCold = gl.getUniformLocation( + this.extractProg, + "uBroilSpeedCold", + )!; + this.uBroilSpeedHot = gl.getUniformLocation( + this.extractProg, + "uBroilSpeedHot", + )!; + this.uNoiseFreq1 = gl.getUniformLocation(this.extractProg, "uNoiseFreq1")!; + this.uNoiseFreq2 = gl.getUniformLocation(this.extractProg, "uNoiseFreq2")!; + this.uContrastLoCold = gl.getUniformLocation( + this.extractProg, + "uContrastLoCold", + )!; + this.uContrastLoHot = gl.getUniformLocation( + this.extractProg, + "uContrastLoHot", + )!; + this.uContrastHiCold = gl.getUniformLocation( + this.extractProg, + "uContrastHiCold", + )!; + this.uContrastHiHot = gl.getUniformLocation( + this.extractProg, + "uContrastHiHot", + )!; + this.uMetaFreq = gl.getUniformLocation(this.extractProg, "uMetaFreq")!; + this.uIntensityCold = gl.getUniformLocation( + this.extractProg, + "uIntensityCold", + )!; + this.uIntensityHot = gl.getUniformLocation( + this.extractProg, + "uIntensityHot", + )!; + this.uMetaInfluenceCold = gl.getUniformLocation( + this.extractProg, + "uMetaInfluenceCold", + )!; + this.uMetaInfluenceHot = gl.getUniformLocation( + this.extractProg, + "uMetaInfluenceHot", + )!; + this.uOpacityFadeEnd = gl.getUniformLocation( + this.extractProg, + "uOpacityFadeEnd", + )!; + this.uBloomColor = gl.getUniformLocation(this.extractProg, "uBloomColor")!; + gl.useProgram(this.extractProg); + gl.uniform1i(gl.getUniformLocation(this.extractProg, "uTileTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.extractProg, "uHeatTex"), 1); + + // --- Blur program --- + this.blurProg = createProgram(gl, fullscreenVertSrc, blurFragSrc); + this.uBlurDir = gl.getUniformLocation(this.blurProg, "uDir")!; + gl.useProgram(this.blurProg); + gl.uniform1i(gl.getUniformLocation(this.blurProg, "uTex"), 0); + + // --- Composite program (camera-projected map quad) --- + this.compositeProg = createProgram(gl, compositeVertSrc, compositeFragSrc); + this.uCompositeCam = gl.getUniformLocation(this.compositeProg, "uCamera")!; + this.uCompositeMapSize = gl.getUniformLocation( + this.compositeProg, + "uMapSize", + )!; + this.uBloomCoverage = gl.getUniformLocation( + this.compositeProg, + "uBloomCoverage", + )!; + gl.useProgram(this.compositeProg); + gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uTex"), 0); + + // --- FBO textures (map resolution) --- + this.texA = this.createBloomTex(mapW, mapH); + this.texB = this.createBloomTex(mapW, mapH); + this.fboA = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.texA, + 0, + ); + this.fboB = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboB); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.texB, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // --- Geometry --- + this.mapVao = createMapQuad(gl, mapW, mapH); + this.quadVao = createFullscreenQuad(gl); + } + + private createBloomTex(w: number, h: number): WebGLTexture { + const gl = this.gl; + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + w, + h, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return tex; + } + + /** Run the full extract → blur → composite pipeline. */ + draw(cameraMatrix: Float32Array, tick: number): void { + const gl = this.gl; + const canvas = gl.canvas as HTMLCanvasElement; + const cw = canvas.width; + const ch = canvas.height; + const mw = this.mapW; + const mh = this.mapH; + + // --- 1. Extract: tile-space bloom --- + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA); + gl.viewport(0, 0, mw, mh); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.disable(gl.BLEND); + + gl.useProgram(this.extractProg); + gl.uniform2f(this.uExtractMapSize, mw, mh); + gl.uniform1f(this.uExtractTick, tick); + + const fb = this.settings.falloutBloom; + gl.uniform1f(this.uBroilSpeedCold, fb.broilSpeedCold); + gl.uniform1f(this.uBroilSpeedHot, fb.broilSpeedHot); + gl.uniform1f(this.uNoiseFreq1, fb.noiseFreq1); + gl.uniform1f(this.uNoiseFreq2, fb.noiseFreq2); + gl.uniform1f(this.uContrastLoCold, fb.contrastLoCold); + gl.uniform1f(this.uContrastLoHot, fb.contrastLoHot); + gl.uniform1f(this.uContrastHiCold, fb.contrastHiCold); + gl.uniform1f(this.uContrastHiHot, fb.contrastHiHot); + gl.uniform1f(this.uMetaFreq, fb.metaFreq); + gl.uniform1f(this.uIntensityCold, fb.intensityCold); + gl.uniform1f(this.uIntensityHot, fb.intensityHot); + gl.uniform1f(this.uMetaInfluenceCold, fb.metaInfluenceCold); + gl.uniform1f(this.uMetaInfluenceHot, fb.metaInfluenceHot); + gl.uniform1f(this.uOpacityFadeEnd, fb.opacityFadeEnd); + gl.uniform3f(this.uBloomColor, fb.bloomR, fb.bloomG, fb.bloomB); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.heatManager.getHeatTex()); + gl.bindVertexArray(this.quadVao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // --- 2. Blur: 2 iterations of separable H+V Gaussian --- + gl.useProgram(this.blurProg); + gl.bindVertexArray(this.quadVao); + + for (let iter = 0; iter < 2; iter++) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboB); + gl.viewport(0, 0, mw, mh); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.uniform2f(this.uBlurDir, 1.0 / mw, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.texA); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.fboA); + gl.viewport(0, 0, mw, mh); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.uniform2f(this.uBlurDir, 0, 1.0 / mh); + gl.bindTexture(gl.TEXTURE_2D, this.texB); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + // --- 3. Composite: camera-projected map quad → screen --- + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, cw, ch); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + + gl.useProgram(this.compositeProg); + gl.uniformMatrix3fv(this.uCompositeCam, false, cameraMatrix); + gl.uniform2f(this.uCompositeMapSize, mw, mh); + gl.uniform1f(this.uBloomCoverage, fb.bloomCoverage); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.texA); + gl.bindVertexArray(this.mapVao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Restore standard alpha blending + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.extractProg); + gl.deleteProgram(this.blurProg); + gl.deleteProgram(this.compositeProg); + gl.deleteFramebuffer(this.fboA); + gl.deleteFramebuffer(this.fboB); + gl.deleteTexture(this.texA); + gl.deleteTexture(this.texB); + gl.deleteVertexArray(this.mapVao); + gl.deleteVertexArray(this.quadVao); + } +} diff --git a/src/client/render/gl/passes/FalloutLightPass.ts b/src/client/render/gl/passes/FalloutLightPass.ts new file mode 100644 index 0000000000..67d065d9bf --- /dev/null +++ b/src/client/render/gl/passes/FalloutLightPass.ts @@ -0,0 +1,235 @@ +/** + * FalloutLightPass — tile-space fallout light extraction + composite. + * + * Extracted from LightmapPass. Two-step: + * 1. Extract fallout light at tile resolution (mapW x mapH) — reads heat + embers + * 2. Composite into the target lightmap FBO via camera-projected map quad (additive) + */ + +import type { RenderSettings } from "../RenderSettings"; +import { + createFullscreenQuad, + createMapQuad, + createProgram, + shaderSrc, +} from "../utils/GlUtils"; +import type { HeatManager } from "../utils/HeatManager"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +import falloutCompositeFragSrc from "../shaders/day-night/fallout-composite.frag.glsl?raw"; +import falloutCompositeVertSrc from "../shaders/day-night/fallout-composite.vert.glsl?raw"; +import falloutLightFragSrc from "../shaders/day-night/fallout-light.frag.glsl?raw"; +import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw"; + +export class FalloutLightPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + private heatManager: HeatManager; + private tileTex: WebGLTexture; + private borderTex: WebGLTexture; + + // Fallout light extraction + private falloutLightProg: WebGLProgram; + private uFalloutMapSize: WebGLUniformLocation; + private uFalloutLightColor: WebGLUniformLocation; + private uFalloutLightIntensity: WebGLUniformLocation; + private uFalloutLightThreshold: WebGLUniformLocation; + private uEmberLightColor: WebGLUniformLocation; + private uEmberLightIntensity: WebGLUniformLocation; + + // Fallout composite (tile-space → lightmap) + private falloutCompositeProg: WebGLProgram; + private uFalloutCompositeCam: WebGLUniformLocation; + private uFalloutCompositeMapSize: WebGLUniformLocation; + + // Tile-space FBO + private falloutFbo: WebGLFramebuffer; + private falloutTex: WebGLTexture; + + // Geometry + private quadVao: WebGLVertexArrayObject; + private mapQuadVao: WebGLVertexArrayObject; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + borderTex: WebGLTexture, + heatManager: HeatManager, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.borderTex = borderTex; + this.heatManager = heatManager; + + // Fallout light extraction program + this.falloutLightProg = createProgram( + gl, + fullscreenNoUvVertSrc, + shaderSrc(falloutLightFragSrc, TILE_DEFINES), + ); + this.uFalloutMapSize = gl.getUniformLocation( + this.falloutLightProg, + "uMapSize", + )!; + this.uFalloutLightColor = gl.getUniformLocation( + this.falloutLightProg, + "uFalloutLightColor", + )!; + this.uFalloutLightIntensity = gl.getUniformLocation( + this.falloutLightProg, + "uFalloutLightIntensity", + )!; + this.uFalloutLightThreshold = gl.getUniformLocation( + this.falloutLightProg, + "uFalloutLightThreshold", + )!; + this.uEmberLightColor = gl.getUniformLocation( + this.falloutLightProg, + "uEmberLightColor", + )!; + this.uEmberLightIntensity = gl.getUniformLocation( + this.falloutLightProg, + "uEmberLightIntensity", + )!; + gl.useProgram(this.falloutLightProg); + gl.uniform1i(gl.getUniformLocation(this.falloutLightProg, "uHeatTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.falloutLightProg, "uTileTex"), 1); + gl.uniform1i(gl.getUniformLocation(this.falloutLightProg, "uBorderTex"), 2); + + // Fallout composite program + this.falloutCompositeProg = createProgram( + gl, + falloutCompositeVertSrc, + falloutCompositeFragSrc, + ); + this.uFalloutCompositeCam = gl.getUniformLocation( + this.falloutCompositeProg, + "uCamera", + )!; + this.uFalloutCompositeMapSize = gl.getUniformLocation( + this.falloutCompositeProg, + "uMapSize", + )!; + gl.useProgram(this.falloutCompositeProg); + gl.uniform1i(gl.getUniformLocation(this.falloutCompositeProg, "uTex"), 0); + + // Tile-space FBO (map resolution) + this.falloutTex = this.createRGBA8Tex(mapW, mapH); + this.falloutFbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.falloutFbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.falloutTex, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + // Geometry + this.quadVao = createFullscreenQuad(gl); + this.mapQuadVao = createMapQuad(gl, mapW, mapH); + } + + private createRGBA8Tex(w: number, h: number): WebGLTexture { + const gl = this.gl; + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + w, + h, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return tex; + } + + /** + * Extract fallout light in tile space, then composite into the target FBO. + * Caller must bind the target FBO and set additive blending before calling. + */ + draw( + cameraMatrix: Float32Array, + targetFbo: WebGLFramebuffer, + targetW: number, + targetH: number, + ): void { + const gl = this.gl; + const dn = this.settings.dayNight; + + // Step 1: Extract fallout light in tile space + gl.bindFramebuffer(gl.FRAMEBUFFER, this.falloutFbo); + gl.viewport(0, 0, this.mapW, this.mapH); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.disable(gl.BLEND); + + gl.useProgram(this.falloutLightProg); + gl.uniform2f(this.uFalloutMapSize, this.mapW, this.mapH); + gl.uniform3f( + this.uFalloutLightColor, + dn.falloutLightR, + dn.falloutLightG, + dn.falloutLightB, + ); + gl.uniform1f(this.uFalloutLightIntensity, dn.falloutLightIntensity); + gl.uniform1f(this.uFalloutLightThreshold, dn.falloutLightThreshold); + gl.uniform3f( + this.uEmberLightColor, + dn.emberLightR, + dn.emberLightG, + dn.emberLightB, + ); + gl.uniform1f(this.uEmberLightIntensity, dn.emberLightIntensity); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.heatManager.getHeatTex()); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.borderTex); + gl.bindVertexArray(this.quadVao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // Step 2: Composite tile-space fallout into target lightmap + gl.bindFramebuffer(gl.FRAMEBUFFER, targetFbo); + gl.viewport(0, 0, targetW, targetH); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE); // additive + + gl.useProgram(this.falloutCompositeProg); + gl.uniformMatrix3fv(this.uFalloutCompositeCam, false, cameraMatrix); + gl.uniform2f(this.uFalloutCompositeMapSize, this.mapW, this.mapH); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.falloutTex); + gl.bindVertexArray(this.mapQuadVao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.falloutLightProg); + gl.deleteProgram(this.falloutCompositeProg); + gl.deleteFramebuffer(this.falloutFbo); + gl.deleteTexture(this.falloutTex); + gl.deleteVertexArray(this.quadVao); + gl.deleteVertexArray(this.mapQuadVao); + } +} diff --git a/src/client/render/gl/passes/LightmapPass.ts b/src/client/render/gl/passes/LightmapPass.ts new file mode 100644 index 0000000000..8a74a57f2a --- /dev/null +++ b/src/client/render/gl/passes/LightmapPass.ts @@ -0,0 +1,206 @@ +/** + * LightmapPass — orchestrator: point lights + fallout lights → blur → final texture. + * + * Owns the quarter-resolution lightmap ping-pong FBOs and the blur shader. + * Delegates light rendering to PointLightPass and FalloutLightPass. + */ + +import type { RenderSettings } from "../RenderSettings"; +import { createFullscreenQuad, createProgram } from "../utils/GlUtils"; +import type { FalloutLightPass } from "./FalloutLightPass"; +import type { PointLightPass } from "./PointLightPass"; + +import blurFragSrc from "../shaders/shared/blur.frag.glsl?raw"; +import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw"; + +export class LightmapPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + + private pointLightPass: PointLightPass; + private falloutLightPass: FalloutLightPass; + + // Blur program + private blurProg: WebGLProgram; + private uBlurDir: WebGLUniformLocation; + + // Quarter-res lightmap ping-pong + private lightFboA: WebGLFramebuffer; + private lightFboB: WebGLFramebuffer; + private lightTexA: WebGLTexture; + private lightTexB: WebGLTexture; + private lightW = 0; + private lightH = 0; + + // Geometry + private quadVao: WebGLVertexArrayObject; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + pointLightPass: PointLightPass, + falloutLightPass: FalloutLightPass, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.pointLightPass = pointLightPass; + this.falloutLightPass = falloutLightPass; + + // Blur program + this.blurProg = createProgram(gl, fullscreenVertSrc, blurFragSrc); + this.uBlurDir = gl.getUniformLocation(this.blurProg, "uDir")!; + gl.useProgram(this.blurProg); + gl.uniform1i(gl.getUniformLocation(this.blurProg, "uTex"), 0); + + // Lightmap FBOs (1×1 placeholder, resized lazily) + this.lightTexA = this.createRGBA8Tex(); + this.lightTexB = this.createRGBA8Tex(); + this.lightFboA = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboA); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.lightTexA, + 0, + ); + this.lightFboB = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboB); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + this.lightTexB, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + + this.quadVao = createFullscreenQuad(gl); + } + + private createRGBA8Tex(): WebGLTexture { + const gl = this.gl; + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + return tex; + } + + private ensureLightSize(w: number, h: number): void { + if (w === this.lightW && h === this.lightH) return; + this.lightW = w; + this.lightH = h; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.lightTexA); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + w, + h, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + gl.bindTexture(gl.TEXTURE_2D, this.lightTexB); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA8, + w, + h, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + null, + ); + } + + /** Generate the lightmap and return the final blurred texture. */ + draw( + cameraMatrix: Float32Array, + sceneW: number, + sceneH: number, + ): WebGLTexture { + const gl = this.gl; + const lw = Math.max(1, sceneW >> 1); + const lh = Math.max(1, sceneH >> 1); + this.ensureLightSize(lw, lh); + + // --- 1. Point lights → FBO A (additive) --- + gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboA); + gl.viewport(0, 0, lw, lh); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE); // additive + + this.pointLightPass.draw(cameraMatrix); + + // --- 2. Fallout light → extract at tile res, composite into FBO A (additive) --- + this.falloutLightPass.draw(cameraMatrix, this.lightFboA, lw, lh); + + // --- 3. Blur: 2 iterations separable H+V Gaussian --- + const zoom = Math.abs(cameraMatrix[0]); + const mapSize = Math.max(this.mapW, this.mapH); + const blurScale = Math.min( + (zoom * mapSize) / this.settings.dayNight.blurZoomDivisor, + 1.0, + ); + + gl.disable(gl.BLEND); + gl.useProgram(this.blurProg); + gl.bindVertexArray(this.quadVao); + + for (let iter = 0; iter < 2; iter++) { + gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboB); + gl.viewport(0, 0, lw, lh); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.uniform2f(this.uBlurDir, blurScale / lw, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.lightTexA); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.lightFboA); + gl.viewport(0, 0, lw, lh); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.uniform2f(this.uBlurDir, 0, blurScale / lh); + gl.bindTexture(gl.TEXTURE_2D, this.lightTexB); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + return this.lightTexA; + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.blurProg); + gl.deleteFramebuffer(this.lightFboA); + gl.deleteFramebuffer(this.lightFboB); + gl.deleteTexture(this.lightTexA); + gl.deleteTexture(this.lightTexB); + gl.deleteVertexArray(this.quadVao); + // pointLightPass and falloutLightPass disposed by renderer + } +} diff --git a/src/client/render/gl/passes/MoveIndicatorPass.ts b/src/client/render/gl/passes/MoveIndicatorPass.ts new file mode 100644 index 0000000000..0279fbb3e9 --- /dev/null +++ b/src/client/render/gl/passes/MoveIndicatorPass.ts @@ -0,0 +1,115 @@ +/** + * MoveIndicatorPass — converging chevron animation at a warship's + * move-target location. Matches the upstream game's MoveIndicatorUI + * but rendered via SDF in a fragment shader. + */ + +import type { RenderSettings } from "../RenderSettings"; +import { createProgram } from "../utils/GlUtils"; + +import fragSrc from "../shaders/move-indicator/move-indicator.frag.glsl?raw"; +import vertSrc from "../shaders/move-indicator/move-indicator.vert.glsl?raw"; + +export class MoveIndicatorPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + + private uCamera: WebGLUniformLocation; + private uCenter: WebGLUniformLocation; + private uElapsed: WebGLUniformLocation; + private uColor: WebGLUniformLocation; + private uPxPerTile: WebGLUniformLocation; + private uStartRadius: WebGLUniformLocation; + private uChevronSize: WebGLUniformLocation; + private uLineWidth: WebGLUniformLocation; + private uDuration: WebGLUniformLocation; + private uConverge: WebGLUniformLocation; + + private active = false; + private centerX = 0; + private centerY = 0; + private colorR = 1; + private colorG = 0; + private colorB = 0; + private startTime = 0; + + constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + this.gl = gl; + this.settings = settings; + this.program = createProgram(gl, vertSrc, fragSrc); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uCenter = gl.getUniformLocation(this.program, "uCenter")!; + this.uElapsed = gl.getUniformLocation(this.program, "uElapsed")!; + this.uColor = gl.getUniformLocation(this.program, "uColor")!; + this.uPxPerTile = gl.getUniformLocation(this.program, "uPxPerTile")!; + this.uStartRadius = gl.getUniformLocation(this.program, "uStartRadius")!; + this.uChevronSize = gl.getUniformLocation(this.program, "uChevronSize")!; + this.uLineWidth = gl.getUniformLocation(this.program, "uLineWidth")!; + this.uDuration = gl.getUniformLocation(this.program, "uDuration")!; + this.uConverge = gl.getUniformLocation(this.program, "uConverge")!; + + // Unit quad [0,1] + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + const buf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + } + + /** + * Trigger the move indicator at world tile (x, y) with player color. + * Each call replaces the previous indicator. + */ + show(x: number, y: number, r: number, g: number, b: number): void { + this.active = true; + this.centerX = x; + this.centerY = y; + this.colorR = r; + this.colorG = g; + this.colorB = b; + this.startTime = performance.now(); + } + + draw(cameraMatrix: Float32Array, zoom: number): void { + if (!this.active) return; + + const s = this.settings.moveIndicator; + const elapsed = performance.now() - this.startTime; + if (elapsed >= s.duration) { + this.active = false; + return; + } + + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uCenter, this.centerX, this.centerY); + gl.uniform1f(this.uElapsed, elapsed); + gl.uniform3f(this.uColor, this.colorR, this.colorG, this.colorB); + gl.uniform1f(this.uPxPerTile, zoom); + gl.uniform1f(this.uStartRadius, s.startRadius); + gl.uniform1f(this.uChevronSize, s.chevronSize); + gl.uniform1f(this.uLineWidth, s.lineWidth); + gl.uniform1f(this.uDuration, s.duration); + gl.uniform1f(this.uConverge, s.converge); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/NightCompositePass.ts b/src/client/render/gl/passes/NightCompositePass.ts new file mode 100644 index 0000000000..13e956e68e --- /dev/null +++ b/src/client/render/gl/passes/NightCompositePass.ts @@ -0,0 +1,80 @@ +/** + * NightCompositePass — scene capture + day/night composite. + * + * Owns the scene capture FBO: terrain + territory render into it when + * day/night is enabled. Composites the captured scene with a blurred + * lightmap: output = scene * min(ambient + lightmap, 1.2). + * + * At full daytime (ambient ≈ 1.0) the composite is a visual identity — + * multiplication by ~1.0 — so the pass runs continuously with no threshold. + */ + +import type { RenderSettings } from "../RenderSettings"; +import { createFullscreenQuad, createProgram } from "../utils/GlUtils"; + +import compositeFragSrc from "../shaders/day-night/composite.frag.glsl?raw"; +import fullscreenVertSrc from "../shaders/shared/fullscreen.vert.glsl?raw"; + +export class NightCompositePass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + + // Composite program + private compositeProg: WebGLProgram; + private uCompositeAmbient: WebGLUniformLocation; + private quadVao: WebGLVertexArrayObject; + + constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + this.gl = gl; + this.settings = settings; + + // --- Composite program --- + this.compositeProg = createProgram(gl, fullscreenVertSrc, compositeFragSrc); + this.uCompositeAmbient = gl.getUniformLocation( + this.compositeProg, + "uAmbient", + )!; + gl.useProgram(this.compositeProg); + gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uSceneTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.compositeProg, "uLightTex"), 1); + + // --- Fullscreen quad --- + this.quadVao = createFullscreenQuad(gl); + } + + // ------------------------------------------------------------------------- + // Ambient + // ------------------------------------------------------------------------- + + getAmbient(): number { + const dn = this.settings.dayNight; + return dn.mode === "dark" ? dn.nightAmbient : dn.dayAmbient; + } + + // ------------------------------------------------------------------------- + // Composite: scene * (ambient + lightmap) → screen + // ------------------------------------------------------------------------- + + /** Pure combiner — receives captured scene + lightmap textures, outputs to screen. */ + draw(sceneTex: WebGLTexture, lightmapTex: WebGLTexture): void { + const gl = this.gl; + gl.disable(gl.BLEND); + + gl.useProgram(this.compositeProg); + gl.uniform1f(this.uCompositeAmbient, this.getAmbient()); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, sceneTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, lightmapTex); + + gl.bindVertexArray(this.quadVao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.compositeProg); + gl.deleteVertexArray(this.quadVao); + } +} diff --git a/src/client/render/gl/passes/NukeTelegraphPass.ts b/src/client/render/gl/passes/NukeTelegraphPass.ts new file mode 100644 index 0000000000..819ebf8b57 --- /dev/null +++ b/src/client/render/gl/passes/NukeTelegraphPass.ts @@ -0,0 +1,152 @@ +/** + * NukeTelegraphPass — renders animated blast-radius circles at the target + * location of each in-flight nuke. + * + * Instanced quads with two concentric circle SDFs (inner filled, outer + * dashed ring). Similar to SAMRadiusPass but with different aesthetics. + */ + +import type { NukeTelegraphData } from "../../types"; +import { DynamicInstanceBuffer } from "../DynamicBuffer"; +import type { RenderSettings } from "../RenderSettings"; +import { createProgram } from "../utils/GlUtils"; + +import fragSrc from "../shaders/nuke-telegraph/nuke-telegraph.frag.glsl?raw"; +import vertSrc from "../shaders/nuke-telegraph/nuke-telegraph.vert.glsl?raw"; + +// Per-instance: x, y, innerRadius, outerRadius +const FLOATS_PER_INSTANCE = 4; + +export class NukeTelegraphPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + + private uCamera: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uTelegraphStyle: WebGLUniformLocation; + private uTelegraphAlpha: WebGLUniformLocation; + private uTelegraphColor: WebGLUniformLocation; + + private instanceCount = 0; + private startTime = performance.now(); + + constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + this.gl = gl; + this.settings = settings; + this.program = createProgram(gl, vertSrc, fragSrc); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uTelegraphStyle = gl.getUniformLocation( + this.program, + "uTelegraphStyle", + )!; + this.uTelegraphAlpha = gl.getUniformLocation( + this.program, + "uTelegraphAlpha", + )!; + this.uTelegraphColor = gl.getUniformLocation( + this.program, + "uTelegraphColor", + )!; + + // VAO + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + // Attribute 0: unit quad [0,1] + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Attribute 1: per-instance vec4 (x, y, innerR, outerR) + const glBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer( + gl, + glBuf, + 16, + FLOATS_PER_INSTANCE, + ); + gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(1, 1); + + gl.bindVertexArray(null); + } + + update(data: NukeTelegraphData[]): void { + const count = data.length; + this.instanceBuf.ensureCapacity(count); + + const buf = this.instanceBuf.float32; + for (let i = 0; i < count; i++) { + const d = data[i]; + const off = i * FLOATS_PER_INSTANCE; + buf[off + 0] = d.x; + buf[off + 1] = d.y; + buf[off + 2] = d.innerRadius; + buf[off + 3] = d.outerRadius; + } + + this.instanceCount = count; + + if (count > 0) { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + count * FLOATS_PER_INSTANCE, + ); + } + } + + draw(cameraMatrix: Float32Array): void { + if (this.instanceCount === 0) return; + + const gl = this.gl; + const s = this.settings.nukeTelegraph; + const time = (performance.now() - this.startTime) / 1000; + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTime, time); + gl.uniform4f( + this.uTelegraphStyle, + s.strokeWidth, + s.dashLen, + s.gapLen, + s.rotationSpeed, + ); + gl.uniform4f( + this.uTelegraphAlpha, + s.baseAlpha, + s.pulseAmplitude, + s.pulseSpeed, + s.fillAlphaOffset, + ); + gl.uniform3f(this.uTelegraphColor, s.colorR, s.colorG, s.colorB); + + gl.bindVertexArray(this.vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.instanceBuf.dispose(); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/NukeTrajectoryPass.ts b/src/client/render/gl/passes/NukeTrajectoryPass.ts new file mode 100644 index 0000000000..328876677a --- /dev/null +++ b/src/client/render/gl/passes/NukeTrajectoryPass.ts @@ -0,0 +1,328 @@ +/** + * NukeTrajectoryPass — renders the nuke trajectory preview arc during + * build mode (Atom Bomb / Hydrogen Bomb ghost active). + * + * Renders as a triangle strip with screen-space line width. The cubic + * Bezier is evaluated on the GPU from 4 control-point uniforms; cumulative + * arc distances are pre-computed on the CPU for accurate pixel-space dashing. + * + * Zone boundary circles and SAM intercept X markers are drawn with a + * separate marker program. + */ + +import type { NukeTrajectoryData } from "../../types"; +import type { RenderSettings } from "../RenderSettings"; +import { createProgram } from "../utils/GlUtils"; + +import markerFragSrc from "../shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl?raw"; +import markerVertSrc from "../shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl?raw"; +import fragSrc from "../shaders/nuke-trajectory/nuke-trajectory.frag.glsl?raw"; +import vertSrc from "../shaders/nuke-trajectory/nuke-trajectory.vert.glsl?raw"; + +const NUM_SEGMENTS = 128; +const VERTS_PER_PAIR = 2; +const FLOATS_PER_VERT = 3; // (t, side, cumDist) + +export class NukeTrajectoryPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + + // Line program + private lineProgram: WebGLProgram; + private lineVAO: WebGLVertexArrayObject; + private lineBuf: WebGLBuffer; + private lineVertexCount: number; + private lineVertices: Float32Array; + private uLineCamera: WebGLUniformLocation; + private uLineP0: WebGLUniformLocation; + private uLineP1: WebGLUniformLocation; + private uLineP2: WebGLUniformLocation; + private uLineP3: WebGLUniformLocation; + private uLinePixelSize: WebGLUniformLocation; + private uLineTUntargetableStart: WebGLUniformLocation; + private uLineTUntargetableEnd: WebGLUniformLocation; + private uLineTSamIntercept: WebGLUniformLocation; + private uLineQuadHalfPx: WebGLUniformLocation; + private uLineLineHalfPx: WebGLUniformLocation; + private uLineOutlineHalfPx: WebGLUniformLocation; + private uLineDashPattern: WebGLUniformLocation; + private uLineLineColor: WebGLUniformLocation; + private uLineInterceptColor: WebGLUniformLocation; + private uLineOutlineColor: WebGLUniformLocation; + private uLineInterceptOutlineColor: WebGLUniformLocation; + + // Marker program + private markerProgram: WebGLProgram; + private markerVAO: WebGLVertexArrayObject; + private uMarkerCamera: WebGLUniformLocation; + private uMarkerP0: WebGLUniformLocation; + private uMarkerP1: WebGLUniformLocation; + private uMarkerP2: WebGLUniformLocation; + private uMarkerP3: WebGLUniformLocation; + private uMarkerPixelSize: WebGLUniformLocation; + private uMarker: WebGLUniformLocation; + private uMarkerRadii: WebGLUniformLocation; + + private visible = false; + private data: NukeTrajectoryData | null = null; + + constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + this.gl = gl; + this.settings = settings; + + // --- Line program --- + this.lineProgram = createProgram(gl, vertSrc, fragSrc); + this.uLineCamera = gl.getUniformLocation(this.lineProgram, "uCamera")!; + this.uLineP0 = gl.getUniformLocation(this.lineProgram, "uP0")!; + this.uLineP1 = gl.getUniformLocation(this.lineProgram, "uP1")!; + this.uLineP2 = gl.getUniformLocation(this.lineProgram, "uP2")!; + this.uLineP3 = gl.getUniformLocation(this.lineProgram, "uP3")!; + this.uLinePixelSize = gl.getUniformLocation( + this.lineProgram, + "uPixelSize", + )!; + this.uLineTUntargetableStart = gl.getUniformLocation( + this.lineProgram, + "uTUntargetableStart", + )!; + this.uLineTUntargetableEnd = gl.getUniformLocation( + this.lineProgram, + "uTUntargetableEnd", + )!; + this.uLineTSamIntercept = gl.getUniformLocation( + this.lineProgram, + "uTSamIntercept", + )!; + this.uLineQuadHalfPx = gl.getUniformLocation( + this.lineProgram, + "uQuadHalfPx", + )!; + this.uLineLineHalfPx = gl.getUniformLocation( + this.lineProgram, + "uLineHalfPx", + )!; + this.uLineOutlineHalfPx = gl.getUniformLocation( + this.lineProgram, + "uOutlineHalfPx", + )!; + this.uLineDashPattern = gl.getUniformLocation( + this.lineProgram, + "uDashPattern", + )!; + this.uLineLineColor = gl.getUniformLocation( + this.lineProgram, + "uLineColor", + )!; + this.uLineInterceptColor = gl.getUniformLocation( + this.lineProgram, + "uInterceptColor", + )!; + this.uLineOutlineColor = gl.getUniformLocation( + this.lineProgram, + "uOutlineColor", + )!; + this.uLineInterceptOutlineColor = gl.getUniformLocation( + this.lineProgram, + "uInterceptOutlineColor", + )!; + + // Triangle strip: (N+1) pairs of left/right vertices + const N = NUM_SEGMENTS; + this.lineVertexCount = (N + 1) * VERTS_PER_PAIR; + this.lineVertices = new Float32Array( + this.lineVertexCount * FLOATS_PER_VERT, + ); + + this.lineVAO = gl.createVertexArray()!; + gl.bindVertexArray(this.lineVAO); + this.lineBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + this.lineVertices.byteLength, + gl.DYNAMIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + + // --- Marker program --- + this.markerProgram = createProgram(gl, markerVertSrc, markerFragSrc); + this.uMarkerCamera = gl.getUniformLocation(this.markerProgram, "uCamera")!; + this.uMarkerP0 = gl.getUniformLocation(this.markerProgram, "uP0")!; + this.uMarkerP1 = gl.getUniformLocation(this.markerProgram, "uP1")!; + this.uMarkerP2 = gl.getUniformLocation(this.markerProgram, "uP2")!; + this.uMarkerP3 = gl.getUniformLocation(this.markerProgram, "uP3")!; + this.uMarkerPixelSize = gl.getUniformLocation( + this.markerProgram, + "uPixelSize", + )!; + this.uMarker = gl.getUniformLocation(this.markerProgram, "uMarker")!; + this.uMarkerRadii = gl.getUniformLocation( + this.markerProgram, + "uMarkerRadii", + )!; + + this.markerVAO = gl.createVertexArray()!; + gl.bindVertexArray(this.markerVAO); + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + } + + update(data: NukeTrajectoryData | null): void { + this.data = data; + this.visible = data !== null; + if (data) this.rebuildVertices(data); + } + + /** Recompute triangle strip vertices with cumulative arc distances. */ + private rebuildVertices(d: NukeTrajectoryData): void { + const N = NUM_SEGMENTS; + const buf = this.lineVertices; + let cumDist = 0; + let prevX = d.p0x; + let prevY = d.p0y; + + for (let i = 0; i <= N; i++) { + const t = i / N; + const T = 1 - t; + const TT = T * T; + const tt = t * t; + const x = + TT * T * d.p0x + + 3 * TT * t * d.p1x + + 3 * T * tt * d.p2x + + tt * t * d.p3x; + const y = + TT * T * d.p0y + + 3 * TT * t * d.p1y + + 3 * T * tt * d.p2y + + tt * t * d.p3y; + + if (i > 0) { + const dx = x - prevX; + const dy = y - prevY; + cumDist += Math.sqrt(dx * dx + dy * dy); + } + prevX = x; + prevY = y; + + const idx = i * VERTS_PER_PAIR * FLOATS_PER_VERT; + buf[idx + 0] = t; + buf[idx + 1] = -1; + buf[idx + 2] = cumDist; + buf[idx + 3] = t; + buf[idx + 4] = 1; + buf[idx + 5] = cumDist; + } + + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.lineBuf); + gl.bufferSubData(gl.ARRAY_BUFFER, 0, buf); + } + + draw(cameraMatrix: Float32Array): void { + if (!this.visible || !this.data) return; + + const gl = this.gl; + const d = this.data; + const s = this.settings.nukeTrajectory; + const pixelSize = 2.0 / (cameraMatrix[0] * gl.drawingBufferWidth); + + // Derived pixel dimensions + const lineHalfPx = s.lineWidth / 2; + const outlineHalfPx = (s.lineWidth + s.outlineWidth) / 2; + const quadHalfPx = outlineHalfPx + 1.0; // AA padding + + // --- Draw trajectory line --- + gl.useProgram(this.lineProgram); + gl.uniformMatrix3fv(this.uLineCamera, false, cameraMatrix); + gl.uniform2f(this.uLineP0, d.p0x, d.p0y); + gl.uniform2f(this.uLineP1, d.p1x, d.p1y); + gl.uniform2f(this.uLineP2, d.p2x, d.p2y); + gl.uniform2f(this.uLineP3, d.p3x, d.p3y); + gl.uniform1f(this.uLinePixelSize, pixelSize); + gl.uniform1f(this.uLineTUntargetableStart, d.tUntargetableStart); + gl.uniform1f(this.uLineTUntargetableEnd, d.tUntargetableEnd); + gl.uniform1f(this.uLineTSamIntercept, d.tSamIntercept); + gl.uniform1f(this.uLineQuadHalfPx, quadHalfPx); + gl.uniform1f(this.uLineLineHalfPx, lineHalfPx); + gl.uniform1f(this.uLineOutlineHalfPx, outlineHalfPx); + gl.uniform4f( + this.uLineDashPattern, + s.dashTargetable, + s.gapTargetable, + s.dashUntargetable, + s.gapUntargetable, + ); + gl.uniform3f(this.uLineLineColor, s.lineR, s.lineG, s.lineB); + gl.uniform3f( + this.uLineInterceptColor, + s.interceptR, + s.interceptG, + s.interceptB, + ); + gl.uniform3f(this.uLineOutlineColor, s.outlineR, s.outlineG, s.outlineB); + gl.uniform3f( + this.uLineInterceptOutlineColor, + s.interceptOutlineR, + s.interceptOutlineG, + s.interceptOutlineB, + ); + + gl.bindVertexArray(this.lineVAO); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, this.lineVertexCount); + + // --- Draw markers --- + this.drawMarkers(cameraMatrix, d, pixelSize); + } + + private drawMarkers( + cameraMatrix: Float32Array, + d: NukeTrajectoryData, + pixelSize: number, + ): void { + const markers: [number, number][] = []; + if (d.tUntargetableStart >= 0) { + markers.push([d.tUntargetableStart, 0]); + markers.push([d.tUntargetableEnd, 0]); + } + if (d.tSamIntercept < 1.0) { + markers.push([d.tSamIntercept, 1]); + } + if (markers.length === 0) return; + + const gl = this.gl; + const s = this.settings.nukeTrajectory; + gl.useProgram(this.markerProgram); + gl.uniformMatrix3fv(this.uMarkerCamera, false, cameraMatrix); + gl.uniform2f(this.uMarkerP0, d.p0x, d.p0y); + gl.uniform2f(this.uMarkerP1, d.p1x, d.p1y); + gl.uniform2f(this.uMarkerP2, d.p2x, d.p2y); + gl.uniform2f(this.uMarkerP3, d.p3x, d.p3y); + gl.uniform1f(this.uMarkerPixelSize, pixelSize); + gl.uniform2f(this.uMarkerRadii, s.markerCircleRadius, s.markerXRadius); + + gl.bindVertexArray(this.markerVAO); + for (const [t, type] of markers) { + gl.uniform4f(this.uMarker, t, type, 0, 0); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.lineProgram); + gl.deleteProgram(this.markerProgram); + gl.deleteVertexArray(this.lineVAO); + gl.deleteVertexArray(this.markerVAO); + } +} diff --git a/src/client/render/gl/passes/PointLightPass.ts b/src/client/render/gl/passes/PointLightPass.ts new file mode 100644 index 0000000000..bbc0abea5f --- /dev/null +++ b/src/client/render/gl/passes/PointLightPass.ts @@ -0,0 +1,237 @@ +/** + * PointLightPass — instanced radial-falloff quads for unit/structure lights. + * + * Single VBO/VAO: units and structures packed together, uploaded once per tick. + * draw() is pure GPU: uniforms + one drawArraysInstanced call. + */ + +import type { RendererConfig, UnitState } from "../../types"; +import { + UT_ATOM_BOMB, + UT_CITY, + UT_DEFENSE_POST, + UT_FACTORY, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_MIRV_WARHEAD, + UT_MISSILE_SILO, + UT_PORT, + UT_SAM_LAUNCHER, + UT_TRADE_SHIP, + UT_TRAIN, + UT_TRANSPORT, + UT_WARSHIP, +} from "../../types"; +import type { RenderSettings } from "../RenderSettings"; +import { createProgram, shaderSrc } from "../utils/GlUtils"; + +import lightFragSrc from "../shaders/day-night/light.frag.glsl?raw"; +import lightVertSrc from "../shaders/day-night/light.vert.glsl?raw"; + +// --------------------------------------------------------------------------- +// Light source configuration +// --------------------------------------------------------------------------- + +interface LightConfig { + r: number; + g: number; + b: number; + radius: number; + intensity: number; +} + +const LIGHT_CONFIGS: Record = { + [UT_CITY]: { r: 1.0, g: 0.85, b: 0.5, radius: 18, intensity: 1.2 }, + [UT_PORT]: { r: 1.0, g: 0.75, b: 0.4, radius: 12, intensity: 1.0 }, + [UT_FACTORY]: { r: 1.0, g: 0.6, b: 0.3, radius: 12, intensity: 1.0 }, + [UT_DEFENSE_POST]: { r: 0.8, g: 0.85, b: 1.0, radius: 10, intensity: 0.9 }, + [UT_SAM_LAUNCHER]: { r: 0.8, g: 0.85, b: 1.0, radius: 10, intensity: 0.9 }, + [UT_MISSILE_SILO]: { r: 1.0, g: 0.4, b: 0.2, radius: 10, intensity: 0.9 }, + [UT_TRANSPORT]: { r: 0.9, g: 0.8, b: 0.6, radius: 6, intensity: 2.7 }, + [UT_TRADE_SHIP]: { r: 0.9, g: 0.8, b: 0.6, radius: 6, intensity: 2.7 }, + [UT_WARSHIP]: { r: 0.9, g: 0.85, b: 0.7, radius: 10, intensity: 2.8 }, + [UT_ATOM_BOMB]: { r: 1.0, g: 0.9, b: 0.7, radius: 16, intensity: 1.1 }, + [UT_HYDROGEN_BOMB]: { r: 1.0, g: 0.95, b: 0.6, radius: 22, intensity: 1.3 }, + [UT_MIRV]: { r: 1.0, g: 0.9, b: 0.7, radius: 18, intensity: 1.2 }, + [UT_MIRV_WARHEAD]: { r: 1.0, g: 0.6, b: 0.3, radius: 12, intensity: 1.0 }, + [UT_TRAIN]: { r: 1.0, g: 0.85, b: 0.5, radius: 8, intensity: 2.0 }, +}; + +const FLOATS_PER_LIGHT = 6; +const BYTES_PER_LIGHT = FLOATS_PER_LIGHT * 4; +const MAX_LIGHT_TYPES = 64; +const MAX_LIGHTS = 12288; // units + structures combined + +export class PointLightPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + + // Program + uniforms + private lightProg: WebGLProgram; + private uLightCam: WebGLUniformLocation; + private uRadiusMultiplier: WebGLUniformLocation; + private uRadiusArr: WebGLUniformLocation; + private uIntensityArr: WebGLUniformLocation; + private uFalloffPower: WebGLUniformLocation; + + // Single instance buffer — units + structures packed together + private lightVao: WebGLVertexArrayObject; + private lightBuf: WebGLBuffer; + private lightData: Float32Array; + private lightCount = 0; + + // Type config + private typeToIdx = new Map(); + private typeConfigs: (LightConfig | undefined)[]; + private typeNames: string[]; + private radiusArr = new Float32Array(MAX_LIGHT_TYPES); + private intensityArr = new Float32Array(MAX_LIGHT_TYPES); + private paletteData: Float32Array; + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + paletteData: Float32Array, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.paletteData = paletteData; + this.mapW = header.mapWidth; + + // Build type → light config mapping + this.typeNames = header.unitTypes; + this.typeConfigs = new Array(header.unitTypes.length); + for (let i = 0; i < header.unitTypes.length; i++) { + this.typeConfigs[i] = LIGHT_CONFIGS[header.unitTypes[i]]; + this.typeToIdx.set(header.unitTypes[i], i); + } + + // Light program + this.lightProg = createProgram( + gl, + shaderSrc(lightVertSrc, { MAX_LIGHT_TYPES }), + lightFragSrc, + ); + this.uLightCam = gl.getUniformLocation(this.lightProg, "uCamera")!; + this.uRadiusMultiplier = gl.getUniformLocation( + this.lightProg, + "uRadiusMultiplier", + )!; + this.uRadiusArr = gl.getUniformLocation(this.lightProg, "uRadius")!; + this.uIntensityArr = gl.getUniformLocation(this.lightProg, "uIntensity")!; + this.uFalloffPower = gl.getUniformLocation( + this.lightProg, + "uFalloffPower", + )!; + + // Instance buffer + VAO + this.lightData = new Float32Array(MAX_LIGHTS * FLOATS_PER_LIGHT); + this.lightBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.lightBuf); + gl.bufferData(gl.ARRAY_BUFFER, this.lightData.byteLength, gl.DYNAMIC_DRAW); + + this.lightVao = gl.createVertexArray()!; + gl.bindVertexArray(this.lightVao); + + // Attribute 0: quad corner [0,1] + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Attribute 1: per-instance vec3 (x, y, typeIdx) + gl.bindBuffer(gl.ARRAY_BUFFER, this.lightBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_LIGHT, 0); + gl.vertexAttribDivisor(1, 1); + + // Attribute 2: per-instance vec3 (r, g, b) + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 3, gl.FLOAT, false, BYTES_PER_LIGHT, 12); + gl.vertexAttribDivisor(2, 1); + + gl.bindVertexArray(null); + } + + /** Pack all light-emitting entities into the instance buffer and upload. Called every tick. */ + updateLights(units: Map): void { + let count = 0; + + for (const unit of units.values()) { + if (!unit.isActive) continue; + const typeIdx = this.typeToIdx.get(unit.unitType); + if (typeIdx === undefined) continue; + const cfg = this.typeConfigs[typeIdx]; + if (!cfg) continue; + if (count >= MAX_LIGHTS) break; + + const x = unit.pos % this.mapW; + const y = (unit.pos - x) / this.mapW; + const off = count * FLOATS_PER_LIGHT; + const pOff = unit.ownerID * 4; + this.lightData[off + 0] = x; + this.lightData[off + 1] = y; + this.lightData[off + 2] = typeIdx; + this.lightData[off + 3] = this.paletteData[pOff]; + this.lightData[off + 4] = this.paletteData[pOff + 1]; + this.lightData[off + 5] = this.paletteData[pOff + 2]; + count++; + } + + this.lightCount = count; + if (count > 0) { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.lightBuf); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.lightData, + 0, + count * FLOATS_PER_LIGHT, + ); + } + } + + /** + * Render instanced point lights into the currently bound FBO. + * Caller must set up additive blending and viewport. + */ + draw(cameraMatrix: Float32Array): void { + if (this.lightCount === 0) return; + + const gl = this.gl; + const dn = this.settings.dayNight; + + gl.useProgram(this.lightProg); + gl.uniformMatrix3fv(this.uLightCam, false, cameraMatrix); + gl.uniform1f(this.uRadiusMultiplier, dn.lightRadiusMultiplier); + gl.uniform1f(this.uFalloffPower, dn.falloffPower); + + for (let i = 0; i < this.typeNames.length; i++) { + const cfg = this.typeConfigs[i]; + if (!cfg) continue; + const ov = this.settings.lightConfigs[this.typeNames[i]]; + this.radiusArr[i] = ov?.radius ?? cfg.radius; + this.intensityArr[i] = ov?.intensity ?? cfg.intensity; + } + gl.uniform1fv(this.uRadiusArr, this.radiusArr); + gl.uniform1fv(this.uIntensityArr, this.intensityArr); + + gl.bindVertexArray(this.lightVao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.lightCount); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.lightProg); + gl.deleteVertexArray(this.lightVao); + gl.deleteBuffer(this.lightBuf); + } +} diff --git a/src/client/render/gl/passes/RadialMenuPass.ts b/src/client/render/gl/passes/RadialMenuPass.ts new file mode 100644 index 0000000000..2518298ec4 --- /dev/null +++ b/src/client/render/gl/passes/RadialMenuPass.ts @@ -0,0 +1,577 @@ +/** + * RadialMenuPass — renders a radial (pie-wheel) context menu as screen-space + * arc segments with emoji icons. + * + * Supports one level of submenus: when a submenu is open, the parent items + * shrink into a smaller inner ring, a back button appears in the center, and + * the submenu items take the outer ring. + * + * Rendering elements (reused for each ring via drawRing): + * 1. Arcs: single quad with SDF annulus + angular segment masking + borders + * 2. Center button: filled circle drawn by the innermost ring + * 3. Icons: instanced quads sampling the emoji atlas + */ + +import type { RadialMenuItem } from "../Events"; +import { createProgram } from "../utils/GlUtils"; + +import arcFragSrc from "../shaders/radial-menu/arcs.frag.glsl?raw"; +import arcVertSrc from "../shaders/radial-menu/arcs.vert.glsl?raw"; +import iconFragSrc from "../shaders/radial-menu/icon.frag.glsl?raw"; +import iconVertSrc from "../shaders/radial-menu/icon.vert.glsl?raw"; + +import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json"; +import { assetUrl } from "src/core/AssetUrls"; + +const emojiAtlasUrl = assetUrl("atlases/emoji-atlas.png"); + +// --------------------------------------------------------------------------- +// Ring layout configs (CSS pixels) +// --------------------------------------------------------------------------- + +interface RingConfig { + outerR: number; + innerR: number; + /** Icon half-size; if a function, receives the segment count. */ + iconHalf: number | ((n: number) => number); + /** Opacity multiplier applied to colors (1 = full, <1 = dimmed). */ + dim: number; +} + +/** Normal top-level ring (game: innerRadius 40, arcWidth 55). */ +const RING_NORMAL: RingConfig = { + outerR: 95, + innerR: 40, + iconHalf: (n) => (n <= 4 ? 20 : n <= 6 ? 17 : 14), + dim: 1.0, +}; + +/** Submenu active ring (game: innerRadius 75, arcWidth 65). */ +const RING_SUBMENU: RingConfig = { + outerR: 140, + innerR: 75, + iconHalf: (n) => (n <= 4 ? 22 : n <= 6 ? 18 : 14), + dim: 1.0, +}; + +/** Parent ring when submenu is open (game: scales to 0.65). */ +const RING_PARENT: RingConfig = { + outerR: 70, + innerR: 32, + iconHalf: 12, + dim: 0.5, +}; +const MAX_SEGMENTS = 8; + +/** Hit-test return value for the center button. */ +export const CENTER_INDEX = -2; + +const BACK_ITEM: RadialMenuItem = { + id: "__back__", + icon: "back-icon", + color: [0.45, 0.45, 0.45], + enabled: true, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildEmojiMap(): Map { + const map = new Map(); + const emojis = (emojiAtlasMeta as { emojis: Record }).emojis; + for (const [key, idx] of Object.entries(emojis)) { + map.set(key, idx); + } + return map; +} + +// --------------------------------------------------------------------------- +// RadialMenuPass +// --------------------------------------------------------------------------- + +export class RadialMenuPass { + private gl: WebGL2RenderingContext; + + // Programs + private arcProg: WebGLProgram; + private iconProg: WebGLProgram; + private vao: WebGLVertexArrayObject; + + // Arc uniform locations + private arcU: { + anchor: WebGLUniformLocation; + viewport: WebGLUniformLocation; + outerR: WebGLUniformLocation; + innerR: WebGLUniformLocation; + segCount: WebGLUniformLocation; + hoveredSeg: WebGLUniformLocation; + segColors: WebGLUniformLocation; + hasCenterBtn: WebGLUniformLocation; + centerColor: WebGLUniformLocation; + centerHovered: WebGLUniformLocation; + }; + + // Icon uniform locations + private iconU: { + anchor: WebGLUniformLocation; + viewport: WebGLUniformLocation; + outerR: WebGLUniformLocation; + innerR: WebGLUniformLocation; + segCount: WebGLUniformLocation; + iconHalf: WebGLUniformLocation; + emojiIndices: WebGLUniformLocation; + centerEmojiIdx: WebGLUniformLocation; + segOpacity: WebGLUniformLocation; + emojiAtlas: WebGLUniformLocation; + emojiCell: WebGLUniformLocation; + emojiCols: WebGLUniformLocation; + emojiAtlasW: WebGLUniformLocation; + emojiAtlasH: WebGLUniformLocation; + }; + + // Emoji + icon atlas + private emojiTex: WebGLTexture | null = null; + private emojiReady = false; + private emojiMap: Map; + private atlasImg: HTMLImageElement | null = null; + private pendingIcons: { key: string; img: CanvasImageSource }[] = []; + + // ---- State ---- + private visible = false; + private anchorX = 0; + private anchorY = 0; + private items: RadialMenuItem[] = []; + private centerItem: RadialMenuItem | null = null; + private hoveredIndex = -1; // -1 = none, 0..n-1 = segment, CENTER_INDEX = center + + // Submenu (one level) + private _inSubmenu = false; + private savedItems: RadialMenuItem[] = []; + private savedCenterItem: RadialMenuItem | null = null; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.emojiMap = buildEmojiMap(); + + // Shared quad VAO + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + + // Arc program + this.arcProg = createProgram(gl, arcVertSrc, arcFragSrc); + this.arcU = { + anchor: gl.getUniformLocation(this.arcProg, "uAnchor")!, + viewport: gl.getUniformLocation(this.arcProg, "uViewport")!, + outerR: gl.getUniformLocation(this.arcProg, "uOuterR")!, + innerR: gl.getUniformLocation(this.arcProg, "uInnerR")!, + segCount: gl.getUniformLocation(this.arcProg, "uSegCount")!, + hoveredSeg: gl.getUniformLocation(this.arcProg, "uHoveredSeg")!, + segColors: gl.getUniformLocation(this.arcProg, "uSegColors")!, + hasCenterBtn: gl.getUniformLocation(this.arcProg, "uHasCenterBtn")!, + centerColor: gl.getUniformLocation(this.arcProg, "uCenterColor")!, + centerHovered: gl.getUniformLocation(this.arcProg, "uCenterHovered")!, + }; + + // Icon program + this.iconProg = createProgram(gl, iconVertSrc, iconFragSrc); + gl.useProgram(this.iconProg); + gl.uniform1i(gl.getUniformLocation(this.iconProg, "uEmojiAtlas"), 0); + const em = emojiAtlasMeta as { + width: number; + height: number; + cellSize: number; + cols: number; + }; + gl.uniform1f( + gl.getUniformLocation(this.iconProg, "uEmojiCell")!, + em.cellSize, + ); + gl.uniform1f(gl.getUniformLocation(this.iconProg, "uEmojiCols")!, em.cols); + gl.uniform1f( + gl.getUniformLocation(this.iconProg, "uEmojiAtlasW")!, + em.width, + ); + gl.uniform1f( + gl.getUniformLocation(this.iconProg, "uEmojiAtlasH")!, + em.height, + ); + + this.iconU = { + anchor: gl.getUniformLocation(this.iconProg, "uAnchor")!, + viewport: gl.getUniformLocation(this.iconProg, "uViewport")!, + outerR: gl.getUniformLocation(this.iconProg, "uOuterR")!, + innerR: gl.getUniformLocation(this.iconProg, "uInnerR")!, + segCount: gl.getUniformLocation(this.iconProg, "uSegCount")!, + iconHalf: gl.getUniformLocation(this.iconProg, "uIconHalf")!, + emojiIndices: gl.getUniformLocation(this.iconProg, "uEmojiIndices")!, + centerEmojiIdx: gl.getUniformLocation(this.iconProg, "uCenterEmojiIdx")!, + segOpacity: gl.getUniformLocation(this.iconProg, "uSegOpacity")!, + emojiAtlas: gl.getUniformLocation(this.iconProg, "uEmojiAtlas")!, + emojiCell: gl.getUniformLocation(this.iconProg, "uEmojiCell")!, + emojiCols: gl.getUniformLocation(this.iconProg, "uEmojiCols")!, + emojiAtlasW: gl.getUniformLocation(this.iconProg, "uEmojiAtlasW")!, + emojiAtlasH: gl.getUniformLocation(this.iconProg, "uEmojiAtlasH")!, + }; + + this.loadEmojiAtlas(); + } + + private loadEmojiAtlas(): void { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + this.atlasImg = img; + this.rebuildAtlasTexture(); + }; + img.src = emojiAtlasUrl; + } + + /** + * Register additional icon images to append to the atlas texture. + * Call from the adapter after loading game SVG icons. + */ + registerIcons(icons: { key: string; img: CanvasImageSource }[]): void { + this.pendingIcons = icons; + if (this.atlasImg) this.rebuildAtlasTexture(); + } + + private rebuildAtlasTexture(): void { + if (!this.atlasImg) return; + + const gl = this.gl; + const meta = emojiAtlasMeta as { + width: number; + height: number; + cellSize: number; + cols: number; + emojis: Record; + }; + const baseCount = Object.keys(meta.emojis).length; + const totalCount = baseCount + this.pendingIcons.length; + const rows = Math.ceil(totalCount / meta.cols); + const height = Math.max(meta.height, rows * meta.cellSize); + + const canvas = document.createElement("canvas"); + canvas.width = meta.width; + canvas.height = height; + const ctx = canvas.getContext("2d")!; + + // Draw existing emoji atlas + ctx.drawImage(this.atlasImg, 0, 0); + + // Append extra icons into new cells (preserving aspect ratio) + // Minimal padding — SVGs are already clean vectors, maximize resolution + const pad = Math.floor(meta.cellSize * 0.04); + const size = meta.cellSize - pad * 2; + for (let i = 0; i < this.pendingIcons.length; i++) { + const idx = baseCount + i; + const col = idx % meta.cols; + const row = Math.floor(idx / meta.cols); + const img = this.pendingIcons[i].img; + const nw = (img as HTMLImageElement).naturalWidth || size; + const nh = (img as HTMLImageElement).naturalHeight || size; + const aspect = nw / nh; + let dw = size, + dh = size; + if (aspect > 1) dh = size / aspect; + else dw = size * aspect; + const ox = (size - dw) / 2; + const oy = (size - dh) / 2; + ctx.drawImage( + img, + col * meta.cellSize + pad + ox, + row * meta.cellSize + pad + oy, + dw, + dh, + ); + this.emojiMap.set(this.pendingIcons[i].key, idx); + } + + // Upload texture + this.emojiTex ??= gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, this.emojiTex); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); + gl.generateMipmap(gl.TEXTURE_2D); + this.emojiReady = true; + + // Update atlas height uniform (texture may be taller now) + gl.useProgram(this.iconProg); + gl.uniform1f(this.iconU.emojiAtlasH, height); + } + + resolveEmoji(icon: string): number { + return this.emojiMap.get(icon) ?? -1; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + show( + anchorX: number, + anchorY: number, + items: RadialMenuItem[], + centerItem?: RadialMenuItem, + ): void { + this.visible = true; + this.anchorX = anchorX; + this.anchorY = anchorY; + this.items = items.slice(0, MAX_SEGMENTS); + this.centerItem = centerItem ?? null; + // Cursor is at the anchor — center button starts hovered + this.hoveredIndex = this.centerItem ? CENTER_INDEX : -1; + this._inSubmenu = false; + this.savedItems = []; + this.savedCenterItem = null; + } + + openSubMenu(subItems: RadialMenuItem[]): void { + this.savedItems = this.items; + this.savedCenterItem = this.centerItem; + this.items = subItems.slice(0, MAX_SEGMENTS); + this.centerItem = BACK_ITEM; + this._inSubmenu = true; + this.hoveredIndex = -1; + } + + goBack(): void { + if (!this._inSubmenu) return; + this.items = this.savedItems; + this.centerItem = this.savedCenterItem; + this._inSubmenu = false; + this.savedItems = []; + this.savedCenterItem = null; + this.hoveredIndex = -1; + } + + hide(): void { + this.visible = false; + this.hoveredIndex = -1; + this._inSubmenu = false; + this.savedItems = []; + this.savedCenterItem = null; + } + + setHover(index: number): void { + this.hoveredIndex = index; + } + + get isVisible(): boolean { + return this.visible; + } + get inSubmenu(): boolean { + return this._inSubmenu; + } + getItems(): readonly RadialMenuItem[] { + return this.items; + } + getCenterItem(): RadialMenuItem | null { + return this.centerItem; + } + + /** Look up an item by hit-test index. */ + getItemAt(index: number): RadialMenuItem | null { + if (index === CENTER_INDEX) return this.centerItem; + if (index >= 0 && index < this.items.length) return this.items[index]; + return null; + } + + // --------------------------------------------------------------------------- + // Hit testing + // --------------------------------------------------------------------------- + + hitTest(screenX: number, screenY: number): number { + if (!this.visible) return -1; + const dx = screenX - this.anchorX; + const dy = screenY - this.anchorY; + const dist = Math.sqrt(dx * dx + dy * dy); + + const active = this._inSubmenu ? RING_SUBMENU : RING_NORMAL; + const centerR = this._inSubmenu ? RING_PARENT.innerR : RING_NORMAL.innerR; + const ringInner = active.innerR; + const ringOuter = active.outerR; + + // Center button + if (dist < centerR) return this.centerItem ? CENTER_INDEX : -1; + + // Gap / parent ring zone (non-interactive) + if (dist < ringInner) return -1; + + // Active ring + if (dist > ringOuter || this.items.length === 0) return -1; + + let angle = Math.atan2(dx, -dy); // 0 = top, CW positive + if (angle < 0) angle += Math.PI * 2; + const n = this.items.length; + const segArc = (Math.PI * 2) / n; + // Rotate so first segment is centered at top (game: startAngle = -π/n) + const shifted = (angle + Math.PI / n) % (Math.PI * 2); + return Math.min(Math.floor(shifted / segArc), n - 1); + } + + // --------------------------------------------------------------------------- + // Rendering + // --------------------------------------------------------------------------- + + draw(): void { + if (!this.visible) return; + if (this.items.length === 0 && !this.centerItem) return; + + const gl = this.gl; + const dpr = window.devicePixelRatio || 1; + const vw = gl.drawingBufferWidth; + const vh = gl.drawingBufferHeight; + const ax = this.anchorX * dpr; + const ay = this.anchorY * dpr; + + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.bindVertexArray(this.vao); + + // Parent ring (dimmed, non-interactive) — drawn first so active ring overlays + if (this._inSubmenu && this.savedItems.length > 0) { + const p = RING_PARENT; + this.drawRing( + ax, + ay, + vw, + vh, + p, + this.savedItems, + -1, + BACK_ITEM, + this.hoveredIndex === CENTER_INDEX, + ); + } + + // Active ring — expands when in submenu + const active = this._inSubmenu ? RING_SUBMENU : RING_NORMAL; + this.drawRing( + ax, + ay, + vw, + vh, + active, + this.items, + this.hoveredIndex >= 0 ? this.hoveredIndex : -1, + this._inSubmenu ? null : this.centerItem, + !this._inSubmenu && this.hoveredIndex === CENTER_INDEX, + ); + } + + /** Draw a single ring (arcs + icons) using a RingConfig. */ + private drawRing( + ax: number, + ay: number, + vw: number, + vh: number, + cfg: RingConfig, + items: readonly RadialMenuItem[], + hoveredSeg: number, + centerItem: RadialMenuItem | null, + centerHovered: boolean, + ): void { + const gl = this.gl; + const dpr = window.devicePixelRatio || 1; + const n = items.length; + const hasCenter = centerItem !== null; + const outerR = cfg.outerR * dpr; + const innerFrac = cfg.innerR / cfg.outerR; + const dim = cfg.dim; + const ih = + typeof cfg.iconHalf === "function" ? cfg.iconHalf(n) : cfg.iconHalf; + const iconHalf = ih * dpr; + + // --- Arcs --- + gl.useProgram(this.arcProg); + gl.uniform2f(this.arcU.anchor, ax, ay); + gl.uniform2f(this.arcU.viewport, vw, vh); + gl.uniform1f(this.arcU.outerR, outerR); + gl.uniform1f(this.arcU.innerR, innerFrac); + gl.uniform1i(this.arcU.segCount, n); + gl.uniform1i(this.arcU.hoveredSeg, hoveredSeg); + + gl.uniform1i(this.arcU.hasCenterBtn, hasCenter ? 1 : 0); + if (hasCenter) { + const cc = centerItem.color; + gl.uniform3f( + this.arcU.centerColor, + cc[0] * dim, + cc[1] * dim, + cc[2] * dim, + ); + gl.uniform1i(this.arcU.centerHovered, centerHovered ? 1 : 0); + } + + const colors = new Float32Array(MAX_SEGMENTS * 4); + for (let i = 0; i < n; i++) { + const c = items[i].color; + colors[i * 4 + 0] = c[0] * dim; + colors[i * 4 + 1] = c[1] * dim; + colors[i * 4 + 2] = c[2] * dim; + colors[i * 4 + 3] = items[i].enabled ? 1 : 0; + } + gl.uniform4fv(this.arcU.segColors, colors); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + // --- Icons --- + if (!this.emojiReady || (n === 0 && !hasCenter)) return; + + gl.useProgram(this.iconProg); + gl.uniform2f(this.iconU.anchor, ax, ay); + gl.uniform2f(this.iconU.viewport, vw, vh); + gl.uniform1f(this.iconU.outerR, outerR); + gl.uniform1f(this.iconU.innerR, innerFrac); + gl.uniform1i(this.iconU.segCount, n); + gl.uniform1f(this.iconU.iconHalf, iconHalf); + + const indices = new Float32Array(MAX_SEGMENTS); + const opacities = new Float32Array(MAX_SEGMENTS); + indices.fill(-1); + opacities.fill(1); + for (let i = 0; i < n; i++) { + indices[i] = this.resolveEmoji(items[i].icon); + opacities[i] = items[i].enabled ? 1.0 : 0.3; + } + gl.uniform1fv(this.iconU.emojiIndices, indices); + gl.uniform1fv(this.iconU.segOpacity, opacities); + + const centerIdx = hasCenter ? this.resolveEmoji(centerItem.icon) : -1; + gl.uniform1f(this.iconU.centerEmojiIdx, centerIdx); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.emojiTex!); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, n + 1); + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.arcProg); + gl.deleteProgram(this.iconProg); + gl.deleteVertexArray(this.vao); + if (this.emojiTex) gl.deleteTexture(this.emojiTex); + } +} diff --git a/src/client/render/gl/passes/RailroadPass.ts b/src/client/render/gl/passes/RailroadPass.ts new file mode 100644 index 0000000000..4c3b89a43e --- /dev/null +++ b/src/client/render/gl/passes/RailroadPass.ts @@ -0,0 +1,340 @@ +/** + * RailroadPass — GPU railroad overlay rendering. + * + * Renders railroad tracks as a fullscreen quad pass, reading rail orientation + * from an R8UI texture. Two LOD modes: detailed 3×3 sub-grid sprites at high + * zoom, screen-space anti-aliased lines at medium zoom. Hidden below minimum + * zoom threshold. + * + * Also renders ghost railroad paths (semi-transparent) for build-mode preview. + * + * Data flow: + * Uint8Array railroadState → R8UI texture (rail type per tile, 0=none, 1-6=type) + * GhostPreviewData → R8UI ghost texture (ghost rail paths) + * R8UI terrainTex → water detection for bridge rendering (shader neighbor lookup) + * R16UI tileTex (shared) → owner lookup for rail color + * RGBA32F paletteTex → player color lookup + */ + +import type { GhostPreviewData } from "../../types"; +import type { RenderSettings } from "../RenderSettings"; +import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw"; +import railroadFragSrc from "../shaders/railroad/railroad.frag.glsl?raw"; +import { getPaletteSize } from "../utils/ColorUtils"; +import { + createMapQuad, + createProgram, + createTexture2D, + shaderSrc, +} from "../utils/GlUtils"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +// --------------------------------------------------------------------------- +// Rail orientation (0-5) → texture value (1-6, 0=none) +// --------------------------------------------------------------------------- + +const VERTICAL = 0; +const HORIZONTAL = 1; +const TOP_LEFT = 2; +const TOP_RIGHT = 3; +const BOTTOM_LEFT = 4; +const BOTTOM_RIGHT = 5; + +function railExtremity(tile: number, next: number, w: number): number { + const dx = (next % w) - (tile % w); + const dy = (next - (next % w)) / w - (tile - (tile % w)) / w; + if (dx === 0) return VERTICAL; + if (dy === 0) return HORIZONTAL; + return VERTICAL; +} + +function railDirection( + prev: number, + cur: number, + next: number, + w: number, +): number { + const x1 = prev % w, + y1 = (prev - x1) / w; + const x2 = cur % w, + y2 = (cur - x2) / w; + const x3 = next % w, + y3 = (next - x3) / w; + const dx1 = x2 - x1, + dy1 = y2 - y1; + const dx2 = x3 - x2, + dy2 = y3 - y2; + if (dx1 === dx2 && dy1 === dy2) { + return dx1 !== 0 ? HORIZONTAL : VERTICAL; + } + if ((dx1 === 0 && dx2 !== 0) || (dx1 !== 0 && dx2 === 0)) { + if (dx1 === 0 && dx2 === 1 && dy1 === -1) return BOTTOM_RIGHT; + if (dx1 === 0 && dx2 === -1 && dy1 === -1) return BOTTOM_LEFT; + if (dx1 === 0 && dx2 === 1 && dy1 === 1) return TOP_RIGHT; + if (dx1 === 0 && dx2 === -1 && dy1 === 1) return TOP_LEFT; + if (dx1 === 1 && dx2 === 0 && dy2 === -1) return TOP_LEFT; + if (dx1 === -1 && dx2 === 0 && dy2 === -1) return TOP_RIGHT; + if (dx1 === 1 && dx2 === 0 && dy2 === 1) return BOTTOM_LEFT; + if (dx1 === -1 && dx2 === 0 && dy2 === 1) return BOTTOM_RIGHT; + } + return VERTICAL; +} + +// --------------------------------------------------------------------------- +// RailroadPass +// --------------------------------------------------------------------------- + +export class RailroadPass { + private program: WebGLProgram; + private railroadTex: WebGLTexture; + private ghostRailTex: WebGLTexture; + private tileTex: WebGLTexture; + private paletteTex: WebGLTexture; + private terrainTex: WebGLTexture; + private vao: WebGLVertexArrayObject; + + private uCamera: WebGLUniformLocation; + private uMapSize: WebGLUniformLocation; + private uZoom: WebGLUniformLocation; + private uRailDetailZoom: WebGLUniformLocation; + private uRailAlpha: WebGLUniformLocation; + private uGhostOwnerID: WebGLUniformLocation; + + private mapW: number; + private mapH: number; + private settings: RenderSettings; + + private cpuRailroadState: Uint8Array; + private railroadDirty = false; + + private cpuGhostRailState: Uint8Array; + private ghostRailDirty = false; + private ghostOwnerID = 0; + + constructor( + private gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + paletteTex: WebGLTexture, + terrainBytes: Uint8Array, + settings: RenderSettings, + ) { + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.paletteTex = paletteTex; + this.settings = settings; + this.cpuRailroadState = new Uint8Array(mapW * mapH); + this.cpuGhostRailState = new Uint8Array(mapW * mapH); + + this.program = createProgram( + gl, + overlayVertSrc, + shaderSrc(railroadFragSrc, { + PALETTE_SIZE: getPaletteSize(), + ...TILE_DEFINES, + }), + ); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uZoom = gl.getUniformLocation(this.program, "uZoom")!; + this.uRailDetailZoom = gl.getUniformLocation( + this.program, + "uRailDetailZoom", + )!; + this.uRailAlpha = gl.getUniformLocation(this.program, "uRailAlpha")!; + this.uGhostOwnerID = gl.getUniformLocation(this.program, "uGhostOwnerID")!; + + // Texture unit bindings + ghost defaults + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uRailroadTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 2); + gl.uniform1i(gl.getUniformLocation(this.program, "uTerrainTex"), 3); + gl.uniform1i(gl.getUniformLocation(this.program, "uGhostRailTex"), 4); + gl.uniform1f(this.uGhostOwnerID, 0); + + // R8UI terrain texture (static, uploaded once for bridge detection) + this.terrainTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: terrainBytes, + filter: gl.NEAREST, + }); + + // R8UI railroad texture + this.railroadTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: this.cpuRailroadState, + filter: gl.NEAREST, + }); + + // R8UI ghost railroad texture (same format, ghost paths only) + this.ghostRailTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: this.cpuGhostRailState, + filter: gl.NEAREST, + }); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + uploadRailroadState(railroadState: Uint8Array): void { + this.cpuRailroadState.set(railroadState); + this.railroadDirty = true; + } + + updateGhostPreview(data: GhostPreviewData | null): void { + this.cpuGhostRailState.fill(0); + + if (data) { + const maxRef = this.mapW * this.mapH; + + // Ghost rail paths (1-6 = orientation) + for (const path of data.ghostRailPaths) { + if (path.length === 0) continue; + const tiles = this.computePathOrientations(path); + for (const t of tiles) { + if (t.ref >= 0 && t.ref < maxRef) { + this.cpuGhostRailState[t.ref] = t.type + 1; + } + } + } + + // Overlapping railroad highlights (7 = green highlight marker) + // overlappingRailroads contains resolved tile refs (not rail IDs) + for (const ref of data.overlappingRailroads) { + if (ref >= 0 && ref < maxRef) { + this.cpuGhostRailState[ref] = 7; + } + } + + this.ghostOwnerID = data.ownerID; + } else { + this.ghostOwnerID = 0; + } + + this.ghostRailDirty = true; + } + + /** Draw the railroad overlay. Must be called with alpha blending enabled. */ + draw(cameraMatrix: Float32Array, zoom: number): void { + const gl = this.gl; + const rs = this.settings.railroad; + + // Skip entirely when below minimum zoom + if (zoom < rs.railMinZoom) return; + + // Flush CPU railroad state → GPU + if (this.railroadDirty) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.railroadTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + this.cpuRailroadState, + ); + this.railroadDirty = false; + } + + // Flush ghost railroad state → GPU + if (this.ghostRailDirty) { + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + this.cpuGhostRailState, + ); + this.ghostRailDirty = false; + } + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1f(this.uZoom, zoom); + gl.uniform1f(this.uRailDetailZoom, rs.railDetailZoom); + gl.uniform1f(this.uRailAlpha, rs.railAlpha); + gl.uniform1f(this.uGhostOwnerID, this.ghostOwnerID); + + // Bind textures: 0=railroad, 1=tile, 2=palette, 3=terrain, 4=ghostRail + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.railroadTex); + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.terrainTex); + + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + // ---- Rail orientation computation ---- + + private computePathOrientations( + tileRefs: number[], + ): Array<{ ref: number; type: number }> { + if (tileRefs.length === 0) return []; + if (tileRefs.length === 1) return [{ ref: tileRefs[0], type: VERTICAL }]; + const w = this.mapW; + const result: Array<{ ref: number; type: number }> = []; + result.push({ + ref: tileRefs[0], + type: railExtremity(tileRefs[0], tileRefs[1], w), + }); + for (let i = 1; i < tileRefs.length - 1; i++) { + result.push({ + ref: tileRefs[i], + type: railDirection(tileRefs[i - 1], tileRefs[i], tileRefs[i + 1], w), + }); + } + const last = tileRefs.length - 1; + result.push({ + ref: tileRefs[last], + type: railExtremity(tileRefs[last], tileRefs[last - 1], w), + }); + return result; + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteTexture(this.railroadTex); + gl.deleteTexture(this.ghostRailTex); + gl.deleteTexture(this.terrainTex); + // Don't delete tileTex or paletteTex — shared with other passes + } +} diff --git a/src/client/render/gl/passes/RangeCirclePass.ts b/src/client/render/gl/passes/RangeCirclePass.ts new file mode 100644 index 0000000000..5fb38e8059 --- /dev/null +++ b/src/client/render/gl/passes/RangeCirclePass.ts @@ -0,0 +1,79 @@ +/** + * RangeCirclePass — draws a translucent white circle showing the effective + * range of a structure during build-mode ghost preview. + * + * Single quad with circle SDF in the fragment shader. + * Active only when a ghost preview with rangeRadius > 0 is set. + */ + +import type { GhostPreviewData } from "../../types"; +import { createProgram } from "../utils/GlUtils"; + +import fragSrc from "../shaders/range-circle/range-circle.frag.glsl?raw"; +import vertSrc from "../shaders/range-circle/range-circle.vert.glsl?raw"; + +export class RangeCirclePass { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + + private uCamera: WebGLUniformLocation; + private uCenter: WebGLUniformLocation; + private uRadius: WebGLUniformLocation; + + private centerX = 0; + private centerY = 0; + private radius = 0; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.program = createProgram(gl, vertSrc, fragSrc); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uCenter = gl.getUniformLocation(this.program, "uCenter")!; + this.uRadius = gl.getUniformLocation(this.program, "uRadius")!; + + // Unit quad [0,1] + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + } + + updateGhostPreview(data: GhostPreviewData | null): void { + if (data && data.rangeRadius > 0) { + this.centerX = data.tileX; + this.centerY = data.tileY; + this.radius = data.rangeRadius; + } else { + this.radius = 0; + } + } + + draw(cameraMatrix: Float32Array): void { + if (this.radius <= 0) return; + + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uCenter, this.centerX, this.centerY); + gl.uniform1f(this.uRadius, this.radius); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/SamRadiusPass.ts b/src/client/render/gl/passes/SamRadiusPass.ts new file mode 100644 index 0000000000..e4fd45a32f --- /dev/null +++ b/src/client/render/gl/passes/SamRadiusPass.ts @@ -0,0 +1,396 @@ +/** + * SAMRadiusPass — renders rotating dashed circles around SAM launchers + * when the player is in build mode (ghost preview active). + * + * Allied SAM ranges are merged via circle union: overlapping circles from + * the same alliance group show as a single combined shape rather than + * overlapping rings. Each circle's visible (uncovered) arcs are emitted + * as separate instances. + * + * Colors by ownership relationship: + * self → green (0, 1, 0) + * ally → yellow (1, 1, 0) + * enemy → red (1, 0, 0) + */ + +import type { UnitState } from "../../types"; +import { UT_SAM_LAUNCHER } from "../../types"; +import { DynamicInstanceBuffer } from "../DynamicBuffer"; +import type { RenderSettings } from "../RenderSettings"; +import { createProgram } from "../utils/GlUtils"; +import { samRange } from "../utils/NukeTrajectory"; + +import fragSrc from "../shaders/sam-radius/sam-radius.frag.glsl?raw"; +import vertSrc from "../shaders/sam-radius/sam-radius.vert.glsl?raw"; + +const TWO_PI = Math.PI * 2; +const EPS = 1e-9; + +// Per-instance: x, y, radius, r, g, b, arcStart, arcEnd +const FLOATS_PER_INSTANCE = 8; + +// Relationship colors +const COLOR_SELF = [0, 1, 0]; // green +const COLOR_ALLY = [1, 1, 0]; // yellow +const COLOR_ENEMY = [1, 0, 0]; // red + +interface SAMCircle { + x: number; + y: number; + radius: number; + color: number[]; + group: number; // alliance group: 0 = friendly, 1 = enemy +} + +type Interval = [number, number]; + +// --------------------------------------------------------------------------- +// Circle union geometry +// --------------------------------------------------------------------------- + +function normalizeAngle(a: number): number { + while (a < 0) a += TWO_PI; + while (a >= TWO_PI) a -= TWO_PI; + return a; +} + +function mergeIntervals(intervals: Interval[]): Interval[] { + if (intervals.length === 0) return []; + + // Split wrapping intervals, then merge + const flat: Interval[] = []; + for (const [s, e] of intervals) { + const ns = normalizeAngle(s); + const ne = normalizeAngle(e); + if (ne < ns) { + flat.push([ns, TWO_PI]); + flat.push([0, ne]); + } else { + flat.push([ns, ne]); + } + } + flat.sort((a, b) => a[0] - b[0]); + + const merged: Interval[] = []; + let cur: Interval = [flat[0][0], flat[0][1]]; + for (let i = 1; i < flat.length; i++) { + const it = flat[i]; + if (it[0] <= cur[1] + EPS) { + cur[1] = Math.max(cur[1], it[1]); + } else { + merged.push(cur); + cur = [it[0], it[1]]; + } + } + merged.push(cur); + return merged; +} + +/** Compute the uncovered arc intervals for circle `a` given all circles. */ +function computeUncoveredArcs(a: SAMCircle, circles: SAMCircle[]): Interval[] { + const covered: Interval[] = []; + + for (const b of circles) { + if (a === b) continue; + if (a.group !== b.group) continue; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const d = Math.hypot(dx, dy); + + // a fully inside b → no visible arcs + if (d + a.radius <= b.radius + EPS) return []; + + // No overlap + if (d >= a.radius + b.radius - EPS) continue; + + // Coincident centers + if (d <= EPS) { + if (b.radius >= a.radius) return []; + continue; + } + + // Angular span on a covered by b (law of cosines) + const cosPhi = + (a.radius * a.radius + d * d - b.radius * b.radius) / (2 * a.radius * d); + const phi = Math.acos(Math.max(-1, Math.min(1, cosPhi))); + const theta = Math.atan2(dy, dx); + covered.push([theta - phi, theta + phi]); + } + + const merged = mergeIntervals(covered); + + // Subtract covered from [0, 2π) + if (merged.length === 0) return [[0, TWO_PI]]; + + const uncovered: Interval[] = []; + let cursor = 0; + for (const [s, e] of merged) { + if (s > cursor + EPS) uncovered.push([cursor, s]); + cursor = Math.max(cursor, e); + } + if (cursor < TWO_PI - EPS) uncovered.push([cursor, TWO_PI]); + + return uncovered; +} + +// --------------------------------------------------------------------------- +// Pass +// --------------------------------------------------------------------------- + +export class SAMRadiusPass { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + + private uCamera: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uOutline: WebGLUniformLocation; + private uStrokeWidth: WebGLUniformLocation; + private uDashLen: WebGLUniformLocation; + private uGapLen: WebGLUniformLocation; + private uRotationSpeed: WebGLUniformLocation; + private uAlpha: WebGLUniformLocation; + private uOutlineWidth: WebGLUniformLocation; + private uOutlineSoftness: WebGLUniformLocation; + + private settings: RenderSettings; + private instanceCount = 0; + private visible = false; + private mapW = 0; + private startTime = performance.now(); + + private localPlayerID = 0; + private allies = new Set(); + + // Owner-color mode fields + private paletteData: Float32Array | null = null; + private colorMode: "perspective" | "owner" = "perspective"; + private allianceClusters: Map = new Map(); + private lastStructures: Map | null = null; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + settings: RenderSettings, + ) { + this.gl = gl; + this.mapW = mapW; + this.settings = settings; + this.program = createProgram(gl, vertSrc, fragSrc); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uOutline = gl.getUniformLocation(this.program, "uOutline")!; + this.uStrokeWidth = gl.getUniformLocation(this.program, "uStrokeWidth")!; + this.uDashLen = gl.getUniformLocation(this.program, "uDashLen")!; + this.uGapLen = gl.getUniformLocation(this.program, "uGapLen")!; + this.uRotationSpeed = gl.getUniformLocation( + this.program, + "uRotationSpeed", + )!; + this.uAlpha = gl.getUniformLocation(this.program, "uAlpha")!; + this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!; + this.uOutlineSoftness = gl.getUniformLocation( + this.program, + "uOutlineSoftness", + )!; + + // VAO + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + // Attribute 0: unit quad [0,1] + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Instance buffer: [x, y, radius, r, g, b, arcStart, arcEnd] + const glBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer( + gl, + glBuf, + 64, + FLOATS_PER_INSTANCE, + ); + + gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); + const stride = FLOATS_PER_INSTANCE * 4; + + // Attribute 1: per-instance vec3 (x, y, radius) + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribDivisor(1, 1); + + // Attribute 2: per-instance vec3 (r, g, b) + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 3, gl.FLOAT, false, stride, 12); + gl.vertexAttribDivisor(2, 1); + + // Attribute 3: per-instance vec2 (arcStart, arcEnd) + gl.enableVertexAttribArray(3); + gl.vertexAttribPointer(3, 2, gl.FLOAT, false, stride, 24); + gl.vertexAttribDivisor(3, 1); + + gl.bindVertexArray(null); + } + + /** Set the local player's ID (from ghost preview ownerID). */ + setLocalPlayer(id: number): void { + if (id === this.localPlayerID) return; + this.localPlayerID = id; + this.rebuild(); + } + + /** Update ally set (player smallIDs allied with local player). */ + setAllies(allies: Set): void { + this.allies = allies; + this.rebuild(); + } + + setPaletteData(data: Float32Array): void { + this.paletteData = data; + } + + setColorMode(mode: "perspective" | "owner"): void { + if (mode === this.colorMode) return; + this.colorMode = mode; + this.rebuild(); + } + + setAllianceClusters(clusters: Map): void { + this.allianceClusters = clusters; + } + + private rebuild(): void { + if (this.lastStructures) this.updateStructures(this.lastStructures); + } + + /** Call with current structures to update SAM positions/radii/colors. */ + updateStructures(structures: Map): void { + this.lastStructures = structures; + const w = this.mapW; + const ownerMode = this.colorMode === "owner"; + + // 1. Collect SAM circles + const circles: SAMCircle[] = []; + for (const u of structures.values()) { + if (u.unitType !== UT_SAM_LAUNCHER) continue; + if (!u.isActive) continue; + + const x = u.pos % w; + const y = (u.pos - x) / w; + + let color: number[]; + let group: number; + + if (ownerMode && this.paletteData) { + // Owner-colored: palette color, alliance-cluster-based merging + const off = u.ownerID * 4; + color = [ + this.paletteData[off], + this.paletteData[off + 1], + this.paletteData[off + 2], + ]; + group = this.allianceClusters.get(u.ownerID) ?? u.ownerID; + } else { + // Perspective: self/ally/enemy colors, binary group + const isFriendly = + u.ownerID === this.localPlayerID || this.allies.has(u.ownerID); + color = + u.ownerID === this.localPlayerID + ? COLOR_SELF + : this.allies.has(u.ownerID) + ? COLOR_ALLY + : COLOR_ENEMY; + group = isFriendly ? 0 : 1; + } + + circles.push({ + x, + y, + radius: samRange(u.level), + color, + group, + }); + } + + // 2. Compute circle unions → uncovered arcs per circle + let count = 0; + for (const c of circles) { + const arcs = computeUncoveredArcs(c, circles); + + for (const [arcStart, arcEnd] of arcs) { + this.instanceBuf.ensureCapacity(count + 1); + + const off = count * FLOATS_PER_INSTANCE; + const data = this.instanceBuf.float32; + data[off + 0] = c.x; + data[off + 1] = c.y; + data[off + 2] = c.radius; + data[off + 3] = c.color[0]; + data[off + 4] = c.color[1]; + data[off + 5] = c.color[2]; + data[off + 6] = arcStart; + data[off + 7] = arcEnd; + count++; + } + } + + this.instanceCount = count; + + if (count > 0) { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + count * FLOATS_PER_INSTANCE, + ); + } + } + + /** Show/hide based on whether build mode is active. */ + setVisible(visible: boolean): void { + this.visible = visible; + } + + draw(cameraMatrix: Float32Array): void { + if (!this.visible || this.instanceCount === 0) return; + + const gl = this.gl; + const time = (performance.now() - this.startTime) / 1000; + + const s = this.settings.samRadius; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTime, time); + gl.uniform1f(this.uOutline, this.colorMode === "owner" ? 1.0 : 0.0); + gl.uniform1f(this.uStrokeWidth, s.strokeWidth); + gl.uniform1f(this.uDashLen, s.dashLen); + gl.uniform1f(this.uGapLen, s.gapLen); + gl.uniform1f(this.uRotationSpeed, s.rotationSpeed); + gl.uniform1f(this.uAlpha, s.alpha); + gl.uniform1f(this.uOutlineWidth, s.outlineWidth); + gl.uniform1f(this.uOutlineSoftness, s.outlineSoftness); + + gl.bindVertexArray(this.vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.instanceBuf.dispose(); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/SelectionBoxPass.ts b/src/client/render/gl/passes/SelectionBoxPass.ts new file mode 100644 index 0000000000..28bb930367 --- /dev/null +++ b/src/client/render/gl/passes/SelectionBoxPass.ts @@ -0,0 +1,119 @@ +/** + * SelectionBoxPass — draws stippled pulsating square borders around selected + * warships. Supports any number of selections; renders one quad per selection. + * + * For typical use (1-50 selected units) the draw-call overhead is fine; if + * this ever becomes hot we could swap to instanced rendering. + */ + +import { createProgram } from "../utils/GlUtils"; + +import fragSrc from "../shaders/selection-box/selection-box.frag.glsl?raw"; +import vertSrc from "../shaders/selection-box/selection-box.vert.glsl?raw"; + +/** Half-size of the selection box in tiles (matches game's SELECTION_BOX_SIZE). */ +const HALF_SIZE = 6; + +export interface SelectionEntry { + centerX: number; + centerY: number; + r: number; + g: number; + b: number; +} + +export class SelectionBoxPass { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + + private uCamera: WebGLUniformLocation; + private uCenter: WebGLUniformLocation; + private uHalfSize: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uColor: WebGLUniformLocation; + + /** Reusable buffer of selections — caller mutates via setSelections(). */ + private readonly selections: SelectionEntry[] = []; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.program = createProgram(gl, vertSrc, fragSrc); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uCenter = gl.getUniformLocation(this.program, "uCenter")!; + this.uHalfSize = gl.getUniformLocation(this.program, "uHalfSize")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uColor = gl.getUniformLocation(this.program, "uColor")!; + + // Unit quad [0,1] + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + gl.bindVertexArray(null); + } + + /** + * Replace the set of selections drawn this frame. Call with [] to hide. + * Stored by reference — the renderer rebuilds the array each frame from + * the current unit positions/colors, so we just swap pointers. + */ + setSelections(entries: readonly SelectionEntry[]): void { + this.selections.length = 0; + for (let i = 0; i < entries.length; i++) { + this.selections.push(entries[i]); + } + } + + /** Legacy single-selection API kept for callers that haven't migrated. */ + update( + active: boolean, + centerX: number, + centerY: number, + r: number, + g: number, + b: number, + ): void { + this.selections.length = 0; + if (active) this.selections.push({ centerX, centerY, r, g, b }); + } + + hide(): void { + this.selections.length = 0; + } + + draw(cameraMatrix: Float32Array, frameTick: number): void { + if (this.selections.length === 0) return; + + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uHalfSize, HALF_SIZE); + gl.uniform1f(this.uTime, frameTick); + gl.bindVertexArray(this.vao); + + // One draw call per selection — for the typical N=1..50, this is cheap. + // (If profiling ever shows it matters, swap to instanced rendering with a + // small per-instance VBO of {centerX, centerY, r, g, b}.) + for (let i = 0; i < this.selections.length; i++) { + const s = this.selections[i]; + gl.uniform2f(this.uCenter, s.centerX, s.centerY); + gl.uniform3f(this.uColor, s.r, s.g, s.b); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/SpawnOverlayPass.ts b/src/client/render/gl/passes/SpawnOverlayPass.ts new file mode 100644 index 0000000000..99f446d559 --- /dev/null +++ b/src/client/render/gl/passes/SpawnOverlayPass.ts @@ -0,0 +1,176 @@ +/** + * SpawnOverlayPass — spawn phase tile highlights + breathing rings. + * + * Active only during spawn phase. Renders: + * 1. Colored highlights on unowned tiles within radius 9 of each human + * player's spawn center (blinks every 5th tick). + * 2. Animated breathing rings around the local player and teammates. + * + * Uses a fullscreen map quad (reuses overlay.vert.glsl) so the fragment + * shader can sample tileTex for ownership and compute distance-based + * effects in tile-space coordinates. + */ + +import type { RenderSettings } from "../RenderSettings"; +import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw"; +import spawnFragSrc from "../shaders/spawn-overlay/spawn-overlay.frag.glsl?raw"; + +const MAX_SPAWNS = 32; + +export interface SpawnCenter { + x: number; + y: number; + r: number; + g: number; + b: number; + isSelf: boolean; + isTeammate: boolean; +} + +export class SpawnOverlayPass { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private vao: WebGLVertexArrayObject; + private tileTex: WebGLTexture; + private settings: RenderSettings["spawnOverlay"]; + + // Uniforms + private uCamera: WebGLUniformLocation; + private uMapSize: WebGLUniformLocation; + private uSpawnCount: WebGLUniformLocation; + private uBreathRadius: WebGLUniformLocation; + private uSpawnA: WebGLUniformLocation; + private uSpawnB: WebGLUniformLocation; + private uHighlightRadiusSq: WebGLUniformLocation; + private uHighlightAlpha: WebGLUniformLocation; + private uSelfRadii: WebGLUniformLocation; + private uMateRadii: WebGLUniformLocation; + private uGradientStops: WebGLUniformLocation; + + private mapW: number; + private mapH: number; + + // State + private active = false; + private centers: SpawnCenter[] = []; + private animTime = 0; + private lastTime = 0; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + settings: RenderSettings["spawnOverlay"], + ) { + this.gl = gl; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.settings = settings; + + this.program = createProgram( + gl, + overlayVertSrc, + shaderSrc(spawnFragSrc, { MAX_SPAWNS, ...TILE_DEFINES }), + ); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uSpawnCount = gl.getUniformLocation(this.program, "uSpawnCount")!; + this.uBreathRadius = gl.getUniformLocation(this.program, "uBreathRadius")!; + this.uSpawnA = gl.getUniformLocation(this.program, "uSpawnA")!; + this.uSpawnB = gl.getUniformLocation(this.program, "uSpawnB")!; + this.uHighlightRadiusSq = gl.getUniformLocation( + this.program, + "uHighlightRadiusSq", + )!; + this.uHighlightAlpha = gl.getUniformLocation( + this.program, + "uHighlightAlpha", + )!; + this.uSelfRadii = gl.getUniformLocation(this.program, "uSelfRadii")!; + this.uMateRadii = gl.getUniformLocation(this.program, "uMateRadii")!; + this.uGradientStops = gl.getUniformLocation( + this.program, + "uGradientStops", + )!; + + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + /** Update spawn overlay state each frame. */ + update(inSpawnPhase: boolean, centers: SpawnCenter[]): void { + this.active = inSpawnPhase && centers.length > 0; + this.centers = centers; + } + + draw(cameraMatrix: Float32Array): void { + if (!this.active) return; + + const gl = this.gl; + const s = this.settings; + const now = performance.now(); + + // Advance animation time + if (this.lastTime > 0) { + this.animTime += (now - this.lastTime) * s.animSpeed; + } + this.lastTime = now; + + const breathRadius = 0.5 + 0.5 * Math.sin(this.animTime); + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1i(this.uSpawnCount, Math.min(this.centers.length, MAX_SPAWNS)); + gl.uniform1f(this.uBreathRadius, breathRadius); + + // Settings-driven uniforms + gl.uniform1f( + this.uHighlightRadiusSq, + s.highlightRadius * s.highlightRadius, + ); + gl.uniform1f(this.uHighlightAlpha, s.highlightAlpha); + gl.uniform4f(this.uSelfRadii, s.selfMinRad, s.selfMaxRad, 0, 0); + gl.uniform4f(this.uMateRadii, s.mateMinRad, s.mateMaxRad, 0, 0); + gl.uniform2f(this.uGradientStops, s.gradientInnerEdge, s.gradientSolidEnd); + + // Upload spawn center data as vec4 arrays + const count = Math.min(this.centers.length, MAX_SPAWNS); + const dataA = new Float32Array(count * 4); + const dataB = new Float32Array(count * 4); + for (let i = 0; i < count; i++) { + const c = this.centers[i]; + dataA[i * 4 + 0] = c.x; + dataA[i * 4 + 1] = c.y; + dataA[i * 4 + 2] = c.r; + dataA[i * 4 + 3] = c.g; + dataB[i * 4 + 0] = c.b; + dataB[i * 4 + 1] = c.isSelf ? 1 : 0; + dataB[i * 4 + 2] = c.isTeammate ? 1 : 0; + dataB[i * 4 + 3] = 0; + } + gl.uniform4fv(this.uSpawnA, dataA); + gl.uniform4fv(this.uSpawnB, dataB); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + // tileTex owned by GPUResources + } +} diff --git a/src/client/render/gl/passes/StructureLevelPass.ts b/src/client/render/gl/passes/StructureLevelPass.ts new file mode 100644 index 0000000000..a727e31e92 --- /dev/null +++ b/src/client/render/gl/passes/StructureLevelPass.ts @@ -0,0 +1,327 @@ +/** + * StructureLevelPass — MSDF-rendered level numbers above structures. + * + * Renders level digits for structures with level > 1 using the same MSDF + * atlas and glyph infrastructure as NamePass. One instanced draw call per frame. + * + * Only visible when zoom > dotsThreshold (matching structure icon visibility). + */ + +import type { RendererConfig, UnitState } from "../../types"; +import { + STRUCTURE_TYPES, + UT_CITY, + UT_DEFENSE_POST, + UT_FACTORY, + UT_MISSILE_SILO, + UT_PORT, + UT_SAM_LAUNCHER, +} from "../../types"; +import { DynamicInstanceBuffer } from "../DynamicBuffer"; +import type { RenderSettings } from "../RenderSettings"; +import { createProgram } from "../utils/GlUtils"; +import type { GlyphTables } from "./name-pass/AtlasData"; +import { buildGlyphTables, parseAtlasData } from "./name-pass/AtlasData"; +import { buildGlyphMetricsTex } from "./name-pass/DataTextures"; +import { layoutString } from "./name-pass/TextLayout"; +import { CHAR_RANGE, MAX_CHARS } from "./name-pass/Types"; + +import { assetUrl } from "src/core/AssetUrls"; +import fragSrc from "../shaders/structure-level/structure-level.frag.glsl?raw"; +import vertSrc from "../shaders/structure-level/structure-level.vert.glsl?raw"; + +const atlasUrl = assetUrl("atlases/msdf-atlas.png"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Atlas column order — must match StructurePass. */ +const STRUCTURE_ORDER = [ + UT_CITY, + UT_PORT, + UT_FACTORY, + UT_DEFENSE_POST, + UT_SAM_LAUNCHER, + UT_MISSILE_SILO, +] as const; + +/** Max characters per level label (handles up to "99"). */ +const MAX_LEVEL_CHARS = 4; +const FLOATS_PER_INSTANCE = 5; // worldX, worldY, cursorX, charCode, atlasIdx +const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; + +// --------------------------------------------------------------------------- +// StructureLevelPass +// --------------------------------------------------------------------------- + +export class StructureLevelPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private program: WebGLProgram; + // Uniform locations + private uCamera: WebGLUniformLocation; + private uZoom: WebGLUniformLocation; + private uIconSize: WebGLUniformLocation; + private uDotsThreshold: WebGLUniformLocation; + private uScaleFactor: WebGLUniformLocation; + private uDistRange: WebGLUniformLocation; + private uOutlineWidth: WebGLUniformLocation; + private uLevelScale: WebGLUniformLocation; + private uHighlightMask: WebGLUniformLocation; + private uHighlightDimAlpha: WebGLUniformLocation; + + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + private instanceCount = 0; + + private glyphMetricsTex: WebGLTexture; + private atlasTex: WebGLTexture | null = null; + private atlasReady = false; + + // CPU-side glyph tables for layoutString + private glyph: GlyphTables; + private kernTable: Int8Array; + private mapW: number; + + // Reusable buffers for layoutString + private charCodes = new Uint8Array(MAX_CHARS); + private cursors = new Float32Array(MAX_CHARS); + + private distanceRange: number; + private fontSize: number; + private atlasScaleH: number; + private base: number; + + /** unitType string → atlas column index (0–5). */ + private typeToAtlasCol = new Map(); + /** Build-button hover highlight bitmask (0 = off). */ + private highlightMask = 0; + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = header.mapWidth; + + // Build unitType string → atlas column mapping + for (let i = 0; i < header.unitTypes.length; i++) { + const col = STRUCTURE_ORDER.indexOf( + header.unitTypes[i] as (typeof STRUCTURE_ORDER)[number], + ); + if (col >= 0) this.typeToAtlasCol.set(header.unitTypes[i], col); + } + + // Parse atlas data (same source as NamePass) + const atlas = parseAtlasData(); + this.glyph = buildGlyphTables(atlas.chars); + this.kernTable = new Int8Array(CHAR_RANGE * CHAR_RANGE); // digits don't kern + this.distanceRange = atlas.distanceRange; + this.fontSize = atlas.fontSize; + this.atlasScaleH = atlas.scaleH; + this.base = atlas.base; + + // Compile shaders + this.program = createProgram(gl, vertSrc, fragSrc); + + // Texture unit bindings + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1); + + // Static uniforms + gl.uniform1f( + gl.getUniformLocation(this.program, "uFontSize")!, + this.fontSize, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uAtlasScaleH")!, + this.atlasScaleH, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, this.base); + + // Dynamic uniform locations + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uZoom = gl.getUniformLocation(this.program, "uZoom")!; + this.uIconSize = gl.getUniformLocation(this.program, "uIconSize")!; + this.uDotsThreshold = gl.getUniformLocation( + this.program, + "uDotsThreshold", + )!; + this.uScaleFactor = gl.getUniformLocation(this.program, "uScaleFactor")!; + this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!; + this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!; + this.uLevelScale = gl.getUniformLocation(this.program, "uLevelScale")!; + this.uHighlightMask = gl.getUniformLocation( + this.program, + "uHighlightMask", + )!; + this.uHighlightDimAlpha = gl.getUniformLocation( + this.program, + "uHighlightDimAlpha", + )!; + + // Glyph metrics data texture + this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas); + + // Start async MSDF atlas load + this.loadAtlas(); + + // Instance buffer + const glBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer( + gl, + glBuf, + 4096, + FLOATS_PER_INSTANCE, + ); + + // VAO + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + // Attribute 0: unit quad [0,1]² + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Attribute 1: per-instance vec4 (worldX, worldY, cursorX, charCode) + gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); + gl.vertexAttribDivisor(1, 1); + + // Attribute 2: per-instance float (atlasIdx) + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 1, gl.FLOAT, false, BYTES_PER_INSTANCE, 16); + gl.vertexAttribDivisor(2, 1); + + gl.bindVertexArray(null); + } + + private loadAtlas(): void { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const gl = this.gl; + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + this.atlasTex = tex; + this.atlasReady = true; + }; + img.src = atlasUrl; + } + + updateStructures(units: Map): void { + let count = 0; + + for (const unit of units.values()) { + if (!STRUCTURE_TYPES.has(unit.unitType)) continue; + if (unit.level <= 1) continue; + + const levelStr = unit.level.toString(); + layoutString( + levelStr, + this.glyph, + this.kernTable, + this.charCodes, + this.cursors, + ); + + const x = unit.pos % this.mapW; + const y = (unit.pos - x) / this.mapW; + const len = Math.min(levelStr.length, MAX_LEVEL_CHARS); + const atlasIdx = this.typeToAtlasCol.get(unit.unitType) ?? 0; + + for (let i = 0; i < len; i++) { + this.instanceBuf.ensureCapacity(count + 1); + + const off = count * FLOATS_PER_INSTANCE; + const data = this.instanceBuf.float32; + data[off + 0] = x; + data[off + 1] = y; + data[off + 2] = this.cursors[i]; + data[off + 3] = this.charCodes[i]; + data[off + 4] = atlasIdx; + count++; + } + } + + this.instanceCount = count; + + if (count > 0) { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + count * FLOATS_PER_INSTANCE, + ); + } + } + + draw(cameraMatrix: Float32Array, zoom: number): void { + if (!this.atlasReady || this.instanceCount === 0) return; + + const gl = this.gl; + const ss = this.settings.structure; + const sl = this.settings.structureLevel; + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uZoom, zoom); + gl.uniform1f(this.uIconSize, ss.iconSize); + gl.uniform1f(this.uDotsThreshold, ss.dotsZoomThreshold); + gl.uniform1f(this.uScaleFactor, ss.iconScaleFactorZoomedOut); + gl.uniform1f(this.uDistRange, this.distanceRange); + gl.uniform1f(this.uOutlineWidth, sl.outlineWidth); + gl.uniform1f(this.uLevelScale, sl.scale); + gl.uniform1i(this.uHighlightMask, this.highlightMask); + gl.uniform1f(this.uHighlightDimAlpha, ss.highlightDimAlpha); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.glyphMetricsTex); + + gl.bindVertexArray(this.vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount); + } + + /** Highlight structures of the given types (null/empty = off). Dims all other types. */ + setHighlightTypes(unitTypes: string[] | null): void { + let mask = 0; + if (unitTypes) { + for (const t of unitTypes) { + const col = this.typeToAtlasCol.get(t); + if (col !== undefined) mask |= 1 << col; + } + } + this.highlightMask = mask; + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.instanceBuf.dispose(); + gl.deleteVertexArray(this.vao); + gl.deleteTexture(this.glyphMetricsTex); + if (this.atlasTex) gl.deleteTexture(this.atlasTex); + } +} diff --git a/src/client/render/gl/passes/StructurePass.ts b/src/client/render/gl/passes/StructurePass.ts new file mode 100644 index 0000000000..42f153143c --- /dev/null +++ b/src/client/render/gl/passes/StructurePass.ts @@ -0,0 +1,442 @@ +/** + * StructurePass — GPU-rendered structures with icon sprites. + * + * Renders a filled circle in player color with a white icon overlay, + * sampled from a pre-built 6-column sprite atlas (generate-sprite-atlases.mjs). + * + * Two LODs based on zoom: + * - zoom > 0.5: full icon with circle background + * - zoom <= 0.5: smaller dots (no icon detail) + * + * One instanced draw call per frame. + * + * Data flow: + * FrameSnapshot.units → filter structures → instance VBO → GPU + */ + +import type { GhostPreviewData, RendererConfig, UnitState } from "../../types"; +import { + UT_CITY, + UT_DEFENSE_POST, + UT_FACTORY, + UT_MISSILE_SILO, + UT_PORT, + UT_SAM_LAUNCHER, +} from "../../types"; +import { DynamicInstanceBuffer } from "../DynamicBuffer"; +import type { RenderSettings } from "../RenderSettings"; +import { getPaletteSize } from "../utils/ColorUtils"; +import { createProgram, shaderSrc } from "../utils/GlUtils"; + +import { assetUrl } from "src/core/AssetUrls"; +import structureFragSrc from "../shaders/structure/structure.frag.glsl?raw"; +import structureVertSrc from "../shaders/structure/structure.vert.glsl?raw"; + +const iconAtlasUrl = assetUrl("atlases/icon-atlas.png"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Structure types in atlas column order. + * Index = atlas column index. + */ +const STRUCTURE_ORDER = [ + UT_CITY, + UT_PORT, + UT_FACTORY, + UT_DEFENSE_POST, + UT_SAM_LAUNCHER, + UT_MISSILE_SILO, +] as const; + +const ATLAS_COLS = STRUCTURE_ORDER.length; + +// --------------------------------------------------------------------------- +// Instance data layout +// --------------------------------------------------------------------------- + +// Per-instance: x, y, ownerID, underConstruction, atlasIdx, markedForDeletion +const FLOATS_PER_INSTANCE = 6; +const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; + +// --------------------------------------------------------------------------- +// StructurePass +// --------------------------------------------------------------------------- + +export class StructurePass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private program: WebGLProgram; + + private uCamera: WebGLUniformLocation; + private uZoom: WebGLUniformLocation; + private uIconSize: WebGLUniformLocation; + private uDotsThreshold: WebGLUniformLocation; + private uDotScale: WebGLUniformLocation; + private uScaleFactor: WebGLUniformLocation; + private uIconGrowZoom: WebGLUniformLocation; + private uShapeScales: WebGLUniformLocation; + private uIconFills: WebGLUniformLocation; + private uGhostAlpha: WebGLUniformLocation; + private uOutlineColor: WebGLUniformLocation; + private uAltView: WebGLUniformLocation; + private uHighlightMask: WebGLUniformLocation; + private uHighlightOutlineW: WebGLUniformLocation; + private uHighlightDimAlpha: WebGLUniformLocation; + + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + private ghostInstanceBuf: WebGLBuffer; + + private paletteTex: WebGLTexture; + private atlasTex: WebGLTexture; + private affiliationTex: WebGLTexture | null = null; + private altView = false; + + private instanceCount = 0; + + /** unitType string → atlas column index (0–5) */ + private typeToAtlasCol = new Map(); + private mapW: number; + + /** Build-button hover highlight: bitmask of atlas columns (0 = off). */ + private highlightMask = 0; + + /** Ghost preview state (null = no ghost). */ + private ghost: GhostPreviewData | null = null; + /** Scratch buffer for the single ghost instance (avoids allocation). */ + private ghostBuf = new Float32Array(FLOATS_PER_INSTANCE); + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + paletteTex: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = header.mapWidth; + this.paletteTex = paletteTex; + + // Build unitType string → atlas column mapping + for (let i = 0; i < header.unitTypes.length; i++) { + const col = STRUCTURE_ORDER.indexOf( + header.unitTypes[i] as (typeof STRUCTURE_ORDER)[number], + ); + if (col >= 0) { + this.typeToAtlasCol.set(header.unitTypes[i], col); + } + } + + // Compile shaders + this.program = createProgram( + gl, + shaderSrc(structureVertSrc, { ATLAS_COLS }), + shaderSrc(structureFragSrc, { + PALETTE_SIZE: getPaletteSize(), + ATLAS_COLS, + }), + ); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uZoom = gl.getUniformLocation(this.program, "uZoom")!; + this.uIconSize = gl.getUniformLocation(this.program, "uIconSize")!; + this.uDotScale = gl.getUniformLocation(this.program, "uDotScale")!; + this.uDotsThreshold = gl.getUniformLocation( + this.program, + "uDotsThreshold", + )!; + this.uScaleFactor = gl.getUniformLocation(this.program, "uScaleFactor")!; + this.uIconGrowZoom = gl.getUniformLocation(this.program, "uIconGrowZoom")!; + this.uShapeScales = gl.getUniformLocation(this.program, "uShapeScales")!; + this.uIconFills = gl.getUniformLocation(this.program, "uIconFills")!; + this.uGhostAlpha = gl.getUniformLocation(this.program, "uGhostAlpha")!; + this.uOutlineColor = gl.getUniformLocation(this.program, "uOutlineColor")!; + this.uAltView = gl.getUniformLocation(this.program, "uAltView")!; + this.uHighlightMask = gl.getUniformLocation( + this.program, + "uHighlightMask", + )!; + this.uHighlightOutlineW = gl.getUniformLocation( + this.program, + "uHighlightOutlineW", + )!; + this.uHighlightDimAlpha = gl.getUniformLocation( + this.program, + "uHighlightDimAlpha", + )!; + + // Texture unit bindings + ghost defaults + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2); + gl.uniform1f(this.uGhostAlpha, 1.0); + gl.uniform3f(this.uOutlineColor, 0, 0, 0); + gl.uniform1i(this.uHighlightMask, 0); + + // Create placeholder atlas texture (1×1 white pixel) + // Replaced asynchronously once SVGs load + this.atlasTex = gl.createTexture()!; + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([255, 255, 255, 255]), + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // Start async atlas build + this.loadAtlas(); + + // --- Instance buffers --- + const instanceGlBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer( + gl, + instanceGlBuf, + 2048, + FLOATS_PER_INSTANCE, + ); + + // Separate tiny buffer for ghost (avoids corrupting real instance data) + this.ghostInstanceBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.ghostInstanceBuf); + gl.bufferData(gl.ARRAY_BUFFER, BYTES_PER_INSTANCE, gl.DYNAMIC_DRAW); + + // --- VAO --- + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + // Attribute 0: unit quad [0,0]→[1,1] + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Attribute 1: per-instance vec4 (x, y, ownerID, underConstruction) + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); + gl.vertexAttribDivisor(1, 1); + + // Attribute 2: per-instance vec2 (atlasIdx, markedForDeletion) + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 16); + gl.vertexAttribDivisor(2, 1); + + gl.bindVertexArray(null); + } + + private async loadAtlas(): Promise { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = iconAtlasUrl; + await img.decode(); + const gl = this.gl; + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + gl.generateMipmap(gl.TEXTURE_2D); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + } + + updateStructures(units: Map): void { + let count = 0; + + for (const unit of units.values()) { + const atlasIdx = this.typeToAtlasCol.get(unit.unitType); + if (atlasIdx === undefined) continue; + + this.instanceBuf.ensureCapacity(count + 1); + + const off = count * FLOATS_PER_INSTANCE; + const x = unit.pos % this.mapW; + const y = (unit.pos - x) / this.mapW; + + this.instanceBuf.float32[off + 0] = x; + this.instanceBuf.float32[off + 1] = y; + this.instanceBuf.float32[off + 2] = unit.ownerID; + this.instanceBuf.float32[off + 3] = unit.underConstruction ? 1 : 0; + this.instanceBuf.float32[off + 4] = atlasIdx; + this.instanceBuf.float32[off + 5] = + unit.markedForDeletion !== false ? 1 : 0; + + count++; + } + + this.instanceCount = count; + + if (count > 0) { + const gl = this.gl; + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + count * FLOATS_PER_INSTANCE, + ); + } + } + + updateGhostPreview(data: GhostPreviewData | null): void { + this.ghost = data; + } + + setAltView(active: boolean): void { + this.altView = active; + } + + /** Highlight structures of the given types (null/empty = off). Dims all other types. */ + setHighlightTypes(unitTypes: string[] | null): void { + let mask = 0; + if (unitTypes) { + for (const t of unitTypes) { + const col = this.typeToAtlasCol.get(t); + if (col !== undefined) mask |= 1 << col; + } + } + this.highlightMask = mask; + } + setAffiliationTex(tex: WebGLTexture): void { + this.affiliationTex = tex; + } + + draw(cameraMatrix: Float32Array, zoom: number): void { + const hasGhost = + this.ghost !== null && this.typeToAtlasCol.has(this.ghost.ghostType); + if (this.instanceCount === 0 && !hasGhost) return; + + const gl = this.gl; + gl.useProgram(this.program); + + const ss = this.settings.structure; + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uZoom, zoom); + gl.uniform1f(this.uIconSize, ss.iconSize); + gl.uniform1f(this.uDotsThreshold, ss.dotsZoomThreshold); + gl.uniform1f(this.uDotScale, ss.dotScale); + gl.uniform1f(this.uScaleFactor, ss.iconScaleFactorZoomedOut); + gl.uniform1f(this.uIconGrowZoom, ss.iconGrowZoom); + + // Build per-structure uniform arrays from settings, ordered by atlas column + const scales = new Float32Array(ATLAS_COLS); + const fills = new Float32Array(ATLAS_COLS); + for (let i = 0; i < STRUCTURE_ORDER.length; i++) { + const cfg = ss.shapes[STRUCTURE_ORDER[i]]; + scales[i] = cfg?.scale ?? 1.0; + fills[i] = cfg?.iconFill ?? 0.6; + } + gl.uniform1fv(this.uShapeScales, scales); + gl.uniform1fv(this.uIconFills, fills); + + gl.uniform1i( + this.uAltView, + this.altView && this.settings.altView.recolorStructures ? 1 : 0, + ); + gl.uniform1i(this.uHighlightMask, this.highlightMask); + gl.uniform1f(this.uHighlightOutlineW, ss.highlightOutlineWidth); + gl.uniform1f(this.uHighlightDimAlpha, ss.highlightDimAlpha); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + + if (this.affiliationTex) { + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex); + } + + gl.bindVertexArray(this.vao); + + // --- Real structures --- + if (this.instanceCount > 0) { + gl.uniform1f(this.uGhostAlpha, 1.0); + gl.uniform3f(this.uOutlineColor, 0, 0, 0); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.instanceCount); + } + + // --- Ghost structure (1 translucent instance with outline) --- + if (hasGhost) { + const g = this.ghost!; + const atlasIdx = this.typeToAtlasCol.get(g.ghostType)!; + + // Temporarily rebind instance attrs to ghost buffer + gl.bindBuffer(gl.ARRAY_BUFFER, this.ghostInstanceBuf); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); + gl.vertexAttribPointer(2, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 16); + + // -- Green highlight on existing structure being upgraded -- + if (g.canUpgrade && g.upgradeTargetTile !== null) { + const tx = g.upgradeTargetTile % this.mapW; + const ty = (g.upgradeTargetTile - tx) / this.mapW; + this.ghostBuf[0] = tx; + this.ghostBuf[1] = ty; + this.ghostBuf[2] = g.ownerID; + this.ghostBuf[3] = 0; + this.ghostBuf[4] = atlasIdx; + this.ghostBuf[5] = 0; + gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.ghostBuf); + + gl.uniform1f(this.uGhostAlpha, 0.6); + gl.uniform3f(this.uOutlineColor, 0.0, 0.8, 0.0); // green highlight + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 1); + } + + // -- Ghost icon at cursor -- + this.ghostBuf[0] = g.tileX; + this.ghostBuf[1] = g.tileY; + this.ghostBuf[2] = g.ownerID; + this.ghostBuf[3] = 0; + this.ghostBuf[4] = atlasIdx; + this.ghostBuf[5] = 0; + gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.ghostBuf); + + gl.uniform1f(this.uGhostAlpha, 0.5); + if (g.canUpgrade) { + gl.uniform3f(this.uOutlineColor, 0.0, 0.8, 0.0); // green tint — upgrade + } else if (g.canBuild) { + gl.uniform3f(this.uOutlineColor, 0, 0, 0); // no tint — valid build + } else { + gl.uniform3f(this.uOutlineColor, 0.8, 0.2, 0.2); // red tint — can't build + } + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, 1); + + // Restore instance attrs to main buffer + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); + gl.vertexAttribPointer(2, 2, gl.FLOAT, false, BYTES_PER_INSTANCE, 16); + } + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.instanceBuf.dispose(); + if (this.ghostInstanceBuf) gl.deleteBuffer(this.ghostInstanceBuf); + gl.deleteVertexArray(this.vao); + gl.deleteTexture(this.atlasTex); + } +} diff --git a/src/client/render/gl/passes/TerrainPass.ts b/src/client/render/gl/passes/TerrainPass.ts new file mode 100644 index 0000000000..b5d16777aa --- /dev/null +++ b/src/client/render/gl/passes/TerrainPass.ts @@ -0,0 +1,77 @@ +/** + * TerrainPass — renders the static terrain map as a textured quad. + * + * The terrain never changes during a replay, so this texture is uploaded + * exactly once and blitted every frame as the opaque background layer. + * + * Vertex shader transforms the map quad by the camera mat3. + * Fragment shader samples the RGBA8 terrain texture with nearest-neighbour + * filtering so each terrain cell stays pixel-crisp at every zoom level. + */ + +import terrainFragSrc from "../shaders/terrain/terrain.frag.glsl?raw"; +import terrainVertSrc from "../shaders/terrain/terrain.vert.glsl?raw"; +import { + createMapQuad, + createProgram, + createTexture2D, + shaderSrc, +} from "../utils/GlUtils"; + +// --------------------------------------------------------------------------- +// TerrainPass +// --------------------------------------------------------------------------- + +export class TerrainPass { + private program: WebGLProgram; + private tex: WebGLTexture; + private vao: WebGLVertexArrayObject; + private uCamera: WebGLUniformLocation; + + constructor( + private gl: WebGL2RenderingContext, + terrainRGBA: Uint8Array, + mapW: number, + mapH: number, + ) { + this.program = createProgram( + gl, + shaderSrc(terrainVertSrc, { MAP_W: mapW, MAP_H: mapH }), + terrainFragSrc, + ); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + + // Static RGBA8 terrain texture — uploaded once, never updated. + this.tex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.RGBA8, + format: gl.RGBA, + type: gl.UNSIGNED_BYTE, + data: terrainRGBA, + filter: gl.NEAREST, // pixel-crisp at all zoom levels + }); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + /** Render the terrain. Call with depth test disabled, no blending. */ + draw(cameraMatrix: Float32Array): void { + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tex); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteTexture(this.tex); + // VAO + buffer leak is acceptable on dispose (context is being destroyed) + } +} diff --git a/src/client/render/gl/passes/TerritoryPass.ts b/src/client/render/gl/passes/TerritoryPass.ts new file mode 100644 index 0000000000..2469507464 --- /dev/null +++ b/src/client/render/gl/passes/TerritoryPass.ts @@ -0,0 +1,371 @@ +/** + * TerritoryPass — territory fill + fallout charcoal ground. + * + * Draws only what should be darkened by the night cycle: + * - Owned territory (player color fill) + * - Unowned fallout (charcoal ground) + * + * No borders, embers, trails, or defense checkerboard — those are + * handled by BorderStampPass and TrailPass at full brightness. + * + * Also owns the CPU-side tile and trail state, flushing to shared + * GPU textures on draw. + */ + +import type { TilePair } from "../../types"; +import type { RenderSettings } from "../RenderSettings"; +import { getPaletteSize } from "../utils/ColorUtils"; +import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils"; +import { OWNER_MASK, TILE_DEFINES } from "../utils/TileCodec"; + +import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw"; +import territoryFragSrc from "../shaders/map-overlay/territory.frag.glsl?raw"; + +export class TerritoryPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + + private program: WebGLProgram; + private uCamera: WebGLUniformLocation; + private uMapSize: WebGLUniformLocation; + private uAltView: WebGLUniformLocation; + private uCharcoalBase: WebGLUniformLocation; + private uCharcoalVariation: WebGLUniformLocation; + private uCharcoalAlpha: WebGLUniformLocation; + private uHighlightOwner: WebGLUniformLocation; + private uHighlightBrighten: WebGLUniformLocation; + private highlightOwner = 0; + + private vao: WebGLVertexArrayObject; + private tileTex: WebGLTexture; + private trailTex: WebGLTexture; + private paletteTex: WebGLTexture; + + private altView = false; + + /** CPU-side tile state (deltas written here, flushed to GPU before draw). */ + private cpuTileState: Uint16Array; + private tilesDirty = false; + + /** CPU-side trail state (R8UI, 0=none, 1–255=ownerID). */ + private cpuTrailState: Uint8Array; + private trailsDirty = false; + + /** Live-game references — bypasses memcpy. Null for replay path. */ + private liveTileRef: Uint16Array | null = null; + private liveTrailRef: Uint8Array | null = null; + + /** Dirty row range for partial tile upload. Infinity/-1 = full upload. */ + private dirtyRowMin = Infinity; + private dirtyRowMax = -1; + + /** Dirty row range for partial trail upload. Infinity/-1 = full upload. */ + private trailDirtyRowMin = Infinity; + private trailDirtyRowMax = -1; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + trailTex: WebGLTexture, + paletteTex: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.trailTex = trailTex; + this.paletteTex = paletteTex; + this.cpuTileState = new Uint16Array(mapW * mapH); + this.cpuTrailState = new Uint8Array(mapW * mapH); + + this.program = createProgram( + gl, + overlayVertSrc, + shaderSrc(territoryFragSrc, { + PALETTE_SIZE: getPaletteSize(), + ...TILE_DEFINES, + }), + ); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uAltView = gl.getUniformLocation(this.program, "uAltView")!; + this.uCharcoalBase = gl.getUniformLocation(this.program, "uCharcoalBase")!; + this.uCharcoalVariation = gl.getUniformLocation( + this.program, + "uCharcoalVariation", + )!; + this.uCharcoalAlpha = gl.getUniformLocation( + this.program, + "uCharcoalAlpha", + )!; + this.uHighlightOwner = gl.getUniformLocation( + this.program, + "uHighlightOwner", + )!; + this.uHighlightBrighten = gl.getUniformLocation( + this.program, + "uHighlightBrighten", + )!; + + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uTileTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + // --------------------------------------------------------------------------- + // Tile data upload + // --------------------------------------------------------------------------- + + /** Full tile state upload (on seek). */ + uploadFullTileState(tileState: Uint16Array): void { + this.liveTileRef = null; + this.cpuTileState.set(tileState); + this.tilesDirty = true; + } + + /** Live-game path: reference the game's own arrays directly. */ + setLiveRefs(tileState: Uint16Array, trailState: Uint8Array): void { + this.liveTileRef = tileState; + this.liveTrailRef = trailState; + this.tilesDirty = true; + this.trailsDirty = true; + } + + /** Apply tile deltas (during playback). */ + uploadDeltaTiles(changedTiles: TilePair[]): void { + const ts = this.cpuTileState; + for (let i = 0; i < changedTiles.length; i++) { + const tp = changedTiles[i]; + ts[tp.ref] = tp.state; + } + this.tilesDirty = true; + } + + /** Live delta: update live ref + compute dirty row range from deltas. */ + applyLiveDelta(tileState: Uint16Array, changedTiles: TilePair[]): void { + this.liveTileRef = tileState; + let minRow = Infinity, + maxRow = -1; + for (let i = 0; i < changedTiles.length; i++) { + const row = (changedTiles[i].ref / this.mapW) | 0; + if (row < minRow) minRow = row; + if (row > maxRow) maxRow = row; + } + if (maxRow >= 0) { + this.dirtyRowMin = Math.min(this.dirtyRowMin, minRow); + this.dirtyRowMax = Math.max(this.dirtyRowMax, maxRow); + } + this.tilesDirty = true; + } + + /** Live trail delta: update live ref + accept dirty row range from TrailManager. */ + applyLiveTrailDelta( + trailState: Uint8Array, + dirtyRowMin: number, + dirtyRowMax: number, + ): void { + this.liveTrailRef = trailState; + if (dirtyRowMax >= 0) { + this.trailDirtyRowMin = Math.min(this.trailDirtyRowMin, dirtyRowMin); + this.trailDirtyRowMax = Math.max(this.trailDirtyRowMax, dirtyRowMax); + } + this.trailsDirty = true; + } + + /** Full trail state upload (on seek). */ + uploadFullTrailState(trailState: Uint8Array): void { + this.liveTrailRef = null; + this.cpuTrailState.set(trailState); + this.trailsDirty = true; + } + + /** Set a single trail tile (during playback advance). */ + setTrailTile(ref: number, ownerID: number): void { + this.cpuTrailState[ref] = ownerID; + this.trailsDirty = true; + } + + /** Clear all trails (on seek before rebuilding). */ + clearTrails(): void { + this.cpuTrailState.fill(0); + this.trailsDirty = true; + } + + // --------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------- + + /** Get ownerID at a tile reference. Returns 0 for unowned. */ + getOwnerAt(tileRef: number): number { + const ts = this.liveTileRef ?? this.cpuTileState; + if (tileRef < 0 || tileRef >= ts.length) return 0; + return ts[tileRef] & OWNER_MASK; + } + + /** AABB of all tiles owned by ownerID. */ + getBBoxForOwner( + ownerID: number, + ): { minX: number; minY: number; maxX: number; maxY: number } | null { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + const w = this.mapW; + const ts = this.liveTileRef ?? this.cpuTileState; + for (let i = 0; i < ts.length; i++) { + if ((ts[i] & OWNER_MASK) === ownerID) { + const x = i % w; + const y = (i - x) / w; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + return minX === Infinity ? null : { minX, minY, maxX, maxY }; + } + + // --------------------------------------------------------------------------- + // GPU flush + draw + // --------------------------------------------------------------------------- + + /** Flush tile texture to GPU early (before heat update reads it). Returns true if data was uploaded. */ + flushTileTexture(): boolean { + if (!this.tilesDirty) return false; + const gl = this.gl; + const src = this.liveTileRef ?? this.cpuTileState; + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + + if (this.dirtyRowMax >= 0) { + // Partial upload — only dirty rows + const minRow = this.dirtyRowMin; + const rowCount = this.dirtyRowMax - minRow + 1; + const offset = minRow * this.mapW; + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + minRow, + this.mapW, + rowCount, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + src.subarray(offset, offset + rowCount * this.mapW), + ); + } else { + // Full upload (first tick, seek, replay full frame, etc.) + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RED_INTEGER, + gl.UNSIGNED_SHORT, + src, + ); + } + + this.dirtyRowMin = Infinity; + this.dirtyRowMax = -1; + this.tilesDirty = false; + return true; + } + + /** Flush trail texture to GPU (called before TrailPass draws). */ + flushTrailTexture(): void { + if (!this.trailsDirty) return; + const gl = this.gl; + const src = this.liveTrailRef ?? this.cpuTrailState; + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.trailTex); + + if (this.trailDirtyRowMax >= 0) { + // Partial upload — only dirty rows + const minRow = this.trailDirtyRowMin; + const rowCount = this.trailDirtyRowMax - minRow + 1; + const offset = minRow * this.mapW; + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + minRow, + this.mapW, + rowCount, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + src.subarray(offset, offset + rowCount * this.mapW), + ); + } else { + // Full upload (first tick, seek, replay, etc.) + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + this.mapW, + this.mapH, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + src, + ); + } + + this.trailDirtyRowMin = Infinity; + this.trailDirtyRowMax = -1; + this.trailsDirty = false; + } + + setAltView(active: boolean): void { + this.altView = active; + } + + /** Set the hovered player's smallID for territory-fill brightening (0 = off). */ + setHighlightOwner(ownerID: number): void { + this.highlightOwner = ownerID; + } + + /** Draw territory fill + fallout charcoal. Blending must be enabled by caller. */ + draw(cameraMatrix: Float32Array): void { + this.flushTileTexture(); + this.flushTrailTexture(); + + const gl = this.gl; + const mo = this.settings.mapOverlay; + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1i(this.uAltView, this.altView ? 1 : 0); + gl.uniform1f(this.uCharcoalBase, mo.charcoalBase); + gl.uniform1f(this.uCharcoalVariation, mo.charcoalVariation); + gl.uniform1f(this.uCharcoalAlpha, mo.charcoalAlpha); + gl.uniform1ui(this.uHighlightOwner, this.highlightOwner); + gl.uniform1f(this.uHighlightBrighten, mo.highlightFillBrighten); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + // tileTex, trailTex, paletteTex owned by GPUResources / renderer + } +} diff --git a/src/client/render/gl/passes/TrailPass.ts b/src/client/render/gl/passes/TrailPass.ts new file mode 100644 index 0000000000..db1cd40ff2 --- /dev/null +++ b/src/client/render/gl/passes/TrailPass.ts @@ -0,0 +1,106 @@ +/** + * TrailPass — boat trail lines. + * + * Simple dedicated pass: for each tile with a non-zero trail owner, + * output the owner's territory color at configurable alpha. + * Always draws at full brightness (after night composite). + */ + +import type { RenderSettings } from "../RenderSettings"; +import { getPaletteSize } from "../utils/ColorUtils"; +import { createMapQuad, createProgram, shaderSrc } from "../utils/GlUtils"; +import { TILE_DEFINES } from "../utils/TileCodec"; + +import overlayVertSrc from "../shaders/map-overlay/overlay.vert.glsl?raw"; +import trailFragSrc from "../shaders/map-overlay/trail.frag.glsl?raw"; + +export class TrailPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + + private program: WebGLProgram; + private uCamera: WebGLUniformLocation; + private uMapSize: WebGLUniformLocation; + private uTrailAlpha: WebGLUniformLocation; + private uAltView: WebGLUniformLocation; + + private vao: WebGLVertexArrayObject; + private trailTex: WebGLTexture; + private paletteTex: WebGLTexture; + private affiliationTex: WebGLTexture | null = null; + private altView = false; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + trailTex: WebGLTexture, + paletteTex: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.trailTex = trailTex; + this.paletteTex = paletteTex; + + this.program = createProgram( + gl, + overlayVertSrc, + shaderSrc(trailFragSrc, { + PALETTE_SIZE: getPaletteSize(), + ...TILE_DEFINES, + }), + ); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uMapSize = gl.getUniformLocation(this.program, "uMapSize")!; + this.uTrailAlpha = gl.getUniformLocation(this.program, "uTrailAlpha")!; + this.uAltView = gl.getUniformLocation(this.program, "uAltView")!; + + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uTrailTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2); + + this.vao = createMapQuad(gl, mapW, mapH); + } + + setAltView(active: boolean): void { + this.altView = active; + } + setAffiliationTex(tex: WebGLTexture): void { + this.affiliationTex = tex; + } + + /** Draw trail overlay. Blending must be enabled by caller. */ + draw(cameraMatrix: Float32Array): void { + const gl = this.gl; + + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform2f(this.uMapSize, this.mapW, this.mapH); + gl.uniform1f(this.uTrailAlpha, this.settings.mapOverlay.trailAlpha); + gl.uniform1i(this.uAltView, this.altView ? 1 : 0); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.trailTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + if (this.affiliationTex) { + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex); + } + + gl.bindVertexArray(this.vao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/UnitPass.ts b/src/client/render/gl/passes/UnitPass.ts new file mode 100644 index 0000000000..95322c6f9a --- /dev/null +++ b/src/client/render/gl/passes/UnitPass.ts @@ -0,0 +1,514 @@ +/** + * UnitPass — GPU-rendered mobile unit sprites. + * + * Renders all mobile (non-structure) units: boats, nukes, shells, SAM + * missiles, and MIRV warheads. All unit types are rotationally symmetric + * — no rotation needed. Sprites are tiny grayscale PNGs colorized on the + * GPU using the standard 3-band gray replacement (180/130/70). Shell and + * MIRV Warhead use programmatic 3×3 white squares (colorized to border + * color). + * + * Two instanced draw calls per frame — ground units and missiles are + * split into separate buffers for correct layer ordering: + * Ground/sea (boats, trains) → rendered below structures + * Missiles (nukes, shells, SAM, MIRV warheads) → rendered above structures + * + * Atlas layout (12 columns × 13px cells, pre-built by generate-sprite-atlases.mjs): + * Col 0: Transport (5×5) + * Col 1: Trade Ship (5×5) + * Col 2: Warship (11×11) + * Col 3: Atom Bomb (7×7) + * Col 4: Hydrogen Bomb (9×9) + * Col 5: MIRV (13×13, grayscale colorized) + * Col 6: SAM Missile (3×3) + * Col 7: Shell (3×3 white square) + * Col 8: MIRV Warhead (3×3 white square) + * Col 9: Train Engine (5×5) + * Col 10: Train Carriage (5×5) + * Col 11: Train Carriage Loaded (5×5) + * + * Data flow: + * FrameSnapshot.units → filter by typeToAtlasIdx → instance VBO → GPU + * Shells emit 2 instances (pos + lastPos) to match live game's 2-pixel trail. + */ + +import { assetUrl } from "src/core/AssetUrls"; +import type { RendererConfig, UnitState } from "../../types"; +import { + TrainType, + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_MIRV_WARHEAD, + UT_SAM_MISSILE, + UT_SHELL, + UT_TRADE_SHIP, + UT_TRAIN, + UT_TRANSPORT, + UT_WARSHIP, +} from "../../types"; +import { DynamicInstanceBuffer } from "../DynamicBuffer"; +import type { RenderSettings } from "../RenderSettings"; +import unitFragSrc from "../shaders/unit/unit.frag.glsl?raw"; +import unitVertSrc from "../shaders/unit/unit.vert.glsl?raw"; +import { getPaletteSize } from "../utils/ColorUtils"; +import { createProgram, shaderSrc } from "../utils/GlUtils"; + +const unitAtlasUrl = assetUrl("atlases/unit-atlas.png"); + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Unit types in atlas column order. Index = atlas column. + * TrainEngine/TrainCarriage/TrainCarriageLoaded are synthetic names — + * they don't match header.unitTypes directly. Train resolution is + * handled specially in updateUnits() via trainType + loaded fields. + */ +const UNIT_ORDER = [ + UT_TRANSPORT, + UT_TRADE_SHIP, + UT_WARSHIP, + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_SAM_MISSILE, + UT_SHELL, + UT_MIRV_WARHEAD, + "TrainEngine", + "TrainCarriage", + "TrainCarriageLoaded", +] as const; + +const ATLAS_COLS = UNIT_ORDER.length; + +// --------------------------------------------------------------------------- +// Instance data layout +// --------------------------------------------------------------------------- + +/** + * Per-instance data (16 bytes): + * float x, y, ownerID — 12 bytes (3 floats) + * uint8 atlasIdx — 1 byte (atlas column 0–11) + * uint8 flags — 1 byte (0 = normal, 1 = flicker, 2 = angry) + * 2 bytes padding — aligns to 4-byte boundary + */ +const FLOATS_PER_INSTANCE = 4; +const BYTES_PER_INSTANCE = FLOATS_PER_INSTANCE * 4; + +/** Flag values — passed as uint8, received as float in shader via normalized attribute */ +const FLAG_NORMAL = 0; +const FLAG_FLICKER = 1; +const FLAG_ANGRY = 2; +const FLAG_TRADE_FRIENDLY = 3; + +/** Atlas column indices for train sub-types (resolved from trainType + loaded) */ +const TRAIN_ENGINE_COL = UNIT_ORDER.indexOf("TrainEngine"); +const TRAIN_CARRIAGE_COL = UNIT_ORDER.indexOf("TrainCarriage"); +const TRAIN_CARRIAGE_LOADED_COL = UNIT_ORDER.indexOf("TrainCarriageLoaded"); + +/** Nuke + warhead types — rendered with flickering hot colors */ +const FLICKER_TYPES: ReadonlySet = new Set([ + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_MIRV_WARHEAD, + UT_SAM_MISSILE, + UT_SHELL, +]); + +/** Missile/projectile types — rendered on top of structures in the layer order. + * Ground/sea units (boats, trains) render below structures. */ +const MISSILE_TYPES: ReadonlySet = new Set([ + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_SAM_MISSILE, + UT_SHELL, + UT_MIRV_WARHEAD, +]); + +// --------------------------------------------------------------------------- +// Helper: create a VAO for instanced unit rendering +// --------------------------------------------------------------------------- + +function createUnitVao( + gl: WebGL2RenderingContext, + quadBuf: WebGLBuffer, + instanceBuf: WebGLBuffer, +): WebGLVertexArrayObject { + const vao = gl.createVertexArray()!; + gl.bindVertexArray(vao); + + // Attribute 0: unit quad [0,0]->[1,1] + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + // Attribute 1: per-instance vec3 (x, y, ownerID) — 3 floats at offset 0 + gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, BYTES_PER_INSTANCE, 0); + gl.vertexAttribDivisor(1, 1); + + // Attribute 2: per-instance (atlasIdx, flags) — 2 uint8s at offset 12, converted to float + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, BYTES_PER_INSTANCE, 12); + gl.vertexAttribDivisor(2, 1); + + gl.bindVertexArray(null); + return vao; +} + +// --------------------------------------------------------------------------- +// UnitPass +// --------------------------------------------------------------------------- + +export class UnitPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private program: WebGLProgram; + + private uCamera: WebGLUniformLocation; + private uTick: WebGLUniformLocation; + private uUnitSize: WebGLUniformLocation; + private uFlickerSpeed: WebGLUniformLocation; + private uAngryColor: WebGLUniformLocation; + private uAltView: WebGLUniformLocation; + + private affiliationTex: WebGLTexture | null = null; + private altView = false; + + // Ground/sea units (boats, trains) — render below structures + private groundVao: WebGLVertexArrayObject; + private groundBuf: DynamicInstanceBuffer; + private groundCount = 0; + + // Missiles/projectiles (nukes, shells, SAM) — render above structures + private missileVao: WebGLVertexArrayObject; + private missileBuf: DynamicInstanceBuffer; + private missileCount = 0; + + private quadBuf: WebGLBuffer; + private paletteTex: WebGLTexture; + private atlasTex: WebGLTexture; + + /** Frame tick received from renderer — drives tick-based effects */ + private frameTick = 0; + + /** unitType string → atlas column (0-11) */ + private typeToAtlasCol = new Map(); + private mapW: number; + + // Trade-friendly detection: enemy trade ships heading to a self/allied port + private localPlayerID = 0; + private friendlyOwners = new Set(); + private structures: Map = new Map(); + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + paletteTex: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = header.mapWidth; + this.paletteTex = paletteTex; + + // Build unitType string → atlas column mapping + for (let i = 0; i < header.unitTypes.length; i++) { + const col = UNIT_ORDER.indexOf( + header.unitTypes[i] as (typeof UNIT_ORDER)[number], + ); + if (col >= 0) { + this.typeToAtlasCol.set(header.unitTypes[i], col); + } + } + + // Compile shaders + this.program = createProgram( + gl, + shaderSrc(unitVertSrc, { ATLAS_COLS }), + shaderSrc(unitFragSrc, { PALETTE_SIZE: getPaletteSize() }), + ); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTick = gl.getUniformLocation(this.program, "uTick")!; + this.uUnitSize = gl.getUniformLocation(this.program, "uUnitSize")!; + this.uFlickerSpeed = gl.getUniformLocation(this.program, "uFlickerSpeed")!; + this.uAngryColor = gl.getUniformLocation(this.program, "uAngryColor")!; + + this.uAltView = gl.getUniformLocation(this.program, "uAltView")!; + + // Texture unit bindings + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uPalette"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uAffiliation"), 2); + + // Create placeholder atlas texture (1x1 gray pixel) + this.atlasTex = gl.createTexture()!; + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([128, 128, 128, 255]), + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // Start async atlas build + this.loadAtlas(); + + // --- Shared quad buffer --- + this.quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + + // --- Ground instance buffer + VAO --- + const groundGlBuf = gl.createBuffer()!; + this.groundBuf = new DynamicInstanceBuffer( + gl, + groundGlBuf, + 1024, + FLOATS_PER_INSTANCE, + ); + this.groundVao = createUnitVao(gl, this.quadBuf, groundGlBuf); + + // --- Missile instance buffer + VAO --- + const missileGlBuf = gl.createBuffer()!; + this.missileBuf = new DynamicInstanceBuffer( + gl, + missileGlBuf, + 512, + FLOATS_PER_INSTANCE, + ); + this.missileVao = createUnitVao(gl, this.quadBuf, missileGlBuf); + } + + private async loadAtlas(): Promise { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = unitAtlasUrl; + await img.decode(); + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + } + + private emitGround( + x: number, + y: number, + ownerID: number, + atlasIdx: number, + flags: number, + ): void { + this.groundBuf.ensureCapacity(this.groundCount + 1); + const off = this.groundCount * FLOATS_PER_INSTANCE; + this.groundBuf.float32[off + 0] = x; + this.groundBuf.float32[off + 1] = y; + this.groundBuf.float32[off + 2] = ownerID; + const byteOff = this.groundCount * BYTES_PER_INSTANCE; + this.groundBuf.uint8[byteOff + 12] = atlasIdx; + this.groundBuf.uint8[byteOff + 13] = flags; + this.groundCount++; + } + + private emitMissile( + x: number, + y: number, + ownerID: number, + atlasIdx: number, + flags: number, + ): void { + this.missileBuf.ensureCapacity(this.missileCount + 1); + const off = this.missileCount * FLOATS_PER_INSTANCE; + this.missileBuf.float32[off + 0] = x; + this.missileBuf.float32[off + 1] = y; + this.missileBuf.float32[off + 2] = ownerID; + const byteOff = this.missileCount * BYTES_PER_INSTANCE; + this.missileBuf.uint8[byteOff + 12] = atlasIdx; + this.missileBuf.uint8[byteOff + 13] = flags; + this.missileCount++; + } + + updateUnits(units: Map, tick: number): void { + this.frameTick = tick; + this.groundCount = 0; + this.missileCount = 0; + + for (const unit of units.values()) { + if (!unit.isActive) continue; + + let atlasIdx = this.typeToAtlasCol.get(unit.unitType); + + // Train sub-type resolution: "Train" isn't in UNIT_ORDER. + // Resolve to engine/carriage/loaded carriage based on trainType + loaded fields. + if (atlasIdx === undefined && unit.unitType === UT_TRAIN) { + const tt = unit.trainType; + if (tt === TrainType.Engine || tt === TrainType.TailEngine) { + atlasIdx = TRAIN_ENGINE_COL; + } else { + atlasIdx = unit.loaded + ? TRAIN_CARRIAGE_LOADED_COL + : TRAIN_CARRIAGE_COL; + } + } + + if (atlasIdx === undefined) continue; + + const isAngryWarship = + unit.unitType === UT_WARSHIP && unit.targetUnitId !== null; + const isFlicker = FLICKER_TYPES.has(unit.unitType); + + // Enemy trade ships heading to a self/allied port get FLAG_TRADE_FRIENDLY + // so alt-view renders them yellow instead of red. + let isTradeFriendly = false; + if ( + unit.unitType === UT_TRADE_SHIP && + unit.targetUnitId !== null && + this.localPlayerID > 0 + ) { + const targetPort = this.structures.get(unit.targetUnitId); + if (targetPort) { + const portOwner = targetPort.ownerID; + isTradeFriendly = + portOwner === this.localPlayerID || + this.friendlyOwners.has(portOwner); + } + } + + const flags = isTradeFriendly + ? FLAG_TRADE_FRIENDLY + : isAngryWarship + ? FLAG_ANGRY + : isFlicker + ? FLAG_FLICKER + : FLAG_NORMAL; + const isMissile = MISSILE_TYPES.has(unit.unitType); + + const x = unit.pos % this.mapW; + const y = (unit.pos - x) / this.mapW; + + if (isMissile) { + this.emitMissile(x, y, unit.ownerID, atlasIdx, flags); + + // Shells emit a second instance at lastPos (2-pixel trail effect) + if (unit.unitType === UT_SHELL && unit.lastPos !== unit.pos) { + const lx = unit.lastPos % this.mapW; + const ly = (unit.lastPos - lx) / this.mapW; + this.emitMissile(lx, ly, unit.ownerID, atlasIdx, flags); + } + } else { + this.emitGround(x, y, unit.ownerID, atlasIdx, flags); + } + } + + const gl = this.gl; + if (this.groundCount > 0) { + gl.bindBuffer(gl.ARRAY_BUFFER, this.groundBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.groundBuf.float32, + 0, + this.groundCount * FLOATS_PER_INSTANCE, + ); + } + if (this.missileCount > 0) { + gl.bindBuffer(gl.ARRAY_BUFFER, this.missileBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.missileBuf.float32, + 0, + this.missileCount * FLOATS_PER_INSTANCE, + ); + } + } + + setAltView(active: boolean): void { + this.altView = active; + } + setAffiliationTex(tex: WebGLTexture): void { + this.affiliationTex = tex; + } + setLocalPlayer(id: number): void { + this.localPlayerID = id; + } + setAllies(allies: Set): void { + this.friendlyOwners = allies; + } + setStructures(structs: Map): void { + this.structures = structs; + } + + /** Bind shared program state + uniforms (call before drawGround/drawMissiles). */ + private bindProgram(cameraMatrix: Float32Array): void { + const gl = this.gl; + gl.useProgram(this.program); + + const us = this.settings.unit; + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTick, this.frameTick); + gl.uniform1f(this.uUnitSize, us.unitSize); + gl.uniform1f(this.uFlickerSpeed, us.flickerSpeed); + gl.uniform3f(this.uAngryColor, us.angryR, us.angryG, us.angryB); + gl.uniform1i(this.uAltView, this.altView ? 1 : 0); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.paletteTex); + + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + + if (this.affiliationTex) { + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.affiliationTex); + } + } + + /** Draw ground/sea units (boats, trains). Render below structures. */ + drawGround(cameraMatrix: Float32Array): void { + if (this.groundCount === 0) return; + this.bindProgram(cameraMatrix); + const gl = this.gl; + gl.bindVertexArray(this.groundVao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.groundCount); + } + + /** Draw missiles/projectiles (nukes, shells, SAM, MIRV warheads). Render above structures. */ + drawMissiles(cameraMatrix: Float32Array): void { + if (this.missileCount === 0) return; + this.bindProgram(cameraMatrix); + const gl = this.gl; + gl.bindVertexArray(this.missileVao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.missileCount); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.groundBuf.dispose(); + this.missileBuf.dispose(); + gl.deleteBuffer(this.quadBuf); + gl.deleteVertexArray(this.groundVao); + gl.deleteVertexArray(this.missileVao); + gl.deleteTexture(this.atlasTex); + } +} diff --git a/src/client/render/gl/passes/fx-pass/FxAttackRingPass.ts b/src/client/render/gl/passes/fx-pass/FxAttackRingPass.ts new file mode 100644 index 0000000000..c522e2880a --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/FxAttackRingPass.ts @@ -0,0 +1,212 @@ +/** + * FxAttackRingPass — persistent animated rings at transport ship target tiles. + * + * Rings fade in when a transport acquires a target, and fade out when the + * target is lost. Uses a rotating dashed-ring shader (attack-ring.vert/frag). + */ + +import type { AttackRingInput } from "../../../types"; +import { DynamicInstanceBuffer } from "../../DynamicBuffer"; +import type { RenderSettings } from "../../RenderSettings"; +import { createProgram } from "../../utils/GlUtils"; + +import attackRingFragSrc from "../../shaders/fx/attack-ring.frag.glsl?raw"; +import attackRingVertSrc from "../../shaders/fx/attack-ring.vert.glsl?raw"; + +export type { AttackRingInput } from "../../../types"; + +// --------------------------------------------------------------------------- +// Active state +// --------------------------------------------------------------------------- + +interface ActiveAttackRing { + unitId: number; + x: number; + y: number; + /** performance.now() when fade-in started or fade-out began. */ + transitionMs: number; + fadingOut: boolean; +} + +// --------------------------------------------------------------------------- +// Instance data layout: x, y, alpha +// --------------------------------------------------------------------------- + +const ATTACK_RING_FLOATS = 3; + +const FADE_IN_MS = 200; +const FADE_OUT_MS = 300; + +// --------------------------------------------------------------------------- +// FxAttackRingPass +// --------------------------------------------------------------------------- + +export class FxAttackRingPass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + + private program: WebGLProgram; + private uCamera: WebGLUniformLocation; + private uTilesPerPx: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uRingWidth: WebGLUniformLocation; + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + private ringCount = 0; + + private active: ActiveAttackRing[] = []; + + constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + this.gl = gl; + this.settings = settings; + + this.program = createProgram(gl, attackRingVertSrc, attackRingFragSrc); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTilesPerPx = gl.getUniformLocation(this.program, "uTilesPerPx")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uRingWidth = gl.getUniformLocation(this.program, "uRingWidth")!; + + const glBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer( + gl, + glBuf, + 8, + ATTACK_RING_FLOATS, + ); + + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(1, 1); + + gl.bindVertexArray(null); + } + + // ------------------------------------------------------------------------- + // Update + // ------------------------------------------------------------------------- + + update(rings: AttackRingInput[]): void { + const now = performance.now(); + const incoming = new Set(); + for (const r of rings) incoming.add(r.unitId); + + // Mark removed rings as fading out + for (const ar of this.active) { + if (!ar.fadingOut && !incoming.has(ar.unitId)) { + ar.fadingOut = true; + ar.transitionMs = now; + } + } + + // Add or refresh rings + for (const r of rings) { + const existing = this.active.find((a) => a.unitId === r.unitId); + if (existing) { + existing.x = r.x; + existing.y = r.y; + if (existing.fadingOut) { + existing.fadingOut = false; + existing.transitionMs = now; + } + } else { + this.active.push({ + unitId: r.unitId, + x: r.x, + y: r.y, + transitionMs: now, + fadingOut: false, + }); + } + } + } + + // ------------------------------------------------------------------------- + // Tick + // ------------------------------------------------------------------------- + + tick(): void { + if (this.active.length === 0) return; + const now = performance.now(); + + // Remove fully faded rings + for (let i = this.active.length - 1; i >= 0; i--) { + const ar = this.active[i]; + if (ar.fadingOut && now - ar.transitionMs >= FADE_OUT_MS) { + this.active[i] = this.active[this.active.length - 1]; + this.active.pop(); + } + } + + const count = this.active.length; + this.instanceBuf.ensureCapacity(count); + + const data = this.instanceBuf.float32; + for (let i = 0; i < count; i++) { + const ar = this.active[i]; + const elapsed = now - ar.transitionMs; + const alpha = ar.fadingOut + ? Math.max(0, 1 - elapsed / FADE_OUT_MS) + : Math.min(1, elapsed / FADE_IN_MS); + const off = i * ATTACK_RING_FLOATS; + data[off + 0] = ar.x; + data[off + 1] = ar.y; + data[off + 2] = alpha; + } + + this.ringCount = count; + } + + // ------------------------------------------------------------------------- + // Draw + // ------------------------------------------------------------------------- + + draw(cameraMatrix: Float32Array, zoom: number): void { + if (this.ringCount === 0) return; + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTilesPerPx, 1 / zoom); + gl.uniform1f(this.uTime, performance.now() / 1000); + gl.uniform1f(this.uRingWidth, this.settings.fx.shockwaveRingWidth); + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + this.ringCount * ATTACK_RING_FLOATS, + ); + gl.bindVertexArray(this.vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.ringCount); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + clear(): void { + this.active.length = 0; + this.ringCount = 0; + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.instanceBuf.dispose(); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts b/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts new file mode 100644 index 0000000000..41f3e4598e --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/FxShockwavePass.ts @@ -0,0 +1,191 @@ +/** + * FxShockwavePass — instanced procedural ring quads. + * + * Spawned alongside sprite FX for nuke and SAM interception events. + * Uses an SDF circle rendered in a unit quad, no texture required. + */ + +import { DynamicInstanceBuffer } from "../../DynamicBuffer"; +import type { RenderSettings } from "../../RenderSettings"; +import { createProgram } from "../../utils/GlUtils"; + +import shockwaveFragSrc from "../../shaders/fx/shockwave.frag.glsl?raw"; +import shockwaveVertSrc from "../../shaders/fx/shockwave.vert.glsl?raw"; + +// --------------------------------------------------------------------------- +// Active state +// --------------------------------------------------------------------------- + +interface ActiveShockwave { + x: number; + y: number; + startMs: number; + durationMs: number; + maxRadius: number; +} + +// --------------------------------------------------------------------------- +// Instance data layout: x, y, radius, alpha +// --------------------------------------------------------------------------- + +const SHOCKWAVE_FLOATS = 4; + +// --------------------------------------------------------------------------- +// FxShockwavePass +// --------------------------------------------------------------------------- + +export class FxShockwavePass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + + private program: WebGLProgram; + private uCamera: WebGLUniformLocation; + private uRingWidth: WebGLUniformLocation; + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + private shockwaveCount = 0; + + private active: ActiveShockwave[] = []; + private timeFn: () => number = () => performance.now(); + + constructor(gl: WebGL2RenderingContext, settings: RenderSettings) { + this.gl = gl; + this.settings = settings; + + this.program = createProgram(gl, shockwaveVertSrc, shockwaveFragSrc); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uRingWidth = gl.getUniformLocation(this.program, "uRingWidth")!; + + const glBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer( + gl, + glBuf, + 16, + SHOCKWAVE_FLOATS, + ); + + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 4, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(1, 1); + + gl.bindVertexArray(null); + } + + // ------------------------------------------------------------------------- + // Spawning + // ------------------------------------------------------------------------- + + pushNukeShockwave(x: number, y: number, nukeRadius: number): void { + const fx = this.settings.fx; + this.active.push({ + x, + y, + startMs: this.timeFn(), + durationMs: fx.nukeShockwaveDurationMs, + maxRadius: nukeRadius * fx.nukeShockwaveRadiusFactor, + }); + } + + pushSAMShockwave(x: number, y: number): void { + const fx = this.settings.fx; + this.active.push({ + x, + y, + startMs: this.timeFn(), + durationMs: fx.samShockwaveDurationMs, + maxRadius: fx.samShockwaveRadius, + }); + } + + // ------------------------------------------------------------------------- + // Tick + // ------------------------------------------------------------------------- + + tick(): void { + if (this.active.length === 0) return; + const now = this.timeFn(); + + for (let i = this.active.length - 1; i >= 0; i--) { + if (now - this.active[i].startMs >= this.active[i].durationMs) { + this.active[i] = this.active[this.active.length - 1]; + this.active.pop(); + } + } + + this.rebuildInstances(now); + } + + private rebuildInstances(now: number): void { + const count = this.active.length; + this.instanceBuf.ensureCapacity(count); + + const data = this.instanceBuf.float32; + for (let i = 0; i < count; i++) { + const sw = this.active[i]; + const t = (now - sw.startMs) / sw.durationMs; + const off = i * SHOCKWAVE_FLOATS; + data[off + 0] = sw.x; + data[off + 1] = sw.y; + data[off + 2] = t * sw.maxRadius; + data[off + 3] = 1 - t; + } + + this.shockwaveCount = count; + } + + // ------------------------------------------------------------------------- + // Draw + // ------------------------------------------------------------------------- + + draw(cameraMatrix: Float32Array): void { + if (this.shockwaveCount === 0) return; + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uRingWidth, this.settings.fx.shockwaveRingWidth); + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + this.shockwaveCount * SHOCKWAVE_FLOATS, + ); + gl.bindVertexArray(this.vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.shockwaveCount); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + setTimeFn(fn: () => number): void { + this.timeFn = fn; + } + + clear(): void { + this.active.length = 0; + this.shockwaveCount = 0; + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.instanceBuf.dispose(); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/passes/fx-pass/FxSpritePass.ts b/src/client/render/gl/passes/fx-pass/FxSpritePass.ts new file mode 100644 index 0000000000..5937583d67 --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/FxSpritePass.ts @@ -0,0 +1,529 @@ +/** + * FxSpritePass — instanced textured quads sampling an animated sprite atlas. + * + * Manages: sprite FX state (explosions, dust, conquest, debris). + * Atlas layout: 12 horizontal sprite strips stacked vertically. + * Pre-built by generate-sprite-atlases.mjs. + */ + +import { MS_PER_TICK, NUKE_EXPLOSION_RADII } from "../../../GameConstants"; +import type { ConquestFx, DeadUnitFx, RendererConfig } from "../../../types"; +import { + STRUCTURE_TYPES, + UT_SHELL, + UT_TRAIN, + UT_WARSHIP, +} from "../../../types"; +import { DynamicInstanceBuffer } from "../../DynamicBuffer"; +import type { RenderSettings } from "../../RenderSettings"; +import { createProgram, shaderSrc } from "../../utils/GlUtils"; + +import fxAtlasMeta from "resources/atlases/fx-atlas-meta.json"; +import { assetUrl } from "src/core/AssetUrls"; + +import spriteFragSrc from "../../shaders/fx/sprite.frag.glsl?raw"; +import spriteVertSrc from "../../shaders/fx/sprite.vert.glsl?raw"; + +const fxAtlasUrl = assetUrl("atlases/fx-atlas.png"); + +// --------------------------------------------------------------------------- +// FX type indices (atlas row) +// --------------------------------------------------------------------------- + +export const FX_NUKE = 0; +export const FX_SAM_EXPLOSION = 1; +export const FX_BUILDING_EXPLOSION = 2; +export const FX_UNIT_EXPLOSION = 3; +export const FX_MINI_EXPLOSION = 4; +export const FX_SINKING_SHIP = 5; +export const FX_MINI_FIRE = 6; +export const FX_MINI_SMOKE = 7; +export const FX_MINI_BIG_SMOKE = 8; +export const FX_MINI_SMOKE_FIRE = 9; +export const FX_DUST = 10; +export const FX_CONQUEST = 11; +const FX_TYPE_COUNT = 12; + +// --------------------------------------------------------------------------- +// FX sprite config (matches AnimatedSpriteLoader) +// --------------------------------------------------------------------------- + +interface FxTypeConfig { + frameWidth: number; + frameCount: number; + frameDurationMs: number; + looping: boolean; +} + +const FX_CONFIG: FxTypeConfig[] = [ + /* 0 Nuke */ { + frameWidth: 60, + frameCount: 9, + frameDurationMs: 70, + looping: false, + }, + /* 1 SAMExplosion */ { + frameWidth: 48, + frameCount: 9, + frameDurationMs: 70, + looping: false, + }, + /* 2 BuildingExpl */ { + frameWidth: 17, + frameCount: 10, + frameDurationMs: 70, + looping: false, + }, + /* 3 UnitExplosion */ { + frameWidth: 19, + frameCount: 4, + frameDurationMs: 70, + looping: false, + }, + /* 4 MiniExplosion */ { + frameWidth: 13, + frameCount: 4, + frameDurationMs: 70, + looping: false, + }, + /* 5 SinkingShip */ { + frameWidth: 16, + frameCount: 14, + frameDurationMs: 90, + looping: false, + }, + /* 6 MiniFire */ { + frameWidth: 7, + frameCount: 6, + frameDurationMs: 100, + looping: true, + }, + /* 7 MiniSmoke */ { + frameWidth: 11, + frameCount: 4, + frameDurationMs: 120, + looping: true, + }, + /* 8 MiniBigSmoke */ { + frameWidth: 24, + frameCount: 5, + frameDurationMs: 120, + looping: true, + }, + /* 9 MiniSmokeFire */ { + frameWidth: 24, + frameCount: 5, + frameDurationMs: 120, + looping: true, + }, + /* 10 Dust */ { + frameWidth: 9, + frameCount: 3, + frameDurationMs: 100, + looping: false, + }, + /* 11 Conquest */ { + frameWidth: 21, + frameCount: 10, + frameDurationMs: 90, + looping: false, + }, +]; + +// --------------------------------------------------------------------------- +// Nuke debris plan +// --------------------------------------------------------------------------- + +const DEBRIS_PLAN = [ + { type: FX_MINI_FIRE, radiusFactor: 1.0, density: 1 / 25 }, + { type: FX_MINI_SMOKE, radiusFactor: 1.0, density: 1 / 28 }, + { type: FX_MINI_BIG_SMOKE, radiusFactor: 0.9, density: 1 / 70 }, + { type: FX_MINI_SMOKE_FIRE, radiusFactor: 0.9, density: 1 / 70 }, +]; + +/** Deterministic float in [0,1) from an integer seed (mulberry32). */ +function seededRandom(seed: number): number { + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +// --------------------------------------------------------------------------- +// Active FX state +// --------------------------------------------------------------------------- + +interface ActiveFx { + x: number; + y: number; + fxType: number; + startMs: number; + lifetimeMs: number; + fadeIn: number; // fraction 0–1 (start of full alpha) + fadeOut: number; // fraction 0–1 (start of fade out) +} + +// --------------------------------------------------------------------------- +// Instance data layout +// --------------------------------------------------------------------------- + +const SPRITE_FLOATS = 4; // x, y, fxType, [frameIdx u8, alpha u8, pad, pad] +const SPRITE_BYTES = 16; + +// --------------------------------------------------------------------------- +// FxSpritePass +// --------------------------------------------------------------------------- + +export class FxSpritePass { + private gl: WebGL2RenderingContext; + private mapW: number; + private settings: RenderSettings; + + private program: WebGLProgram; + private uCamera: WebGLUniformLocation; + private uFxUV: WebGLUniformLocation; + private uFxWorld: WebGLUniformLocation; + private vao: WebGLVertexArrayObject; + private instanceBuf: DynamicInstanceBuffer; + private spriteCount = 0; + private atlasTex: WebGLTexture; + private atlasReady = false; + + private activeFx: ActiveFx[] = []; + private timeFn: () => number = () => performance.now(); + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + settings: RenderSettings, + ) { + this.gl = gl; + this.mapW = header.mapWidth; + this.settings = settings; + + this.program = createProgram( + gl, + shaderSrc(spriteVertSrc, { FX_TYPE_COUNT }), + spriteFragSrc, + ); + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uFxUV = gl.getUniformLocation(this.program, "uFxUV")!; + this.uFxWorld = gl.getUniformLocation(this.program, "uFxWorld")!; + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0); + + // Placeholder atlas (1x1 transparent) + this.atlasTex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([0, 0, 0, 0]), + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + // Instance buffer + const glBuf = gl.createBuffer()!; + this.instanceBuf = new DynamicInstanceBuffer(gl, glBuf, 256, SPRITE_FLOATS); + + // VAO + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + const quadBuf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, quadBuf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + gl.bindBuffer(gl.ARRAY_BUFFER, glBuf); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, SPRITE_BYTES, 0); + gl.vertexAttribDivisor(1, 1); + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 2, gl.UNSIGNED_BYTE, false, SPRITE_BYTES, 12); + gl.vertexAttribDivisor(2, 1); + + gl.bindVertexArray(null); + + this.loadAtlas(); + } + + // ------------------------------------------------------------------------- + // Atlas loading + // ------------------------------------------------------------------------- + + private async loadAtlas(): Promise { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.src = fxAtlasUrl; + await img.decode(); + const gl = this.gl; + + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + + const meta = fxAtlasMeta; + const uvData = new Float32Array(FX_TYPE_COUNT * 4); + const worldData = new Float32Array(FX_TYPE_COUNT * 4); + + for (let i = 0; i < FX_TYPE_COUNT; i++) { + const row = meta.rows[i]; + uvData[i * 4 + 0] = row.yOffset / meta.height; + uvData[i * 4 + 1] = row.height / meta.height; + uvData[i * 4 + 2] = row.worldWidth / meta.width; + uvData[i * 4 + 3] = 0; + worldData[i * 4 + 0] = row.worldWidth; + worldData[i * 4 + 1] = row.worldHeight; + worldData[i * 4 + 2] = 0; + worldData[i * 4 + 3] = 0; + } + + gl.useProgram(this.program); + gl.uniform4fv(this.uFxUV, uvData); + gl.uniform4fv(this.uFxWorld, worldData); + + this.atlasReady = true; + } + + // ------------------------------------------------------------------------- + // Spawning + // ------------------------------------------------------------------------- + + applyRailroadDust(tileRefs: number[]): void { + const now = this.timeFn(); + for (const ref of tileRefs) { + if (Math.random() > 0.33) continue; + const x = ref % this.mapW; + const y = (ref - x) / this.mapW; + this.pushFx(x, y, FX_DUST, now); + } + } + + applyConquestEvents(events: ConquestFx[]): void { + const now = this.timeFn(); + const fx = this.settings.fx; + for (const evt of events) { + const startMs = now - (evt.tickAge ?? 0) * MS_PER_TICK; + if (now - startMs >= fx.conquestLifetimeMs) continue; + this.activeFx.push({ + x: evt.x, + y: evt.y, + fxType: FX_CONQUEST, + startMs, + lifetimeMs: fx.conquestLifetimeMs, + fadeIn: fx.conquestFadeIn, + fadeOut: fx.conquestFadeOut, + }); + } + } + + /** + * Spawn sprite FX for a dead unit. Returns the nuke radius if a nuke + * exploded (so the orchestrator can also spawn a shockwave), or null. + */ + spawnFxForUnit(unit: DeadUnitFx, now: number): void { + const typeName = unit.unitType; + const x = unit.pos % this.mapW; + const y = (unit.pos - x) / this.mapW; + + const nukeRadius = NUKE_EXPLOSION_RADII[typeName]; + if (nukeRadius !== undefined) { + if (unit.reachedTarget) { + this.spawnNukeSprites(x, y, nukeRadius, now, unit.pos); + } else { + this.pushFx(x, y, FX_SAM_EXPLOSION, now); + } + return; + } + + if (typeName === UT_WARSHIP) { + this.pushFx(x, y, FX_UNIT_EXPLOSION, now); + this.pushFx(x, y, FX_SINKING_SHIP, now); + return; + } + + if (typeName === UT_SHELL && unit.reachedTarget) { + this.pushFx(x, y, FX_MINI_EXPLOSION, now); + return; + } + + if (typeName === UT_TRAIN && !unit.reachedTarget) { + this.pushFx(x, y, FX_MINI_EXPLOSION, now); + return; + } + + if (STRUCTURE_TYPES.has(typeName)) { + this.pushFx(x, y, FX_BUILDING_EXPLOSION, now); + } + } + + private spawnNukeSprites( + x: number, + y: number, + radius: number, + now: number, + pos: number, + ): void { + this.pushFx(x, y, FX_NUKE, now); + + let debrisIdx = 0; + for (const { type, radiusFactor, density } of DEBRIS_PLAN) { + const count = Math.max(0, Math.floor(radius * density)); + const r = radius * radiusFactor; + for (let i = 0; i < count; i++) { + const seed = pos * 997 + debrisIdx++; + const angle = seededRandom(seed) * Math.PI * 2; + const dist = seededRandom(seed + 0x10000) * (r / 2); + const dx = Math.floor(Math.cos(angle) * dist); + const dy = Math.floor(Math.sin(angle) * dist); + this.pushDebris(x + dx, y + dy, type, now); + } + } + } + + pushFx(x: number, y: number, fxType: number, now: number): void { + const cfg = FX_CONFIG[fxType]; + this.activeFx.push({ + x, + y, + fxType, + startMs: now, + lifetimeMs: cfg.frameDurationMs * cfg.frameCount, + fadeIn: 0, + fadeOut: 1, + }); + } + + private pushDebris(x: number, y: number, fxType: number, now: number): void { + const fx = this.settings.fx; + this.activeFx.push({ + x, + y, + fxType, + startMs: now, + lifetimeMs: fx.debrisLifetimeMs, + fadeIn: fx.debrisFadeIn, + fadeOut: fx.debrisFadeOut, + }); + } + + // ------------------------------------------------------------------------- + // Tick + // ------------------------------------------------------------------------- + + tick(): void { + if (this.activeFx.length === 0) return; + const now = this.timeFn(); + + for (let i = this.activeFx.length - 1; i >= 0; i--) { + if (now - this.activeFx[i].startMs >= this.activeFx[i].lifetimeMs) { + this.activeFx[i] = this.activeFx[this.activeFx.length - 1]; + this.activeFx.pop(); + } + } + + this.rebuildInstances(now); + } + + private rebuildInstances(now: number): void { + const count = this.activeFx.length; + this.instanceBuf.ensureCapacity(count); + + for (let i = 0; i < count; i++) { + const fx = this.activeFx[i]; + const cfg = FX_CONFIG[fx.fxType]; + const elapsed = now - fx.startMs; + + let frameIdx: number; + if (cfg.looping) { + const cycle = cfg.frameDurationMs * cfg.frameCount; + frameIdx = Math.floor((elapsed % cycle) / cfg.frameDurationMs); + } else { + frameIdx = Math.min( + Math.floor(elapsed / cfg.frameDurationMs), + cfg.frameCount - 1, + ); + } + + let alpha = 255; + if (fx.fadeIn > 0 || fx.fadeOut < 1) { + const t = elapsed / fx.lifetimeMs; + if (t < fx.fadeIn) { + alpha = Math.floor((t / fx.fadeIn) * 255); + } else if (t > fx.fadeOut) { + alpha = Math.floor(((1 - t) / (1 - fx.fadeOut)) * 255); + } + } + + const off = i * SPRITE_FLOATS; + this.instanceBuf.float32[off + 0] = fx.x; + this.instanceBuf.float32[off + 1] = fx.y; + this.instanceBuf.float32[off + 2] = fx.fxType; + const byteOff = i * SPRITE_BYTES; + this.instanceBuf.uint8[byteOff + 12] = frameIdx; + this.instanceBuf.uint8[byteOff + 13] = alpha; + } + + this.spriteCount = count; + } + + // ------------------------------------------------------------------------- + // Draw + // ------------------------------------------------------------------------- + + draw(cameraMatrix: Float32Array): void { + if (this.spriteCount === 0 || !this.atlasReady) return; + const gl = this.gl; + gl.useProgram(this.program); + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex); + gl.bindBuffer(gl.ARRAY_BUFFER, this.instanceBuf.buffer); + gl.bufferSubData( + gl.ARRAY_BUFFER, + 0, + this.instanceBuf.float32, + 0, + this.spriteCount * SPRITE_FLOATS, + ); + gl.bindVertexArray(this.vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.spriteCount); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + setTimeFn(fn: () => number): void { + this.timeFn = fn; + } + + clear(): void { + this.activeFx.length = 0; + this.spriteCount = 0; + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + this.instanceBuf.dispose(); + gl.deleteVertexArray(this.vao); + gl.deleteTexture(this.atlasTex); + } +} diff --git a/src/client/render/gl/passes/fx-pass/index.ts b/src/client/render/gl/passes/fx-pass/index.ts new file mode 100644 index 0000000000..12ec3c0b73 --- /dev/null +++ b/src/client/render/gl/passes/fx-pass/index.ts @@ -0,0 +1,126 @@ +/** + * FxPass — orchestrates three independent GPU effect sub-passes: + * 1. FxSpritePass — animated sprite atlas (explosions, dust, conquest) + * 2. FxShockwavePass — procedural rings for nuke/SAM events + * 3. FxAttackRingPass — persistent rings at transport ship targets + * + * Spawn events that produce both a sprite and a shockwave (nukes, SAM + * interceptions) are coordinated here so each sub-pass stays self-contained. + */ + +import { MS_PER_TICK, NUKE_EXPLOSION_RADII } from "../../../GameConstants"; +import type { + AttackRingInput, + ConquestFx, + DeadUnitFx, + RendererConfig, +} from "../../../types"; +import type { RenderSettings } from "../../RenderSettings"; +import { FxAttackRingPass } from "./FxAttackRingPass"; +import { FxShockwavePass } from "./FxShockwavePass"; +import { FxSpritePass } from "./FxSpritePass"; + +export type { AttackRingInput } from "../../../types"; + +export class FxPass { + private spritePass: FxSpritePass; + private shockwavePass: FxShockwavePass; + private attackRingPass: FxAttackRingPass; + private mapW: number; + private timeFn: () => number = () => performance.now(); + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + settings: RenderSettings, + ) { + this.mapW = header.mapWidth; + this.spritePass = new FxSpritePass(gl, header, settings); + this.shockwavePass = new FxShockwavePass(gl, settings); + this.attackRingPass = new FxAttackRingPass(gl, settings); + } + + // ------------------------------------------------------------------------- + // Spawning — coordinated across sub-passes + // ------------------------------------------------------------------------- + + applyDeadUnits(deadUnits: DeadUnitFx[]): void { + const now = this.timeFn(); + for (const unit of deadUnits) { + const startMs = now - (unit.tickAge ?? 0) * MS_PER_TICK; + this.spawnUnit(unit, startMs); + } + } + + private spawnUnit(unit: DeadUnitFx, now: number): void { + const typeName = unit.unitType; + const x = unit.pos % this.mapW; + const y = (unit.pos - x) / this.mapW; + + const nukeRadius = NUKE_EXPLOSION_RADII[typeName]; + if (nukeRadius !== undefined) { + if (unit.reachedTarget) { + this.spritePass.spawnFxForUnit(unit, now); + this.shockwavePass.pushNukeShockwave(x, y, nukeRadius); + } else { + // SAM interception: sprite pass handles the SAM explosion sprite + this.spritePass.spawnFxForUnit(unit, now); + this.shockwavePass.pushSAMShockwave(x, y); + } + return; + } + + // All other units: sprite-only effects + this.spritePass.spawnFxForUnit(unit, now); + } + + applyRailroadDust(tileRefs: number[]): void { + this.spritePass.applyRailroadDust(tileRefs); + } + + applyConquestEvents(events: ConquestFx[]): void { + this.spritePass.applyConquestEvents(events); + } + + updateAttackRings(rings: AttackRingInput[]): void { + this.attackRingPass.update(rings); + } + + // ------------------------------------------------------------------------- + // Per-frame + // ------------------------------------------------------------------------- + + tick(): void { + this.spritePass.tick(); + this.shockwavePass.tick(); + this.attackRingPass.tick(); + } + + draw(cameraMatrix: Float32Array, zoom: number): void { + this.spritePass.draw(cameraMatrix); + this.shockwavePass.draw(cameraMatrix); + this.attackRingPass.draw(cameraMatrix, zoom); + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + setTimeFn(fn: () => number): void { + this.timeFn = fn; + this.spritePass.setTimeFn(fn); + this.shockwavePass.setTimeFn(fn); + } + + clear(): void { + this.spritePass.clear(); + this.shockwavePass.clear(); + this.attackRingPass.clear(); + } + + dispose(): void { + this.spritePass.dispose(); + this.shockwavePass.dispose(); + this.attackRingPass.dispose(); + } +} diff --git a/src/client/render/gl/passes/name-pass/AtlasData.ts b/src/client/render/gl/passes/name-pass/AtlasData.ts new file mode 100644 index 0000000000..e385a452f0 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/AtlasData.ts @@ -0,0 +1,86 @@ +/** + * Atlas data parsing — extracts font metrics, glyph lookup tables, + * kerning data, and icon atlas index maps from static JSON assets. + */ + +import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json"; +import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json"; +import atlasData from "resources/atlases/msdf-atlas.json"; +import type { BMChar, BMKerning, ParsedAtlas } from "./Types"; +import { CHAR_RANGE } from "./Types"; + +// --------------------------------------------------------------------------- +// Atlas parsing +// --------------------------------------------------------------------------- + +export function parseAtlasData(): ParsedAtlas { + return { + fontSize: atlasData.info.size, + base: atlasData.common.base, + scaleW: atlasData.common.scaleW, + scaleH: atlasData.common.scaleH, + distanceRange: (atlasData as any).distanceField?.distanceRange ?? 4, + chars: atlasData.chars as BMChar[], + kernings: (atlasData.kernings ?? []) as BMKerning[], + }; +} + +// --------------------------------------------------------------------------- +// CPU-side glyph lookup tables +// --------------------------------------------------------------------------- + +export interface GlyphTables { + advance: Float32Array; // [CHAR_RANGE] — xadvance per char ID + xOffset: Float32Array; // [CHAR_RANGE] — xoffset (left bearing) per char ID + visW: Float32Array; // [CHAR_RANGE] — visible glyph width per char ID +} + +export function buildGlyphTables(chars: BMChar[]): GlyphTables { + const advance = new Float32Array(CHAR_RANGE); + const xOffset = new Float32Array(CHAR_RANGE); + const visW = new Float32Array(CHAR_RANGE); + for (const ch of chars) { + if (ch.id < CHAR_RANGE) { + advance[ch.id] = ch.xadvance; + xOffset[ch.id] = ch.xoffset; + visW[ch.id] = ch.width; + } + } + return { advance, xOffset, visW }; +} + +// --------------------------------------------------------------------------- +// Kerning table (amounts are small integers: typically -7 to +4) +// --------------------------------------------------------------------------- + +export function buildKernTable(kernings: BMKerning[]): Int8Array { + const table = new Int8Array(CHAR_RANGE * CHAR_RANGE); + for (const k of kernings) { + if (k.first < CHAR_RANGE && k.second < CHAR_RANGE) { + table[k.first * CHAR_RANGE + k.second] = k.amount; + } + } + return table; +} + +// --------------------------------------------------------------------------- +// Icon atlas lookups +// --------------------------------------------------------------------------- + +export function buildFlagLookup(): Map { + const map = new Map(); + const meta = flagAtlasMeta as { flags: Record }; + for (const [code, idx] of Object.entries(meta.flags)) { + map.set(code, idx); + } + return map; +} + +export function buildEmojiLookup(): Map { + const map = new Map(); + const meta = emojiAtlasMeta as { emojis: Record }; + for (const [ch, idx] of Object.entries(meta.emojis)) { + map.set(ch, idx); + } + return map; +} diff --git a/src/client/render/gl/passes/name-pass/DataTextures.ts b/src/client/render/gl/passes/name-pass/DataTextures.ts new file mode 100644 index 0000000000..7738bd18ac --- /dev/null +++ b/src/client/render/gl/passes/name-pass/DataTextures.ts @@ -0,0 +1,89 @@ +/** + * Data texture factories for the NamePass subsystem. + * Uses createTexture2D from gl-utils to eliminate boilerplate. + */ + +import { createTexture2D } from "../../utils/GlUtils"; +import type { ParsedAtlas } from "./Types"; +import { CHAR_RANGE, LINES_PER_PLAYER, MAX_CHARS } from "./Types"; + +/** Glyph metrics: CHAR_RANGE x 2, RGBA32F. Static — uploaded once. */ +export function buildGlyphMetricsTex( + gl: WebGL2RenderingContext, + atlas: ParsedAtlas, +): WebGLTexture { + const data = new Float32Array(CHAR_RANGE * 2 * 4); + + for (const ch of atlas.chars) { + if (ch.id >= CHAR_RANGE) continue; + // Row 0: xadvance, xoffset, yoffset, width + const r0 = ch.id * 4; + data[r0 + 0] = ch.xadvance; + data[r0 + 1] = ch.xoffset; + data[r0 + 2] = ch.yoffset; + data[r0 + 3] = ch.width; + // Row 1: height, atlasU0, atlasV0, atlasU1 + const r1 = (CHAR_RANGE + ch.id) * 4; + data[r1 + 0] = ch.height; + data[r1 + 1] = ch.x / atlas.scaleW; + data[r1 + 2] = ch.y / atlas.scaleH; + data[r1 + 3] = (ch.x + ch.width) / atlas.scaleW; + // v1 is computed in shader as v0 + height/scaleH + } + + return createTexture2D(gl, { + width: CHAR_RANGE, + height: 2, + internalFormat: gl.RGBA32F, + format: gl.RGBA, + type: gl.FLOAT, + data, + }); +} + +/** Cursor positions: MAX_CHARS x (maxPlayers * LINES_PER_PLAYER), R32F. Dynamic. */ +export function buildCursorTex( + gl: WebGL2RenderingContext, + maxPlayers: number, +): WebGLTexture { + const height = maxPlayers * LINES_PER_PLAYER; + return createTexture2D(gl, { + width: MAX_CHARS, + height, + internalFormat: gl.R32F, + format: gl.RED, + type: gl.FLOAT, + data: new Float32Array(MAX_CHARS * height), + }); +} + +/** String data: MAX_CHARS x (maxPlayers * LINES_PER_PLAYER), R8UI. Dynamic. */ +export function buildStringTex( + gl: WebGL2RenderingContext, + maxPlayers: number, +): WebGLTexture { + const height = maxPlayers * LINES_PER_PLAYER; + return createTexture2D(gl, { + width: MAX_CHARS, + height, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: new Uint8Array(MAX_CHARS * height), + }); +} + +/** Player data: 8 x maxPlayers, RGBA32F. Dynamic. */ +export function buildPlayerDataTex( + gl: WebGL2RenderingContext, + maxPlayers: number, +): WebGLTexture { + return createTexture2D(gl, { + width: 8, + height: maxPlayers, + internalFormat: gl.RGBA32F, + format: gl.RGBA, + type: gl.FLOAT, + data: new Float32Array(8 * maxPlayers * 4), + }); +} diff --git a/src/client/render/gl/passes/name-pass/DebugProgram.ts b/src/client/render/gl/passes/name-pass/DebugProgram.ts new file mode 100644 index 0000000000..c9c7e18efc --- /dev/null +++ b/src/client/render/gl/passes/name-pass/DebugProgram.ts @@ -0,0 +1,91 @@ +/** + * DebugProgram — wireframe bounding boxes for name/flag layout debugging. + * + * Owns: shader program, uniform locations. + * The shared playerDataTex is passed in but not owned/deleted. + */ + +import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json"; +import type { RenderSettings } from "../../RenderSettings"; +import debugBoxFragSrc from "../../shaders/name/debug-box.frag.glsl?raw"; +import debugBoxVertSrc from "../../shaders/name/debug-box.vert.glsl?raw"; +import { createProgram } from "../../utils/GlUtils"; +import type { ParsedAtlas } from "./Types"; + +export class DebugProgram { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private playerDataTex: WebGLTexture; + private maxPlayers: number; + + private uCamera: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uLerpSpeed: WebGLUniformLocation; + private uCullThreshold: WebGLUniformLocation; + private uNameScaleFactor: WebGLUniformLocation; + private uNameScaleCap: WebGLUniformLocation; + + constructor( + gl: WebGL2RenderingContext, + atlas: ParsedAtlas, + playerDataTex: WebGLTexture, + maxPlayers: number, + ) { + this.gl = gl; + this.playerDataTex = playerDataTex; + this.maxPlayers = maxPlayers; + + const fm = flagAtlasMeta as any; + this.program = createProgram(gl, debugBoxVertSrc, debugBoxFragSrc); + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0); + gl.uniform1f( + gl.getUniformLocation(this.program, "uFontSize")!, + atlas.fontSize, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base); + gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW); + gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH); + + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!; + this.uCullThreshold = gl.getUniformLocation( + this.program, + "uCullThreshold", + )!; + this.uNameScaleFactor = gl.getUniformLocation( + this.program, + "uNameScaleFactor", + )!; + this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!; + } + + draw( + cameraMatrix: Float32Array, + settings: RenderSettings, + vao: WebGLVertexArrayObject, + ): void { + const gl = this.gl; + const ns = settings.name; + gl.useProgram(this.program); + + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTime, performance.now() / 1000); + gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed); + gl.uniform1f(this.uCullThreshold, ns.cullThreshold); + gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor); + gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex); + + gl.bindVertexArray(vao); + // 3 instances per player: name box, flag box, center dot + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.maxPlayers * 3); + } + + dispose(): void { + this.gl.deleteProgram(this.program); + } +} diff --git a/src/client/render/gl/passes/name-pass/IconProgram.ts b/src/client/render/gl/passes/name-pass/IconProgram.ts new file mode 100644 index 0000000000..447522049f --- /dev/null +++ b/src/client/render/gl/passes/name-pass/IconProgram.ts @@ -0,0 +1,189 @@ +/** + * IconProgram — instanced flag + emoji icons beside player names. + * + * Owns: shader program, uniform locations, flag atlas + emoji atlas textures. + * The shared playerDataTex is passed in but not owned/deleted. + */ + +import emojiAtlasMeta from "resources/atlases/emoji-atlas-meta.json"; +import flagAtlasMeta from "resources/atlases/flag-atlas-meta.json"; +import { assetUrl } from "src/core/AssetUrls"; +import type { RenderSettings } from "../../RenderSettings"; +import iconFragSrc from "../../shaders/name/icon.frag.glsl?raw"; +import iconVertSrc from "../../shaders/name/icon.vert.glsl?raw"; +import { createProgram } from "../../utils/GlUtils"; +import type { ParsedAtlas } from "./Types"; + +const emojiAtlasUrl = assetUrl("atlases/emoji-atlas.png"); +const flagAtlasUrl = assetUrl("atlases/flag-atlas.png"); + +export class IconProgram { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private playerDataTex: WebGLTexture; + private maxPlayers: number; + + private flagAtlasTex: WebGLTexture | null = null; + private emojiAtlasTex: WebGLTexture | null = null; + private iconsReady = false; + + // Dynamic uniform locations + private uCamera: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uLerpSpeed: WebGLUniformLocation; + private uCullThreshold: WebGLUniformLocation; + private uNameScaleFactor: WebGLUniformLocation; + private uNameScaleCap: WebGLUniformLocation; + private uEmojiRowOffset: WebGLUniformLocation; + + constructor( + gl: WebGL2RenderingContext, + atlas: ParsedAtlas, + playerDataTex: WebGLTexture, + maxPlayers: number, + ) { + this.gl = gl; + this.playerDataTex = playerDataTex; + this.maxPlayers = maxPlayers; + + this.program = createProgram(gl, iconVertSrc, iconFragSrc); + gl.useProgram(this.program); + + // Texture unit bindings + gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uFlagAtlas"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uEmojiAtlas"), 2); + + // Static uniforms from atlas metadata + const fm = flagAtlasMeta as any; + const em = emojiAtlasMeta as any; + gl.uniform1f( + gl.getUniformLocation(this.program, "uFontSize")!, + atlas.fontSize, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base); + gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellW")!, fm.cellW); + gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCellH")!, fm.cellH); + gl.uniform1f(gl.getUniformLocation(this.program, "uFlagCols")!, fm.cols); + gl.uniform1f(gl.getUniformLocation(this.program, "uFlagAtlasW")!, fm.width); + gl.uniform1f( + gl.getUniformLocation(this.program, "uFlagAtlasH")!, + fm.height, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uEmojiCell")!, + em.cellSize, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uEmojiCols")!, em.cols); + gl.uniform1f( + gl.getUniformLocation(this.program, "uEmojiAtlasW")!, + em.width, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uEmojiAtlasH")!, + em.height, + ); + + // Dynamic uniform locations + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!; + this.uCullThreshold = gl.getUniformLocation( + this.program, + "uCullThreshold", + )!; + this.uNameScaleFactor = gl.getUniformLocation( + this.program, + "uNameScaleFactor", + )!; + this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!; + this.uEmojiRowOffset = gl.getUniformLocation( + this.program, + "uEmojiRowOffset", + )!; + + this.loadAtlases(); + } + + get ready(): boolean { + return this.iconsReady; + } + + private loadAtlases(): void { + const gl = this.gl; + const load = (url: string, cb: (tex: WebGLTexture) => void) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + img, + ); + gl.generateMipmap(gl.TEXTURE_2D); + cb(tex); + }; + img.src = url; + }; + load(flagAtlasUrl, (tex) => { + this.flagAtlasTex = tex; + this.iconsReady = + this.flagAtlasTex !== null && this.emojiAtlasTex !== null; + }); + load(emojiAtlasUrl, (tex) => { + this.emojiAtlasTex = tex; + this.iconsReady = + this.flagAtlasTex !== null && this.emojiAtlasTex !== null; + }); + } + + draw( + cameraMatrix: Float32Array, + settings: RenderSettings, + vao: WebGLVertexArrayObject, + ): void { + if (!this.iconsReady) return; + + const gl = this.gl; + const ns = settings.name; + gl.useProgram(this.program); + + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTime, performance.now() / 1000); + gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed); + gl.uniform1f(this.uCullThreshold, ns.cullThreshold); + gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor); + gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap); + gl.uniform1f(this.uEmojiRowOffset, ns.emojiRowOffset); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.flagAtlasTex!); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.emojiAtlasTex!); + + gl.bindVertexArray(vao); + gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, this.maxPlayers * 2); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + if (this.flagAtlasTex) gl.deleteTexture(this.flagAtlasTex); + if (this.emojiAtlasTex) gl.deleteTexture(this.emojiAtlasTex); + } +} diff --git a/src/client/render/gl/passes/name-pass/StatusIconProgram.ts b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts new file mode 100644 index 0000000000..ba071543f9 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/StatusIconProgram.ts @@ -0,0 +1,166 @@ +/** + * StatusIconProgram — instanced status icons above player names. + * + * Renders up to 8 status icons per player (crown, traitor, disconnected, + * alliance, alliance request, target, embargo, nuke). Each instance reads + * individual float flags from pd5/pd6 to decide whether to draw. + * + * Owns: shader program, uniform locations, status atlas texture. + * The shared playerDataTex is passed in but not owned/deleted. + */ + +import statusAtlasMeta from "resources/atlases/status-atlas-meta.json"; +import { assetUrl } from "src/core/AssetUrls"; +import type { RenderSettings } from "../../RenderSettings"; +import statusFragSrc from "../../shaders/name/status-icon.frag.glsl?raw"; +import statusVertSrc from "../../shaders/name/status-icon.vert.glsl?raw"; +import { createProgram } from "../../utils/GlUtils"; +import type { ParsedAtlas } from "./Types"; + +const statusAtlasUrl = assetUrl("atlases/status-atlas.png"); + +const MAX_STATUS_ICONS = 8; + +export class StatusIconProgram { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private playerDataTex: WebGLTexture; + private maxPlayers: number; + + private statusAtlasTex: WebGLTexture | null = null; + private atlasReady = false; + + // Dynamic uniform locations + private uCamera: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uLerpSpeed: WebGLUniformLocation; + private uCullThreshold: WebGLUniformLocation; + private uNameScaleFactor: WebGLUniformLocation; + private uNameScaleCap: WebGLUniformLocation; + private uStatusRowOffset: WebGLUniformLocation; + + constructor( + gl: WebGL2RenderingContext, + atlas: ParsedAtlas, + playerDataTex: WebGLTexture, + maxPlayers: number, + ) { + this.gl = gl; + this.playerDataTex = playerDataTex; + this.maxPlayers = maxPlayers; + + this.program = createProgram(gl, statusVertSrc, statusFragSrc); + gl.useProgram(this.program); + + // Texture unit bindings + gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uStatusAtlas"), 1); + + // Static uniforms from atlas metadata + const sm = statusAtlasMeta as any; + gl.uniform1f( + gl.getUniformLocation(this.program, "uFontSize")!, + atlas.fontSize, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uFontBase")!, atlas.base); + gl.uniform1f( + gl.getUniformLocation(this.program, "uStatusCell")!, + sm.cellSize, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uStatusCols")!, sm.cols); + gl.uniform1f( + gl.getUniformLocation(this.program, "uStatusAtlasW")!, + sm.width, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uStatusAtlasH")!, + sm.height, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uStatusPad")!, + sm.pad ?? 0, + ); + + // Dynamic uniform locations + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!; + this.uCullThreshold = gl.getUniformLocation( + this.program, + "uCullThreshold", + )!; + this.uNameScaleFactor = gl.getUniformLocation( + this.program, + "uNameScaleFactor", + )!; + this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!; + this.uStatusRowOffset = gl.getUniformLocation( + this.program, + "uStatusRowOffset", + )!; + + this.loadAtlas(); + } + + private loadAtlas(): void { + const gl = this.gl; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + gl.LINEAR_MIPMAP_LINEAR, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + gl.generateMipmap(gl.TEXTURE_2D); + this.statusAtlasTex = tex; + this.atlasReady = true; + }; + img.src = statusAtlasUrl; + } + + draw( + cameraMatrix: Float32Array, + settings: RenderSettings, + vao: WebGLVertexArrayObject, + ): void { + if (!this.atlasReady) return; + + const gl = this.gl; + const ns = settings.name; + gl.useProgram(this.program); + + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTime, performance.now() / 1000); + gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed); + gl.uniform1f(this.uCullThreshold, ns.cullThreshold); + gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor); + gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap); + gl.uniform1f(this.uStatusRowOffset, ns.statusRowOffset); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.statusAtlasTex!); + + gl.bindVertexArray(vao); + gl.drawArraysInstanced( + gl.TRIANGLES, + 0, + 6, + this.maxPlayers * MAX_STATUS_ICONS, + ); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + if (this.statusAtlasTex) gl.deleteTexture(this.statusAtlasTex); + } +} diff --git a/src/client/render/gl/passes/name-pass/TextLayout.ts b/src/client/render/gl/passes/name-pass/TextLayout.ts new file mode 100644 index 0000000000..6b41dca4e4 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/TextLayout.ts @@ -0,0 +1,74 @@ +/** + * Pure CPU text shaping — cursor position computation and number formatting. + * No WebGL dependency. + */ + +import type { GlyphTables } from "./AtlasData"; +import { CHAR_RANGE, MAX_CHARS } from "./Types"; + +export interface LayoutResult { + charCodes: Uint8Array; // char code per slot (MAX_CHARS, zero-padded) + cursors: Float32Array; // centered cursor X per slot (MAX_CHARS) + halfWidth: number; // visual half-width in font units +} + +/** + * Lay out a string: encode char codes, compute advance-based cursor X + * positions, then center on visual bounds. + * + * Writes into caller-provided buffers to avoid allocation. + */ +export function layoutString( + text: string, + glyph: GlyphTables, + kernTable: Int8Array, + charCodes: Uint8Array, + cursors: Float32Array, +): number { + charCodes.fill(0); + cursors.fill(0); + const len = Math.min(text.length, MAX_CHARS); + + for (let i = 0; i < len; i++) { + charCodes[i] = text.charCodeAt(i); + } + + // Advance-based cursor positions + let cumulative = 0; + let prevCode = 0; + for (let i = 0; i < len; i++) { + const code = charCodes[i]; + cursors[i] = cumulative; + let adv = glyph.advance[code]; + if (i > 0) { + adv += kernTable[prevCode * CHAR_RANGE + code]; + } + cumulative += adv; + prevCode = code; + } + + // Center on visual bounds (not advance bounds) + const firstCode = charCodes[0]; + const lastCode = charCodes[len - 1]; + const visualLeft = cursors[0] + glyph.xOffset[firstCode]; + const visualRight = + cursors[len - 1] + glyph.xOffset[lastCode] + glyph.visW[lastCode]; + const visualCenter = (visualLeft + visualRight) * 0.5; + for (let i = 0; i < len; i++) { + cursors[i] -= visualCenter; + } + + return (visualRight - visualLeft) * 0.5; +} + +/** Format internal troop count for display (internal values are 10x display). */ +export function formatTroops(internalTroops: number): string { + const troops = internalTroops / 10; + if (troops >= 1_000_000) { + return (troops / 1_000_000).toFixed(1) + "M"; + } + if (troops >= 1_000) { + return (troops / 1_000).toFixed(1) + "K"; + } + return troops.toFixed(0); +} diff --git a/src/client/render/gl/passes/name-pass/TextProgram.ts b/src/client/render/gl/passes/name-pass/TextProgram.ts new file mode 100644 index 0000000000..d76c57e40d --- /dev/null +++ b/src/client/render/gl/passes/name-pass/TextProgram.ts @@ -0,0 +1,200 @@ +/** + * TextProgram — MSDF text rendering (player names + troop counts). + * + * Owns: shader program, uniform locations, MSDF atlas texture (async loaded). + * Shared textures (glyphMetrics, cursor, strings, playerData) are passed in + * and bound at draw time but not owned/deleted by this class. + */ + +import { assetUrl } from "src/core/AssetUrls"; +import type { RenderSettings } from "../../RenderSettings"; +import nameFragSrc from "../../shaders/name/name.frag.glsl?raw"; +import nameVertSrc from "../../shaders/name/name.vert.glsl?raw"; +import { createProgram, shaderSrc } from "../../utils/GlUtils"; +import type { ParsedAtlas } from "./Types"; +import { LINES_PER_PLAYER, MAX_CHARS } from "./Types"; + +const atlasUrl = assetUrl("atlases/msdf-atlas.png"); + +export interface TextProgramTextures { + glyphMetrics: WebGLTexture; + cursor: WebGLTexture; + strings: WebGLTexture; + playerData: WebGLTexture; +} + +export class TextProgram { + private gl: WebGL2RenderingContext; + private program: WebGLProgram; + private textures: TextProgramTextures; + + // Async-loaded MSDF atlas + private atlasTex: WebGLTexture | null = null; + private atlasReady = false; + + // Uniform locations + private uCamera: WebGLUniformLocation; + private uTime: WebGLUniformLocation; + private uDistRange: WebGLUniformLocation; + private uLerpSpeed: WebGLUniformLocation; + private uCullThreshold: WebGLUniformLocation; + private uNameScaleFactor: WebGLUniformLocation; + private uNameScaleCap: WebGLUniformLocation; + private uTroopSizeMultiplier: WebGLUniformLocation; + private uOutlineWidth: WebGLUniformLocation; + private uNightAmbient: WebGLUniformLocation; + private uOutlineColor: WebGLUniformLocation; + private uOutlineUsePlayerColor: WebGLUniformLocation; + private uFillUsePlayerColor: WebGLUniformLocation; + + private distanceRange: number; + + constructor( + gl: WebGL2RenderingContext, + atlas: ParsedAtlas, + textures: TextProgramTextures, + ) { + this.gl = gl; + this.textures = textures; + this.distanceRange = atlas.distanceRange; + + this.program = createProgram( + gl, + shaderSrc(nameVertSrc, { MAX_CHARS, LINES_PER_PLAYER }), + nameFragSrc, + ); + + // Texture unit bindings + gl.useProgram(this.program); + gl.uniform1i(gl.getUniformLocation(this.program, "uAtlas"), 0); + gl.uniform1i(gl.getUniformLocation(this.program, "uGlyphMetrics"), 1); + gl.uniform1i(gl.getUniformLocation(this.program, "uCursorX"), 2); + gl.uniform1i(gl.getUniformLocation(this.program, "uStrings"), 3); + gl.uniform1i(gl.getUniformLocation(this.program, "uPlayerData"), 4); + + // Static uniforms + gl.uniform1f( + gl.getUniformLocation(this.program, "uFontSize")!, + atlas.fontSize, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uAtlasScaleW")!, + atlas.scaleW, + ); + gl.uniform1f( + gl.getUniformLocation(this.program, "uAtlasScaleH")!, + atlas.scaleH, + ); + gl.uniform1f(gl.getUniformLocation(this.program, "uBase")!, atlas.base); + + // Dynamic uniform locations + this.uCamera = gl.getUniformLocation(this.program, "uCamera")!; + this.uTime = gl.getUniformLocation(this.program, "uTime")!; + this.uDistRange = gl.getUniformLocation(this.program, "uDistRange")!; + this.uLerpSpeed = gl.getUniformLocation(this.program, "uLerpSpeed")!; + this.uCullThreshold = gl.getUniformLocation( + this.program, + "uCullThreshold", + )!; + this.uNameScaleFactor = gl.getUniformLocation( + this.program, + "uNameScaleFactor", + )!; + this.uNameScaleCap = gl.getUniformLocation(this.program, "uNameScaleCap")!; + this.uTroopSizeMultiplier = gl.getUniformLocation( + this.program, + "uTroopSizeMultiplier", + )!; + this.uOutlineWidth = gl.getUniformLocation(this.program, "uOutlineWidth")!; + this.uNightAmbient = gl.getUniformLocation(this.program, "uNightAmbient")!; + this.uOutlineColor = gl.getUniformLocation(this.program, "uOutlineColor")!; + this.uOutlineUsePlayerColor = gl.getUniformLocation( + this.program, + "uOutlineUsePlayerColor", + )!; + this.uFillUsePlayerColor = gl.getUniformLocation( + this.program, + "uFillUsePlayerColor", + )!; + + this.loadAtlas(); + } + + get ready(): boolean { + return this.atlasReady; + } + + private loadAtlas(): void { + const gl = this.gl; + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); + this.atlasTex = tex; + this.atlasReady = true; + }; + img.src = atlasUrl; + } + + draw( + cameraMatrix: Float32Array, + settings: RenderSettings, + vao: WebGLVertexArrayObject, + maxPlayers: number, + ambient: number, + ): void { + if (!this.atlasReady) return; + + const gl = this.gl; + const ns = settings.name; + gl.useProgram(this.program); + + gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); + gl.uniform1f(this.uTime, performance.now() / 1000); + gl.uniform1f(this.uDistRange, this.distanceRange); + gl.uniform1f(this.uLerpSpeed, ns.lerpSpeed); + gl.uniform1f(this.uCullThreshold, ns.cullThreshold); + gl.uniform1f(this.uNameScaleFactor, ns.nameScaleFactor); + gl.uniform1f(this.uNameScaleCap, ns.nameScaleCap); + gl.uniform1f(this.uTroopSizeMultiplier, ns.troopSizeMultiplier); + gl.uniform1f(this.uOutlineWidth, ns.outlineWidth); + gl.uniform1f(this.uNightAmbient, ambient); + gl.uniform3f(this.uOutlineColor, ns.outlineR, ns.outlineG, ns.outlineB); + gl.uniform1f( + this.uOutlineUsePlayerColor, + ns.outlineUsePlayerColor ? 1.0 : 0.0, + ); + gl.uniform1f(this.uFillUsePlayerColor, ns.fillUsePlayerColor ? 1.0 : 0.0); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTex!); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.textures.glyphMetrics); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.textures.cursor); + gl.activeTexture(gl.TEXTURE3); + gl.bindTexture(gl.TEXTURE_2D, this.textures.strings); + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.textures.playerData); + + gl.bindVertexArray(vao); + gl.drawArraysInstanced( + gl.TRIANGLES, + 0, + 6, + maxPlayers * LINES_PER_PLAYER * MAX_CHARS, + ); + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.program); + if (this.atlasTex) gl.deleteTexture(this.atlasTex); + } +} diff --git a/src/client/render/gl/passes/name-pass/Types.ts b/src/client/render/gl/passes/name-pass/Types.ts new file mode 100644 index 0000000000..85e3d9c8b7 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/Types.ts @@ -0,0 +1,88 @@ +/** + * Shared types and constants for the NamePass subsystem. + */ + +// --------------------------------------------------------------------------- +// BMFont JSON types +// --------------------------------------------------------------------------- + +export interface BMChar { + id: number; + char: string; + width: number; + height: number; + xoffset: number; + yoffset: number; + xadvance: number; + x: number; + y: number; + page: number; +} + +export interface BMKerning { + first: number; + second: number; + amount: number; +} + +export interface ParsedAtlas { + fontSize: number; + base: number; + scaleW: number; + scaleH: number; + distanceRange: number; + chars: BMChar[]; + kernings: BMKerning[]; +} + +// --------------------------------------------------------------------------- +// Per-player CPU-side state +// --------------------------------------------------------------------------- + +export interface PlayerSlot { + index: number; + playerID: string; + static: import("../../../types").PlayerStatic; + + srcX: number; + srcY: number; + srcScale: number; + tgtX: number; + tgtY: number; + tgtScale: number; + startTime: number; + + alive: boolean; + nameLen: number; + troopLen: number; + lastTroopStr: string; + flagAtlasIdx: number; + emojiAtlasIdx: number; + nameHalfWidth: number; + + // Status flags (individual booleans, written as 1.0/0.0 to GPU) + crown: boolean; + traitor: boolean; + disconnected: boolean; + alliance: boolean; + allianceReq: boolean; + target: boolean; + embargo: boolean; + nukeActive: boolean; + nukeTargetsMe: boolean; + traitorRemainingTicks: number; + allianceFraction: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Max char ID in the atlas (Latin Extended-A goes to 383). */ +export const CHAR_RANGE = 384; + +/** Max characters per text line (name or troop count). */ +export const MAX_CHARS = 32; + +/** Lines per player: 0 = name, 1 = troop count. */ +export const LINES_PER_PLAYER = 2; diff --git a/src/client/render/gl/passes/name-pass/index.ts b/src/client/render/gl/passes/name-pass/index.ts new file mode 100644 index 0000000000..10598307c8 --- /dev/null +++ b/src/client/render/gl/passes/name-pass/index.ts @@ -0,0 +1,573 @@ +/** + * NamePass — GPU-rendered player names + troop counts using MSDF text. + * + * All text layout, interpolation, and sizing runs on the GPU via instanced + * rendering. CPU cost per frame is effectively zero: one uniform update and + * one instanced draw call. Data changes (position/size targets, troop counts) + * are pushed as tiny texture sub-updates. + * + * Submodules: + * - text-program — MSDF text shader (names + troop counts) + * - icon-program — instanced flag + emoji icons + * - debug-program — wireframe bounding boxes for layout debugging + * - atlas-data — font/atlas parsing + glyph lookup tables + * - text-layout — pure CPU text shaping (cursor positions) + * - data-textures — GL data texture factories + * - types — shared interfaces + constants + */ + +import type { + NameEntry, + PlayerState, + PlayerStatic, + PlayerStatusData, + RendererConfig, +} from "../../../types"; +import { PlayerTypeEnum } from "../../../types"; +import type { RenderSettings } from "../../RenderSettings"; +import { createFullscreenQuad } from "../../utils/GlUtils"; + +import type { GlyphTables } from "./AtlasData"; +import { + buildEmojiLookup, + buildFlagLookup, + buildGlyphTables, + buildKernTable, + parseAtlasData, +} from "./AtlasData"; +import { + buildCursorTex, + buildGlyphMetricsTex, + buildPlayerDataTex, + buildStringTex, +} from "./DataTextures"; +import { DebugProgram } from "./DebugProgram"; +import { IconProgram } from "./IconProgram"; +import { StatusIconProgram } from "./StatusIconProgram"; +import { formatTroops, layoutString } from "./TextLayout"; +import { TextProgram } from "./TextProgram"; +import type { PlayerSlot } from "./Types"; +import { LINES_PER_PLAYER, MAX_CHARS } from "./Types"; + +export class NamePass { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + + // Shared geometry + private vao: WebGLVertexArrayObject; + + // Shared data textures + private glyphMetricsTex: WebGLTexture; + private cursorTex: WebGLTexture; + private stringTex: WebGLTexture; + private playerDataTex: WebGLTexture; + + // Sub-programs + private textProgram: TextProgram; + private iconProgram: IconProgram; + private statusIconProgram: StatusIconProgram; + private debugProgram: DebugProgram; + + // Atlas + glyph data + private glyph: GlyphTables; + private kernTable: Int8Array; + + // Player management + private playerByID: Map; + private smallIDToPlayerID: Map; + private slots: Map = new Map(); + private maxPlayers: number; + private playerColors: Map = new Map(); + private flagCodeToIndex: Map; + private emojiCharToIndex: Map; + + // CPU-side mirrors — batched upload in draw() + private cpuPlayerData: Float32Array; + private cpuStringData: Uint8Array; + private cpuCursorData: Float32Array; + private playerDataDirty = false; + private stringDataDirty = false; + private cursorDataDirty = false; + + // Reusable buffers for text layout + private stringRow: Uint8Array; + private cursorRow: Float32Array; + + // Reusable per-tick lookup maps (avoid allocation + GC) + private alivePlayerIDs = new Set(); + private troopsByPlayerID = new Map(); + private playerStateByID = new Map(); + + constructor( + gl: WebGL2RenderingContext, + header: RendererConfig, + paletteData: Float32Array, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.maxPlayers = header.maxPlayers ?? header.players.length; + + // Parse atlas + build CPU lookup tables + const atlas = parseAtlasData(); + this.glyph = buildGlyphTables(atlas.chars); + this.kernTable = buildKernTable(atlas.kernings); + this.flagCodeToIndex = buildFlagLookup(); + this.emojiCharToIndex = buildEmojiLookup(); + + // Build player lookups and extract territory colors from palette + this.playerByID = new Map(); + this.smallIDToPlayerID = new Map(); + for (const p of header.players) { + this.playerByID.set(p.id, p); + this.smallIDToPlayerID.set(p.smallID, p.id); + const off = p.smallID * 4; + this.playerColors.set(p.id, [ + paletteData[off], + paletteData[off + 1], + paletteData[off + 2], + ]); + } + + // CPU-side texture mirrors + reusable layout buffers + const textRows = this.maxPlayers * LINES_PER_PLAYER; + this.cpuPlayerData = new Float32Array(8 * this.maxPlayers * 4); + this.cpuStringData = new Uint8Array(MAX_CHARS * textRows); + this.cpuCursorData = new Float32Array(MAX_CHARS * textRows); + this.stringRow = new Uint8Array(MAX_CHARS); + this.cursorRow = new Float32Array(MAX_CHARS); + + // Shared VAO (unit [0,1]² quad) + this.vao = createFullscreenQuad(gl); + + // Data textures + this.glyphMetricsTex = buildGlyphMetricsTex(gl, atlas); + this.cursorTex = buildCursorTex(gl, this.maxPlayers); + this.stringTex = buildStringTex(gl, this.maxPlayers); + this.playerDataTex = buildPlayerDataTex(gl, this.maxPlayers); + + // Sub-programs + this.textProgram = new TextProgram(gl, atlas, { + glyphMetrics: this.glyphMetricsTex, + cursor: this.cursorTex, + strings: this.stringTex, + playerData: this.playerDataTex, + }); + this.iconProgram = new IconProgram( + gl, + atlas, + this.playerDataTex, + this.maxPlayers, + ); + this.statusIconProgram = new StatusIconProgram( + gl, + atlas, + this.playerDataTex, + this.maxPlayers, + ); + this.debugProgram = new DebugProgram( + gl, + atlas, + this.playerDataTex, + this.maxPlayers, + ); + } + + // ------------------------------------------------------------------------- + // Late player registration (bots arrive on tick 1) + // ------------------------------------------------------------------------- + + /** Register players that arrived after construction (palette already updated). */ + addPlayers(players: PlayerStatic[], paletteData: Float32Array): void { + for (const p of players) { + if (this.playerByID.has(p.id)) continue; + this.playerByID.set(p.id, p); + this.smallIDToPlayerID.set(p.smallID, p.id); + const off = p.smallID * 4; + this.playerColors.set(p.id, [ + paletteData[off], + paletteData[off + 1], + paletteData[off + 2], + ]); + } + } + + // ------------------------------------------------------------------------- + // Name updates — called by GPURenderer + // ------------------------------------------------------------------------- + + updateNames( + names: Map, + players: Map, + snap: boolean, + statusData?: Map, + ): void { + const now = performance.now() / 1000; + + // Build alive set and emoji lookup from smallID → playerID + const alivePlayerIDs = this.alivePlayerIDs; + alivePlayerIDs.clear(); + const troopsByPlayerID = this.troopsByPlayerID; + troopsByPlayerID.clear(); + const playerStateByID = this.playerStateByID; + playerStateByID.clear(); + for (const [, ps] of players) { + const pid = this.smallIDToPlayerID.get(ps.smallID); + if (!pid) continue; + if (ps.isAlive) alivePlayerIDs.add(pid); + troopsByPlayerID.set(pid, ps.troops ?? 0); + playerStateByID.set(pid, ps); + } + + // Assign slot indices to players (stable ordering by header index) + let nextSlotIndex = 0; + for (const p of this.playerByID.values()) { + if (!this.slots.has(p.id)) { + const flagCode = p.flag; + this.slots.set(p.id, { + index: nextSlotIndex++, + playerID: p.id, + static: p, + srcX: 0, + srcY: 0, + srcScale: 0, + tgtX: 0, + tgtY: 0, + tgtScale: 0, + startTime: now, + alive: false, + nameLen: 0, + troopLen: 0, + lastTroopStr: "", + flagAtlasIdx: flagCode + ? (this.flagCodeToIndex.get(flagCode) ?? -1) + : -1, + emojiAtlasIdx: -1, + nameHalfWidth: 0, + crown: false, + traitor: false, + disconnected: false, + alliance: false, + allianceReq: false, + target: false, + embargo: false, + nukeActive: false, + nukeTargetsMe: false, + traitorRemainingTicks: 0, + allianceFraction: 0, + }); + } else { + nextSlotIndex = Math.max( + nextSlotIndex, + this.slots.get(p.id)!.index + 1, + ); + } + } + + for (const [playerID, entry] of names) { + const slot = this.slots.get(playerID); + if (!slot) continue; + + const alive = alivePlayerIDs.has(playerID); + + // Skip dead players already marked dead — no work needed + if (!alive && !slot.alive) continue; + + // Newly dead: mark and write once, then skip expensive work + if (!alive && slot.alive) { + slot.alive = false; + this.writePlayerDataRow(slot); + continue; + } + + // Track whether anything changed that requires a GPU write + let dirty = !slot.alive; // first time alive → must write + slot.alive = alive; + + // Write name string (only on first encounter) + if (slot.nameLen === 0) { + const name = slot.static.displayName; + slot.nameLen = Math.min(name.length, MAX_CHARS); + slot.nameHalfWidth = this.uploadStringRow( + slot.index * LINES_PER_PLAYER, + name, + ); + dirty = true; + } + + // Write troop count string (only if changed) + const troops = troopsByPlayerID.get(playerID) ?? 0; + const troopStr = formatTroops(troops); + if (troopStr !== slot.lastTroopStr) { + slot.troopLen = Math.min(troopStr.length, MAX_CHARS); + slot.lastTroopStr = troopStr; + this.uploadStringRow(slot.index * LINES_PER_PLAYER + 1, troopStr); + dirty = true; + } + + // Check if target position changed — only then recompute lerp source + if ( + entry.x !== slot.tgtX || + entry.y !== slot.tgtY || + entry.size !== slot.tgtScale + ) { + if (!snap) { + const elapsed = now - slot.startTime; + const t = Math.min( + 1 - Math.exp(-this.settings.name.lerpSpeed * elapsed), + 1, + ); + slot.srcX = slot.srcX + (slot.tgtX - slot.srcX) * t; + slot.srcY = slot.srcY + (slot.tgtY - slot.srcY) * t; + slot.srcScale = slot.srcScale + (slot.tgtScale - slot.srcScale) * t; + } else { + slot.srcX = entry.x; + slot.srcY = entry.y; + slot.srcScale = entry.size; + } + slot.tgtX = entry.x; + slot.tgtY = entry.y; + slot.tgtScale = entry.size; + slot.startTime = now; + dirty = true; + } + + // Resolve active broadcast emoji for this player + let newEmoji = -1; + const ps = playerStateByID.get(playerID); + if (ps?.outgoingEmojis && ps.outgoingEmojis.length > 0) { + for (const e of ps.outgoingEmojis) { + if (e.recipientID === "AllPlayers") { + const idx = this.emojiCharToIndex.get(e.message); + if (idx !== undefined) { + newEmoji = idx; + break; + } + } + } + } + if (newEmoji !== slot.emojiAtlasIdx) { + slot.emojiAtlasIdx = newEmoji; + dirty = true; + } + + // Resolve status data from per-player map — diff each field + const sd = statusData?.get(slot.static.smallID); + const crown = sd?.crown ?? false; + const traitor = sd?.traitor ?? false; + const disconnected = sd?.disconnected ?? false; + const alliance = sd?.alliance ?? false; + const allianceReq = sd?.allianceReq ?? false; + const target = sd?.target ?? false; + const embargo = sd?.embargo ?? false; + const nukeActive = sd?.nukeActive ?? false; + const nukeTargetsMe = sd?.nukeTargetsMe ?? false; + const traitorRemainingTicks = sd?.traitorRemainingTicks ?? 0; + const allianceFraction = sd?.allianceFraction ?? 0; + + if ( + crown !== slot.crown || + traitor !== slot.traitor || + disconnected !== slot.disconnected || + alliance !== slot.alliance || + allianceReq !== slot.allianceReq || + target !== slot.target || + embargo !== slot.embargo || + nukeActive !== slot.nukeActive || + nukeTargetsMe !== slot.nukeTargetsMe || + traitorRemainingTicks !== slot.traitorRemainingTicks || + allianceFraction !== slot.allianceFraction + ) { + slot.crown = crown; + slot.traitor = traitor; + slot.disconnected = disconnected; + slot.alliance = alliance; + slot.allianceReq = allianceReq; + slot.target = target; + slot.embargo = embargo; + slot.nukeActive = nukeActive; + slot.nukeTargetsMe = nukeTargetsMe; + slot.traitorRemainingTicks = traitorRemainingTicks; + slot.allianceFraction = allianceFraction; + dirty = true; + } + + if (dirty) this.writePlayerDataRow(slot); + } + + // Update alive/dead status for players not in the names map + for (const [pid, slot] of this.slots) { + if (!names.has(pid) && slot.alive) { + slot.alive = false; + this.writePlayerDataRow(slot); + } + } + } + + // ------------------------------------------------------------------------- + // Texture sub-update helpers + // ------------------------------------------------------------------------- + + /** Lay out a string into CPU buffers (flushed to GPU in draw). Returns halfWidth. */ + private uploadStringRow(row: number, text: string): number { + const halfWidth = layoutString( + text, + this.glyph, + this.kernTable, + this.stringRow, + this.cursorRow, + ); + + const off = row * MAX_CHARS; + this.cpuStringData.set(this.stringRow, off); + this.cpuCursorData.set(this.cursorRow, off); + this.stringDataDirty = true; + this.cursorDataDirty = true; + + return halfWidth; + } + + /** Pack player data into the CPU buffer (flushed to GPU in draw). */ + private writePlayerDataRow(slot: PlayerSlot): void { + const d = this.cpuPlayerData; + const off = slot.index * 32; // 8 columns × 4 floats per RGBA texel + + // Column 0: srcX, srcY, srcScale, startTime + d[off + 0] = slot.srcX; + d[off + 1] = slot.srcY; + d[off + 2] = slot.srcScale; + d[off + 3] = slot.startTime; + + // Column 1: tgtX, tgtY, tgtScale, alive + d[off + 4] = slot.tgtX; + d[off + 5] = slot.tgtY; + d[off + 6] = slot.tgtScale; + d[off + 7] = slot.alive ? 1.0 : 0.0; + + // Column 2: player territory color (r, g, b) + alpha + const color = this.playerColors.get(slot.playerID) ?? [0, 0, 0]; + d[off + 8] = color[0]; + d[off + 9] = color[1]; + d[off + 10] = color[2]; + d[off + 11] = 1.0; + + // Column 3: nameLen, troopLen, isHuman, nameHalfWidth + d[off + 12] = slot.nameLen; + d[off + 13] = slot.troopLen; + d[off + 14] = slot.static.playerType === PlayerTypeEnum.Human ? 1.0 : 0.0; + d[off + 15] = slot.nameHalfWidth; + + // Column 4: flagAtlasIdx, emojiAtlasIdx, [free], [free] + d[off + 16] = slot.flagAtlasIdx; + d[off + 17] = slot.emojiAtlasIdx; + d[off + 18] = 0; + d[off + 19] = 0; + + // Column 5: crown, traitor, disconnected, alliance + d[off + 20] = slot.crown ? 1.0 : 0.0; + d[off + 21] = slot.traitor ? 1.0 : 0.0; + d[off + 22] = slot.disconnected ? 1.0 : 0.0; + d[off + 23] = slot.alliance ? 1.0 : 0.0; + + // Column 6: allianceReq, target, embargo, nukeActive + d[off + 24] = slot.allianceReq ? 1.0 : 0.0; + d[off + 25] = slot.target ? 1.0 : 0.0; + d[off + 26] = slot.embargo ? 1.0 : 0.0; + d[off + 27] = slot.nukeActive ? 1.0 : 0.0; + + // Column 7: nukeTargetsMe, traitorRemainingTicks, allianceFraction, [free] + d[off + 28] = slot.nukeTargetsMe ? 1.0 : 0.0; + d[off + 29] = slot.traitorRemainingTicks; + d[off + 30] = slot.allianceFraction; + d[off + 31] = 0; + + this.playerDataDirty = true; + } + + // ------------------------------------------------------------------------- + // Render + // ------------------------------------------------------------------------- + + draw(cameraMatrix: Float32Array, ambient: number): void { + if (!this.textProgram.ready) return; + if (this.slots.size === 0) return; + + const gl = this.gl; + if (this.stringDataDirty) { + gl.bindTexture(gl.TEXTURE_2D, this.stringTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + MAX_CHARS, + this.maxPlayers * LINES_PER_PLAYER, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + this.cpuStringData, + ); + this.stringDataDirty = false; + } + if (this.cursorDataDirty) { + gl.bindTexture(gl.TEXTURE_2D, this.cursorTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + MAX_CHARS, + this.maxPlayers * LINES_PER_PLAYER, + gl.RED, + gl.FLOAT, + this.cpuCursorData, + ); + this.cursorDataDirty = false; + } + if (this.playerDataDirty) { + gl.bindTexture(gl.TEXTURE_2D, this.playerDataTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + 8, + this.maxPlayers, + gl.RGBA, + gl.FLOAT, + this.cpuPlayerData, + ); + this.playerDataDirty = false; + } + + this.textProgram.draw( + cameraMatrix, + this.settings, + this.vao, + this.maxPlayers, + ambient, + ); + this.iconProgram.draw(cameraMatrix, this.settings, this.vao); + this.statusIconProgram.draw(cameraMatrix, this.settings, this.vao); + + if (this.settings.passEnabled.nameDebug) { + this.debugProgram.draw(cameraMatrix, this.settings, this.vao); + } + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + dispose(): void { + const gl = this.gl; + this.textProgram.dispose(); + this.iconProgram.dispose(); + this.statusIconProgram.dispose(); + this.debugProgram.dispose(); + gl.deleteTexture(this.glyphMetricsTex); + gl.deleteTexture(this.cursorTex); + gl.deleteTexture(this.stringTex); + gl.deleteTexture(this.playerDataTex); + gl.deleteVertexArray(this.vao); + } +} diff --git a/src/client/render/gl/render-settings.json b/src/client/render/gl/render-settings.json new file mode 100644 index 0000000000..e51eaef0f3 --- /dev/null +++ b/src/client/render/gl/render-settings.json @@ -0,0 +1,314 @@ +{ + "passEnabled": { + "terrain": true, + "mapOverlay": true, + "structure": true, + "unit": true, + "name": true, + "falloutBloom": true, + "railroad": true, + "fx": true, + "bar": true, + "nameDebug": false + }, + "falloutBloom": { + "broilSpeedCold": 0.0018, + "broilSpeedHot": 0, + "noiseFreq1": 0.059, + "noiseFreq2": 0.171, + "contrastLoCold": 0.52, + "contrastLoHot": 0, + "contrastHiCold": 1, + "contrastHiHot": 0, + "metaFreq": 0.02, + "intensityCold": 0.15, + "intensityHot": 0.6, + "metaInfluenceCold": 1, + "metaInfluenceHot": 0, + "opacityFadeEnd": 1, + "bloomR": 0.054901960784313725, + "bloomG": 0.8196078431372549, + "bloomB": 0, + "bloomCoverage": 1.1, + "heatDecayPerTick": 1 + }, + "dayNight": { + "mode": "light", + "nightAmbient": 0.15, + "dayAmbient": 1, + "falloffPower": 2, + "falloutLightR": 0.15, + "falloutLightG": 0.95, + "falloutLightB": 0.15, + "falloutLightIntensity": 5.2, + "falloutLightThreshold": 0.01, + "emberLightR": 1, + "emberLightG": 0.4, + "emberLightB": 0.05, + "emberLightIntensity": 3, + "blurZoomDivisor": 4, + "lightRadiusMultiplier": 1 + }, + "mapOverlay": { + "trailAlpha": 0.588, + "defenseCheckerDarken": 0.7, + "charcoalBase": 0, + "charcoalVariation": 0.05, + "charcoalAlpha": 0.87, + "emberThresholdUnowned": 0.85, + "emberThresholdOwned": 0.875, + "emberFlickerSpeed": 0.12, + "emberColorDarkR": 0.6, + "emberColorDarkG": 0.15, + "emberColorDarkB": 0, + "emberColorBrightR": 1, + "emberColorBrightG": 0.5, + "emberColorBrightB": 0.05, + "emberStrengthUnowned": 0.5, + "highlightBrighten": 0.25, + "highlightFillBrighten": 0.15, + "highlightThicken": 2, + "defensePostRange": 30, + "embargoTintRatio": 0.35, + "friendlyTintRatio": 0.35 + }, + "railroad": { + "railMinZoom": 2, + "railDetailZoom": 4, + "railAlpha": 1 + }, + "structure": { + "iconSize": 50, + "dotsZoomThreshold": 1.2, + "dotScale": 0.3, + "iconScaleFactorZoomedOut": 3, + "iconGrowZoom": 7, + "shapes": { + "City": { + "scale": 1, + "iconFill": 0.85 + }, + "Port": { + "scale": 1.08, + "iconFill": 0.85 + }, + "Factory": { + "scale": 1, + "iconFill": 0.85 + }, + "Defense Post": { + "scale": 1, + "iconFill": 0.8 + }, + "SAM Launcher": { + "scale": 1.4, + "iconFill": 1 + }, + "Missile Silo": { + "scale": 1.55, + "iconFill": 0.85 + } + }, + "highlightOutlineWidth": 0.04, + "highlightDimAlpha": 0.3 + }, + "structureLevel": { + "scale": 1.2, + "outlineWidth": 1.4 + }, + "bar": { + "healthBarW": 11, + "healthBarH": 3, + "healthBarOffsetY": -6, + "progressBarW": 14, + "progressBarH": 3, + "progressBarOffsetY": 6, + "borderWidth": 1, + "threshold1": 0.25, + "threshold2": 0.5, + "threshold3": 0.75, + "colorRedR": 0.91, + "colorRedG": 0.098, + "colorRedB": 0.098, + "colorOrangeR": 0.941, + "colorOrangeG": 0.478, + "colorOrangeB": 0.098, + "colorYellowR": 0.792, + "colorYellowG": 0.906, + "colorYellowB": 0.059, + "colorGreenR": 0.173, + "colorGreenG": 0.937, + "colorGreenB": 0.071 + }, + "unit": { + "unitSize": 13, + "flickerSpeed": 0.3, + "angryR": 0.784, + "angryG": 0, + "angryB": 0 + }, + "name": { + "lerpSpeed": 10, + "cullThreshold": 0.008, + "nameScaleFactor": 0.4, + "nameScaleCap": 3, + "troopSizeMultiplier": 0.6, + "outlineWidth": 1.4, + "outlineR": 1.0, + "outlineG": 1.0, + "outlineB": 1.0, + "outlineUsePlayerColor": false, + "fillUsePlayerColor": true, + "emojiRowOffset": 1.4, + "statusRowOffset": 2.5 + }, + "fx": { + "shockwaveRingWidth": 0.04, + "nukeShockwaveDurationMs": 1500, + "nukeShockwaveRadiusFactor": 1.5, + "samShockwaveDurationMs": 800, + "samShockwaveRadius": 40, + "debrisLifetimeMs": 6000, + "debrisFadeIn": 0.1, + "debrisFadeOut": 0.8, + "conquestLifetimeMs": 2500, + "conquestFadeIn": 0.1, + "conquestFadeOut": 0.6 + }, + "nukeTrajectory": { + "lineWidth": 1.25, + "outlineWidth": 1.5, + "dashTargetable": 8, + "gapTargetable": 4, + "dashUntargetable": 2, + "gapUntargetable": 6, + "lineR": 1, + "lineG": 1, + "lineB": 1, + "interceptR": 1, + "interceptG": 0.314, + "interceptB": 0.314, + "outlineR": 0.549, + "outlineG": 0.549, + "outlineB": 0.549, + "interceptOutlineR": 0.588, + "interceptOutlineG": 0.353, + "interceptOutlineB": 0.353, + "markerCircleRadius": 6, + "markerXRadius": 8 + }, + "nukeTelegraph": { + "strokeWidth": 1.5, + "dashLen": 12, + "gapLen": 6, + "rotationSpeed": 20, + "baseAlpha": 0.85, + "pulseAmplitude": 0.1, + "pulseSpeed": 3, + "fillAlphaOffset": 0.6, + "colorR": 1, + "colorG": 0, + "colorB": 0 + }, + "moveIndicator": { + "startRadius": 13, + "chevronSize": 5, + "lineWidth": 2, + "duration": 800, + "converge": 0.7 + }, + "samRadius": { + "strokeWidth": 1.5, + "dashLen": 12, + "gapLen": 6, + "rotationSpeed": 14, + "alpha": 0.8, + "outlineWidth": 0.4, + "outlineSoftness": 0.15 + }, + "bonusPopup": { + "scale": 6, + "lifetimeMs": 1500, + "riseSpeed": 3, + "yOffset": -3, + "outlineWidth": 2, + "colorR": 1, + "colorG": 1, + "colorB": 1, + "minScreenScale": 0.15, + "cullZoom": 0.3 + }, + "spawnOverlay": { + "highlightRadius": 9, + "highlightAlpha": 1.0, + "selfMinRad": 8, + "selfMaxRad": 24, + "mateMinRad": 5, + "mateMaxRad": 14, + "animSpeed": 0.0035, + "gradientInnerEdge": 0.01, + "gradientSolidEnd": 0.1 + }, + "altView": { + "gridFontSize": 16, + "recolorStructures": true + }, + "lightConfigs": { + "City": { + "radius": 18, + "intensity": 1.2 + }, + "Port": { + "radius": 12, + "intensity": 1 + }, + "Factory": { + "radius": 12, + "intensity": 1 + }, + "Defense Post": { + "radius": 10, + "intensity": 0.9 + }, + "SAM Launcher": { + "radius": 10, + "intensity": 0.9 + }, + "Missile Silo": { + "radius": 10, + "intensity": 0.9 + }, + "Transport": { + "radius": 6, + "intensity": 2.7 + }, + "Trade Ship": { + "radius": 6, + "intensity": 2.7 + }, + "Warship": { + "radius": 10, + "intensity": 2.8 + }, + "Atom Bomb": { + "radius": 16, + "intensity": 1.1 + }, + "Hydrogen Bomb": { + "radius": 22, + "intensity": 1.3 + }, + "MIRV": { + "radius": 18, + "intensity": 1.2 + }, + "MIRV Warhead": { + "radius": 12, + "intensity": 1 + }, + "Train": { + "radius": 8, + "intensity": 2 + } + } +} diff --git a/src/client/render/gl/shaders/bar/bar.frag.glsl b/src/client/render/gl/shaders/bar/bar.frag.glsl new file mode 100644 index 0000000000..9ebb15ab62 --- /dev/null +++ b/src/client/render/gl/shaders/bar/bar.frag.glsl @@ -0,0 +1,41 @@ +#version 300 es +precision highp float; + +uniform vec2 uBarSize; +uniform float uBorderWidth; +uniform vec3 uThresholds; +uniform vec3 uColorRed; +uniform vec3 uColorOrange; +uniform vec3 uColorYellow; +uniform vec3 uColorGreen; + +in vec2 vLocalPos; +flat in float vProgress; + +out vec4 fragColor; + +void main() { + float x = vLocalPos.x; + float y = vLocalPos.y; + float w = uBarSize.x; + float h = uBarSize.y; + + // Border on each side + float bw = uBorderWidth; + bool inBorder = x < bw || x > w - bw || y < bw || y > h - bw; + + // Colored fill region + float fillWidth = vProgress * (w - 2.0 * bw); + bool inFill = !inBorder && (x - bw) < fillWidth; + + if (inFill) { + vec3 color; + if (vProgress < uThresholds.x) color = uColorRed; + else if (vProgress < uThresholds.y) color = uColorOrange; + else if (vProgress < uThresholds.z) color = uColorYellow; + else color = uColorGreen; + fragColor = vec4(color, 1.0); + } else { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + } +} diff --git a/src/client/render/gl/shaders/bar/bar.vert.glsl b/src/client/render/gl/shaders/bar/bar.vert.glsl new file mode 100644 index 0000000000..237b906ff9 --- /dev/null +++ b/src/client/render/gl/shaders/bar/bar.vert.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; // unit quad [0,1]x[0,1] +layout(location = 1) in vec3 aInstData; // x, y, progress + +uniform mat3 uCamera; +uniform vec2 uBarSize; // (width, height) in world tiles +uniform vec2 uBarOffset; // offset from unit center in tiles + +out vec2 vLocalPos; // [0, barWidth] x [0, barHeight] +flat out float vProgress; + +void main() { + float worldX = aInstData.x; + float worldY = aInstData.y; + vProgress = aInstData.z; + + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + vec2 barOrigin = center + uBarOffset; + vec2 worldPos = barOrigin + aPos * uBarSize; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = aPos * uBarSize; +} diff --git a/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl b/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl new file mode 100644 index 0000000000..c5ca55d806 --- /dev/null +++ b/src/client/render/gl/shaders/border-compute/border-compute.frag.glsl @@ -0,0 +1,123 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTileTex; // R16UI — tile state per cell +uniform usampler2D uRelationTex; // R8UI — relationship matrix (ownerA × ownerB) +uniform vec2 uMapSize; +uniform uint uHighlightOwner; +uniform int uHighlightThicken; // Chebyshev radius for highlight expansion +uniform float uTick; +uniform float uEmberThresholdUnowned; +uniform float uEmberThresholdOwned; +uniform float uEmberFlickerSpeed; + +// Defense post proximity — (x, y, ownerID, _) per post +uniform vec4 uDefensePosts[MAX_DEFENSE_POSTS]; +uniform int uDefensePostCount; +uniform float uDefensePostRange; + +out vec4 fragColor; + +uint getOwner(ivec2 c) { + if (c.x < 0 || c.y < 0 || c.x >= int(uMapSize.x) || c.y >= int(uMapSize.y)) + return 0u; + return texelFetch(uTileTex, c, 0).r & uint(OWNER_MASK); +} + +void main() { + ivec2 tc = ivec2(gl_FragCoord.xy); + if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard; + + uint raw = texelFetch(uTileTex, tc, 0).r; + uint owner = raw & uint(OWNER_MASK); + bool fallout = (raw & (1u << FALLOUT_BIT)) != 0u; + + // --- Border detection --- + float borderType = 0.0; // 0=interior, ~0.5=normal border, ~1.0=highlight border + uint maxRel = 0u; // 0=neutral, 1=friendly, 2=embargo + + if (owner != 0u) { + // Cardinal neighbor check (standard border) + uint n = getOwner(tc + ivec2( 0, -1)); + uint s = getOwner(tc + ivec2( 0, 1)); + uint w = getOwner(tc + ivec2(-1, 0)); + uint e = getOwner(tc + ivec2( 1, 0)); + + bool isBorder = (n != owner) || (s != owner) || (w != owner) || (e != owner); + + if (isBorder) { + borderType = 0.5; // normal border + + // Relationship lookup for each cardinal neighbor with different owner + if (n != owner && n != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, n), 0).r); + if (s != owner && s != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, s), 0).r); + if (w != owner && w != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, w), 0).r); + if (e != owner && e != 0u) maxRel = max(maxRel, texelFetch(uRelationTex, ivec2(owner, e), 0).r); + } + + // Highlight: N-tile Chebyshev expansion + if (uHighlightOwner != 0u && owner == uHighlightOwner) { + if (isBorder) { + borderType = 1.0; // upgrade to highlight border + } else { + // Check expanding rings for any tile with different owner + for (int d = 1; d <= 10; d++) { + if (d > uHighlightThicken) break; + bool found = false; + // Check all tiles at Chebyshev distance d + for (int i = -d; i <= d; i++) { + // Top/bottom edges + if (getOwner(tc + ivec2(i, -d)) != owner) { found = true; break; } + if (getOwner(tc + ivec2(i, d)) != owner) { found = true; break; } + } + if (!found) { + for (int i = -d + 1; i <= d - 1; i++) { + // Left/right edges (excluding corners already checked) + if (getOwner(tc + ivec2(-d, i)) != owner) { found = true; break; } + if (getOwner(tc + ivec2( d, i)) != owner) { found = true; break; } + } + } + if (found) { + borderType = 1.0; // highlight border + break; + } + } + } + } + } + + // --- Defense post proximity --- + float defenseFlag = 0.0; + if (borderType > 0.0 && owner != 0u) { + float rangeSq = uDefensePostRange * uDefensePostRange; + for (int i = 0; i < MAX_DEFENSE_POSTS; i++) { + if (i >= uDefensePostCount) break; + vec4 dp = uDefensePosts[i]; + if (uint(dp.z) != owner) continue; + float dx = float(tc.x) - dp.x; + float dy = float(tc.y) - dp.y; + if (dx * dx + dy * dy <= rangeSq) { + defenseFlag = 1.0; + break; + } + } + } + + // --- Ember detection --- + float emberIntensity = 0.0; + if (fallout) { + float h = fract(sin(float(tc.x) * 12.9898 + float(tc.y) * 78.233) * 43758.5453); + float h2 = fract(sin(float(tc.x) * 63.7 + float(tc.y) * 157.3) * 23421.631); + float threshold = (owner == 0u) ? uEmberThresholdUnowned : uEmberThresholdOwned; + if (h2 > threshold) { + float flicker = max(0.0, sin(uTick * uEmberFlickerSpeed + h * 12.0) * 0.8 + 0.2); + flicker *= flicker; // sharpen + emberIntensity = flicker; + } + } + + // A = relationship: 0.0=neutral, 0.5=friendly, 1.0=embargo + float relation = float(maxRel) * 0.5; + fragColor = vec4(borderType, emberIntensity, defenseFlag, relation); +} diff --git a/src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl b/src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl new file mode 100644 index 0000000000..870ce6f0ea --- /dev/null +++ b/src/client/render/gl/shaders/conquest-popup/conquest-popup.frag.glsl @@ -0,0 +1,38 @@ +#version 300 es +precision highp float; + +uniform sampler2D uAtlas; +uniform float uDistRange; + +in vec2 vUV; +flat in float vAlpha; +flat in vec3 vColor; +flat in float vOutlineWidth; +out vec4 fragColor; + +float median(float r, float g, float b) { + return max(min(r, g), min(max(r, g), b)); +} + +void main() { + if (vAlpha <= 0.0) discard; + + vec3 msd = texture(uAtlas, vUV).rgb; + float sd = median(msd.r, msd.g, msd.b); + + vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0)); + vec2 screenTexSize = 1.0 / fwidth(vUV); + float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); + + float screenPxDist = screenPxRange * (sd - 0.5); + float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0); + + // Colored text with dark outline + float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0); + float effectiveOutline = min(vOutlineWidth, maxOutline); + float outlineDist = screenPxDist + effectiveOutline; + float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0); + + vec3 color = mix(vec3(0.0), vColor, fillAlpha); + fragColor = vec4(color, outlineAlpha * vAlpha); +} diff --git a/src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl b/src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl new file mode 100644 index 0000000000..7f508abbc4 --- /dev/null +++ b/src/client/render/gl/shaders/conquest-popup/conquest-popup.vert.glsl @@ -0,0 +1,89 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +// Per-instance: worldX, worldY, cursorX, charCode +layout(location = 1) in vec4 aInst; +// Per-instance: alpha, colorR, colorG, colorB +layout(location = 2) in vec4 aStyle; +// Per-instance: scale, outlineWidth +layout(location = 3) in vec2 aScaleOutline; + +uniform sampler2D uGlyphMetrics; // CHAR_RANGE x 2, RGBA32F + +uniform mat3 uCamera; +uniform float uFontSize; +uniform float uAtlasScaleH; +uniform float uBase; +uniform float uZoom; +uniform float uMinScreenScale; // minimum world-scale factor when zoomed out + +out vec2 vUV; +flat out float vAlpha; +flat out vec3 vColor; +flat out float vOutlineWidth; + +void main() { + float worldX = aInst.x; + float worldY = aInst.y; + float cursorX = aInst.z; + int charCode = int(aInst.w); + + if (charCode == 0 || aStyle.x <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vAlpha = 0.0; + vColor = vec3(1.0); + vOutlineWidth = 0.0; + return; + } + + // Per-instance scale (world units per font em). + // Zoom-aware minimum: ensure popups don't shrink below minScreenScale when + // zoomed out. effectiveScale = max(scale, minScreenScale / zoom) so that + // at low zoom the popup grows in world-space to maintain a minimum screen + // footprint. + float effectiveScale = max(aScaleOutline.x, uMinScreenScale / uZoom); + float worldScale = effectiveScale / uFontSize; + + // Glyph metrics from data texture + vec4 m0 = texelFetch(uGlyphMetrics, ivec2(charCode, 0), 0); + vec4 m1 = texelFetch(uGlyphMetrics, ivec2(charCode, 1), 0); + + float glyphW = m0.w; + float glyphH = m1.x; + float u0 = m1.y; + float v0 = m1.z; + float u1 = m1.w; + float v1 = v0 + glyphH / uAtlasScaleH; + + if (glyphW <= 0.0 || glyphH <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vAlpha = 0.0; + vColor = vec3(1.0); + vOutlineWidth = 0.0; + return; + } + + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + float baselineY = -uBase * 0.5; + + vec2 glyphOrigin = vec2( + cursorX + m0.y, + baselineY + m0.z + ) * worldScale; + + vec2 glyphSize = vec2(glyphW, glyphH) * worldScale; + + vec2 worldPos = center + glyphOrigin + aPos * glyphSize; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); + vAlpha = aStyle.x; + vColor = aStyle.yzw; + vOutlineWidth = aScaleOutline.y; +} diff --git a/src/client/render/gl/shaders/crosshair/crosshair.frag.glsl b/src/client/render/gl/shaders/crosshair/crosshair.frag.glsl new file mode 100644 index 0000000000..430797df2d --- /dev/null +++ b/src/client/render/gl/shaders/crosshair/crosshair.frag.glsl @@ -0,0 +1,32 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; // [-1, +1] + +uniform vec3 uColor; + +out vec4 fragColor; + +const float LINE_HALF_W = 0.08; // line half-width (normalized to quad) +const float GAP = 0.15; // center gap radius (normalized) +const float AA = 0.02; // anti-alias width + +void main() { + float ax = abs(vLocal.x); + float ay = abs(vLocal.y); + + // Horizontal arm: |y| < lineWidth, |x| > gap + float hMask = smoothstep(LINE_HALF_W + AA, LINE_HALF_W - AA, ay) + * smoothstep(GAP - AA, GAP + AA, ax) + * (1.0 - smoothstep(1.0 - AA, 1.0, ax)); + + // Vertical arm: |x| < lineWidth, |y| > gap + float vMask = smoothstep(LINE_HALF_W + AA, LINE_HALF_W - AA, ax) + * smoothstep(GAP - AA, GAP + AA, ay) + * (1.0 - smoothstep(1.0 - AA, 1.0, ay)); + + float mask = max(hMask, vMask); + if (mask < 0.01) discard; + + fragColor = vec4(uColor, mask * 0.9); +} diff --git a/src/client/render/gl/shaders/crosshair/crosshair.vert.glsl b/src/client/render/gl/shaders/crosshair/crosshair.vert.glsl new file mode 100644 index 0000000000..99e6c1fcb3 --- /dev/null +++ b/src/client/render/gl/shaders/crosshair/crosshair.vert.glsl @@ -0,0 +1,22 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; // [0,1] quad + +uniform mat3 uCamera; +uniform vec2 uCenter; // world tile coords +uniform float uHalfSize; // half-size in pixels (screen space) +uniform vec2 uViewport; // canvas width, height in pixels + +out vec2 vLocal; // [-1, +1] + +void main() { + vLocal = aPos * 2.0 - 1.0; + + // Project center to clip space + vec3 clip = uCamera * vec3(uCenter + 0.5, 1.0); + + // Offset in screen pixels → NDC + vec2 pixelToNDC = 2.0 / uViewport; + gl_Position = vec4(clip.xy + vLocal * uHalfSize * pixelToNDC, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl new file mode 100644 index 0000000000..7d8c7a5cde --- /dev/null +++ b/src/client/render/gl/shaders/day-night/border-stamp.frag.glsl @@ -0,0 +1,79 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTileTex; +uniform sampler2D uPalette; +uniform sampler2D uBorderTex; // RGBA8 — border flags from BorderComputePass +uniform sampler2D uAffiliation; // 256×2 RGBA8 — affiliation colors (row 0 = border) +uniform vec2 uMapSize; +uniform int uAltView; +uniform float uHighlightBrighten; +uniform float uDefenseCheckerDarken; +uniform float uEmbargoTintRatio; +uniform float uFriendlyTintRatio; +uniform vec3 uEmberColorDark; +uniform vec3 uEmberColorBright; +uniform float uEmberStrengthUnowned; + +in vec2 vWorldPos; +out vec4 fragColor; + +void main() { + ivec2 tc = ivec2(floor(vWorldPos)); + if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard; + + uint raw = texelFetch(uTileTex, tc, 0).r; + uint owner = raw & uint(OWNER_MASK); + + // Read pre-computed border flags from BorderComputePass + vec4 borderData = texelFetch(uBorderTex, tc, 0); + float borderType = borderData.r; // 0=interior, ~0.5=normal, ~1.0=highlight + float emberIntensity = borderData.g; // 0–1 flicker value + bool defense = borderData.b > 0.5; // defense post proximity + float relation = borderData.a; // 0.0=neutral, ~0.5=friendly, ~1.0=embargo + + bool isBorder = borderType > 0.25; + bool isHighlightBorder = borderType > 0.75; + + // --- Border stamp: full-brightness border color --- + if (isBorder && owner != 0u) { + vec3 bc; + if (uAltView != 0) { + // Alt-view: pure affiliation color from palette row 0 + bc = texelFetch(uAffiliation, ivec2(int(owner), 0), 0).rgb; + } else { + float u = (float(owner) + 0.5) / float(PALETTE_SIZE); + bc = texture(uPalette, vec2(u, 0.75)).rgb; + if (isHighlightBorder) { + bc = mix(bc, vec3(1.0), uHighlightBrighten); + } + // Relationship tint (applied BEFORE defense checkerboard, matching game) + if (relation > 0.75) { + bc = mix(bc, vec3(1.0, 0.0, 0.0), uEmbargoTintRatio); + } else if (relation > 0.25) { + bc = mix(bc, vec3(0.0, 1.0, 0.0), uFriendlyTintRatio); + } + // Defense bonus: checkerboard darken (applied AFTER tint, matching game) + if (defense) { + bool checker = ((tc.x + tc.y) & 1) == 1; + if (checker) bc *= uDefenseCheckerDarken; + } + } + fragColor = vec4(bc, 1.0); + return; + } + + // --- Ember stamp: full-brightness ember on fallout tiles --- + if (emberIntensity > 0.0) { + float h = fract(sin(float(tc.x) * 12.9898 + float(tc.y) * 78.233) * 43758.5453); + vec3 ember = mix(uEmberColorDark, uEmberColorBright, h) * emberIntensity * uEmberStrengthUnowned; + float a = max(ember.r, max(ember.g, ember.b)); + if (a > 0.01) { + fragColor = vec4(ember, 1.0); + return; + } + } + + discard; +} diff --git a/src/client/render/gl/shaders/day-night/border-stamp.vert.glsl b/src/client/render/gl/shaders/day-night/border-stamp.vert.glsl new file mode 100644 index 0000000000..a1bc71dc34 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/border-stamp.vert.glsl @@ -0,0 +1,10 @@ +#version 300 es +precision highp float; +layout(location = 0) in vec2 aPos; +uniform mat3 uCamera; +out vec2 vWorldPos; +void main() { + vec3 clip = uCamera * vec3(aPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + vWorldPos = aPos; +} diff --git a/src/client/render/gl/shaders/day-night/composite.frag.glsl b/src/client/render/gl/shaders/day-night/composite.frag.glsl new file mode 100644 index 0000000000..611e788c24 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/composite.frag.glsl @@ -0,0 +1,18 @@ +#version 300 es +precision highp float; + +uniform sampler2D uSceneTex; +uniform sampler2D uLightTex; +uniform float uAmbient; + +in vec2 vUV; +out vec4 fragColor; + +void main() { + vec3 scene = texture(uSceneTex, vUV).rgb; + vec3 light = texture(uLightTex, vUV).rgb; + + // Scale lights inversely with ambient — invisible at full day, full strength at deep night + vec3 illumination = min(vec3(uAmbient) + light * (1.0 - uAmbient), vec3(1.2)); + fragColor = vec4(scene * illumination, 1.0); +} diff --git a/src/client/render/gl/shaders/day-night/fallout-composite.frag.glsl b/src/client/render/gl/shaders/day-night/fallout-composite.frag.glsl new file mode 100644 index 0000000000..011e09ffb8 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/fallout-composite.frag.glsl @@ -0,0 +1,8 @@ +#version 300 es +precision highp float; +uniform sampler2D uTex; +in vec2 vUV; +out vec4 fragColor; +void main() { + fragColor = texture(uTex, vUV); +} diff --git a/src/client/render/gl/shaders/day-night/fallout-composite.vert.glsl b/src/client/render/gl/shaders/day-night/fallout-composite.vert.glsl new file mode 100644 index 0000000000..0794903b25 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/fallout-composite.vert.glsl @@ -0,0 +1,11 @@ +#version 300 es +precision highp float; +layout(location = 0) in vec2 aPos; +uniform mat3 uCamera; +uniform vec2 uMapSize; +out vec2 vUV; +void main() { + vec3 clip = uCamera * vec3(aPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + vUV = aPos / uMapSize; +} diff --git a/src/client/render/gl/shaders/day-night/fallout-light.frag.glsl b/src/client/render/gl/shaders/day-night/fallout-light.frag.glsl new file mode 100644 index 0000000000..3d4246d099 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/fallout-light.frag.glsl @@ -0,0 +1,40 @@ +#version 300 es +precision highp float; +precision highp usampler2D; +uniform sampler2D uHeatTex; +uniform usampler2D uTileTex; +uniform sampler2D uBorderTex; +uniform vec2 uMapSize; +uniform vec3 uFalloutLightColor; +uniform float uFalloutLightIntensity; +uniform float uFalloutLightThreshold; +uniform vec3 uEmberLightColor; +uniform float uEmberLightIntensity; +out vec4 fragColor; +void main() { + ivec2 tc = ivec2(gl_FragCoord.xy); + if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard; + + uint raw = texelFetch(uTileTex, tc, 0).r; + bool fallout = (raw & (1u << FALLOUT_BIT)) != 0u; + if (!fallout) discard; + + float heat = texelFetch(uHeatTex, tc, 0).r; + + // Green fallout glow + vec3 light = vec3(0.0); + if (heat >= uFalloutLightThreshold) { + float fi = heat * uFalloutLightIntensity; + light += uFalloutLightColor * fi; + } + + // Ember light — read pre-computed flicker from BorderComputePass + float emberIntensity = texelFetch(uBorderTex, tc, 0).g; + if (emberIntensity > 0.0) { + light += uEmberLightColor * emberIntensity * uEmberLightIntensity; + } + + float a = max(light.r, max(light.g, light.b)); + if (a < 0.001) discard; + fragColor = vec4(light, a); +} diff --git a/src/client/render/gl/shaders/day-night/light.frag.glsl b/src/client/render/gl/shaders/day-night/light.frag.glsl new file mode 100644 index 0000000000..199e2657e0 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/light.frag.glsl @@ -0,0 +1,20 @@ +#version 300 es +precision highp float; + +in vec2 vLocalPos; +flat in vec3 vColor; +flat in float vIntensity; + +uniform float uFalloffPower; + +out vec4 fragColor; + +void main() { + float dist = length(vLocalPos) * 2.0; // [0, 1] from center to edge + if (dist > 1.0) discard; + + float falloff = pow(1.0 - dist, uFalloffPower); + + float brightness = falloff * vIntensity; + fragColor = vec4(vColor * brightness, brightness); +} diff --git a/src/client/render/gl/shaders/day-night/light.vert.glsl b/src/client/render/gl/shaders/day-night/light.vert.glsl new file mode 100644 index 0000000000..73c88a4331 --- /dev/null +++ b/src/client/render/gl/shaders/day-night/light.vert.glsl @@ -0,0 +1,29 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; // quad corner [0,1] +layout(location = 1) in vec3 aLightPosIdx; // x, y, typeIdx +layout(location = 2) in vec3 aLightColor; // r, g, b + +uniform mat3 uCamera; +uniform float uRadiusMultiplier; +uniform float uRadius[MAX_LIGHT_TYPES]; +uniform float uIntensity[MAX_LIGHT_TYPES]; + +out vec2 vLocalPos; +flat out vec3 vColor; +flat out float vIntensity; + +void main() { + int typeIdx = int(aLightPosIdx.z); + float radius = uRadius[typeIdx] * uRadiusMultiplier; + vec2 center = vec2(aLightPosIdx.x + 0.5, aLightPosIdx.y + 0.5); + vec2 worldPos = center + (aPos - 0.5) * radius * 2.0; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = aPos - 0.5; // [-0.5, 0.5] + vColor = aLightColor.rgb; + vIntensity = uIntensity[typeIdx]; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/composite.frag.glsl b/src/client/render/gl/shaders/fallout-bloom/composite.frag.glsl new file mode 100644 index 0000000000..0ce5ef82c3 --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/composite.frag.glsl @@ -0,0 +1,10 @@ +#version 300 es +precision highp float; +uniform sampler2D uTex; +uniform float uBloomCoverage; +in vec2 vUV; +out vec4 fragColor; +void main() { + vec4 bloom = texture(uTex, vUV); + fragColor = bloom * uBloomCoverage; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl b/src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl new file mode 100644 index 0000000000..0794903b25 --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/composite.vert.glsl @@ -0,0 +1,11 @@ +#version 300 es +precision highp float; +layout(location = 0) in vec2 aPos; +uniform mat3 uCamera; +uniform vec2 uMapSize; +out vec2 vUV; +void main() { + vec3 clip = uCamera * vec3(aPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + vUV = aPos / uMapSize; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/extract.frag.glsl b/src/client/render/gl/shaders/fallout-bloom/extract.frag.glsl new file mode 100644 index 0000000000..e6653425dd --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/extract.frag.glsl @@ -0,0 +1,82 @@ +#version 300 es +precision highp float; +precision highp usampler2D; +uniform usampler2D uTileTex; +uniform vec2 uMapSize; +uniform float uTick; + +uniform float uBroilSpeedCold; +uniform float uBroilSpeedHot; +uniform float uNoiseFreq1; +uniform float uNoiseFreq2; +uniform float uContrastLoCold; +uniform float uContrastLoHot; +uniform float uContrastHiCold; +uniform float uContrastHiHot; +uniform float uMetaFreq; +uniform float uIntensityCold; +uniform float uIntensityHot; +uniform float uMetaInfluenceCold; +uniform float uMetaInfluenceHot; +uniform float uOpacityFadeEnd; +uniform vec3 uBloomColor; + +uniform sampler2D uHeatTex; + +out vec4 fragColor; + +float hash3(vec3 p) { + return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); +} +float vnoise3(vec3 p) { + vec3 i = floor(p); + vec3 f = fract(p); + f = f * f * (3.0 - 2.0 * f); + float n000 = hash3(i); + float n100 = hash3(i + vec3(1, 0, 0)); + float n010 = hash3(i + vec3(0, 1, 0)); + float n110 = hash3(i + vec3(1, 1, 0)); + float n001 = hash3(i + vec3(0, 0, 1)); + float n101 = hash3(i + vec3(1, 0, 1)); + float n011 = hash3(i + vec3(0, 1, 1)); + float n111 = hash3(i + vec3(1, 1, 1)); + return mix( + mix(mix(n000, n100, f.x), mix(n010, n110, f.x), f.y), + mix(mix(n001, n101, f.x), mix(n011, n111, f.x), f.y), + f.z); +} + +void main() { + // Tile-space: viewport is mapW x mapH, one fragment per tile. + // gl_FragCoord.xy gives exact integer tile coords — completely + // deterministic, independent of camera position/zoom. + ivec2 tc = ivec2(gl_FragCoord.xy); + if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard; + + uint raw = texelFetch(uTileTex, tc, 0).r; + if ((raw & (1u << FALLOUT_BIT)) == 0u) discard; + + float heat = texelFetch(uHeatTex, tc, 0).r; + vec2 tileCenter = vec2(tc) + 0.5; + + float speed = mix(uBroilSpeedCold, uBroilSpeedHot, heat); + float t = uTick * speed; + + float n1 = vnoise3(vec3(tileCenter * uNoiseFreq1, t)); + float n2 = vnoise3(vec3(tileCenter * uNoiseFreq2, t * 1.3)); + float broil = n1 * 0.6 + n2 * 0.4; + + float lo = mix(uContrastLoCold, uContrastLoHot, heat); + float hi = mix(uContrastHiCold, uContrastHiHot, heat); + broil = smoothstep(lo, hi, broil); + + float meta = vnoise3(vec3(tileCenter * uMetaFreq, t * 0.5)); + + float baseIntensity = mix(uIntensityCold, uIntensityHot, heat); + float metaInfluence = mix(uMetaInfluenceCold, uMetaInfluenceHot, heat); + float intensity = baseIntensity * mix(1.0, meta, metaInfluence); + + float opacity = smoothstep(0.0, uOpacityFadeEnd, heat); + + fragColor = vec4(uBloomColor, 1.0) * broil * intensity * opacity; +} diff --git a/src/client/render/gl/shaders/fallout-bloom/heat-decay.frag.glsl b/src/client/render/gl/shaders/fallout-bloom/heat-decay.frag.glsl new file mode 100644 index 0000000000..41519a9837 --- /dev/null +++ b/src/client/render/gl/shaders/fallout-bloom/heat-decay.frag.glsl @@ -0,0 +1,30 @@ +#version 300 es +precision highp float; +precision highp usampler2D; +uniform sampler2D uHeatTex; +uniform usampler2D uTileTex; +uniform usampler2D uPrevTileTex; +uniform vec2 uMapSize; +uniform float uDecay; +out vec4 fragColor; +void main() { + ivec2 tc = ivec2(gl_FragCoord.xy); + if (tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) discard; + + float heat = texelFetch(uHeatTex, tc, 0).r; + uint curr = texelFetch(uTileTex, tc, 0).r; + uint prev = texelFetch(uPrevTileTex, tc, 0).r; + + bool wasFallout = (prev & (1u << FALLOUT_BIT)) != 0u; + bool isFallout = (curr & (1u << FALLOUT_BIT)) != 0u; + + if (isFallout && !wasFallout) { + heat = 1.0; + } else if (!isFallout && wasFallout) { + heat = 0.0; + } else { + heat = max(0.0, heat - uDecay / 255.0); + } + + fragColor = vec4(heat, 0.0, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/fx/attack-ring.frag.glsl b/src/client/render/gl/shaders/fx/attack-ring.frag.glsl new file mode 100644 index 0000000000..8dd9b39694 --- /dev/null +++ b/src/client/render/gl/shaders/fx/attack-ring.frag.glsl @@ -0,0 +1,40 @@ +#version 300 es +precision highp float; + +uniform float uTime; // seconds, for rotation +uniform float uRingWidth; // line thickness in normalized coords + +in vec2 vLocalPos; +flat in float vAlpha; + +out vec4 fragColor; + +const float INNER_R = 0.5; +const float OUTER_R = 0.8; +const float INNER_DASHES = 8.0; +const float OUTER_DASHES = 2.0; +const float PI = 3.14159265; + +void main() { + float dist = length(vLocalPos); + float angle = atan(vLocalPos.y, vLocalPos.x); + + // Inner ring — thin, many dashes, rotating clockwise + float innerDist = abs(dist - INNER_R); + float innerRing = 1.0 - smoothstep(0.0, uRingWidth * 2.0, innerDist); + float innerAngle = angle + uTime * 1.2; + float innerDash = smoothstep(0.4, 0.5, abs(fract(innerAngle * INNER_DASHES / (2.0 * PI)) - 0.5) * 2.0); + innerRing *= innerDash; + + // Outer ring — thick, few dashes, counter-rotating + float outerDist = abs(dist - OUTER_R); + float outerRing = 1.0 - smoothstep(0.0, uRingWidth * 3.0, outerDist); + float outerAngle = angle - uTime * 0.6; + float outerDash = smoothstep(0.3, 0.4, abs(fract(outerAngle * OUTER_DASHES / (2.0 * PI)) - 0.5) * 2.0); + outerRing *= outerDash; + + float ring = max(innerRing, outerRing); + if (ring < 0.01) discard; + + fragColor = vec4(1.0, 0.0, 0.0, ring * vAlpha); +} diff --git a/src/client/render/gl/shaders/fx/attack-ring.vert.glsl b/src/client/render/gl/shaders/fx/attack-ring.vert.glsl new file mode 100644 index 0000000000..3c66df3976 --- /dev/null +++ b/src/client/render/gl/shaders/fx/attack-ring.vert.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec3 aInstData; // x, y, alpha + +uniform mat3 uCamera; +uniform float uTilesPerPx; + +out vec2 vLocalPos; +flat out float vAlpha; + +// Upstream outer ring = 16 screen-px; quad needs headroom for SDF AA. +const float RING_SCREEN_PX = 20.0; + +void main() { + vec2 center = vec2(aInstData.x + 0.5, aInstData.y + 0.5); + vAlpha = aInstData.z; + + float worldRadius = RING_SCREEN_PX * uTilesPerPx; + vec2 worldPos = center + (aPos - 0.5) * worldRadius * 2.0; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = (aPos - 0.5) * 2.0; +} diff --git a/src/client/render/gl/shaders/fx/shockwave.frag.glsl b/src/client/render/gl/shaders/fx/shockwave.frag.glsl new file mode 100644 index 0000000000..81bb071b12 --- /dev/null +++ b/src/client/render/gl/shaders/fx/shockwave.frag.glsl @@ -0,0 +1,17 @@ +#version 300 es +precision highp float; + +uniform float uRingWidth; + +in vec2 vLocalPos; +flat in float vAlpha; + +out vec4 fragColor; + +void main() { + float dist = length(vLocalPos); + float ringDist = abs(dist - 1.0); + float ring = 1.0 - smoothstep(0.0, uRingWidth, ringDist); + if (ring < 0.01) discard; + fragColor = vec4(1.0, 1.0, 1.0, ring * vAlpha); +} diff --git a/src/client/render/gl/shaders/fx/shockwave.vert.glsl b/src/client/render/gl/shaders/fx/shockwave.vert.glsl new file mode 100644 index 0000000000..e27aa4037f --- /dev/null +++ b/src/client/render/gl/shaders/fx/shockwave.vert.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec4 aInstData; // x, y, radius, alpha + +uniform mat3 uCamera; + +out vec2 vLocalPos; +flat out float vAlpha; + +// Extra margin so the ring's outer feathering isn't clipped at the quad edge. +const float MARGIN = 1.1; // 10% beyond ring radius + +void main() { + vec2 center = vec2(aInstData.x + 0.5, aInstData.y + 0.5); + float r = aInstData.z; + vAlpha = aInstData.w; + + vec2 worldPos = center + (aPos - 0.5) * r * 2.0 * MARGIN; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // Scale vLocalPos by the same margin so dist=1.0 stays at the ring radius + vLocalPos = (aPos - 0.5) * 2.0 * MARGIN; +} diff --git a/src/client/render/gl/shaders/fx/sprite.frag.glsl b/src/client/render/gl/shaders/fx/sprite.frag.glsl new file mode 100644 index 0000000000..f076ea6896 --- /dev/null +++ b/src/client/render/gl/shaders/fx/sprite.frag.glsl @@ -0,0 +1,15 @@ +#version 300 es +precision highp float; + +uniform sampler2D uAtlas; + +in vec2 vAtlasUV; +flat in float vAlpha; + +out vec4 fragColor; + +void main() { + vec4 texel = texture(uAtlas, vAtlasUV); + if (texel.a < 0.01) discard; + fragColor = vec4(texel.rgb, texel.a * vAlpha); +} diff --git a/src/client/render/gl/shaders/fx/sprite.vert.glsl b/src/client/render/gl/shaders/fx/sprite.vert.glsl new file mode 100644 index 0000000000..56f83aeb31 --- /dev/null +++ b/src/client/render/gl/shaders/fx/sprite.vert.glsl @@ -0,0 +1,33 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec3 aInstPos; // x, y, fxType +layout(location = 2) in vec2 aInstFlags; // frameIdx (uint8), alpha (uint8) + +uniform mat3 uCamera; +uniform vec4 uFxUV[FX_TYPE_COUNT]; // vTop, vSpan, uFrameSpan, 0 +uniform vec4 uFxWorld[FX_TYPE_COUNT]; // worldW, worldH, 0, 0 + +out vec2 vAtlasUV; +flat out float vAlpha; + +void main() { + int type = int(aInstPos.z + 0.5); + float frameIdx = floor(aInstFlags.x + 0.5); + float alpha = aInstFlags.y / 255.0; + + vec4 uv = uFxUV[type]; + vec4 world = uFxWorld[type]; + + vec2 center = vec2(aInstPos.x + 0.5, aInstPos.y + 0.5); + vec2 worldPos = center + (aPos - 0.5) * vec2(world.x, world.y); + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + float u = (frameIdx + aPos.x) * uv.z; + float v = uv.x + aPos.y * uv.y; + vAtlasUV = vec2(u, v); + vAlpha = alpha; +} diff --git a/src/client/render/gl/shaders/glsl.d.ts b/src/client/render/gl/shaders/glsl.d.ts new file mode 100644 index 0000000000..03d75630b4 --- /dev/null +++ b/src/client/render/gl/shaders/glsl.d.ts @@ -0,0 +1,4 @@ +declare module "*.glsl?raw" { + const source: string; + export default source; +} diff --git a/src/client/render/gl/shaders/grid/grid.frag.glsl b/src/client/render/gl/shaders/grid/grid.frag.glsl new file mode 100644 index 0000000000..7174d0efac --- /dev/null +++ b/src/client/render/gl/shaders/grid/grid.frag.glsl @@ -0,0 +1,104 @@ +#version 300 es +precision highp float; + +uniform vec2 uMapSize; +uniform float uCellSize; +uniform float uZoom; +uniform float uFontSize; +uniform sampler2D uGlyphTex; + +in vec2 vWorldPos; +out vec4 fragColor; + +const float GLYPH_COUNT = 36.0; // 0-9, A-Z + +void main() { + vec2 wp = vWorldPos; + if (wp.x < 0.0 || wp.y < 0.0 || wp.x >= uMapSize.x || wp.y >= uMapSize.y) + discard; + + float cs = uCellSize; + float px = 1.0 / uZoom; // 1 screen pixel in world units + float lineW = px * 1.25; + + // Grid cell index + position within cell + int cellCol = int(floor(wp.x / cs)); + int cellRow = int(floor(wp.y / cs)); + float localX = wp.x - float(cellCol) * cs; + float localY = wp.y - float(cellRow) * cs; + + // --- Grid lines (at cell boundaries) --- + if (localX < lineW || localY < lineW) { + fragColor = vec4(1.0, 1.0, 1.0, 0.35); + return; + } + + // --- Labels (only when cells are large enough on screen) --- + float cellScreenPx = cs * uZoom; + if (cellScreenPx < 60.0) discard; + + float fontSize = clamp(uFontSize + (uZoom - 1.0) * 1.2, uFontSize * 0.9, uFontSize * 1.6); + float gw = fontSize * 0.6 * px; // glyph width in world units + float gh = fontSize * px; // glyph height + float pad = 8.0 * px; // padding from cell corner + float bgPad = 2.0 * px; // background extends beyond text + + float lx = localX - pad; + float ly = localY - pad; + + // Compute label characters: row alpha + col digits + // Atlas indices: 0-9 = digits '0'-'9', 10-35 = letters 'A'-'Z' + int c0, c1 = -1, c2 = -1, c3 = -1; + int nc; + + // Row part (A, B, ..., Z, AA, AB, ...) + if (cellRow < 26) { + c0 = cellRow + 10; + nc = 1; + } else { + c0 = (cellRow / 26 - 1) + 10; + c1 = (cellRow % 26) + 10; + nc = 2; + } + + // Col part (1-indexed: 1, 2, ..., 50) + int colNum = cellCol + 1; + if (nc == 1) { + if (colNum < 10) { c1 = colNum; nc = 2; } + else { c1 = colNum / 10; c2 = colNum % 10; nc = 3; } + } else { + if (colNum < 10) { c2 = colNum; nc = 3; } + else { c2 = colNum / 10; c3 = colNum % 10; nc = 4; } + } + + float totalW = float(nc) * gw; + + // Check label background area (text + padding) + if (lx < -bgPad || ly < -bgPad || lx >= totalW + bgPad || ly >= gh + bgPad) + discard; + + // Check if on actual glyph + if (lx >= 0.0 && ly >= 0.0 && lx < totalW && ly < gh) { + int ci = int(floor(lx / gw)); + if (ci < nc) { + int g; + if (ci == 0) g = c0; + else if (ci == 1) g = c1; + else if (ci == 2) g = c2; + else g = c3; + + float cu = fract(lx / gw); + float cv = ly / gh; + float au = (float(g) + cu) / GLYPH_COUNT; + float mask = texture(uGlyphTex, vec2(au, cv)).r; + + if (mask > 0.3) { + fragColor = vec4(1.0, 1.0, 1.0, 0.9); + return; + } + } + } + + // Background behind label + fragColor = vec4(0.08, 0.08, 0.08, 0.7); +} diff --git a/src/client/render/gl/shaders/map-overlay/overlay.vert.glsl b/src/client/render/gl/shaders/map-overlay/overlay.vert.glsl new file mode 100644 index 0000000000..80f9eb8dc1 --- /dev/null +++ b/src/client/render/gl/shaders/map-overlay/overlay.vert.glsl @@ -0,0 +1,14 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; + +out vec2 vWorldPos; + +void main() { + vec3 clip = uCamera * vec3(aPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + vWorldPos = aPos; +} diff --git a/src/client/render/gl/shaders/map-overlay/territory.frag.glsl b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl new file mode 100644 index 0000000000..91664f4b49 --- /dev/null +++ b/src/client/render/gl/shaders/map-overlay/territory.frag.glsl @@ -0,0 +1,51 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTileTex; // R16UI — tile state per cell +uniform sampler2D uPalette; // RGBA32F — player colors + +uniform vec2 uMapSize; +uniform int uAltView; +uniform float uCharcoalBase; +uniform float uCharcoalVariation; +uniform float uCharcoalAlpha; +uniform uint uHighlightOwner; // 0 = no highlight; otherwise smallID of hovered owner +uniform float uHighlightBrighten; // mix amount toward white for highlighted tiles + +in vec2 vWorldPos; +out vec4 fragColor; + +void main() { + ivec2 tc = ivec2(floor(vWorldPos)); + if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) + discard; + + uint raw = texelFetch(uTileTex, tc, 0).r; + uint owner = raw & uint(OWNER_MASK); + bool fallout = (raw & (1u << FALLOUT_BIT)) != 0u; + + if (owner == 0u && !fallout) discard; + + // Alt-view: hide territory fill, keep fallout charcoal + if (uAltView != 0 && owner != 0u) discard; + + // --- Fallout charcoal ground (unowned) --- + if (owner == 0u && fallout) { + float h = fract(sin(float(tc.x) * 12.9898 + float(tc.y) * 78.233) * 43758.5453); + float charcoal = uCharcoalBase + h * uCharcoalVariation; + fragColor = vec4(vec3(charcoal), uCharcoalAlpha); + return; + } + + // --- Territory fill (owned) --- + float u = (float(owner) + 0.5) / float(PALETTE_SIZE); + vec4 color = texture(uPalette, vec2(u, 0.25)); + + // Hover highlight: brighten every tile owned by the hovered player. + if (uHighlightOwner != 0u && owner == uHighlightOwner) { + color.rgb = mix(color.rgb, vec3(1.0), uHighlightBrighten); + } + + fragColor = color; +} diff --git a/src/client/render/gl/shaders/map-overlay/trail.frag.glsl b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl new file mode 100644 index 0000000000..45d8328194 --- /dev/null +++ b/src/client/render/gl/shaders/map-overlay/trail.frag.glsl @@ -0,0 +1,31 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTrailTex; // R8UI — trail ownerID per cell (0 = none) +uniform sampler2D uPalette; // RGBA32F — player colors +uniform sampler2D uAffiliation; // RGBA8 — affiliation colors (row 0 = border, row 1 = unit) +uniform vec2 uMapSize; +uniform float uTrailAlpha; +uniform int uAltView; + +in vec2 vWorldPos; +out vec4 fragColor; + +void main() { + ivec2 tc = ivec2(floor(vWorldPos)); + if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) + discard; + + uint trailOwner = texelFetch(uTrailTex, tc, 0).r; + if (trailOwner == 0u) discard; + + vec3 color; + if (uAltView != 0) { + color = texelFetch(uAffiliation, ivec2(int(trailOwner), 1), 0).rgb; + } else { + float u = (float(trailOwner) + 0.5) / float(PALETTE_SIZE); + color = texture(uPalette, vec2(u, 0.25)).rgb; + } + fragColor = vec4(color, uTrailAlpha); +} diff --git a/src/client/render/gl/shaders/move-indicator/move-indicator.frag.glsl b/src/client/render/gl/shaders/move-indicator/move-indicator.frag.glsl new file mode 100644 index 0000000000..f347a311df --- /dev/null +++ b/src/client/render/gl/shaders/move-indicator/move-indicator.frag.glsl @@ -0,0 +1,62 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; // [-1, +1] over ±HALF tiles + +uniform float uElapsed; // wall-clock ms since activation +uniform vec3 uColor; // RGB [0-1] +uniform float uPxPerTile; // camera zoom (pixels per tile) +uniform float uStartRadius; // screen px +uniform float uChevronSize; // screen px +uniform float uLineWidth; // screen px +uniform float uDuration; // ms +uniform float uConverge; // 0–1 + +out vec4 fragColor; + +const float HALF = 16.0; // quad half-size in tiles (must match vertex shader) + +// SDF: distance to a V-chevron pointing in +Y, centered at origin. +// The chevron has wings at (±w, -wingOff) meeting tip at (0, +tipOff). +float chevronSDF(vec2 p, float w, float tipOff, float wingOff) { + p.x = abs(p.x); + vec2 a = vec2(w, -wingOff); + vec2 b = vec2(0.0, tipOff); + vec2 ab = b - a; + float t = clamp(dot(p - a, ab) / dot(ab, ab), 0.0, 1.0); + return length(p - a - ab * t); +} + +void main() { + float t = uElapsed / uDuration; + if (t >= 1.0) discard; + + // Convert vLocal to screen pixels relative to center + float px = vLocal.x * HALF * uPxPerTile; + float py = vLocal.y * HALF * uPxPerTile; + + // Scale factor (matches game: grows above zoom 10) + float sc = uPxPerTile > 10.0 ? 1.0 + (uPxPerTile - 10.0) / 10.0 : 1.0; + + float radius = uStartRadius * sc * (1.0 - t * uConverge); + float cs = uChevronSize * sc; + float tipOff = cs * 0.4; + float wingOff = cs * 0.6; + float w = cs; // wing half-width + + // 4 chevrons pointing inward + float d = chevronSDF(vec2(px, -(py - radius)), w, tipOff, wingOff); + d = min(d, chevronSDF(vec2(px, py + radius), w, tipOff, wingOff)); + d = min(d, chevronSDF(vec2(py, -(px - radius)), w, tipOff, wingOff)); + d = min(d, chevronSDF(vec2(py, px + radius), w, tipOff, wingOff)); + + // Anti-aliased stroke (in screen pixels) + float half_w = uLineWidth * sc * 0.5; + float aa = 1.0; + float mask = 1.0 - smoothstep(half_w - aa, half_w + aa, d); + + if (mask < 0.01) discard; + + float alpha = 1.0 - t; + fragColor = vec4(uColor, alpha * mask); +} diff --git a/src/client/render/gl/shaders/move-indicator/move-indicator.vert.glsl b/src/client/render/gl/shaders/move-indicator/move-indicator.vert.glsl new file mode 100644 index 0000000000..aaf41ed736 --- /dev/null +++ b/src/client/render/gl/shaders/move-indicator/move-indicator.vert.glsl @@ -0,0 +1,20 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; +uniform vec2 uCenter; // world-space tile center + +out vec2 vLocal; // [-1, +1] local quad space + +void main() { + vLocal = aPos * 2.0 - 1.0; + + // Quad covers ±16 tiles around center (enough for the chevrons) + float r = 16.0; + vec2 world = uCenter + 0.5 + vLocal * r; + + vec3 clip = uCamera * vec3(world, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/name/debug-box.frag.glsl b/src/client/render/gl/shaders/name/debug-box.frag.glsl new file mode 100644 index 0000000000..6c8fb4b740 --- /dev/null +++ b/src/client/render/gl/shaders/name/debug-box.frag.glsl @@ -0,0 +1,32 @@ +#version 300 es +precision highp float; + +in vec2 vUV; +flat in int vBoxType; +flat in vec4 vColor; + +out vec4 fragColor; + +void main() { + if (vColor.a <= 0.0) discard; + + if (vBoxType == 2) { + // Center crosshair — draw a + shape, discard the four corner quadrants + float cx = abs(vUV.x - 0.5); + float cy = abs(vUV.y - 0.5); + // Each arm is 0.15 wide (30% of half-width) + if (cx > 0.15 && cy > 0.15) discard; + fragColor = vColor; + } else { + // Wireframe border for name/flag boxes + float borderWidth = 1.5; + vec2 pixelSize = fwidth(vUV); + vec2 border = borderWidth * pixelSize; + + if (vUV.x > border.x && vUV.x < 1.0 - border.x && + vUV.y > border.y && vUV.y < 1.0 - border.y) { + discard; + } + fragColor = vColor; + } +} diff --git a/src/client/render/gl/shaders/name/debug-box.vert.glsl b/src/client/render/gl/shaders/name/debug-box.vert.glsl new file mode 100644 index 0000000000..2ec7fdcd67 --- /dev/null +++ b/src/client/render/gl/shaders/name/debug-box.vert.glsl @@ -0,0 +1,108 @@ +#version 300 es +precision highp float; +precision highp int; + +layout(location = 0) in vec2 aPos; // unit quad [0,0]→[1,1] + +uniform sampler2D uPlayerData; +uniform mat3 uCamera; +uniform float uTime; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uFontSize; +uniform float uFontBase; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; + +// Flag layout (for computing flag box) +uniform float uFlagCellW; +uniform float uFlagCellH; + +out vec2 vUV; +flat out int vBoxType; // 0=name, 1=flag, 2=center +flat out vec4 vColor; + +void main() { + // 3 debug boxes per player: 0=name, 1=flag, 2=center crosshair + int playerIdx = gl_InstanceID / 3; + int boxType = gl_InstanceID - playerIdx * 3; + + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); + vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); + vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); + + // Skip dead players + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vBoxType = -1; + vColor = vec4(0.0); + return; + } + + // Lerped world position (same as name.vert.glsl) + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // Sizing pipeline (must match name.vert.glsl exactly) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + + // Zoom-based culling + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uFontBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vBoxType = -1; + vColor = vec4(0.0); + return; + } + + float nameHalfWidth = pd3.w; + + vec2 boxMin, boxMax; + + if (boxType == 0) { + // Name text bounding box (green) + float halfW = nameHalfWidth * nameWorldScale; + float halfH = uFontBase * nameWorldScale * 0.5; + boxMin = vec2(wx - halfW, wy - halfH); + boxMax = vec2(wx + halfW, wy + halfH); + vColor = vec4(0.0, 1.0, 0.0, 0.9); + } else if (boxType == 1) { + // Flag bounding box (yellow) + float flagIdx = pd4.x; + if (flagIdx < 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vBoxType = -1; + vColor = vec4(0.0); + return; + } + float halfW = nameHalfWidth * nameWorldScale; + float flagWorldH = uFontBase * nameWorldScale * 1.2; + float flagWorldW = flagWorldH * (uFlagCellW / uFlagCellH); + boxMin = vec2(wx - halfW - flagWorldW, wy - flagWorldH * 0.5); + boxMax = vec2(wx - halfW, wy + flagWorldH * 0.5); + vColor = vec4(1.0, 1.0, 0.0, 0.9); + } else { + // Center crosshair (cyan) — fixed world size proportional to name + float arm = uFontBase * nameWorldScale * 0.3; + boxMin = vec2(wx - arm, wy - arm); + boxMax = vec2(wx + arm, wy + arm); + vColor = vec4(0.0, 1.0, 1.0, 1.0); + } + + vUV = aPos; + vBoxType = boxType; + vec2 worldPos = mix(boxMin, boxMax, aPos); + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/name/icon.frag.glsl b/src/client/render/gl/shaders/name/icon.frag.glsl new file mode 100644 index 0000000000..050b83984d --- /dev/null +++ b/src/client/render/gl/shaders/name/icon.frag.glsl @@ -0,0 +1,24 @@ +#version 300 es +precision highp float; + +uniform sampler2D uFlagAtlas; +uniform sampler2D uEmojiAtlas; + +in vec2 vUV; +flat in int vIconType; // 0 = flag, 1 = emoji, -1 = discard + +out vec4 fragColor; + +void main() { + if (vIconType < 0) discard; + + vec4 texel; + if (vIconType == 0) { + texel = texture(uFlagAtlas, vUV); + } else { + texel = texture(uEmojiAtlas, vUV); + } + + if (texel.a < 0.01) discard; + fragColor = texel; +} diff --git a/src/client/render/gl/shaders/name/icon.vert.glsl b/src/client/render/gl/shaders/name/icon.vert.glsl new file mode 100644 index 0000000000..1caad7ae1b --- /dev/null +++ b/src/client/render/gl/shaders/name/icon.vert.glsl @@ -0,0 +1,154 @@ +#version 300 es +precision highp float; +precision highp int; + +// Unit quad vertex position [0,0]→[1,1] +layout(location = 0) in vec2 aPos; + +// Data textures (shared with name shader) +uniform sampler2D uPlayerData; // PLAYER_DATA_COLS × MAX_PLAYERS, RGBA32F + +// Uniforms +uniform mat3 uCamera; +uniform float uTime; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; +uniform float uFontSize; // atlas reference font size (same as name shader's uFontSize) +uniform float uFontBase; // atlas baseline height (same as name shader's uBase) + +// Flag atlas layout +uniform float uFlagCellW; // texels per flag cell (width) +uniform float uFlagCellH; // texels per flag cell (height) +uniform float uFlagCols; // columns in flag atlas +uniform float uFlagAtlasW; // flag atlas texture width +uniform float uFlagAtlasH; // flag atlas texture height + +// Emoji atlas layout +uniform float uEmojiCell; // texels per emoji cell (square) +uniform float uEmojiCols; // columns in emoji atlas +uniform float uEmojiAtlasW; // emoji atlas texture width +uniform float uEmojiAtlasH; // emoji atlas texture height + +// Row offset (multiples of uFontBase * nameWorldScale) +uniform float uEmojiRowOffset; + +out vec2 vUV; +flat out int vIconType; // 0 = flag, 1 = emoji, -1 = discard + +void main() { + // Decode instance ID → playerIdx + iconType (0=flag, 1=emoji) + int playerIdx = gl_InstanceID / 2; + int iconType = gl_InstanceID - playerIdx * 2; + + // Read player data + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive + vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, nameHalfWidth + vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0); // flagIdx, emojiIdx, [free], [free] + + // Early out: dead player + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + return; + } + + // Get atlas index for this icon type + float atlasIdx = (iconType == 0) ? pd4.x : pd4.y; + if (atlasIdx < 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + return; + } + + // Lerped world position and size (same math as name.vert.glsl) + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // Sizing pipeline (must match name.vert.glsl exactly) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + + // Zoom-based culling (same as name shader) + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uFontBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vIconType = -1; + return; + } + + float nameHalfWidth = pd3.w; // in font units (pre-scaled by nameWorldScale at runtime) + + // Compute icon size and position based on type + float iconW, iconH; + float cellW, cellH, cols, atlasW, atlasH; + vec2 iconOrigin; + + if (iconType == 0) { + // FLAG — to the left of the name + cellW = uFlagCellW; + cellH = uFlagCellH; + cols = uFlagCols; + atlasW = uFlagAtlasW; + atlasH = uFlagAtlasH; + + // Flag world size: height matches ~120% of the name text height + float flagWorldH = uFontBase * nameWorldScale * 1.2; + float flagWorldW = flagWorldH * (cellW / cellH); + + // Position: left of name, vertically centered on the name baseline + iconOrigin = vec2( + wx - nameHalfWidth * nameWorldScale - flagWorldW, + wy - flagWorldH * 0.5 + ); + iconW = flagWorldW; + iconH = flagWorldH; + } else { + // EMOJI — above the name + cellW = uEmojiCell; + cellH = uEmojiCell; + cols = uEmojiCols; + atlasW = uEmojiAtlasW; + atlasH = uEmojiAtlasH; + + // Emoji world size: slightly larger than name text height + float emojiWorldSize = uFontBase * nameWorldScale * 1.0; + + // Position: centered above name + iconOrigin = vec2( + wx - emojiWorldSize * 0.5, + wy - uFontBase * nameWorldScale * uEmojiRowOffset + ); + iconW = emojiWorldSize; + iconH = emojiWorldSize; + } + + // Quad world position + vec2 worldPos = iconOrigin + aPos * vec2(iconW, iconH); + + // Camera transform + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // UV from atlas grid + int idx = int(atlasIdx); + int col = idx - (idx / int(cols)) * int(cols); + int row = idx / int(cols); + float u0 = float(col) * cellW / atlasW; + float v0 = float(row) * cellH / atlasH; + float u1 = u0 + cellW / atlasW; + float v1 = v0 + cellH / atlasH; + vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); + vIconType = iconType; +} diff --git a/src/client/render/gl/shaders/name/name.frag.glsl b/src/client/render/gl/shaders/name/name.frag.glsl new file mode 100644 index 0000000000..b2524f8b51 --- /dev/null +++ b/src/client/render/gl/shaders/name/name.frag.glsl @@ -0,0 +1,62 @@ +#version 300 es +precision highp float; + +uniform sampler2D uAtlas; +uniform float uDistRange; +uniform float uOutlineWidth; +uniform float uNightAmbient; +uniform vec3 uOutlineColor; +uniform float uOutlineUsePlayerColor; +uniform float uFillUsePlayerColor; + +in vec2 vUV; +in vec4 vPlayerColor; // player territory color (rgb) + alpha +in float vIsHuman; // 1.0 = human, 0.0 = bot/nation +out vec4 fragColor; + +float median(float r, float g, float b) { + return max(min(r, g), min(max(r, g), b)); +} + +void main() { + // Degenerate fragment — skip + if (vPlayerColor.a <= 0.0) discard; + + // Stagger fill/border curves so they never share the same gray. + // t² for border (stays dark longer, snaps white late) and √t for fill (inverse). + // At midpoint t=0.5: border=0.25 (dark), fill=0.71 (light) — always distinct. + float t = 1.0 - uNightAmbient; + float borderT = t * t; + float fillT = sqrt(t); + + // Compute fill color: player color, or cycle-aware white↔black (inverse of border) + vec3 defaultFill = mix(uOutlineColor, vec3(0.0), fillT); + vec3 fillColor = mix(defaultFill, vPlayerColor.rgb, uFillUsePlayerColor); + + vec3 msd = texture(uAtlas, vUV).rgb; + float sd = median(msd.r, msd.g, msd.b); + + vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0)); + vec2 screenTexSize = 1.0 / fwidth(vUV); + float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); + + float screenPxDist = screenPxRange * (sd - 0.5); + float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0); + + if (uOutlineWidth > 0.0) { + // The SDF saturates at sd=0 (screenPxDist = -screenPxRange*0.5). + // Reserve a 1px margin so saturated fragments always get alpha=0. + float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0); + float effectiveOutline = min(uOutlineWidth, maxOutline); + + float outlineDist = screenPxDist + effectiveOutline; + float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0); + + vec3 nightOutlineColor = mix(vec3(0.0), uOutlineColor, borderT); + vec3 borderColor = mix(nightOutlineColor, vPlayerColor.rgb, uOutlineUsePlayerColor); + vec3 color = mix(borderColor, fillColor, fillAlpha); + fragColor = vec4(color, vPlayerColor.a * outlineAlpha); + } else { + fragColor = vec4(fillColor, vPlayerColor.a * fillAlpha); + } +} diff --git a/src/client/render/gl/shaders/name/name.vert.glsl b/src/client/render/gl/shaders/name/name.vert.glsl new file mode 100644 index 0000000000..42b6c0c3d0 --- /dev/null +++ b/src/client/render/gl/shaders/name/name.vert.glsl @@ -0,0 +1,156 @@ +#version 300 es +precision highp float; +precision highp int; +precision highp usampler2D; + +// Unit quad vertex position [0,0]→[1,1] +layout(location = 0) in vec2 aPos; + +// Data textures +uniform sampler2D uGlyphMetrics; // CHAR_RANGE × 2, RGBA32F +uniform sampler2D uCursorX; // MAX_CHARS × (MAX_PLAYERS*2), R32F — pre-computed centered cursor X +uniform usampler2D uStrings; // MAX_CHARS × (MAX_PLAYERS*2), R8UI +uniform sampler2D uPlayerData; // 4 × MAX_PLAYERS, RGBA32F + +// Uniforms +uniform mat3 uCamera; +uniform float uTime; +uniform float uFontSize; // atlas reference font size +uniform float uAtlasScaleW; // atlas texture width +uniform float uAtlasScaleH; // atlas texture height +uniform float uBase; // atlas baseline height + +const int MAX_CHARS_PER_LINE = MAX_CHARS; +const int LINES = LINES_PER_PLAYER; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; +uniform float uTroopSizeMultiplier; + +out vec2 vUV; +out vec4 vPlayerColor; // player territory color (rgb) + alpha +out float vIsHuman; // 1.0 for human, 0.0 for bot/nation + +void main() { + // 1. Decode instance ID → playerIdx, lineIdx, charPos + int slotsPerPlayer = LINES * MAX_CHARS_PER_LINE; + int playerIdx = gl_InstanceID / slotsPerPlayer; + int remainder = gl_InstanceID - playerIdx * slotsPerPlayer; + int lineIdx = remainder / MAX_CHARS_PER_LINE; + int charPos = remainder - lineIdx * MAX_CHARS_PER_LINE; + + // 2. Read player data + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive + vec4 pd2 = texelFetch(uPlayerData, ivec2(2, playerIdx), 0); // r, g, b, a + vec4 pd3 = texelFetch(uPlayerData, ivec2(3, playerIdx), 0); // nameLen, troopLen, isHuman, 0 + + // Early out: dead player + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // String length for this line + int len = (lineIdx == 0) ? int(pd3.x) : int(pd3.y); + if (charPos >= len) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 3. Read char code at this position + int stringRow = playerIdx * LINES + lineIdx; + uint charCode = texelFetch(uStrings, ivec2(charPos, stringRow), 0).r; + if (charCode == 0u) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 4. Compute lerped world position and size + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // 5. Sizing pipeline (matches NameLayer.ts) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + float worldScale = nameWorldScale; + + // Troop count is smaller + if (lineIdx == 1) { + worldScale *= uTroopSizeMultiplier; + } + + // Zoom-based culling: compute screen-space size and skip if too small + // uCamera[0][0] is the x-scale component of the camera matrix + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 6. Read pre-computed centered cursor X position + float cursorX = texelFetch(uCursorX, ivec2(charPos, stringRow), 0).r; + + // 7. Glyph metrics for this character + vec4 m0 = texelFetch(uGlyphMetrics, ivec2(int(charCode), 0), 0); // xadvance, xoffset, yoffset, width + vec4 m1 = texelFetch(uGlyphMetrics, ivec2(int(charCode), 1), 0); // height, atlasU0, atlasV0, atlasU1 + // atlasV1 packed: we need 5 values from 2 RGBA texels (8 slots), so atlasV1 is in m0 slot? + // Actually let's use: m0=(xadvance, xoffset, yoffset, width), m1=(height, u0, v0, u1), and compute v1 + float glyphW = m0.w; + float glyphH = m1.x; + float u0 = m1.y; + float v0 = m1.z; + float u1 = m1.w; + float v1 = v0 + glyphH / uAtlasScaleH; + + // Degenerate if glyph has no size (e.g. space) + if (glyphW <= 0.0 || glyphH <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vPlayerColor = vec4(0.0); + vIsHuman = 0.0; + return; + } + + // 8. Compute world-space quad position + float baselineY = -uBase * 0.5; // center vertically + // Use name-line scale for offset so troops sit below the name, not overlapping + float lineOffsetY = (lineIdx == 1) ? uBase * nameWorldScale * 1.1 : 0.0; + + vec2 glyphOrigin = vec2( + cursorX + m0.y, // + xoffset + baselineY + m0.z // + yoffset + ) * worldScale; + + vec2 glyphSize = vec2(glyphW, glyphH) * worldScale; + + vec2 worldPos = vec2(wx, wy + lineOffsetY) + glyphOrigin + aPos * glyphSize; + + // 9. Camera transform + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // 10. UV interpolation across quad + vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); + vPlayerColor = pd2; // player territory color (rgb) + alpha + vIsHuman = pd3.z; // 1.0 = human, 0.0 = bot/nation +} diff --git a/src/client/render/gl/shaders/name/status-icon.frag.glsl b/src/client/render/gl/shaders/name/status-icon.frag.glsl new file mode 100644 index 0000000000..e8ac082c03 --- /dev/null +++ b/src/client/render/gl/shaders/name/status-icon.frag.glsl @@ -0,0 +1,40 @@ +#version 300 es +precision highp float; + +uniform sampler2D uStatusAtlas; + +in vec2 vUV; +in vec2 vLocalUV; +flat in int vDiscard; +flat in float vAllianceFraction; +flat in vec2 vFadedUV0; +flat in vec2 vFadedUV1; +flat in float vFlashAlpha; + +out vec4 fragColor; + +void main() { + if (vDiscard != 0) discard; + + vec4 texel = texture(uStatusAtlas, vUV); + + // Alliance drain: composite faded icon behind colored icon, clipped by fraction. + // Matches the game's CSS clip-path: inset(topCut% -2px 0 -2px) behavior. + if (vAllianceFraction > 0.0) { + // Game formula: topCut = 20 + (1-fraction) * 80 * 0.78 (% → 0..1) + float topCut = 0.20 + (1.0 - vAllianceFraction) * 0.624; + + // Sample faded icon at corresponding local position + vec2 fadedUV = mix(vFadedUV0, vFadedUV1, vLocalUV); + vec4 fadedTexel = texture(uStatusAtlas, fadedUV); + + // Above the cut line → show faded; below → show colored + texel = vLocalUV.y < topCut ? fadedTexel : texel; + } + + // Traitor flash: modulate alpha for urgency pulse + texel.a *= vFlashAlpha; + + if (texel.a < 0.01) discard; + fragColor = texel; +} diff --git a/src/client/render/gl/shaders/name/status-icon.vert.glsl b/src/client/render/gl/shaders/name/status-icon.vert.glsl new file mode 100644 index 0000000000..dd5c6af27e --- /dev/null +++ b/src/client/render/gl/shaders/name/status-icon.vert.glsl @@ -0,0 +1,217 @@ +#version 300 es +precision highp float; +precision highp int; + +// Unit quad vertex position [0,0]→[1,1] +layout(location = 0) in vec2 aPos; + +// Data textures (shared with name shader) +uniform sampler2D uPlayerData; // 8 × MAX_PLAYERS, RGBA32F + +// Uniforms +uniform mat3 uCamera; +uniform float uTime; +uniform float uLerpSpeed; +uniform float uCullThreshold; +uniform float uNameScaleFactor; +uniform float uNameScaleCap; +uniform float uFontSize; +uniform float uFontBase; + +// Status atlas layout +uniform float uStatusCell; // texels per cell (square) +uniform float uStatusCols; // columns in atlas +uniform float uStatusAtlasW; // atlas texture width +uniform float uStatusAtlasH; // atlas texture height +uniform float uStatusPad; // transparent padding in texels per side + +// Configurable layout +uniform float uStatusRowOffset; // row Y offset (multiples of uFontBase * nameWorldScale) + +out vec2 vUV; +out vec2 vLocalUV; // 0..1 within the icon cell +flat out int vDiscard; +flat out float vAllianceFraction; // 0 = no drain effect, >0 = active drain +flat out vec2 vFadedUV0; // top-left UV of faded alliance cell +flat out vec2 vFadedUV1; // bottom-right UV of faded alliance cell +flat out float vFlashAlpha; // traitor flash opacity (1.0 = fully visible) + +// Status flag float array — indexed by icon slot. +// Slot mapping: 0=crown, 1=traitor, 2=disconnected, 3=alliance, +// 4=allianceReq, 5=target, 6=embargo, 7=nukeActive +float statusFlag[8]; + +// Read status flags from pd5/pd6 into the statusFlag array. +void readStatusFlags(int playerIdx) { + vec4 pd5 = texelFetch(uPlayerData, ivec2(5, playerIdx), 0); + vec4 pd6 = texelFetch(uPlayerData, ivec2(6, playerIdx), 0); + statusFlag[0] = pd5.x; // crown + statusFlag[1] = pd5.y; // traitor + statusFlag[2] = pd5.z; // disconnected + statusFlag[3] = pd5.w; // alliance + statusFlag[4] = pd6.x; // allianceReq + statusFlag[5] = pd6.y; // target + statusFlag[6] = pd6.z; // embargo + statusFlag[7] = pd6.w; // nukeActive +} + +// Count active icons with index < pos. +int countBelow(int pos) { + int count = 0; + for (int i = 0; i < pos; i++) { + if (statusFlag[i] > 0.5) count++; + } + return count; +} + +// Compute padded UV rect for an atlas cell. +// Returns (u0, v0) in xy and (u1, v1) in zw, inset by pad pixels. +vec4 cellUV(int idx) { + int col = idx - (idx / int(uStatusCols)) * int(uStatusCols); + int row = idx / int(uStatusCols); + float u0 = (float(col) * uStatusCell + uStatusPad) / uStatusAtlasW; + float v0 = (float(row) * uStatusCell + uStatusPad) / uStatusAtlasH; + float iconSize = uStatusCell - 2.0 * uStatusPad; + float u1 = u0 + iconSize / uStatusAtlasW; + float v1 = v0 + iconSize / uStatusAtlasH; + return vec4(u0, v0, u1, v1); +} + +void main() { + // Decode instance ID → playerIdx + iconSlot (0..7) + int playerIdx = gl_InstanceID / 8; + int iconSlot = gl_InstanceID - playerIdx * 8; + + // Read player data + vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime + vec4 pd1 = texelFetch(uPlayerData, ivec2(1, playerIdx), 0); // tgtX, tgtY, tgtScale, alive + vec4 pd7 = texelFetch(uPlayerData, ivec2(7, playerIdx), 0); // nukeTargetsMe, traitorRemainingTicks, allianceFraction, [free] + + // Early out: dead player + if (pd1.w <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vLocalUV = vec2(0.0); + vDiscard = 1; + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + vFlashAlpha = 1.0; + return; + } + + // Read status flags into array + readStatusFlags(playerIdx); + + // Early out: this icon slot is inactive + if (statusFlag[iconSlot] < 0.5) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vLocalUV = vec2(0.0); + vDiscard = 1; + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + vFlashAlpha = 1.0; + return; + } + + // Lerped world position and size (same math as name.vert.glsl) + float elapsed = uTime - pd0.w; + float t = clamp(1.0 - exp(-uLerpSpeed * elapsed), 0.0, 1.0); + float wx = mix(pd0.x, pd1.x, t); + float wy = mix(pd0.y, pd1.y, t); + float ws = mix(pd0.z, pd1.z, t); + + // Sizing pipeline (must match name.vert.glsl exactly) + float baseSize = max(1.0, floor(ws)); + float nameSize = max(4.0, floor(baseSize * uNameScaleFactor)); + float nameScale = min(baseSize * 0.25, uNameScaleCap); + float nameWorldScale = (nameSize * nameScale) / uFontSize; + + // Zoom-based culling (same as name shader) + float cameraScale = length(vec2(uCamera[0][0], uCamera[1][0])); + float screenSize = nameWorldScale * uFontBase * cameraScale; + if (screenSize < uCullThreshold) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vLocalUV = vec2(0.0); + vDiscard = 1; + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + vFlashAlpha = 1.0; + return; + } + + // Icon world size: matches name text height + float iconWorldSize = uFontBase * nameWorldScale * 1.1; + + // Count active icons and position of this one (left-to-right) + int totalActive = 0; + for (int i = 0; i < 8; i++) { + if (statusFlag[i] > 0.5) totalActive++; + } + int myIndex = countBelow(iconSlot); + + // Horizontal centering: spread icons evenly above the name + float gap = iconWorldSize * 0.15; + float totalWidth = float(totalActive) * iconWorldSize + float(totalActive - 1) * gap; + float startX = wx - totalWidth * 0.5; + float iconX = startX + float(myIndex) * (iconWorldSize + gap); + + // Position: row above the emoji row + float iconY = wy - uFontBase * nameWorldScale * uStatusRowOffset; + + // Determine atlas index + // Slots 0-6 map directly to atlas indices 0-6 + // Slot 7 (nuke): use nukeRed (7) if nukeTargetsMe, else nukeWhite (8) + int atlasIdx = iconSlot; + if (iconSlot == 7) { + atlasIdx = (pd7.x > 0.5) ? 7 : 8; + } + + // Quad world position + vec2 iconOrigin = vec2(iconX, iconY); + vec2 worldPos = iconOrigin + aPos * vec2(iconWorldSize, iconWorldSize); + + // Camera transform + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // UV from atlas grid (padded to avoid mipmap bleed) + vec4 uv = cellUV(atlasIdx); + vUV = vec2(mix(uv.x, uv.z, aPos.x), mix(uv.y, uv.w, aPos.y)); + vLocalUV = aPos; + + // Alliance drain: slot 3 = alliance icon + float allianceFrac = pd7.z; + if (iconSlot == 3 && allianceFrac > 0.0 && allianceFrac < 1.0) { + vAllianceFraction = allianceFrac; + // Faded alliance icon is at atlas index 9 + vec4 fadedUV = cellUV(9); + vFadedUV0 = fadedUV.xy; + vFadedUV1 = fadedUV.zw; + } else { + vAllianceFraction = 0.0; + vFadedUV0 = vec2(0.0); + vFadedUV1 = vec2(0.0); + } + + // Traitor flash: slot 1 = traitor icon + // Frequency ramps linearly from 2 Hz (at 15s) to 5 Hz (at 0s). + // Phase = uTime*2 + elapsed²*0.1 — the quadratic term adds smooth + // acceleration without phase discontinuities between ticks. + vFlashAlpha = 1.0; + if (iconSlot == 1) { + float remaining = pd7.y; // ticks (0-300, 10/sec) + float remainingSec = remaining / 10.0; // seconds + if (remainingSec <= 15.0 && remainingSec > 0.0) { + float elapsed = 15.0 - remainingSec; + float phase = uTime * 2.0 + elapsed * elapsed * 0.1; + vFlashAlpha = 0.3 + 0.7 * (0.5 + 0.5 * cos(phase * 6.2832)); + } + } + + vDiscard = 0; +} diff --git a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl new file mode 100644 index 0000000000..dbc2081f79 --- /dev/null +++ b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.frag.glsl @@ -0,0 +1,56 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; +flat in float vInnerRadius; +flat in float vOuterRadius; + +uniform float uTime; // seconds +uniform vec4 uTelegraphStyle; // (strokeWidth, dashLen, gapLen, rotationSpeed) +uniform vec4 uTelegraphAlpha; // (baseAlpha, pulseAmplitude, pulseSpeed, fillAlphaOffset) +uniform vec3 uTelegraphColor; + +out vec4 fragColor; + +void main() { + float strokeWidth = uTelegraphStyle.x; + float dashLen = uTelegraphStyle.y; + float gapLen = uTelegraphStyle.z; + float rotationSpeed = uTelegraphStyle.w; + float baseAlphaVal = uTelegraphAlpha.x; + float pulseAmp = uTelegraphAlpha.y; + float pulseSpd = uTelegraphAlpha.z; + float fillAlphaOff = uTelegraphAlpha.w; + + float paddedR = vOuterRadius + 2.0; + float dist = length(vLocal) * paddedR; + + // Base alpha with gentle pulsation + float baseAlpha = baseAlphaVal + pulseAmp * sin(uTime * pulseSpd); + + // Inner circle: filled disc + stroke + float innerFill = 1.0 - smoothstep(vInnerRadius - 0.5, vInnerRadius, dist); + float innerStroke = smoothstep(vInnerRadius - strokeWidth - 0.5, vInnerRadius - strokeWidth, dist) + * (1.0 - smoothstep(vInnerRadius + strokeWidth, vInnerRadius + strokeWidth + 0.5, dist)); + + // Outer circle: dashed ring + float outerRing = smoothstep(vOuterRadius - strokeWidth - 0.5, vOuterRadius - strokeWidth, dist) + * (1.0 - smoothstep(vOuterRadius + strokeWidth, vOuterRadius + strokeWidth + 0.5, dist)); + + // Dash pattern on outer ring + float angle = atan(vLocal.y, vLocal.x); + float arcPos = angle * vOuterRadius; + float period = dashLen + gapLen; + float dashPhase = mod(arcPos + uTime * rotationSpeed, period); + float dashAlpha = 1.0 - smoothstep(dashLen - 0.5, dashLen + 0.5, dashPhase); + + // Combine + float fillAlpha = innerFill * max(0.0, baseAlpha - fillAlphaOff); + float strokeAlpha = innerStroke * baseAlpha; + float outerAlpha = outerRing * dashAlpha * baseAlpha; + + float alpha = max(max(fillAlpha, strokeAlpha), outerAlpha); + if (alpha < 0.01) discard; + + fragColor = vec4(uTelegraphColor, alpha); +} diff --git a/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl new file mode 100644 index 0000000000..a53be58d0c --- /dev/null +++ b/src/client/render/gl/shaders/nuke-telegraph/nuke-telegraph.vert.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +// Unit quad [0,1] +layout(location = 0) in vec2 aPos; +// Per-instance: x, y, innerRadius, outerRadius +layout(location = 1) in vec4 aInstance; + +uniform mat3 uCamera; + +out vec2 vLocal; // [-1, +1] local coords +flat out float vInnerRadius; +flat out float vOuterRadius; + +void main() { + vLocal = aPos * 2.0 - 1.0; + vInnerRadius = aInstance.z; + vOuterRadius = aInstance.w; + + // Expand quad to cover outer circle bbox + padding + float r = aInstance.w + 2.0; + vec2 center = aInstance.xy + 0.5; + vec2 worldPos = center + vLocal * r; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl new file mode 100644 index 0000000000..62ce075694 --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.frag.glsl @@ -0,0 +1,45 @@ +#version 300 es +precision highp float; + +in vec2 vUV; +flat in float vType; + +out vec4 fragColor; + +// Colors matching upstream +const vec3 COLOR_WHITE = vec3(1.0); +const vec3 OUTLINE_GRAY = vec3(0.549); // rgba(140, 140, 140) +const vec3 COLOR_RED = vec3(1.0, 0.0, 0.0); +const vec3 OUTLINE_BLACK = vec3(0.0); + +void main() { + float alpha = 0.0; + vec3 color = vec3(1.0); + + if (vType < 0.5) { + // Circle marker at untargetable zone boundary + // White ring with gray outline (upstream: 4px radius, 1.25px stroke) + float dist = length(vUV); + float ring = abs(dist - 0.55); + float lineAlpha = 1.0 - smoothstep(0.06, 0.12, ring); + float outlineAlpha = 1.0 - smoothstep(0.14, 0.22, ring); + float blend = outlineAlpha > 0.01 ? lineAlpha / outlineAlpha : 1.0; + color = mix(OUTLINE_GRAY, COLOR_WHITE, blend); + alpha = outlineAlpha; + } else { + // X marker at SAM intercept point + // Red X with black outline (upstream: 6px arms, 2px stroke) + float d1 = abs(vUV.x - vUV.y) * 0.7071; + float d2 = abs(vUV.x + vUV.y) * 0.7071; + float minD = min(d1, d2); + float circleMask = 1.0 - smoothstep(0.7, 0.85, length(vUV)); + float lineAlpha = (1.0 - smoothstep(0.08, 0.16, minD)) * circleMask; + float outlineAlpha = (1.0 - smoothstep(0.18, 0.28, minD)) * circleMask; + float blend = outlineAlpha > 0.01 ? lineAlpha / outlineAlpha : 1.0; + color = mix(OUTLINE_BLACK, COLOR_RED, blend); + alpha = outlineAlpha; + } + + if (alpha < 0.01) discard; + fragColor = vec4(color, alpha); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl new file mode 100644 index 0000000000..4f75479cec --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory-marker.vert.glsl @@ -0,0 +1,33 @@ +#version 300 es +precision highp float; + +// Unit quad [-1, +1] +layout(location = 0) in vec2 aCorner; + +uniform mat3 uCamera; +uniform vec2 uP0, uP1, uP2, uP3; +uniform float uPixelSize; +uniform vec4 uMarker; // (t, type: 0=circle 1=X, 0, 0) +uniform vec2 uMarkerRadii; // (circleRadiusPx, xRadiusPx) + +out vec2 vUV; +flat out float vType; + +vec2 bezier(float t) { + float T = 1.0 - t; + float TT = T * T; + float tt = t * t; + return TT * T * uP0 + 3.0 * TT * t * uP1 + 3.0 * T * tt * uP2 + tt * t * uP3; +} + +void main() { + vType = uMarker.y; + vUV = aCorner; + + vec2 center = bezier(uMarker.x) + 0.5; + float radius = (vType < 0.5 ? uMarkerRadii.x : uMarkerRadii.y) * uPixelSize; + vec2 pos = center + aCorner * radius; + + vec3 clip = uCamera * vec3(pos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.frag.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.frag.glsl new file mode 100644 index 0000000000..f291e702ba --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.frag.glsl @@ -0,0 +1,54 @@ +#version 300 es +precision highp float; + +in float vT; +in float vArcDist; +in float vEdgeDist; + +uniform float uPixelSize; +uniform float uTUntargetableStart; // -1 = no zone +uniform float uTUntargetableEnd; // -1 = no zone +uniform float uTSamIntercept; // 1.0 = no intercept + +// Settings uniforms +uniform float uQuadHalfPx; // total half-width of quad in pixels +uniform float uLineHalfPx; // main line half-width in pixels +uniform float uOutlineHalfPx; // outline half-width in pixels +uniform vec4 uDashPattern; // (dashTargetable, gapTargetable, dashUntargetable, gapUntargetable) +uniform vec3 uLineColor; // normal line color +uniform vec3 uInterceptColor; // line color after SAM intercept +uniform vec3 uOutlineColor; // outline color (normal) +uniform vec3 uInterceptOutlineColor; // outline color (after intercept) + +out vec4 fragColor; + +void main() { + // Zone classification + bool inUntargetable = uTUntargetableStart >= 0.0 + && vT >= uTUntargetableStart + && vT <= uTUntargetableEnd; + bool intercepted = vT >= uTSamIntercept; + + // Dash pattern (pixel space) + float dashLen = inUntargetable ? uDashPattern.z : uDashPattern.x; + float gapLen = inUntargetable ? uDashPattern.w : uDashPattern.y; + float period = dashLen + gapLen; + float pixelDist = vArcDist / uPixelSize; + float phase = mod(pixelDist, period); + float dashAlpha = 1.0 - smoothstep(dashLen - 0.5, dashLen + 0.5, phase); + if (dashAlpha < 0.01) discard; + + // Line vs outline (pixel distance from center line) + float d = abs(vEdgeDist) * uQuadHalfPx; + float lineAlpha = 1.0 - smoothstep(uLineHalfPx - 0.4, uLineHalfPx + 0.4, d); + float outlineAlpha = 1.0 - smoothstep(uOutlineHalfPx - 0.4, uOutlineHalfPx + 0.4, d); + if (outlineAlpha < 0.01) discard; + + // Color selection + vec3 lineColor = intercepted ? uInterceptColor : uLineColor; + vec3 outlineColor = intercepted ? uInterceptOutlineColor : uOutlineColor; + float blend = outlineAlpha > 0.01 ? lineAlpha / outlineAlpha : 1.0; + vec3 color = mix(outlineColor, lineColor, blend); + + fragColor = vec4(color, outlineAlpha * dashAlpha); +} diff --git a/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.vert.glsl b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.vert.glsl new file mode 100644 index 0000000000..fd7a4d4bef --- /dev/null +++ b/src/client/render/gl/shaders/nuke-trajectory/nuke-trajectory.vert.glsl @@ -0,0 +1,48 @@ +#version 300 es +precision highp float; + +// Per-vertex: (t along curve, side: -1 or +1, cumulative arc distance) +layout(location = 0) in vec3 aVertex; + +uniform mat3 uCamera; +uniform vec2 uP0, uP1, uP2, uP3; // Bezier control points +uniform float uPixelSize; // world units per pixel +uniform float uQuadHalfPx; // half-width of quad in pixels + +out float vT; // curve parameter (0..1) +out float vArcDist; // cumulative arc distance (world units) +out float vEdgeDist; // -1..+1 across the line width + +vec2 bezier(float t) { + float T = 1.0 - t; + float TT = T * T; + float tt = t * t; + return TT * T * uP0 + 3.0 * TT * t * uP1 + 3.0 * T * tt * uP2 + tt * t * uP3; +} + +vec2 bezierDeriv(float t) { + float T = 1.0 - t; + return 3.0 * (T * T * (uP1 - uP0) + 2.0 * T * t * (uP2 - uP1) + t * t * (uP3 - uP2)); +} + +void main() { + float t = aVertex.x; + float side = aVertex.y; + + vec2 pos = bezier(t); + vec2 tang = bezierDeriv(t); + float tangLen = length(tang); + vec2 normTang = tangLen > 0.001 ? tang / tangLen : vec2(1.0, 0.0); + vec2 perp = vec2(-normTang.y, normTang.x); + + float halfWidth = uQuadHalfPx * uPixelSize; + pos += perp * side * halfWidth; + pos += 0.5; // tile center offset + + vT = t; + vEdgeDist = side; + vArcDist = aVertex.z; + + vec3 clip = uCamera * vec3(pos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/radial-menu/arcs.frag.glsl b/src/client/render/gl/shaders/radial-menu/arcs.frag.glsl new file mode 100644 index 0000000000..12481b223d --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/arcs.frag.glsl @@ -0,0 +1,132 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; // [-1, +1], distance 1.0 = outerR + +uniform float uInnerR; // inner radius as fraction of outerR [0,1] +uniform int uSegCount; // number of segments (1..8) +uniform int uHoveredSeg; // hovered segment index (-1 = none) +uniform vec4 uSegColors[8]; // per-segment: rgb + enabled (a: 1 = enabled, 0 = disabled) + +// Center button +uniform int uHasCenterBtn; // 1 = show center button +uniform vec3 uCenterColor; // center button RGB +uniform int uCenterHovered; // 1 = center button hovered + +out vec4 fragColor; + +const float GAP = 0.03; // radians gap between segments (game: padAngle 0.03) +const float AA = 0.010; // anti-alias width (normalized coords) +const float BORDER_W = 0.024; // border width, non-hovered +const float BORDER_W_HOV = 0.034; // border width, hovered (thicker) +const float PI = 3.14159265359; +const float TWO_PI = 6.28318530718; + +void main() { + float dist = length(vLocal); + + // --- Center button zone --- + if (dist < uInnerR - AA) { + if (uHasCenterBtn == 0) discard; + + // Solid center fill — fade alpha only at outer edge + float centerAlpha = 1.0 - smoothstep(uInnerR - AA * 3.0, uInnerR - AA, dist); + + bool cHov = uCenterHovered > 0; + float cbw = cHov ? BORDER_W_HOV : BORDER_W; + vec3 cbCol = cHov ? vec3(1.0) : vec3(0.88); + + // Crisp border at outer edge of center circle + float borderDist = uInnerR - AA - dist; + float border = 1.0 - smoothstep(cbw - AA, cbw + AA, borderDist); + + vec3 color = uCenterColor; + if (cHov) color = mix(color, vec3(1.0), 0.2); + color = mix(color, cbCol, border); + + float cAlpha = cHov ? 0.92 : 0.6; + fragColor = vec4(color, cAlpha * centerAlpha); + return; + } + + // --- Ring zone --- + if (uSegCount == 0) discard; // center-only mode + + // Annulus mask + float outer = 1.0 - smoothstep(1.0 - AA, 1.0, dist); + float inner = smoothstep(uInnerR - AA, uInnerR + AA, dist); + float ring = outer * inner; + if (ring < 0.01) discard; + + // Angle: 0 at top, increasing clockwise [0, 2π] + float angle = atan(vLocal.x, -vLocal.y); + if (angle < 0.0) angle += TWO_PI; + + // Rotate so first segment is centered at top (game: startAngle = -π/n) + float segArc = TWO_PI / float(uSegCount); + float offset = PI / float(uSegCount); + float shifted = mod(angle + offset, TWO_PI); + + // Segment index (in rotated space) + int segIdx = int(floor(shifted / segArc)); + segIdx = min(segIdx, uSegCount - 1); + + // Gap mask between segments + float segStart = float(segIdx) * segArc; + float segEnd = segStart + segArc; + float halfGap = GAP * 0.5; + + float gap = 1.0; + if (uSegCount > 1) { + gap = smoothstep(segStart + halfGap - AA, segStart + halfGap + AA, shifted) + * (1.0 - smoothstep(segEnd - halfGap - AA, segEnd - halfGap + AA, shifted)); + } + + float alpha = ring * gap; + if (alpha < 0.01) discard; + + // Segment color + hover state + vec4 seg = uSegColors[segIdx]; + vec3 color = seg.rgb; + bool enabled = seg.a > 0.5; + bool hovered = (segIdx == uHoveredSeg && enabled); + + // Pick border width & color based on hover + float bw = hovered ? BORDER_W_HOV : BORDER_W; + vec3 borderCol = hovered ? vec3(1.0) : vec3(0.88); + + // --- Borders --- + // Outer edge + float outerBorder = 1.0 - smoothstep(bw - AA, bw + AA, 1.0 - dist); + // Inner edge + float innerBorder = 1.0 - smoothstep(bw - AA, bw + AA, dist - uInnerR); + // Radial lines at gap edges + float angBorder = 0.0; + if (uSegCount > 1) { + float angleInSeg = shifted - segStart; + float distToStart = angleInSeg - halfGap; + float distToEnd = (segArc - halfGap) - angleInSeg; + // Convert angular distance to approximate normalized arc-length + float nearestAng = min(distToStart, distToEnd) * dist; + angBorder = 1.0 - smoothstep(bw - AA, bw + AA, nearestAng); + } + float border = max(max(outerBorder, innerBorder), angBorder); + + // Disabled segments: desaturate + darken + if (!enabled) { + float lum = dot(color, vec3(0.3, 0.6, 0.1)); + color = vec3(lum) * 0.4; + } + + // Hover highlight: brighten fill + if (hovered) { + color = mix(color, vec3(1.0), 0.2); + } + + // Blend border on top + color = mix(color, borderCol, border); + + // Opacity: hovered → nearly opaque, default → slightly transparent, disabled → dim + float segAlpha = enabled ? (hovered ? 0.92 : 0.6) : 0.4; + fragColor = vec4(color, alpha * segAlpha); +} diff --git a/src/client/render/gl/shaders/radial-menu/arcs.vert.glsl b/src/client/render/gl/shaders/radial-menu/arcs.vert.glsl new file mode 100644 index 0000000000..5e5b379abe --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/arcs.vert.glsl @@ -0,0 +1,24 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; // [0,1] quad + +uniform vec2 uAnchor; // anchor in device pixels +uniform float uOuterR; // outer radius in device pixels +uniform vec2 uViewport; // drawingBuffer width, height + +out vec2 vLocal; // [-1, +1] square pixel-space + +void main() { + vLocal = aPos * 2.0 - 1.0; + + // Expand quad to [-outerR, +outerR] in device pixels around anchor + vec2 pos = uAnchor + vLocal * uOuterR; + + // Device pixels → NDC + gl_Position = vec4( + pos.x / uViewport.x * 2.0 - 1.0, + 1.0 - pos.y / uViewport.y * 2.0, + 0.0, 1.0 + ); +} diff --git a/src/client/render/gl/shaders/radial-menu/icon.frag.glsl b/src/client/render/gl/shaders/radial-menu/icon.frag.glsl new file mode 100644 index 0000000000..79862e014c --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/icon.frag.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +in vec2 vUV; +flat in float vAtlasIdx; +flat in float vOpacity; + +uniform sampler2D uEmojiAtlas; +uniform float uEmojiCell; +uniform float uEmojiCols; +uniform float uEmojiAtlasW; +uniform float uEmojiAtlasH; + +out vec4 fragColor; + +void main() { + if (vAtlasIdx < 0.0) discard; + + float col = mod(vAtlasIdx, uEmojiCols); + float row = floor(vAtlasIdx / uEmojiCols); + + vec2 cellOrigin = vec2(col * uEmojiCell / uEmojiAtlasW, row * uEmojiCell / uEmojiAtlasH); + vec2 cellSize = vec2(uEmojiCell / uEmojiAtlasW, uEmojiCell / uEmojiAtlasH); + + vec4 texel = texture(uEmojiAtlas, cellOrigin + vUV * cellSize); + fragColor = vec4(texel.rgb, texel.a * vOpacity); +} diff --git a/src/client/render/gl/shaders/radial-menu/icon.vert.glsl b/src/client/render/gl/shaders/radial-menu/icon.vert.glsl new file mode 100644 index 0000000000..ee6fc4899e --- /dev/null +++ b/src/client/render/gl/shaders/radial-menu/icon.vert.glsl @@ -0,0 +1,74 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; // [0,1] quad + +uniform vec2 uAnchor; // anchor in device pixels +uniform float uOuterR; // outer radius in device pixels +uniform float uInnerR; // inner radius as fraction of outerR [0,1] +uniform vec2 uViewport; // drawingBuffer width, height +uniform int uSegCount; // number of segments +uniform float uIconHalf; // icon half-size in device pixels +uniform float uEmojiIndices[8]; // atlas index per segment (-1 = none) +uniform float uCenterEmojiIdx; // atlas index for center icon (-1 = none) +uniform float uSegOpacity[8]; // per-segment opacity (0..1) + +out vec2 vUV; +flat out float vAtlasIdx; +flat out float vOpacity; + +const float PI = 3.14159265359; +const float TWO_PI = 6.28318530718; + +void main() { + int segIdx = gl_InstanceID; + + // Center icon: last instance (index == uSegCount) + if (segIdx == uSegCount) { + vAtlasIdx = uCenterEmojiIdx; + vOpacity = 1.0; // center icon always full opacity + if (vAtlasIdx < 0.0) { + gl_Position = vec4(2.0, 2.0, 0.0, 1.0); + vUV = vec2(0.0); + return; + } + // Position at anchor center — always upright + vec2 local = aPos * 2.0 - 1.0; + vec2 pos = uAnchor + local * uIconHalf; + gl_Position = vec4( + pos.x / uViewport.x * 2.0 - 1.0, + 1.0 - pos.y / uViewport.y * 2.0, + 0.0, 1.0 + ); + vUV = aPos; + return; + } + + vAtlasIdx = uEmojiIndices[segIdx]; + vOpacity = uSegOpacity[segIdx]; + + if (vAtlasIdx < 0.0 || segIdx >= uSegCount) { + gl_Position = vec4(2.0, 2.0, 0.0, 1.0); + vUV = vec2(0.0); + return; + } + + // Arc center position — rotated so first segment is centered at top + float segArc = TWO_PI / float(uSegCount); + float offset = PI / float(uSegCount); + float angle = (float(segIdx) + 0.5) * segArc - offset; + float midR = (uInnerR + 1.0) * 0.5 * uOuterR; + vec2 center = uAnchor + vec2(sin(angle), -cos(angle)) * midR; + + // Quad corners — always axis-aligned (upright icons) + vec2 local = aPos * 2.0 - 1.0; + vec2 pos = center + local * uIconHalf; + + gl_Position = vec4( + pos.x / uViewport.x * 2.0 - 1.0, + 1.0 - pos.y / uViewport.y * 2.0, + 0.0, 1.0 + ); + + vUV = aPos; +} diff --git a/src/client/render/gl/shaders/railroad/railroad.frag.glsl b/src/client/render/gl/shaders/railroad/railroad.frag.glsl new file mode 100644 index 0000000000..0b363625d3 --- /dev/null +++ b/src/client/render/gl/shaders/railroad/railroad.frag.glsl @@ -0,0 +1,155 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uRailroadTex; // R8UI — rail type per tile (0=none, 1-6) +uniform usampler2D uGhostRailTex; // R8UI — ghost rail type per tile (0=none, 1-6) +uniform usampler2D uTileTex; // R16UI — tile state (for owner lookup) +uniform sampler2D uPalette; // RGBA32F — player colors +uniform usampler2D uTerrainTex; // R8UI — terrain bytes (bit 7 = isLand) + +uniform vec2 uMapSize; +uniform float uZoom; +uniform float uRailDetailZoom; +uniform float uRailAlpha; +uniform float uGhostOwnerID; // Player smallID for ghost rail color + +in vec2 vWorldPos; +out vec4 fragColor; + +// Bridge pixel positions per rail type, from OpenFrontIO's RailroadSprites.ts. +// Tests whether 2x-pixel offset (lp) from a tile origin is a bridge pixel. +// Bridge pixel positions from game's RailroadSprites.ts, with -2 offsets +// shifted to -1 to close the gap (game's rail extends into neighbors, ours doesn't). +bool isBridgePixel(uint rt, ivec2 lp) { + int x = lp.x, y = lp.y; + if (rt == 1u) { // Vertical + return (x == -1 || x == 2) && y >= -1 && y <= 1; + } else if (rt == 2u) { // Horizontal + return (y == -1 && x >= -1 && x <= 1) + || (y == 2 && x >= -1 && x <= 1) + || (y == 3 && (x == -1 || x == 1)); + } else if (rt == 3u) { // TopLeft + return (x == -1 && (y == -1 || y == 2)) + || (x == 0 && y == 1) + || (x == 1 && y == 0) + || (x == 2 && y == -1); + } else if (rt == 4u) { // TopRight + return (x == -1 && (y == -1 || y == 0)) + || (x == 0 && y == 1) + || (x == 1 && y == 2) + || (x == 2 && (y == -1 || y == 2)); + } else if (rt == 5u) { // BottomLeft + return (x == -1 && (y == -1 || y == 2)) + || (x == 0 && y == -1) + || (x == 1 && y == 0) + || (x == 2 && (y == 1 || y == 2)); + } else if (rt == 6u) { // BottomRight + return (x == -1 && y >= 0 && y <= 2) + || (x == 0 && y == -1) + || (x == 1 && y == -1) + || (x == 2 && (y == -1 || y == 2)); + } + return false; +} + +// Compute rail pixel coverage for a given rail type at fractional tile position. +// Returns 0.0 for miss, 1.0 for hit (detailed mode), or AA coverage (line mode). +float railCoverage(uint rt, vec2 f) { + if (rt == 0u) return 0.0; + + if (uZoom >= uRailDetailZoom) { + // Detailed mode: 3x3 sub-grid with cross-ties + float T = 1.0 / 3.0; + float T2 = 2.0 / 3.0; + bool center = (f.x >= T && f.x < T2 && f.y >= T && f.y < T2); + bool hit = false; + if (rt == 1u) { + hit = (f.x < T) || (f.x >= T2) || center; + } else if (rt == 2u) { + hit = (f.y < T) || (f.y >= T2) || center; + } else if (rt == 3u) { + hit = (f.y < T) || (f.x < T) || center; + } else if (rt == 4u) { + hit = (f.y < T) || (f.x >= T2) || center; + } else if (rt == 5u) { + hit = (f.y >= T2) || (f.x < T) || center; + } else if (rt == 6u) { + hit = (f.y >= T2) || (f.x >= T2) || center; + } + return hit ? 1.0 : 0.0; + } else { + // Simplified mode: fill entire tile (tiles are small at this zoom) + return 1.0; + } +} + +void main() { + ivec2 tc = ivec2(floor(vWorldPos)); + + if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) + discard; + + uint railType = texelFetch(uRailroadTex, tc, 0).r; + uint ghostRailType = texelFetch(uGhostRailTex, tc, 0).r; + vec2 f = fract(vWorldPos); + + // Compute coverage for real and ghost rails + float realCov = railCoverage(railType, f); + // Ghost only renders where there is no real rail (values 1-6 = ghost path) + // Value 7 = highlight marker (existing rail turns green) + float ghostCov = (ghostRailType >= 1u && ghostRailType <= 6u && railType == 0u) + ? railCoverage(ghostRailType, f) + : 0.0; + bool highlighted = (ghostRailType == 7u && railType != 0u); + + bool hitRail = (realCov * uRailAlpha > 0.001); + bool hitGhost = (ghostCov * uRailAlpha > 0.001); + + // --- Bridge: check 3x3 neighborhood for water+rail tiles --- + bool hitBridge = false; + ivec2 fp = ivec2(floor(vWorldPos * 2.0)); // fragment pos in game's 2x-pixel grid + + for (int dy = -1; dy <= 1 && !hitBridge; dy++) { + for (int dx = -1; dx <= 1 && !hitBridge; dx++) { + ivec2 ntc = tc + ivec2(dx, dy); + if (ntc.x < 0 || ntc.y < 0 || ntc.x >= int(uMapSize.x) || ntc.y >= int(uMapSize.y)) + continue; + uint nRail = texelFetch(uRailroadTex, ntc, 0).r; + if (nRail == 0u) continue; + uint nTerr = texelFetch(uTerrainTex, ntc, 0).r; + if ((nTerr & 0x80u) != 0u) continue; // land tile, no bridge + ivec2 lp = fp - ntc * 2; + if (isBridgePixel(nRail, lp)) hitBridge = true; + } + } + + if (!hitBridge && !hitRail && !hitGhost) discard; + + // --- Color output --- + vec3 bridgeColor = vec3(0.773, 0.271, 0.282); + + if (hitRail) { + float railAlpha = uRailAlpha * realCov; + uint tileRaw = texelFetch(uTileTex, tc, 0).r; + uint owner = tileRaw & uint(OWNER_MASK); + vec3 railColor = owner != 0u + ? texture(uPalette, vec2((float(owner) + 0.5) / float(PALETTE_SIZE), 0.75)).rgb + : vec3(0.75); + // Overlapping railroad highlight — green tint + if (highlighted) railColor = vec3(0.2, 0.85, 0.3); + if (hitBridge) { + fragColor = vec4(mix(bridgeColor, railColor, railAlpha), 1.0); + } else { + fragColor = vec4(railColor, railAlpha); + } + } else if (hitGhost) { + float ghostAlpha = uRailAlpha * ghostCov * 0.5; + vec3 ghostColor = uGhostOwnerID > 0.0 + ? texture(uPalette, vec2((uGhostOwnerID + 0.5) / float(PALETTE_SIZE), 0.75)).rgb + : vec3(0.75); + fragColor = vec4(ghostColor, ghostAlpha); + } else { + fragColor = vec4(bridgeColor, 1.0); + } +} diff --git a/src/client/render/gl/shaders/range-circle/range-circle.frag.glsl b/src/client/render/gl/shaders/range-circle/range-circle.frag.glsl new file mode 100644 index 0000000000..62744ab377 --- /dev/null +++ b/src/client/render/gl/shaders/range-circle/range-circle.frag.glsl @@ -0,0 +1,27 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; // [-1, +1] + +uniform float uRadius; + +out vec4 fragColor; + +void main() { + float dist = length(vLocal) * (uRadius + 1.0); // world-space distance from center + float edge = uRadius; + + // Smooth fill: inside the circle at 20% white + float fill = 1.0 - smoothstep(edge - 0.5, edge + 0.5, dist); + + // Stroke: 1-tile-wide ring at the edge + float strokeInner = edge - 1.0; + float strokeOuter = edge; + float stroke = smoothstep(strokeInner - 0.5, strokeInner + 0.5, dist) + * (1.0 - smoothstep(strokeOuter - 0.5, strokeOuter + 0.5, dist)); + + float alpha = fill * 0.2 + stroke * 0.5; + if (alpha < 0.001) discard; + + fragColor = vec4(1.0, 1.0, 1.0, alpha); +} diff --git a/src/client/render/gl/shaders/range-circle/range-circle.vert.glsl b/src/client/render/gl/shaders/range-circle/range-circle.vert.glsl new file mode 100644 index 0000000000..407dbf57b5 --- /dev/null +++ b/src/client/render/gl/shaders/range-circle/range-circle.vert.glsl @@ -0,0 +1,24 @@ +#version 300 es +precision highp float; + +// Unit quad [0,1] +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; +uniform vec2 uCenter; // world-space circle center (tile coords) +uniform float uRadius; // world-space radius in tiles + +out vec2 vLocal; // [-1, +1] local coords within the quad + +void main() { + // Map [0,1] → [-1,+1] + vLocal = aPos * 2.0 - 1.0; + + // Expand quad to cover circle bbox in world space + // Add 1-tile padding for the stroke + float r = uRadius + 1.0; + vec2 worldPos = uCenter + 0.5 + vLocal * r; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/sam-radius/sam-radius.frag.glsl b/src/client/render/gl/shaders/sam-radius/sam-radius.frag.glsl new file mode 100644 index 0000000000..a3c08ee5ae --- /dev/null +++ b/src/client/render/gl/shaders/sam-radius/sam-radius.frag.glsl @@ -0,0 +1,74 @@ +#version 300 es +precision highp float; + +in vec2 vLocal; +flat in float vRadius; +flat in vec3 vColor; +flat in vec2 vArcBounds; + +uniform float uTime; +uniform float uOutline; // 1.0 = owner mode (outline edges), 0.0 = perspective +uniform float uStrokeWidth; // ring half-width (world units) +uniform float uDashLen; // dash length (world units) +uniform float uGapLen; // gap length (world units) +uniform float uRotationSpeed; // rotation (world units/sec) +uniform float uAlpha; // base opacity +uniform float uOutlineWidth; // outline border width (world units) +uniform float uOutlineSoftness; // smoothstep range (0 = hard edge) + +out vec4 fragColor; + +const float TWO_PI = 6.2831853; + +void main() { + float paddedR = vRadius + 2.0; + float dist = length(vLocal) * paddedR; + + // Ring mask: stroke centered on the circle edge + float ringAlpha = smoothstep(vRadius - uStrokeWidth - 0.5, vRadius - uStrokeWidth, dist) + * (1.0 - smoothstep(vRadius + uStrokeWidth, vRadius + uStrokeWidth + 0.5, dist)); + + if (ringAlpha < 0.01) discard; + + // Arc clipping + float angle = atan(vLocal.y, vLocal.x); + float normAngle = angle < 0.0 ? angle + TWO_PI : angle; + bool fullCircle = vArcBounds.y - vArcBounds.x >= TWO_PI - 0.001; + if (!fullCircle) { + if (normAngle < vArcBounds.x || normAngle > vArcBounds.y) discard; + } + + // Dash pattern along circumference + float arcPos = angle * vRadius; + float period = uDashLen + uGapLen; + float dashPhase = mod(arcPos + uTime * uRotationSpeed, period); + float dashAlpha = 1.0 - smoothstep(uDashLen - 0.5, uDashLen + 0.5, dashPhase); + + float alpha = ringAlpha * dashAlpha * uAlpha; + if (alpha < 0.01) discard; + + // Outline: darken fragments near any edge of each dash segment + float edgeFade = 1.0; + if (uOutline > 0.5) { + float ow = uOutlineWidth; + float soft = uOutlineSoftness; + // Radial edges (inner/outer ring boundary) + float fromInner = dist - (vRadius - uStrokeWidth); + float fromOuter = (vRadius + uStrokeWidth) - dist; + edgeFade = min(smoothstep(ow - soft, ow + soft, fromInner), + smoothstep(ow - soft, ow + soft, fromOuter)); + // Dash start/end edges (circumferential) + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, dashPhase)); + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, uDashLen - dashPhase)); + // Arc endpoint edges (where circle union clips the arc) + if (!fullCircle) { + float arcDistStart = (normAngle - vArcBounds.x) * vRadius; + float arcDistEnd = (vArcBounds.y - normAngle) * vRadius; + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, arcDistStart)); + edgeFade = min(edgeFade, smoothstep(ow - soft, ow + soft, arcDistEnd)); + } + } + + vec3 finalColor = vColor * edgeFade; + fragColor = vec4(finalColor, alpha); +} diff --git a/src/client/render/gl/shaders/sam-radius/sam-radius.vert.glsl b/src/client/render/gl/shaders/sam-radius/sam-radius.vert.glsl new file mode 100644 index 0000000000..a0682028f0 --- /dev/null +++ b/src/client/render/gl/shaders/sam-radius/sam-radius.vert.glsl @@ -0,0 +1,33 @@ +#version 300 es +precision highp float; + +// Unit quad [0,1] +layout(location = 0) in vec2 aPos; +// Per-instance: x, y, radius +layout(location = 1) in vec3 aInstance; +// Per-instance: r, g, b +layout(location = 2) in vec3 aColor; +// Per-instance: arcStart, arcEnd +layout(location = 3) in vec2 aArcBounds; + +uniform mat3 uCamera; + +out vec2 vLocal; // [-1, +1] local coords +flat out float vRadius; // world-space radius for this instance +flat out vec3 vColor; // relationship color +flat out vec2 vArcBounds; // arc start/end in [0, 2PI) + +void main() { + vLocal = aPos * 2.0 - 1.0; + vRadius = aInstance.z; + vColor = aColor; + vArcBounds = aArcBounds; + + // Expand quad to cover circle bbox + padding for stroke + float r = aInstance.z + 2.0; + vec2 center = aInstance.xy + 0.5; + vec2 worldPos = center + vLocal * r; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/selection-box/selection-box.frag.glsl b/src/client/render/gl/shaders/selection-box/selection-box.frag.glsl new file mode 100644 index 0000000000..3d17fe2680 --- /dev/null +++ b/src/client/render/gl/shaders/selection-box/selection-box.frag.glsl @@ -0,0 +1,38 @@ +#version 300 es +precision highp float; + +in vec2 vWorld; + +uniform vec2 uCenter; +uniform float uHalfSize; +uniform float uTime; // frame tick counter (increments each frame) +uniform vec3 uColor; // RGB [0-1], already lightened + +out vec4 fragColor; + +void main() { + // Tile-space relative to center + vec2 tile = floor(vWorld); + vec2 rel = tile - floor(uCenter); + float hs = uHalfSize; + + // Is this tile on the border of the selection box? + bool inXRange = rel.x >= -hs && rel.x <= hs; + bool inYRange = rel.y >= -hs && rel.y <= hs; + bool isXEdge = abs(rel.x - hs) < 0.5 || abs(rel.x + hs) < 0.5; + bool isYEdge = abs(rel.y - hs) < 0.5 || abs(rel.y + hs) < 0.5; + + bool onBorder = (isXEdge && inYRange) || (isYEdge && inXRange); + if (!onBorder) discard; + + // Stipple: checkerboard pattern (every other tile) + float stipple = mod(tile.x + tile.y, 2.0); + if (stipple > 0.5) discard; + + // Pulsating alpha (matches game: base 200/255 ± 55/255) + float baseAlpha = 0.784; // 200/255 + float pulseAmp = 0.216; // 55/255 + float alpha = baseAlpha + sin(uTime * 0.1) * pulseAmp; + + fragColor = vec4(uColor, alpha); +} diff --git a/src/client/render/gl/shaders/selection-box/selection-box.vert.glsl b/src/client/render/gl/shaders/selection-box/selection-box.vert.glsl new file mode 100644 index 0000000000..03f2a86e46 --- /dev/null +++ b/src/client/render/gl/shaders/selection-box/selection-box.vert.glsl @@ -0,0 +1,22 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; +uniform vec2 uCenter; // world-space tile center (integer) +uniform float uHalfSize; // box half-size in tiles + +out vec2 vWorld; // world-space position + +void main() { + // Map [0,1] → [-1,+1] + vec2 local = aPos * 2.0 - 1.0; + + // Expand quad to cover box + 1-tile padding for AA + float r = uHalfSize + 1.0; + vWorld = uCenter + 0.5 + local * r; + + vec3 clip = uCamera * vec3(vWorld, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/shared/blur.frag.glsl b/src/client/render/gl/shaders/shared/blur.frag.glsl new file mode 100644 index 0000000000..dc445f9ee4 --- /dev/null +++ b/src/client/render/gl/shaders/shared/blur.frag.glsl @@ -0,0 +1,16 @@ +#version 300 es +precision highp float; +uniform sampler2D uTex; +uniform vec2 uDir; +in vec2 vUV; +out vec4 fragColor; +const float w[5] = float[5](0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216); +void main() { + vec4 result = texture(uTex, vUV) * w[0]; + for (int i = 1; i < 5; i++) { + vec2 off = uDir * float(i); + result += texture(uTex, vUV + off) * w[i]; + result += texture(uTex, vUV - off) * w[i]; + } + fragColor = result; +} diff --git a/src/client/render/gl/shaders/shared/fullscreen-no-uv.vert.glsl b/src/client/render/gl/shaders/shared/fullscreen-no-uv.vert.glsl new file mode 100644 index 0000000000..95a8b6944a --- /dev/null +++ b/src/client/render/gl/shaders/shared/fullscreen-no-uv.vert.glsl @@ -0,0 +1,5 @@ +#version 300 es +layout(location = 0) in vec2 aPos; +void main() { + gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/client/render/gl/shaders/shared/fullscreen.vert.glsl b/src/client/render/gl/shaders/shared/fullscreen.vert.glsl new file mode 100644 index 0000000000..9a1858d753 --- /dev/null +++ b/src/client/render/gl/shaders/shared/fullscreen.vert.glsl @@ -0,0 +1,8 @@ +#version 300 es +precision highp float; +layout(location = 0) in vec2 aPos; +out vec2 vUV; +void main() { + gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); + vUV = aPos; +} diff --git a/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl new file mode 100644 index 0000000000..8e116ebd2a --- /dev/null +++ b/src/client/render/gl/shaders/spawn-overlay/spawn-overlay.frag.glsl @@ -0,0 +1,101 @@ +#version 300 es +precision highp float; +precision highp usampler2D; + +uniform usampler2D uTileTex; +uniform vec2 uMapSize; + +// Spawn center data packed as vec4 pairs: +// A[i] = (x, y, r, g) +// B[i] = (b, isSelf, isTeammate, _) +uniform vec4 uSpawnA[MAX_SPAWNS]; +uniform vec4 uSpawnB[MAX_SPAWNS]; +uniform int uSpawnCount; + +uniform float uBreathRadius; // normalized [0..1], animated via sin + +// Configurable parameters (from render settings) +uniform float uHighlightRadiusSq; // tile highlight radius squared +uniform float uHighlightAlpha; // tile highlight opacity +uniform vec4 uSelfRadii; // (minR, maxR, _, _) +uniform vec4 uMateRadii; // (minR, maxR, _, _) +uniform vec2 uGradientStops; // (innerEdge, solidEnd) + +in vec2 vWorldPos; +out vec4 fragColor; + +void main() { + ivec2 tc = ivec2(floor(vWorldPos)); + if (tc.x < 0 || tc.y < 0 || tc.x >= int(uMapSize.x) || tc.y >= int(uMapSize.y)) + discard; + + uint raw = texelFetch(uTileTex, tc, 0).r; + uint owner = raw & uint(OWNER_MASK); + bool unowned = (owner == 0u); + + vec4 result = vec4(0.0); + + for (int i = 0; i < MAX_SPAWNS; i++) { + if (i >= uSpawnCount) break; + + vec2 center = uSpawnA[i].xy; + vec3 color = vec3(uSpawnA[i].zw, uSpawnB[i].x); + float isSelf = uSpawnB[i].y; + float isTeammate = uSpawnB[i].z; + + float dx = vWorldPos.x - center.x; + float dy = vWorldPos.y - center.y; + float distSq = dx * dx + dy * dy; + float dist = sqrt(distSq); + + // --- Tile highlights (not for self or teammates) --- + if (isSelf < 0.5 && isTeammate < 0.5 && unowned && distSq <= uHighlightRadiusSq) { + float a = uHighlightAlpha; + result.rgb = mix(result.rgb, color, a * (1.0 - result.a)); + result.a = result.a + a * (1.0 - result.a); + } + + // --- Breathing rings (self or teammate only) --- + float minR, maxR; + if (isSelf > 0.5) { + minR = uSelfRadii.x; + maxR = uSelfRadii.y; + } else if (isTeammate > 0.5) { + minR = uMateRadii.x; + maxR = uMateRadii.y; + } else { + continue; + } + + // Breathing ring: the gradient halo shrinks/expands in radius AND its + // opacity pulses in phase with the breath — both driven by uBreathRadius. + // Smooth bell shape: glow ramps up from center to the inner edge, stays + // solid through the ring's body, then fades out past solidEnd. No hard + // cutoffs at either side. + float scale = 0.5 + 0.65 * uBreathRadius; // 0.5 → 1.15 of base radius + float bMinR = minR * scale; + float bMaxR = maxR * scale; + float range = bMaxR - bMinR; + float t = (dist - bMinR) / range; + float solidEnd = uGradientStops.y; + float alpha = 0.0; + if (dist < bMinR) { + // Inner glow: linear ramp from 0 at center to 1 at the ring's inner edge. + alpha = dist / max(bMinR, 0.001); + } else if (t < solidEnd) { + alpha = 1.0; + } else if (t < 1.0) { + alpha = 1.0 - (t - solidEnd) / (1.0 - solidEnd); + } + if (alpha > 0.0) { + // Opacity pulses 35% → 100% in phase with the radius. + alpha *= 0.35 + 0.65 * uBreathRadius; + result.rgb = mix(result.rgb, color, alpha * (1.0 - result.a)); + result.a = result.a + alpha * (1.0 - result.a); + } + } + + if (result.a < 0.001) discard; + // result is premultiplied; convert to straight for SRC_ALPHA blending + fragColor = vec4(result.rgb / result.a, result.a); +} diff --git a/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl b/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl new file mode 100644 index 0000000000..c4127fbdb2 --- /dev/null +++ b/src/client/render/gl/shaders/structure-level/structure-level.frag.glsl @@ -0,0 +1,50 @@ +#version 300 es +precision highp float; + +uniform sampler2D uAtlas; +uniform float uDistRange; +uniform float uOutlineWidth; +uniform int uHighlightMask; +uniform float uHighlightDimAlpha; + +in vec2 vUV; +flat in float vAlive; +flat in float vAtlasIdx; +out vec4 fragColor; + +float median(float r, float g, float b) { + return max(min(r, g), min(max(r, g), b)); +} + +void main() { + if (vAlive <= 0.0) discard; + + vec3 msd = texture(uAtlas, vUV).rgb; + float sd = median(msd.r, msd.g, msd.b); + + vec2 unitRange = uDistRange / vec2(textureSize(uAtlas, 0)); + vec2 screenTexSize = 1.0 / fwidth(vUV); + float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0); + + float screenPxDist = screenPxRange * (sd - 0.5); + float fillAlpha = clamp(screenPxDist + 0.5, 0.0, 1.0); + + // White text with dark outline + float maxOutline = max(screenPxRange * 0.5 - 1.0, 0.0); + float effectiveOutline = min(uOutlineWidth, maxOutline); + float outlineDist = screenPxDist + effectiveOutline; + float outlineAlpha = clamp(outlineDist + 0.5, 0.0, 1.0); + + vec3 color = mix(vec3(0.0), vec3(1.0), fillAlpha); + float finalAlpha = outlineAlpha; + + // Dim level text for non-highlighted structure types + if (uHighlightMask != 0) { + int bit = 1 << int(vAtlasIdx + 0.5); + if ((uHighlightMask & bit) == 0) { + finalAlpha *= uHighlightDimAlpha; + } + } + + fragColor = vec4(color, finalAlpha); +} diff --git a/src/client/render/gl/shaders/structure-level/structure-level.vert.glsl b/src/client/render/gl/shaders/structure-level/structure-level.vert.glsl new file mode 100644 index 0000000000..27ef723cac --- /dev/null +++ b/src/client/render/gl/shaders/structure-level/structure-level.vert.glsl @@ -0,0 +1,96 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +// Per-instance: worldX, worldY, cursorX, charCode +layout(location = 1) in vec4 aInst; +layout(location = 2) in float aAtlasIdx; + +uniform sampler2D uGlyphMetrics; // CHAR_RANGE x 2, RGBA32F + +uniform mat3 uCamera; +uniform float uZoom; + +// Structure icon sizing (mirrors structure.vert.glsl) +uniform float uIconSize; +uniform float uDotsThreshold; +uniform float uScaleFactor; + +// Text sizing +uniform float uFontSize; +uniform float uAtlasScaleH; +uniform float uBase; +uniform float uLevelScale; + +out vec2 vUV; +flat out float vAlive; +flat out float vAtlasIdx; + +void main() { + float worldX = aInst.x; + float worldY = aInst.y; + float cursorX = aInst.z; + int charCode = int(aInst.w); + + // Same icon scale logic as structure.vert.glsl + float iconScale; + if (uZoom <= uDotsThreshold) { + iconScale = 0.0; // hidden in dots mode + } else { + iconScale = min(1.0, uZoom / uScaleFactor); + } + + // Cull when invisible + if (iconScale <= 0.0 || charCode == 0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vAlive = 0.0; + return; + } + vAlive = 1.0; + vAtlasIdx = aAtlasIdx; + + float halfIconSize = uIconSize * iconScale * 0.5 / uZoom; + + // Level text scale: proportional to icon size + float levelScale = halfIconSize * uLevelScale / uFontSize; + + // Glyph metrics from data texture + vec4 m0 = texelFetch(uGlyphMetrics, ivec2(charCode, 0), 0); // xadvance, xoffset, yoffset, width + vec4 m1 = texelFetch(uGlyphMetrics, ivec2(charCode, 1), 0); // height, u0, v0, u1 + + float glyphW = m0.w; + float glyphH = m1.x; + float u0 = m1.y; + float v0 = m1.z; + float u1 = m1.w; + float v1 = v0 + glyphH / uAtlasScaleH; + + // Skip degenerate glyphs + if (glyphW <= 0.0 || glyphH <= 0.0) { + gl_Position = vec4(0.0); + vUV = vec2(0.0); + vAlive = 0.0; + return; + } + + // Position above icon center + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + float baselineY = -uBase * 0.5; + float yOff = -halfIconSize - levelScale * uBase * 0.6; // above icon top edge + + vec2 glyphOrigin = vec2( + cursorX + m0.y, // + xoffset + baselineY + m0.z // + yoffset + ) * levelScale; + + vec2 glyphSize = vec2(glyphW, glyphH) * levelScale; + + vec2 worldPos = center + vec2(0.0, yOff) + glyphOrigin + aPos * glyphSize; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vUV = vec2(mix(u0, u1, aPos.x), mix(v0, v1, aPos.y)); +} diff --git a/src/client/render/gl/shaders/structure/structure.frag.glsl b/src/client/render/gl/shaders/structure/structure.frag.glsl new file mode 100644 index 0000000000..677bb3b848 --- /dev/null +++ b/src/client/render/gl/shaders/structure/structure.frag.glsl @@ -0,0 +1,168 @@ +#version 300 es +precision highp float; + +uniform sampler2D uPalette; +uniform sampler2D uAtlas; +uniform sampler2D uAffiliation; // 256×2 RGBA8 — row 1 = unit affiliation +uniform float uDotsThreshold; +uniform float uGhostAlpha; // 1.0 = normal, <1.0 = ghost transparency +uniform vec3 uOutlineColor; // ghost outline color (vec3(0) = no outline) +uniform int uAltView; +uniform int uHighlightMask; // bitmask of atlas columns to highlight (0 = off) +uniform float uHighlightOutlineW; // outline width for highlighted structures +uniform float uHighlightDimAlpha; // alpha multiplier for non-highlighted structures + +in vec2 vLocalPos; +in vec2 vAtlasUV; +flat in float vOwnerID; +flat in float vUnderConstruction; +flat in float vMarkedForDeletion; +flat in float vZoom; +flat in float vAtlasIdx; +flat in float vShapeScale; + +out vec4 fragColor; + +vec3 rgb2hsv(vec3 c) { + vec4 K = vec4(0.0, -1.0/3.0, 2.0/3.0, -1.0); + vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); + vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} + +vec3 hsv2rgb(vec3 c) { + vec3 p = abs(fract(c.xxx + vec3(1.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0); + return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +vec3 darken(vec3 rgb, float vScale) { + vec3 hsv = rgb2hsv(rgb); + hsv.z *= vScale; + return hsv2rgb(hsv); +} + +#define PI 3.14159265 + +// Signed distance to regular polygon edge. +// R = circumradius (center-to-vertex), n = sides, rot = rotation in radians. +// Returns negative inside, positive outside. +float sdPolygon(vec2 p, float R, float n, float rot) { + float an = PI / n; + float a = atan(p.y, p.x) - rot; + a = mod(a + an, 2.0 * an) - an; + return length(p) * cos(a) - R * cos(an); +} + +// Per-structure-type shape SDF. +// Atlas indices: 0=City, 1=Port, 2=Factory, 3=DefensePost, 4=SAM, 5=Silo +float shapeSDF(vec2 p, float R) { + if (vAtlasIdx < 0.5 || (vAtlasIdx > 1.5 && vAtlasIdx < 2.5)) + return length(p) - R; // City / Factory → circle + if (vAtlasIdx < 1.5) + return sdPolygon(p, R, 5.0, PI * 0.5); // Port → pentagon (vertex up) + if (vAtlasIdx < 3.5) + return sdPolygon(p, R, 8.0, 0.0); // Defense Post → octagon (flat top) + if (vAtlasIdx < 4.5) + return sdPolygon(p, R, 4.0, 0.0); // SAM Launcher → square (flat sides) + return sdPolygon(p, R, 3.0, PI * 0.5); // Missile Silo → triangle (vertex up) +} + +void main() { + float dist = length(vLocalPos); + float radius = 0.45; + float borderWidth = 0.06 / vShapeScale; + + float sdf = shapeSDF(vLocalPos, radius); + float fw = fwidth(dist); + + // When highlight is active, expand the region to include the outer outline band. + float highlightOutlineW = uHighlightMask != 0 ? uHighlightOutlineW / vShapeScale : 0.0; + float outerAlpha = 1.0 - smoothstep(-fw, fw, sdf - highlightOutlineW); + + if (outerAlpha <= 0.0) discard; + + float borderMask = 1.0 - smoothstep(-fw, fw, sdf + borderWidth); + + // Player color + vec4 fillColor; + vec4 borderColor; + + if (uAltView != 0 && vUnderConstruction < 0.5) { + vec3 ac = texelFetch(uAffiliation, ivec2(int(vOwnerID), 1), 0).rgb; + fillColor = vec4(darken(ac, 0.65), 1.0); + borderColor = vec4(darken(ac, 0.35), 1.0); + } else if (vUnderConstruction > 0.5) { + fillColor = vec4(198.0/255.0, 198.0/255.0, 198.0/255.0, 1.0); + borderColor = vec4(127.0/255.0, 127.0/255.0, 127.0/255.0, 1.0); + } else { + float u = (vOwnerID + 0.5) / float(PALETTE_SIZE); + fillColor = texture(uPalette, vec2(u, 0.25)); + borderColor = texture(uPalette, vec2(u, 0.75)); + // Darken via HSV value so hue/saturation stay intact + // vScale < 1.0 = darker, > 1.0 = brighter + fillColor.rgb = darken(fillColor.rgb, 0.65); + borderColor.rgb = darken(borderColor.rgb, 0.35); + fillColor.a = 1.0; + borderColor.a = 1.0; + } + + vec4 bgColor = mix(borderColor, fillColor, borderMask); + + // Sample icon from atlas (white on transparent) + // Only show icon detail when zoomed in enough + float iconAlpha = 0.0; + if (vZoom > uDotsThreshold) { + // Clamp UV to this atlas column to prevent bleeding into neighbours + // when uIconFill shrinks the icon (expanding UV range beyond column). + float colStart = vAtlasIdx / float(ATLAS_COLS); + float colEnd = (vAtlasIdx + 1.0) / float(ATLAS_COLS); + vec2 safeUV = vec2(clamp(vAtlasUV.x, colStart, colEnd), clamp(vAtlasUV.y, 0.0, 1.0)); + vec4 iconSample = texture(uAtlas, safeUV); + // Zero out icon outside the valid UV region (clamped pixels would repeat the edge) + float inBounds = step(colStart, vAtlasUV.x) * step(vAtlasUV.x, colEnd) + * step(0.0, vAtlasUV.y) * step(vAtlasUV.y, 1.0); + // Clip to fill area so icon doesn't bleed into the border ring. + iconAlpha = iconSample.a * borderMask * inBounds; + } + + // Composite: white icon over player-colored shape + vec3 finalRGB = mix(bgColor.rgb, vec3(1.0), iconAlpha); + + // Red X overlay for units marked for deletion + if (vMarkedForDeletion > 0.5) { + float lineW = max(0.025, fw * 1.5); + float d1 = abs(vLocalPos.x - vLocalPos.y) * 0.7071; // dist to y=x diagonal + float d2 = abs(vLocalPos.x + vLocalPos.y) * 0.7071; // dist to y=-x diagonal + float dMin = min(d1, d2); + // Extend arms close to the circle edge + float maskR = max(radius * 1.55, fw * 6.0); + float mask = 1.0 - smoothstep(maskR - fw, maskR, dist); + float xLine = (1.0 - smoothstep(lineW - fw, lineW + fw, dMin)) * mask; + finalRGB = mix(finalRGB, vec3(1.0, 0.25, 0.25), xLine * 0.95); + } + + // Ghost tint — blend entire surface toward uOutlineColor when non-zero + float tintActive = step(0.01, dot(uOutlineColor, uOutlineColor)); + finalRGB = mix(finalRGB, uOutlineColor, tintActive * 0.5); + + float finalAlpha = bgColor.a * outerAlpha * uGhostAlpha; + + // Build-button hover highlight: white outline on matching types, dim the rest + if (uHighlightMask != 0) { + int bit = 1 << int(vAtlasIdx + 0.5); + if ((uHighlightMask & bit) != 0) { + // White outline band outside the shape edge (matches game's OutlineFilter) + float shapeEdge = 1.0 - smoothstep(-fw, fw, sdf); // 1 inside shape, 0 outside + float expandedEdge = 1.0 - smoothstep(-fw, fw, sdf - highlightOutlineW); // includes outline band + float outlineBand = expandedEdge - shapeEdge; // 1 in outline region only + finalRGB = mix(finalRGB, vec3(1.0), outlineBand); + finalAlpha = max(finalAlpha, outlineBand); + } else { + finalAlpha *= uHighlightDimAlpha; + } + } + + fragColor = vec4(finalRGB, finalAlpha); +} diff --git a/src/client/render/gl/shaders/structure/structure.vert.glsl b/src/client/render/gl/shaders/structure/structure.vert.glsl new file mode 100644 index 0000000000..41be22f301 --- /dev/null +++ b/src/client/render/gl/shaders/structure/structure.vert.glsl @@ -0,0 +1,72 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +// Per-instance: x, y, ownerID, underConstruction, atlasIdx, markedForDeletion +layout(location = 1) in vec4 aInst0; // x, y, ownerID, underConstruction +layout(location = 2) in vec2 aInst1; // atlasIdx, markedForDeletion + +uniform mat3 uCamera; +uniform float uZoom; + +uniform float uIconSize; +uniform float uDotsThreshold; +uniform float uDotScale; +uniform float uScaleFactor; +uniform float uIconGrowZoom; +uniform float uShapeScales[ATLAS_COLS]; +uniform float uIconFills[ATLAS_COLS]; + +out vec2 vLocalPos; +out vec2 vAtlasUV; +flat out float vOwnerID; +flat out float vUnderConstruction; +flat out float vMarkedForDeletion; +flat out float vZoom; +flat out float vAtlasIdx; +flat out float vShapeScale; + +void main() { + float worldX = aInst0.x; + float worldY = aInst0.y; + vOwnerID = aInst0.z; + vUnderConstruction = aInst0.w; + vMarkedForDeletion = aInst1.y; + vZoom = uZoom; + vAtlasIdx = aInst1.x; + + float iconScale; + if (uZoom <= uDotsThreshold) { + iconScale = uDotScale; + } else if (uZoom >= uIconGrowZoom) { + // World-anchored: grow proportionally to zoom so the structure covers a + // fixed area of the map. Past this zoom, structures should feel like + // they're "on" the canvas rather than overlaid at constant pixel size. + iconScale = uZoom / uIconGrowZoom; + } else { + iconScale = min(1.0, uZoom / uScaleFactor); + } + + int shapeIdx = int(aInst1.x); + float shapeScale = uShapeScales[shapeIdx]; + vShapeScale = shapeScale; + + float halfSize = uIconSize * iconScale * 0.5 / uZoom * shapeScale; + + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + vec2 worldPos = center + (aPos - 0.5) * halfSize * 2.0; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = aPos - 0.5; + + // Atlas UV: icons stay the same world size regardless of shape scaling, + // and are further shrunk by per-shape iconFill (0-1) to add padding inside the frame. + float uvExpand = shapeScale / uIconFills[shapeIdx]; + float scaledX = 0.5 + (aPos.x - 0.5) * uvExpand; + float scaledY = 0.5 + (aPos.y - 0.5) * uvExpand; + float colU = (aInst1.x + scaledX) / float(ATLAS_COLS); + vAtlasUV = vec2(colU, scaledY); +} diff --git a/src/client/render/gl/shaders/terrain/terrain.frag.glsl b/src/client/render/gl/shaders/terrain/terrain.frag.glsl new file mode 100644 index 0000000000..869bfff503 --- /dev/null +++ b/src/client/render/gl/shaders/terrain/terrain.frag.glsl @@ -0,0 +1,11 @@ +#version 300 es +precision highp float; + +uniform sampler2D uTerrain; + +in vec2 vUV; +out vec4 fragColor; + +void main() { + fragColor = texture(uTerrain, vUV); +} diff --git a/src/client/render/gl/shaders/terrain/terrain.vert.glsl b/src/client/render/gl/shaders/terrain/terrain.vert.glsl new file mode 100644 index 0000000000..a712bada22 --- /dev/null +++ b/src/client/render/gl/shaders/terrain/terrain.vert.glsl @@ -0,0 +1,17 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +uniform mat3 uCamera; + +out vec2 vUV; + +void main() { + vec3 clip = uCamera * vec3(aPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + // aPos ranges [0, mapW] × [0, mapH] — normalize to [0,1] UV + // (mapSize is baked into the quad vertices, so we pass it as a uniform) + vUV = aPos / vec2(float(MAP_W), float(MAP_H)); +} diff --git a/src/client/render/gl/shaders/unit/unit.frag.glsl b/src/client/render/gl/shaders/unit/unit.frag.glsl new file mode 100644 index 0000000000..2667082339 --- /dev/null +++ b/src/client/render/gl/shaders/unit/unit.frag.glsl @@ -0,0 +1,93 @@ +#version 300 es +precision highp float; + +uniform sampler2D uPalette; +uniform sampler2D uAtlas; +uniform sampler2D uAffiliation; // 256×2 RGBA8 — row 1 = unit affiliation +uniform float uTick; +uniform float uFlickerSpeed; +uniform vec3 uAngryColor; +uniform int uAltView; + +in vec2 vLocalPos; +in vec2 vAtlasUV; +flat in float vOwnerID; +flat in float vFlags; +flat in float vHash; + +out vec4 fragColor; + +// Flag constants — must match CPU-side FLAG_* values +const float FLAG_FLICKER = 1.0; +const float FLAG_ANGRY = 2.0; +const float FLAG_TRADE_FRIENDLY = 3.0; + +// Ally color for trade-friendly override (yellow — matches affiliation.ts ALLY) +const vec3 ALLY_COLOR = vec3(1.0, 1.0, 0.0); + +// Flicker hot colors: red → orange → yellow → white +const vec3 FLICKER_COLORS[4] = vec3[4]( + vec3(1.0, 0.0, 0.0), // red + vec3(1.0, 0.5, 0.0), // orange + vec3(1.0, 1.0, 0.0), // yellow + vec3(1.0, 1.0, 1.0) // white +); + +void main() { + vec4 texel = texture(uAtlas, vAtlasUV); + + // Discard fully transparent pixels + if (texel.a < 0.01) discard; + + float gray = texel.r; + + // Alt-view: solid affiliation color, no gray-replacement bands + if (uAltView != 0) { + // Enemy trade ships heading to a self/allied port render as yellow (ally) + vec3 ac = vFlags > 2.5 + ? ALLY_COLOR + : texelFetch(uAffiliation, ivec2(int(vOwnerID), 1), 0).rgb; + fragColor = vec4(ac, texel.a); + return; + } + + // Player color lookup from palette + float u = (vOwnerID + 0.5) / float(PALETTE_SIZE); + vec3 territoryColor = texture(uPalette, vec2(u, 0.25)).rgb; + vec3 borderColor = texture(uPalette, vec2(u, 0.75)).rgb; + + // Flag states (uint8 passed as float via vertex attribute): + // 0 = normal + // 1 = flicker (nukes/warheads — cycling hot colors) + // 2 = angry (warships attacking — solid red territory band) + if (vFlags > 1.5) { + // Angry: solid red territory band + territoryColor = uAngryColor; + } else if (vFlags > 0.5) { + // Flicker: cycle through hot colors, offset by position hash + float phase = fract(uTick * uFlickerSpeed + vHash); + int idx = int(phase * 4.0) % 4; + territoryColor = FLICKER_COLORS[idx]; + borderColor = FLICKER_COLORS[(idx + 2) % 4]; + } + + // Three-band gray replacement: + // 180/255 ~ 0.706 -> territory color (light band) + // 130/255 ~ 0.510 -> spawn/mid color (interpolated) + // 70/255 ~ 0.275 -> border color (dark band) + vec3 spawnColor = mix(territoryColor, borderColor, 0.5); + + vec3 color; + if (gray > 0.6) { + // Light band (180) -> territory color + color = territoryColor; + } else if (gray > 0.4) { + // Mid band (130) -> spawn color + color = spawnColor; + } else { + // Dark band (70) -> border color + color = borderColor; + } + + fragColor = vec4(color, texel.a); +} diff --git a/src/client/render/gl/shaders/unit/unit.vert.glsl b/src/client/render/gl/shaders/unit/unit.vert.glsl new file mode 100644 index 0000000000..640734d984 --- /dev/null +++ b/src/client/render/gl/shaders/unit/unit.vert.glsl @@ -0,0 +1,46 @@ +#version 300 es +precision highp float; + +layout(location = 0) in vec2 aPos; + +// Per-instance attributes +layout(location = 1) in vec3 aInstPos; // x, y, ownerID +layout(location = 2) in vec2 aInstFlags; // atlasIdx (uint8→float), flags (uint8→float) + +uniform mat3 uCamera; + +uniform float uUnitSize; + +out vec2 vLocalPos; +out vec2 vAtlasUV; +flat out float vOwnerID; +flat out float vFlags; // 0.0 = normal, 1.0 = flicker, 2.0 = angry +flat out float vHash; // per-instance hash for flicker phase offset + +void main() { + float worldX = aInstPos.x; + float worldY = aInstPos.y; + vOwnerID = aInstPos.z; + + float atlasCol = aInstFlags.x; + vFlags = aInstFlags.y; + + // Position-based hash so each unit flickers independently + vHash = fract(worldX * 0.1731 + worldY * 0.3179); + + // UNIT_SIZE is in world-space tiles — no zoom division needed. + // Units scale with the map like territory tiles do. + float halfSize = uUnitSize * 0.5; + + vec2 center = vec2(worldX + 0.5, worldY + 0.5); + vec2 worldPos = center + (aPos - 0.5) * halfSize * 2.0; + + vec3 clip = uCamera * vec3(worldPos, 1.0); + gl_Position = vec4(clip.xy, 0.0, 1.0); + + vLocalPos = aPos; + + // Atlas UV: map quad [0,1] to the correct column + float colU = (atlasCol + aPos.x) / float(ATLAS_COLS); + vAtlasUV = vec2(colU, aPos.y); +} diff --git a/src/client/render/gl/utils/Affiliation.ts b/src/client/render/gl/utils/Affiliation.ts new file mode 100644 index 0000000000..7504567746 --- /dev/null +++ b/src/client/render/gl/utils/Affiliation.ts @@ -0,0 +1,171 @@ +/** + * Affiliation palette — maps ownerID → affiliation color for alt-view. + * + * TEX_W×2 RGBA8 texture (TEX_W = PALETTE_SIZE = 4096): + * Row 0: border colors (4-state: self/ally/neutral/embargo) + * Row 1: unit colors (3-state: self/ally/enemy) + * + * Rebuilt when localPlayerID or relationship data changes. + */ + +import { getPaletteSize } from "./ColorUtils"; +import { createTexture2D } from "./GlUtils"; + +// Relationship constants (must match adapter.ts) +const RELATION_NEUTRAL = 0; +const RELATION_FRIENDLY = 1; +const RELATION_EMBARGO = 2; + +// Affiliation RGB values (upstream PastelTheme) +const SELF_R = 0, + SELF_G = 255, + SELF_B = 0; +const ALLY_R = 255, + ALLY_G = 255, + ALLY_B = 0; +const NEUTRAL_R = 128, + NEUTRAL_G = 128, + NEUTRAL_B = 128; +const ENEMY_R = 255, + ENEMY_G = 0, + ENEMY_B = 0; + +const TEX_W = getPaletteSize(); // 4096 — covers full 12-bit smallID range +const TEX_H = 2; + +export class AffiliationPalette { + private gl: WebGL2RenderingContext; + private tex: WebGLTexture; + private cpuData = new Uint8Array(TEX_W * TEX_H * 4); + private dirty = false; + + // Cached inputs for rebuilding + private localPlayerID = 0; + private relationData: Uint8Array | null = null; + private relationSize = 0; + + constructor(gl: WebGL2RenderingContext) { + this.gl = gl; + this.rebuild(); // initialize to spectator-mode defaults (gray borders, red units) + this.tex = createTexture2D(gl, { + width: TEX_W, + height: TEX_H, + internalFormat: gl.RGBA8, + format: gl.RGBA, + type: gl.UNSIGNED_BYTE, + data: this.cpuData, + filter: gl.NEAREST, + }); + this.dirty = false; // already baked into initial upload + } + + getTexture(): WebGLTexture { + return this.tex; + } + + setLocalPlayer(id: number): void { + if (id === this.localPlayerID) return; + this.localPlayerID = id; + this.rebuild(); + } + + updateRelations(data: Uint8Array, size: number): void { + this.relationData = data; + this.relationSize = size; + this.rebuild(); + } + + /** Flush to GPU if dirty (call before drawing alt-view passes). */ + flush(): void { + if (!this.dirty) return; + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.tex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + TEX_W, + TEX_H, + gl.RGBA, + gl.UNSIGNED_BYTE, + this.cpuData, + ); + this.dirty = false; + } + + private rebuild(): void { + const d = this.cpuData; + const lp = this.localPlayerID; + const rel = this.relationData; + const rs = this.relationSize; + + for (let owner = 0; owner < TEX_W; owner++) { + // Determine relationship + let relation = RELATION_NEUTRAL; + if (rel && lp > 0 && owner > 0 && owner < rs && lp < rs) { + relation = rel[lp * rs + owner]; + } + const isSelf = owner > 0 && owner === lp; + + // Row 0: border colors (4-state) + const bOff = owner * 4; + if (owner === 0) { + d[bOff] = 0; + d[bOff + 1] = 0; + d[bOff + 2] = 0; + d[bOff + 3] = 0; + } else if (isSelf) { + d[bOff] = SELF_R; + d[bOff + 1] = SELF_G; + d[bOff + 2] = SELF_B; + d[bOff + 3] = 255; + } else if (relation === RELATION_FRIENDLY) { + d[bOff] = ALLY_R; + d[bOff + 1] = ALLY_G; + d[bOff + 2] = ALLY_B; + d[bOff + 3] = 255; + } else if (relation === RELATION_EMBARGO) { + d[bOff] = ENEMY_R; + d[bOff + 1] = ENEMY_G; + d[bOff + 2] = ENEMY_B; + d[bOff + 3] = 255; + } else { + d[bOff] = NEUTRAL_R; + d[bOff + 1] = NEUTRAL_G; + d[bOff + 2] = NEUTRAL_B; + d[bOff + 3] = 255; + } + + // Row 1: unit colors (3-state — no neutral, neutral→enemy) + const uOff = (TEX_W + owner) * 4; + if (owner === 0) { + d[uOff] = 0; + d[uOff + 1] = 0; + d[uOff + 2] = 0; + d[uOff + 3] = 0; + } else if (isSelf) { + d[uOff] = SELF_R; + d[uOff + 1] = SELF_G; + d[uOff + 2] = SELF_B; + d[uOff + 3] = 255; + } else if (relation === RELATION_FRIENDLY) { + d[uOff] = ALLY_R; + d[uOff + 1] = ALLY_G; + d[uOff + 2] = ALLY_B; + d[uOff + 3] = 255; + } else { + d[uOff] = ENEMY_R; + d[uOff + 1] = ENEMY_G; + d[uOff + 2] = ENEMY_B; + d[uOff + 3] = 255; + } + } + + this.dirty = true; + } + + dispose(): void { + this.gl.deleteTexture(this.tex); + } +} diff --git a/src/client/render/gl/utils/ColorUtils.ts b/src/client/render/gl/utils/ColorUtils.ts new file mode 100644 index 0000000000..68b008c451 --- /dev/null +++ b/src/client/render/gl/utils/ColorUtils.ts @@ -0,0 +1,90 @@ +/** + * GPU-ready color utilities. + * + * Terrain RGBA: Uint8Array(w × h × 4) — one RGBA pixel per tile, computed + * from PastelTheme rules applied to the raw terrain byte layout. + * + * Player palette is NOT built here — consumers provide a pre-built + * Float32Array(PALETTE_SIZE × 2 × 4) to the GPURenderer constructor. + */ + +/** Must cover 12-bit smallID range (0-4095). */ +const PALETTE_SIZE = 4096; + +export function getPaletteSize(): number { + return PALETTE_SIZE; +} + +// ---------- Terrain ---------- + +/** + * Compute a static RGBA8 texture from raw terrain bytes. + * Replicates PastelTheme.terrainColor() on the CPU. + * + * Terrain byte layout per tile: + * bit 7: isLand + * bit 6: isShoreline + * bit 5: isOcean (water only) + * bits 0-4: magnitude (0-31) + */ +export function buildTerrainRGBA( + terrainBytes: Uint8Array, + w: number, + h: number, +): Uint8Array { + const pixels = new Uint8Array(w * h * 4); + + for (let i = 0; i < w * h; i++) { + const tb = terrainBytes[i]; + const isLand = (tb & 0x80) !== 0; + const isShoreline = (tb & 0x40) !== 0; + const magnitude = tb & 0x1f; + + let r: number, g: number, b: number; + + if (isLand && isShoreline) { + // Shore (sand) + r = 204; + g = 203; + b = 158; + } else if (isLand) { + if (magnitude < 10) { + // Plains + r = 190; + g = 220 - 2 * magnitude; + b = 138; + } else if (magnitude < 20) { + // Highland + r = 200 + 2 * magnitude; + g = 183 + 2 * magnitude; + b = 138 + 2 * magnitude; + } else { + // Mountain + const v = Math.min(255, 230 + Math.floor(magnitude / 2)); + r = v; + g = v; + b = v; + } + } else if (isShoreline) { + // Shoreline water + r = 100; + g = 143; + b = 255; + } else { + // Deep water + const m = Math.min(magnitude, 10); + const offset = 11 - m; + r = Math.max(0, 70 - 10 + offset); + g = Math.max(0, 132 - 10 + offset); + b = Math.max(0, 180 - 10 + offset); + } + + const off = i * 4; + pixels[off] = r; + pixels[off + 1] = g; + pixels[off + 2] = b; + pixels[off + 3] = 255; + } + + return pixels; +} diff --git a/src/client/render/gl/utils/GlUtils.ts b/src/client/render/gl/utils/GlUtils.ts new file mode 100644 index 0000000000..53b59abc03 --- /dev/null +++ b/src/client/render/gl/utils/GlUtils.ts @@ -0,0 +1,209 @@ +/** + * WebGL2 utility functions: shader compilation, texture creation, VAO helpers. + */ + +export function compileShader( + gl: WebGL2RenderingContext, + type: number, + source: string, +): WebGLShader { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(shader) ?? ""; + gl.deleteShader(shader); + throw new Error(`Shader compile error:\n${log}`); + } + return shader; +} + +export function createProgram( + gl: WebGL2RenderingContext, + vertSrc: string, + fragSrc: string, +): WebGLProgram { + const vs = compileShader(gl, gl.VERTEX_SHADER, vertSrc); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc); + const program = gl.createProgram()!; + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(program) ?? ""; + gl.deleteProgram(program); + throw new Error(`Program link error:\n${log}`); + } + return program; +} + +export interface TextureOpts { + width: number; + height: number; + internalFormat: number; + format: number; + type: number; + data: ArrayBufferView | null; + filter?: number; + wrap?: number; +} + +export function createTexture2D( + gl: WebGL2RenderingContext, + opts: TextureOpts, +): WebGLTexture { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MIN_FILTER, + opts.filter ?? gl.NEAREST, + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_MAG_FILTER, + opts.filter ?? gl.NEAREST, + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_S, + opts.wrap ?? gl.CLAMP_TO_EDGE, + ); + gl.texParameteri( + gl.TEXTURE_2D, + gl.TEXTURE_WRAP_T, + opts.wrap ?? gl.CLAMP_TO_EDGE, + ); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + opts.internalFormat, + opts.width, + opts.height, + 0, + opts.format, + opts.type, + opts.data, + ); + return tex; +} + +/** + * Create a VAO with a quad covering [0,0]→[mapWidth, mapHeight] in world coords. + * Two triangles, positions only. Attribute location 0. + */ +/** + * Create a VAO with a [0,1]² fullscreen quad. Two triangles, positions only. + * Attribute location 0. Used for post-process passes (blur, composite, etc.). + */ +export function createFullscreenQuad( + gl: WebGL2RenderingContext, +): WebGLVertexArrayObject { + const vao = gl.createVertexArray()!; + gl.bindVertexArray(vao); + + const buf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), + gl.STATIC_DRAW, + ); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + gl.bindVertexArray(null); + return vao; +} + +/** + * Inject `#define` constants into a GLSL shader source string. + * Inserts definitions immediately after the `#version` line. + * + * Usage: + * shaderSrc(blurFrag, { PALETTE_SIZE: 4096 }) + * // → "#version 300 es\n#define PALETTE_SIZE 4096\n..." + */ +export function shaderSrc( + source: string, + defines: Record, +): string { + const defs = Object.entries(defines) + .map(([k, v]) => `#define ${k} ${v}`) + .join("\n"); + return source.replace("#version 300 es", `#version 300 es\n${defs}`); +} + +export interface RenderTarget { + fbo: WebGLFramebuffer; + tex: WebGLTexture; + w: number; + h: number; +} + +/** + * Bind a render target FBO, set viewport, clear, run draw callback, then + * restore the default framebuffer. Returns the target texture for chaining. + */ +export function toTarget( + gl: WebGL2RenderingContext, + target: RenderTarget, + draw: () => void, +): WebGLTexture { + gl.bindFramebuffer(gl.FRAMEBUFFER, target.fbo); + gl.viewport(0, 0, target.w, target.h); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + draw(); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return target.tex; +} + +/** + * Bind the screen (default framebuffer), set viewport, run draw callback. + */ +export function toScreen( + gl: WebGL2RenderingContext, + w: number, + h: number, + draw: () => void, +): void { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, w, h); + draw(); +} + +export function createMapQuad( + gl: WebGL2RenderingContext, + mapWidth: number, + mapHeight: number, +): WebGLVertexArrayObject { + const vao = gl.createVertexArray()!; + gl.bindVertexArray(vao); + + const positions = new Float32Array([ + 0, + 0, + mapWidth, + 0, + 0, + mapHeight, + 0, + mapHeight, + mapWidth, + 0, + mapWidth, + mapHeight, + ]); + + const buf = gl.createBuffer()!; + gl.bindBuffer(gl.ARRAY_BUFFER, buf); + gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + gl.bindVertexArray(null); + return vao; +} diff --git a/src/client/render/gl/utils/GpuResources.ts b/src/client/render/gl/utils/GpuResources.ts new file mode 100644 index 0000000000..9e0c849861 --- /dev/null +++ b/src/client/render/gl/utils/GpuResources.ts @@ -0,0 +1,78 @@ +/** + * GPUResources — shared GPU textures created once, passed to all passes. + * + * Eliminates getter chains, setBorderTex/setHeatTex late-wiring, and + * construction-order dependencies between passes. + */ + +import { createTexture2D } from "./GlUtils"; + +export interface GPUResources { + tileTex: WebGLTexture; // R16UI — tile ownership + flags + trailTex: WebGLTexture; // R8UI — trail owner per tile + paletteTex: WebGLTexture; // RGBA32F — player colors + borderTex: WebGLTexture; // RGBA8 — border type + ember + defense + heatTexA: WebGLTexture; // R8 — fallout heat ping-pong A + heatTexB: WebGLTexture; // R8 — fallout heat ping-pong B +} + +export function createGPUResources( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + paletteTex: WebGLTexture, + borderTex: WebGLTexture, +): GPUResources { + const tileTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R16UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_SHORT, + data: null, + filter: gl.NEAREST, + }); + + const trailTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + const heatTexA = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8, + format: gl.RED, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + const heatTexB = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R8, + format: gl.RED, + type: gl.UNSIGNED_BYTE, + data: null, + filter: gl.NEAREST, + }); + + return { tileTex, trailTex, paletteTex, borderTex, heatTexA, heatTexB }; +} + +export function disposeGPUResources( + gl: WebGL2RenderingContext, + res: GPUResources, +): void { + gl.deleteTexture(res.tileTex); + gl.deleteTexture(res.trailTex); + // paletteTex and borderTex are owned by renderer and BorderComputePass respectively + gl.deleteTexture(res.heatTexA); + gl.deleteTexture(res.heatTexB); +} diff --git a/src/client/render/gl/utils/HeatManager.ts b/src/client/render/gl/utils/HeatManager.ts new file mode 100644 index 0000000000..119175769a --- /dev/null +++ b/src/client/render/gl/utils/HeatManager.ts @@ -0,0 +1,319 @@ +/** + * HeatManager — GPU-side fallout heat decay and transition detection. + * + * Extracted from FalloutBloomPass. Owns the heat ping-pong textures, the + * previous-tile-state snapshot, and the combined transition+decay shader. + * + * Used by both FalloutBloomPass (bloom extract reads heat) and LightmapPass + * (fallout light reads heat). Shared heat textures come from GPUResources. + */ + +import type { RenderSettings } from "../RenderSettings"; +import { + createFullscreenQuad, + createProgram, + createTexture2D, + shaderSrc, +} from "./GlUtils"; +import { FALLOUT_BIT, TILE_DEFINES } from "./TileCodec"; + +import heatDecayFragSrc from "../shaders/fallout-bloom/heat-decay.frag.glsl?raw"; +import fullscreenNoUvVertSrc from "../shaders/shared/fullscreen-no-uv.vert.glsl?raw"; + +export class HeatManager { + private gl: WebGL2RenderingContext; + private settings: RenderSettings; + private mapW: number; + private mapH: number; + private tileTex: WebGLTexture; + + // Heat ping-pong (R8, per-tile: 255=fresh, decays toward 0) + private heatTexA: WebGLTexture; + private heatTexB: WebGLTexture; + private heatFboA: WebGLFramebuffer; + private heatFboB: WebGLFramebuffer; + /** 0 = read A / write B, 1 = read B / write A */ + private heatCurrent = 0; + + // Previous tile state (R16UI) — GPU-side snapshot for transition detection + private prevTileTex: WebGLTexture; + private prevTileFbo: WebGLFramebuffer; + private tileTexReadFbo: WebGLFramebuffer; + /** True on first frame and after seek — blit tileTex→prevTileTex without transitions. */ + private needsPrevTileCopy = true; + + // Pending CPU → GPU writes + private pendingDecay = 0; + private pendingFullHeat: Uint8Array | null = null; + /** + * True when heat may be non-zero anywhere — gates the decay pass. + * Set true on each game tick (shader may detect new fallout transitions). + * Set false once accumulated decay since last activation exceeds 255 (fully drained). + */ + private heatActive = false; + /** Accumulated decay since heatActive was last set true. */ + private decayAccumulated = 0; + + // Decay program + private decayProg: WebGLProgram; + private uDecayMapSize: WebGLUniformLocation; + private uDecayAmount: WebGLUniformLocation; + + // Geometry + private quadVao: WebGLVertexArrayObject; + + constructor( + gl: WebGL2RenderingContext, + mapW: number, + mapH: number, + tileTex: WebGLTexture, + heatTexA: WebGLTexture, + heatTexB: WebGLTexture, + settings: RenderSettings, + ) { + this.gl = gl; + this.settings = settings; + this.mapW = mapW; + this.mapH = mapH; + this.tileTex = tileTex; + this.heatTexA = heatTexA; + this.heatTexB = heatTexB; + + this.heatFboA = this.createFboFor(heatTexA); + this.heatFboB = this.createFboFor(heatTexB); + + // Previous tile state texture (R16UI, for GPU transition detection) + this.prevTileTex = createTexture2D(gl, { + width: mapW, + height: mapH, + internalFormat: gl.R16UI, + format: gl.RED_INTEGER, + type: gl.UNSIGNED_SHORT, + data: null, + filter: gl.NEAREST, + }); + this.prevTileFbo = this.createFboFor(this.prevTileTex); + this.tileTexReadFbo = this.createFboFor(tileTex); + + // Decay program (tile-space, combined transition + decay) + this.decayProg = createProgram( + gl, + fullscreenNoUvVertSrc, + shaderSrc(heatDecayFragSrc, TILE_DEFINES), + ); + this.uDecayMapSize = gl.getUniformLocation(this.decayProg, "uMapSize")!; + this.uDecayAmount = gl.getUniformLocation(this.decayProg, "uDecay")!; + gl.useProgram(this.decayProg); + gl.uniform1i(gl.getUniformLocation(this.decayProg, "uHeatTex"), 0); + gl.uniform1i(gl.getUniformLocation(this.decayProg, "uTileTex"), 1); + gl.uniform1i(gl.getUniformLocation(this.decayProg, "uPrevTileTex"), 2); + + this.quadVao = createFullscreenQuad(gl); + } + + private createFboFor(tex: WebGLTexture): WebGLFramebuffer { + const gl = this.gl; + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D( + gl.FRAMEBUFFER, + gl.COLOR_ATTACHMENT0, + gl.TEXTURE_2D, + tex, + 0, + ); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return fbo; + } + + /** Current heat read texture. */ + private get heatReadTex(): WebGLTexture { + return this.heatCurrent === 0 ? this.heatTexA : this.heatTexB; + } + private get heatWriteFbo(): WebGLFramebuffer { + return this.heatCurrent === 0 ? this.heatFboB : this.heatFboA; + } + private swapHeat(): void { + this.heatCurrent = 1 - this.heatCurrent; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** Current heat texture for reading (bloom extract and lightmap). */ + getHeatTex(): WebGLTexture { + return this.heatReadTex; + } + + /** + * Run GPU heat update: detect fallout-bit transitions, apply decay, + * then snapshot tileTex → prevTileTex. + * + * Call once per frame after tile texture is flushed to GPU. + */ + updateHeat(): void { + const gl = this.gl; + const mw = this.mapW; + const mh = this.mapH; + + // 1. Upload reconstructed heat on seek + if (this.pendingFullHeat) { + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.heatReadTex); + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + 0, + 0, + mw, + mh, + gl.RED, + gl.UNSIGNED_BYTE, + this.pendingFullHeat, + ); + this.pendingFullHeat = null; + } + + // 2. First frame / seek: copy tileTex → prevTileTex, skip transitions + if (this.needsPrevTileCopy) { + this.blitTileToPrev(); + this.needsPrevTileCopy = false; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return; + } + + // 3. Skip decay pass when nothing to do — no pending decay and heat already settled. + // Still blit tileTex→prevTileTex when a tick fired (pendingDecay > 0) so transition + // detection stays accurate if heat activates later. + if (!this.heatActive && this.pendingDecay === 0) return; + if (!this.heatActive) { + // Tick fired but no heat — just keep prevTileTex in sync and bail. + this.blitTileToPrev(); + this.pendingDecay = 0; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return; + } + + // 4. Combined transition detection + decay (GPU ping-pong) + gl.bindFramebuffer(gl.FRAMEBUFFER, this.heatWriteFbo); + gl.viewport(0, 0, mw, mh); + gl.disable(gl.BLEND); + + gl.useProgram(this.decayProg); + gl.uniform2f(this.uDecayMapSize, mw, mh); + gl.uniform1f(this.uDecayAmount, this.pendingDecay); + + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.heatReadTex); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.tileTex); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, this.prevTileTex); + gl.bindVertexArray(this.quadVao); + gl.drawArrays(gl.TRIANGLES, 0, 6); + + this.swapHeat(); + this.decayAccumulated += this.pendingDecay; + if (this.decayAccumulated >= 255) this.heatActive = false; + this.pendingDecay = 0; + + // 5. Snapshot current tileTex → prevTileTex for next frame + this.blitTileToPrev(); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + /** GPU blit: tileTex → prevTileTex (R16UI, NEAREST). */ + private blitTileToPrev(): void { + const gl = this.gl; + gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.tileTexReadFbo); + gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, this.prevTileFbo); + gl.blitFramebuffer( + 0, + 0, + this.mapW, + this.mapH, + 0, + 0, + this.mapW, + this.mapH, + gl.COLOR_BUFFER_BIT, + gl.NEAREST, + ); + } + + /** + * Reset heat state on seek. Reconstructs heat from nuke history and + * masks out recaptured tiles. + */ + resetForSeek( + tileState: Uint16Array, + nukeEvents?: Array<{ tick: number; tiles: number[] }>, + currentTick?: number, + ): void { + let hasHeat = false; + if (nukeEvents && nukeEvents.length > 0 && currentTick !== undefined) { + const heat = this.reconstructHeat(nukeEvents, currentTick); + this.maskHeat(heat, tileState); + this.pendingFullHeat = heat; + hasHeat = heat.some((v) => v > 0); + } else { + this.pendingFullHeat = new Uint8Array(this.mapW * this.mapH); + } + this.pendingDecay = 0; + this.decayAccumulated = 0; + this.heatActive = hasHeat; + this.needsPrevTileCopy = true; + } + + /** Accumulate heat decay for one game tick. */ + decayHeat(): void { + this.pendingDecay += this.settings.falloutBloom.heatDecayPerTick; + // A tick fired — the shader may detect new fallout transitions, so heat is potentially active. + if (!this.heatActive) { + this.heatActive = true; + this.decayAccumulated = 0; + } + } + + // --------------------------------------------------------------------------- + // Internals + // --------------------------------------------------------------------------- + + private reconstructHeat( + nukeEvents: Array<{ tick: number; tiles: number[] }>, + currentTick: number, + ): Uint8Array { + const heat = new Uint8Array(this.mapW * this.mapH); + const decay = this.settings.falloutBloom.heatDecayPerTick; + for (const evt of nukeEvents) { + if (evt.tick > currentTick) continue; + const elapsed = currentTick - evt.tick; + const h = Math.round(255 - elapsed * decay); + if (h <= 0) continue; + for (const ref of evt.tiles) { + if (heat[ref] < h) heat[ref] = h; + } + } + return heat; + } + + private maskHeat(heat: Uint8Array, tileState: Uint16Array): void { + for (let i = 0; i < heat.length; i++) { + if (heat[i] > 0 && (tileState[i] & FALLOUT_BIT) === 0) { + heat[i] = 0; + } + } + } + + dispose(): void { + const gl = this.gl; + gl.deleteProgram(this.decayProg); + gl.deleteFramebuffer(this.heatFboA); + gl.deleteFramebuffer(this.heatFboB); + gl.deleteFramebuffer(this.prevTileFbo); + gl.deleteFramebuffer(this.tileTexReadFbo); + gl.deleteTexture(this.prevTileTex); + gl.deleteVertexArray(this.quadVao); + } +} diff --git a/src/client/render/gl/utils/NukeTrajectory.ts b/src/client/render/gl/utils/NukeTrajectory.ts new file mode 100644 index 0000000000..d020a65429 --- /dev/null +++ b/src/client/render/gl/utils/NukeTrajectory.ts @@ -0,0 +1,261 @@ +/** + * Nuke trajectory computation — Bezier control points and color thresholds. + * + * Matches upstream PathFinder.Parabola.ts + Line.ts math exactly. + * Pure functions, no game dependencies. + */ + +import type { NukeTrajectoryData } from "../../types"; + +// Upstream constants +const PARABOLA_MIN_HEIGHT = 50; +const TARGETABLE_RANGE = 150; +const TARGETABLE_RANGE_SQ = TARGETABLE_RANGE * TARGETABLE_RANGE; +const THRESHOLD_SAMPLES = 64; + +// SAM range formula: 150 - 480 / (level + 5) +const MAX_SAM_RANGE = 150; +const SAM_RANGE_DIVISOR = 480; +const SAM_RANGE_OFFSET = 5; + +export function samRange(level: number): number { + return MAX_SAM_RANGE - SAM_RANGE_DIVISOR / (level + SAM_RANGE_OFFSET); +} + +export interface SAMInfo { + x: number; + y: number; + rangeSq: number; +} + +/** Cubic Bezier evaluation at parameter t. */ +function bezier( + t: number, + p0: number, + p1: number, + p2: number, + p3: number, +): number { + const T = 1 - t; + return ( + T * T * T * p0 + 3 * T * T * t * p1 + 3 * T * t * t * p2 + t * t * t * p3 + ); +} + +function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +function distSq(ax: number, ay: number, bx: number, by: number): number { + const dx = ax - bx; + const dy = ay - by; + return dx * dx + dy * dy; +} + +/** + * Compute Bezier control points matching upstream parabola pathfinder. + * + * The curve bows perpendicular to the src→dst line. `directionUp` controls + * which side (in Y) the arc bows toward (upstream convention: true = -Y). + */ +export function computeNukeControlPoints( + srcX: number, + srcY: number, + dstX: number, + dstY: number, + mapH: number, + directionUp: boolean, +): { + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; +} { + const dx = dstX - srcX; + const dy = dstY - srcY; + const dist = Math.sqrt(dx * dx + dy * dy); + const maxHeight = Math.max(dist / 3, PARABOLA_MIN_HEIGHT); + const hm = directionUp ? -1 : 1; + + return { + p0x: srcX, + p0y: srcY, + p1x: srcX + dx / 4, + p1y: clamp(srcY + dy / 4 + hm * maxHeight, 0, mapH - 1), + p2x: srcX + (dx * 3) / 4, + p2y: clamp(srcY + (dy * 3) / 4 + hm * maxHeight, 0, mapH - 1), + p3x: dstX, + p3y: dstY, + }; +} + +/** Binary-search for the exact t where distSq to (cx,cy) crosses rangeSq. */ +function refineCrossing( + cp: { + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; + }, + cx: number, + cy: number, + rangeSq: number, + tLo: number, + tHi: number, + exitingRange: boolean, +): number { + for (let i = 0; i < 10; i++) { + const tMid = (tLo + tHi) * 0.5; + const x = bezier(tMid, cp.p0x, cp.p1x, cp.p2x, cp.p3x); + const y = bezier(tMid, cp.p0y, cp.p1y, cp.p2y, cp.p3y); + const inside = distSq(x, y, cx, cy) <= rangeSq; + if (exitingRange ? inside : !inside) tLo = tMid; + else tHi = tMid; + } + return (tLo + tHi) * 0.5; +} + +/** + * Sample the Bezier curve at regular t intervals and find color threshold + * t-values for untargetable zones and SAM intercept. + * + * Uses binary search refinement for sub-sample precision so that zone + * boundary markers don't jiggle when the cursor moves. + */ +export function computeTrajectoryThresholds( + cp: { + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; + }, + srcX: number, + srcY: number, + dstX: number, + dstY: number, + sams: readonly SAMInfo[], +): { + tUntargetableStart: number; + tUntargetableEnd: number; + tSamIntercept: number; +} { + let tUntargetableStart = -1; + let tUntargetableEnd = -1; + let tSamIntercept = 1.0; + + const dt = 1.0 / THRESHOLD_SAMPLES; + + // Pass 1: find untargetable zone boundaries + for (let i = 1; i <= THRESHOLD_SAMPLES; i++) { + const t = i * dt; + const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x); + const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y); + + if (tUntargetableStart < 0) { + // Looking for first point outside source range + if (distSq(x, y, srcX, srcY) > TARGETABLE_RANGE_SQ) { + if (distSq(x, y, dstX, dstY) < TARGETABLE_RANGE_SQ) { + // Overlapping source & target range — no untargetable zone + break; + } + tUntargetableStart = refineCrossing( + cp, + srcX, + srcY, + TARGETABLE_RANGE_SQ, + t - dt, + t, + true, + ); + } + } else { + // Looking for first point inside target range + if (distSq(x, y, dstX, dstY) < TARGETABLE_RANGE_SQ) { + tUntargetableEnd = refineCrossing( + cp, + dstX, + dstY, + TARGETABLE_RANGE_SQ, + t - dt, + t, + false, + ); + break; + } + } + } + + // Pass 2: find SAM intercept (skip untargetable zone) + if (sams.length > 0) { + for (let i = 1; i <= THRESHOLD_SAMPLES; i++) { + const t = i * dt; + + // Skip untargetable segment + if ( + tUntargetableStart >= 0 && + t >= tUntargetableStart && + t <= tUntargetableEnd + ) { + continue; + } + + const x = bezier(t, cp.p0x, cp.p1x, cp.p2x, cp.p3x); + const y = bezier(t, cp.p0y, cp.p1y, cp.p2y, cp.p3y); + + for (const sam of sams) { + if (distSq(x, y, sam.x, sam.y) <= sam.rangeSq) { + tSamIntercept = refineCrossing( + cp, + sam.x, + sam.y, + sam.rangeSq, + t - dt, + t, + false, + ); + break; + } + } + if (tSamIntercept < 1.0) break; + } + } + + return { tUntargetableStart, tUntargetableEnd, tSamIntercept }; +} + +/** + * Build complete NukeTrajectoryData from source/target positions. + * Convenience function combining control point + threshold computation. + */ +export function buildNukeTrajectory( + srcX: number, + srcY: number, + dstX: number, + dstY: number, + mapH: number, + directionUp: boolean, + sams: readonly SAMInfo[], +): NukeTrajectoryData { + const cp = computeNukeControlPoints( + srcX, + srcY, + dstX, + dstY, + mapH, + directionUp, + ); + const th = computeTrajectoryThresholds(cp, srcX, srcY, dstX, dstY, sams); + return { ...cp, ...th }; +} diff --git a/src/client/render/gl/utils/TileCodec.ts b/src/client/render/gl/utils/TileCodec.ts new file mode 100644 index 0000000000..227016a4c9 --- /dev/null +++ b/src/client/render/gl/utils/TileCodec.ts @@ -0,0 +1,18 @@ +/** + * Tile state bit layout (R16UI). Single source of truth for TypeScript + GLSL. + * + * Bits 0-11: ownerID (player smallID, 0 = unowned) + * Bit 13: fallout + * Bit 14: defense bonus + */ + +export const OWNER_MASK = 0xfff; // bits 0-11 +export const FALLOUT_BIT = 1 << 13; // bit 13 +export const DEFENSE_BIT = 1 << 14; // bit 14 + +/** GLSL #define values for shaderSrc() injection. Bit indices, not masks. */ +export const TILE_DEFINES = { + OWNER_MASK: 0xfff, // used as uint(OWNER_MASK) in GLSL + FALLOUT_BIT: 13, // used as (1u << FALLOUT_BIT) in GLSL + DEFENSE_BIT: 14, // used as (1u << DEFENSE_BIT) in GLSL +}; diff --git a/src/client/render/gl/vite-env.d.ts b/src/client/render/gl/vite-env.d.ts new file mode 100644 index 0000000000..04933cc420 --- /dev/null +++ b/src/client/render/gl/vite-env.d.ts @@ -0,0 +1,8 @@ +declare module "*.glsl?raw" { + const src: string; + export default src; +} +declare module "*.png?url" { + const url: string; + export default url; +} diff --git a/src/client/render/types/FrameData.ts b/src/client/render/types/FrameData.ts new file mode 100644 index 0000000000..04890f591f --- /dev/null +++ b/src/client/render/types/FrameData.ts @@ -0,0 +1,76 @@ +import type { FrameEvents } from "./FrameEvents"; +import type { + AttackRingInput, + NameEntry, + NukeTelegraphData, + PlayerState, + PlayerStatusData, + TilePair, + UnitState, +} from "./Renderer"; + +/** + * FrameData — the boundary contract between game integration and features. + * + * Produced once per frame by a driver (shim for live, codec for replay). + * All feature consumers (renderer, minimap, stats) read from this interface. + * They never touch game internals directly. + */ +export interface FrameData { + // ── Core accumulated state ──────────────────────────────────────────── + + readonly tick: number; + /** True during spawn phase (before gameplay begins). Always false for replay. */ + readonly inSpawnPhase: boolean; + readonly tileState: Uint16Array; + readonly trailState: Uint8Array; + readonly railroadState: Uint8Array; + readonly units: ReadonlyMap; + readonly players: ReadonlyMap; + readonly names: ReadonlyMap; + + // ── Per-frame events ────────────────────────────────────────────────── + + /** Everything that happened this frame — rendering FX and stats events. */ + readonly events: FrameEvents; + + // ── Upload hints ────────────────────────────────────────────────────── + + /** + * Changed tiles this frame for delta uploads. + * - `null` or `undefined` → full upload needed (live mode or keyframe seek) + * - array → delta upload (replay sequential advance) + */ + readonly changedTiles?: TilePair[] | null; + readonly railroadDirty: boolean; + readonly revealedRailTiles: number[]; + + /** + * Trail dirty row range for partial GPU upload. + * - `dirtyRowMin > dirtyRowMax` → no trail changes (skip upload) + * - Otherwise → upload rows [min, max] from trailState + * Only meaningful in `tileMode: "live"`. + */ + readonly trailDirtyRowMin: number; + readonly trailDirtyRowMax: number; + + // ── Derived (computed once by producer) ──────────────────────────────── + + readonly playerStatus: ReadonlyMap; + readonly relationMatrix: Uint8Array; + readonly relationSize: number; + readonly allianceClusters: ReadonlyMap; + readonly nukeTelegraphs: NukeTelegraphData[]; + readonly attackRings: AttackRingInput[]; + /** True when structures changed this tick (added/removed/level change). */ + readonly structuresDirty: boolean; + + // ── Upload semantics ────────────────────────────────────────────────── + + /** + * How tile data should reach the GPU: + * - `"live"` — arrays are mutated in-place by shim each tick (zero-copy refs) + * - `"copy"` — arrays may be swapped/reconstructed (renderer must copy) + */ + readonly tileMode: "live" | "copy"; +} diff --git a/src/client/render/types/FrameEvents.ts b/src/client/render/types/FrameEvents.ts new file mode 100644 index 0000000000..9ae6abb66e --- /dev/null +++ b/src/client/render/types/FrameEvents.ts @@ -0,0 +1,114 @@ +import type { + ConquestFx, + DeadUnitFx, + PlayerState, + UnitState, +} from "./Renderer"; + +// ── Supporting event types ────────────────────────────────────────────── + +export interface AllianceFormedEvent { + requestorID: number; + recipientID: number; +} + +export interface AllianceBrokenEvent { + traitorID: number; + betrayedID: number; +} + +export interface AllianceExpiredEvent { + player1ID: number; + player2ID: number; +} + +export interface EmbargoEvent { + type: "start" | "stop"; + playerID: number; + embargoedID: number; +} + +export interface TargetEvent { + playerID: number; + targetID: number; +} + +export interface BonusEvent { + playerID: string; + smallID: number; + tile: number; + gold: number; + troops: number; +} + +export interface NukeIncomingEvent { + playerID: number; +} + +export interface EmojiEvent { + senderID: number; + message: string; +} + +export interface DisplayMessageEvent { + messageType: number; + playerID: number | null; + goldAmount?: number; + params?: Record; +} + +export interface WinEvent { + /** Tuple: ["player", ...playerIds] or ["team"|"nation", name, ...playerIds] */ + winner: string[]; +} + +// ── Empty events constant ─────────────────────────────────────────────── + +/** Shared empty-events object. Safe to reuse — all arrays are empty and never mutated. */ +export const EMPTY_FRAME_EVENTS: FrameEvents = { + deadUnits: [], + conquestEvents: [], + unitUpdates: [], + playerUpdates: [], + allianceFormed: [], + allianceBroken: [], + allianceExpired: [], + embargoEvents: [], + targetEvents: [], + bonusEvents: [], + nukeIncoming: [], + emojis: [], + displayMessages: [], + wins: [], + gamePaused: null, +}; + +// ── FrameEvents ───────────────────────────────────────────────────────── + +/** + * Everything that happened THIS frame. Accumulated state and derived data + * live on FrameData directly — per-frame ephemeral events live here. + * + * Empty arrays when nothing happened. Producers must always populate every + * field (no undefined — consumers shouldn't need null checks). + */ +export interface FrameEvents { + // Rendering events + readonly deadUnits: DeadUnitFx[]; + readonly conquestEvents: ConquestFx[]; + + // Stats events + readonly unitUpdates: UnitState[]; + readonly playerUpdates: PlayerState[]; + readonly allianceFormed: AllianceFormedEvent[]; + readonly allianceBroken: AllianceBrokenEvent[]; + readonly allianceExpired: AllianceExpiredEvent[]; + readonly embargoEvents: EmbargoEvent[]; + readonly targetEvents: TargetEvent[]; + readonly bonusEvents: BonusEvent[]; + readonly nukeIncoming: NukeIncomingEvent[]; + readonly emojis: EmojiEvent[]; + readonly displayMessages: DisplayMessageEvent[]; + readonly wins: WinEvent[]; + readonly gamePaused: boolean | null; +} diff --git a/src/client/render/types/FrameSource.ts b/src/client/render/types/FrameSource.ts new file mode 100644 index 0000000000..b2a2a4e50e --- /dev/null +++ b/src/client/render/types/FrameSource.ts @@ -0,0 +1,38 @@ +import type { FrameData } from "./FrameData"; +import type { PlayerStatic } from "./Renderer"; + +/** + * Static per-session metadata. Set once at game-start, never changes. + */ +export interface GameStartConfig { + gameID: string; + mapWidth: number; + mapHeight: number; + /** 0 for spectator/replay. */ + localPlayerSmallID: number; + players: PlayerStatic[]; + gameMode?: string; + difficulty?: string; + numLandTiles?: number; +} + +/** + * Mode-agnostic frame source. Features subscribe here and don't care + * whether data comes from a live game or a replay file. + * + * All subscription methods return an unsubscribe function. + * + * Late-join: `onGameStart` fires immediately with cached config if + * subscribed after game-start. `onFrame` does NOT late-fire — subscriber + * waits for the next real tick. + * + * Game-end: `onGameEnd` fires on win detection. `onFrame` continues + * emitting — the simulation runs past game-end. + */ +export interface FrameSource { + onFrame(handler: (frame: FrameData) => void): () => void; + onGameStart(handler: (config: GameStartConfig) => void): () => void; + onGameEnd(handler: () => void): () => void; + /** null before game-start. Stays valid after game-end (same session). */ + readonly config: GameStartConfig | null; +} diff --git a/src/client/render/types/Game.ts b/src/client/render/types/Game.ts new file mode 100644 index 0000000000..5a6809240f --- /dev/null +++ b/src/client/render/types/Game.ts @@ -0,0 +1,17 @@ +/** + * The frame data type that both the live game and encoder consume. + * This matches the GameUpdateViewData from the live game's update loop. + */ +export interface GameUpdateViewData { + tick: number; + updates: Record; + packedTileUpdates: unknown; + packedMotionPlans?: Uint32Array; + playerNameViewData: Record; +} + +/** + * Minimal GameStartInfo for the encoder's finish() call. + * The actual object is opaque JSON — we just need it to be serializable. + */ +export type GameStartInfo = Record; diff --git a/src/client/render/types/GameUpdates.ts b/src/client/render/types/GameUpdates.ts new file mode 100644 index 0000000000..40bae1a5d2 --- /dev/null +++ b/src/client/render/types/GameUpdates.ts @@ -0,0 +1,171 @@ +/** + * Game update type constants and typed event payloads. + * + * Shared contract between shim (live game) and codec (replay). + * Values must match the LIVE deployed game's GameUpdates.ts. + */ + +// --------------------------------------------------------------------------- +// GameUpdateType constants +// --------------------------------------------------------------------------- + +export const GameUpdateType = { + Tile: 0, + Unit: 1, + Player: 2, + DisplayEvent: 3, + DisplayChatEvent: 4, + AllianceRequest: 5, + AllianceRequestReply: 6, + BrokeAlliance: 7, + AllianceExpired: 8, + AllianceExtension: 9, + TargetPlayer: 10, + Emoji: 11, + Win: 12, + Hash: 13, + UnitIncoming: 14, + BonusEvent: 15, + RailroadDestructionEvent: 16, + RailroadConstructionEvent: 17, + RailroadSnapEvent: 18, + ConquestEvent: 19, + EmbargoEvent: 20, + GamePaused: 21, + NukeDetonation: 22, +} as const; + +/** MessageType enum values from the game source. */ +export const MessageType = { + SAM_HIT: 9, + SENT_GOLD_TO_PLAYER: 18, + RECEIVED_GOLD_FROM_PLAYER: 19, + RECEIVED_GOLD_FROM_TRADE: 20, + SENT_TROOPS_TO_PLAYER: 21, + RECEIVED_TROOPS_FROM_PLAYER: 22, +} as const; + +// --------------------------------------------------------------------------- +// Typed update payloads (keyed by GameUpdateType values) +// --------------------------------------------------------------------------- + +export type PlayerType = "HUMAN" | "NATION" | "BOT"; + +export interface UnitEventUpdate { + id: number; + unitType: string; + ownerID: number; + pos: number; + lastPos?: number; + isActive: boolean; + level: number; + underConstruction?: boolean; + markedForDeletion: number | false; + lastOwnerID?: number; + trainType?: string; + loaded?: boolean; + targetUnitId?: number; + targetTile?: number; + health?: number; + troops?: number; + reachedTarget?: boolean; + retreating?: boolean; + targetable?: boolean; + hasTrainStation?: boolean; + missileTimerQueue?: number[]; +} + +export interface PlayerEventUpdate { + id: string; + clientID?: string | null; + smallID: number; + displayName: string; + playerType: PlayerType; + team?: string | null; + isAlive: boolean; + troops: number; + gold: bigint; + tilesOwned: number; + outgoingAttacks?: AttackEventUpdate[]; + incomingAttacks?: AttackEventUpdate[]; + allies?: number[]; + betrayals?: number; +} + +export interface AttackEventUpdate { + troops: number; +} + +export interface WinUpdate { + /** Winner tuple: ["player", ...playerIds] or ["team"|"nation", name, ...playerIds] */ + winner?: [string, ...string[]]; +} + +export interface AllianceReplyUpdate { + accepted: boolean; + request?: { requestorID: number; recipientID: number }; +} + +export interface BrokeAllianceUpdate { + traitorID: number; + betrayedID: number; +} + +export interface AllianceExpiredUpdate { + player1ID: number; + player2ID: number; +} + +export interface EmbargoUpdate { + event: "start" | "stop"; + playerID: number; + embargoedID: number; +} + +export interface TargetPlayerUpdate { + playerID: number; + targetID: number; +} + +export interface BonusUpdate { + player: string; + tile?: number; + gold: number; + troops: number; +} + +export interface UnitIncomingUpdate { + playerID: number; +} + +export interface EmojiUpdate { + emoji?: { senderID: number; message: string }; +} + +export interface DisplayMessageUpdate { + messageType: number; + playerID: number | null; + goldAmount?: bigint | number; + params?: Record; +} + +export interface GamePausedUpdate { + paused: boolean; +} + +export interface RailroadConstructionUpdate { + id: number; + tiles: number[]; +} + +export interface RailroadDestructionUpdate { + id: number; +} + +export interface RailroadSnapUpdate { + originalId: number; + newId1: number; + newId2: number; + tiles1: number[]; + tiles2: number[]; +} diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts new file mode 100644 index 0000000000..be262a75f9 --- /dev/null +++ b/src/client/render/types/Renderer.ts @@ -0,0 +1,208 @@ +/** TrainType enum — numeric values matching UnitState.trainType. */ +export enum TrainType { + Engine = 0, + TailEngine = 1, + Carriage = 2, +} + +/** Numeric player type — matching PlayerStatic.playerType. */ +export enum PlayerTypeEnum { + Human = 0, + Bot = 1, + Nation = 2, +} + +/** Static player data from the header dictionary */ +export interface PlayerStatic { + smallID: number; + id: string; + name: string; + displayName: string; + clientID: string | null; + playerType: PlayerTypeEnum; + team: string | null; + isLobbyCreator: boolean; + flag?: string; + /** Hex color (e.g. "#ff0000"). Populated from territoryColor (live) or palette (replay). */ + color?: string; +} + +export interface AttackData { + attackerID: number; + targetID: number; + troops: number; + id: string; + retreating: boolean; +} + +export interface AllianceData { + id: number; + other: string; + createdAt: number; + expiresAt: number; + hasExtensionRequest: boolean; +} + +export interface EmojiData { + message: string; + senderID: number; + recipientID: number | "AllPlayers"; + createdAt: number; +} + +export interface PlayerState { + smallID: number; + isAlive: boolean; + isDisconnected: boolean; + tilesOwned: number; + gold: number; + troops: number; + isTraitor: boolean; + traitorRemainingTicks: number; + betrayals: number; + hasSpawned: boolean; + lastDeleteUnitTick: number; + allies: number[]; + embargoes: number[]; + targets: number[]; + outgoingAttacks: AttackData[]; + incomingAttacks: AttackData[]; + outgoingAllianceRequests: string[]; + alliances: AllianceData[]; + outgoingEmojis: EmojiData[]; +} + +export interface UnitState { + id: number; + unitType: string; + ownerID: number; + lastOwnerID: number | null; + pos: number; + lastPos: number; + isActive: boolean; + reachedTarget: boolean; + retreating: boolean; + targetable: boolean; + markedForDeletion: number | false; // -1 -> false, else tick + health: number | null; + underConstruction: boolean; + targetUnitId: number | null; + targetTile: number | null; + troops: number; + missileTimerQueue: number[]; + level: number; + hasTrainStation: boolean; + trainType: number | null; // 0=Engine, 1=TailEngine, 2=Carriage + loaded: boolean | null; + constructionStartTick: number | null; +} + +/** Minimal dead-unit data needed by the FX pass. */ +export interface DeadUnitFx { + unitType: string; + pos: number; + reachedTarget: boolean; + /** Ticks since the event occurred (0 = this frame, >0 = seeked past it). */ + tickAge?: number; +} + +/** Conquest event data for the gold popup + sword sprite FX. */ +export interface ConquestFx { + x: number; // world tile X (conquered player's name location) + y: number; // world tile Y + gold: number; // gold amount awarded + /** Ticks since the event occurred (0 = this frame, >0 = seeked past it). */ + tickAge?: number; +} + +export interface TilePair { + ref: number; + state: number; +} + +export interface NameEntry { + playerID: string; + x: number; + y: number; + size: number; +} + +/** Per-player status data for the GPU name/status-icon passes. */ +export interface PlayerStatusData { + crown: boolean; + traitor: boolean; + disconnected: boolean; + alliance: boolean; + allianceReq: boolean; + target: boolean; + embargo: boolean; + nukeActive: boolean; + nukeTargetsMe: boolean; + traitorRemainingTicks: number; + allianceFraction: number; +} + +/** Ghost structure preview data for build-mode visualization. */ +export interface GhostPreviewData { + ghostType: string; // UnitType string ("City", "Port", etc.) + tileX: number; // Hover tile X + tileY: number; // Hover tile Y + canBuild: boolean; // Valid placement? + canUpgrade: boolean; // Upgrading existing structure? + cost: number; // Gold cost + ghostRailPaths: number[][]; // TileRef paths (City/Port only) + overlappingRailroads: number[]; // Rail IDs in snap zone + ownerID: number; // Player's smallID (for color) + /** Tile position of existing structure being upgraded (null if fresh build). */ + upgradeTargetTile: number | null; + /** Range radius in tiles for the placement circle (0 = no circle). */ + rangeRadius: number; +} + +/** Nuke trajectory preview data — Bezier control points + color thresholds. */ +export interface NukeTrajectoryData { + /** Bezier control points (world-space tile coordinates). */ + p0x: number; + p0y: number; + p1x: number; + p1y: number; + p2x: number; + p2y: number; + p3x: number; + p3y: number; + /** t-value (0..1) where bomb leaves source's targetable range. -1 if ranges overlap. */ + tUntargetableStart: number; + /** t-value (0..1) where bomb enters target's targetable range. -1 if ranges overlap. */ + tUntargetableEnd: number; + /** t-value (0..1) of first SAM intercept point. 1.0 = no intercept. */ + tSamIntercept: number; +} + +/** Input data for attack ring visualization. */ +export interface AttackRingInput { + x: number; + y: number; + unitId: number; +} + +/** In-flight nuke target circle data. */ +export interface NukeTelegraphData { + x: number; + y: number; + innerRadius: number; + outerRadius: number; +} + +/** Lean config for constructing the GPU renderer — no replay-specific fields. */ +export interface RendererConfig { + mapWidth: number; + mapHeight: number; + unitTypes: string[]; + players: PlayerStatic[]; + /** + * Pre-allocated player capacity for GPU textures. + * Defaults to `players.length` when omitted. Set higher when players + * arrive after construction (e.g. bots are created on tick 1). + */ + maxPlayers?: number; +} diff --git a/src/client/render/types/Replay.ts b/src/client/render/types/Replay.ts new file mode 100644 index 0000000000..a6018a78c3 --- /dev/null +++ b/src/client/render/types/Replay.ts @@ -0,0 +1,144 @@ +import type { + ConquestFx, + DeadUnitFx, + NameEntry, + PlayerState, + PlayerStatic, + RendererConfig, + TilePair, + UnitState, +} from "./Renderer"; + +/** Chunk index entry — one per chunk in the file */ +export interface ChunkIndexEntry { + compressedOffset: number; + compressedSize: number; + decompressedSize: number; + frameCount: number; +} + +/** Subset of header available after streaming preamble (before full file download). */ +export interface StreamableReplayInfo extends RendererConfig { + totalFrames: number; + keyframeInterval: number; + numLandTiles: number; + gameStartInfo: unknown; + chunks: ChunkIndexEntry[]; +} + +/** Parsed v6 file header + dictionaries + chunk index + trailer sections */ +export interface ReplayHeader extends StreamableReplayInfo { + magic: number; + version: number; + gameID: string; + totalFrames: number; + keyframeInterval: number; + numLandTiles: number; + processedAt: number; + processingDurationMs: number; + gameStartInfo: unknown; + players: PlayerStatic[]; + /** Chunk index — per-chunk offsets and sizes */ + chunks: ChunkIndexEntry[]; + /** Nuke detonation events — top-level index for seek-time heat reconstruction */ + nukeEvents: Array<{ tick: number; tiles: number[] }>; + /** Railroad events — top-level index for seek-time railroad reconstruction */ + railroadEvents: Array<{ tick: number; type: number; data: unknown }>; + /** Motion plan events — top-level index for plan-driven unit positions and trails */ + motionPlanEvents: MotionPlanRecord[]; + /** Construction start events — top-level index for seek-time construction progress */ + constructionStarts: Array<{ unitId: number; startTick: number }>; + /** Conquest events — top-level index for seek-time gold popup + sword sprite */ + conquestEvents: Array<{ tick: number; x: number; y: number; gold: number }>; + /** Dead unit events — top-level index for seek-time explosion/death FX */ + deadUnitEvents: Array<{ + tick: number; + unitType: string; + pos: number; + reachedTarget: boolean; + }>; + /** Player elimination events — tick when each player's isAlive transitioned to false */ + eliminationEvents: Array<{ tick: number; smallID: number }>; +} + +/** Raw decoded v4 keyframe data — tile data is a raw Uint16Array blob */ +export interface RawKeyframe { + type: 0; + tick: number; + /** Raw tile blob: Uint16Array[mapWidth x mapHeight]. Direct GPU upload. */ + tileBlob: Uint16Array; + players: Map; + units: Map; + names: Map; + miscUpdates: Record | null; +} + +/** Raw decoded delta frame data */ +export interface RawDelta { + type: 1; + tick: number; + tiles: TilePair[]; + playerDeltas: Map; // new or changed players (full state after applying delta) + playersRemoved: number[]; + unitDeltas: Map; + unitsRemoved: number[]; + nameChanges: Map; + miscUpdates: Record | null; +} + +export type RawFrame = RawKeyframe | RawDelta; + +/** Full accumulated game state at a given tick */ +export interface FrameSnapshot { + tick: number; + players: Map; + units: Map; + names: Map; + /** Tiles changed in this frame only (for incremental rendering). null = full upload needed. */ + changedTiles: TilePair[] | null; + /** Units that died this frame (FX-only data). Empty on keyframes. */ + deadUnits: DeadUnitFx[]; + /** Conquest events active at this tick (from global index). */ + conquestEvents: ConquestFx[]; + /** Per-frame misc updates (alliances, donations, trades, etc.). null = none. */ + miscUpdates: Record | null; +} + +/** + * Inflate function type — platform provides its implementation. + * Node: zlib.inflateSync, Browser: pako.inflate + */ +export type InflateFn = (data: Uint8Array) => Uint8Array; + +/** + * Gzip function type — platform provides its implementation. + * Node: zlib.gzipSync, Browser: pako.gzip + */ +export type GzipFn = (data: Uint8Array) => Uint8Array | Promise; + +// --------------------------------------------------------------------------- +// Motion plan records — stored as a file-level index for plan-driven units +// (transport ships, trade ships, trains). +// --------------------------------------------------------------------------- + +export interface GridPlanRecord { + kind: "grid"; + unitId: number; + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; +} + +export interface TrainPlanRecord { + kind: "train"; + engineUnitId: number; + carUnitIds: Uint32Array; + planId: number; + startTick: number; + speed: number; + spacing: number; + path: Uint32Array; +} + +export type MotionPlanRecord = GridPlanRecord | TrainPlanRecord; diff --git a/src/client/render/types/UnitType.ts b/src/client/render/types/UnitType.ts new file mode 100644 index 0000000000..70dd17efde --- /dev/null +++ b/src/client/render/types/UnitType.ts @@ -0,0 +1,83 @@ +/** + * Canonical unit type string constants. + * + * These match the strings the upstream game sends in UnitEventUpdate.unitType. + * Use these instead of raw string literals to prevent typos and enable + * find-all-references. + */ + +// --------------------------------------------------------------------------- +// Individual unit type constants +// --------------------------------------------------------------------------- + +// Mobile units +export const UT_TRANSPORT = "Transport" as const; +export const UT_TRADE_SHIP = "Trade Ship" as const; +export const UT_WARSHIP = "Warship" as const; +export const UT_ATOM_BOMB = "Atom Bomb" as const; +export const UT_HYDROGEN_BOMB = "Hydrogen Bomb" as const; +export const UT_MIRV = "MIRV" as const; +export const UT_SAM_MISSILE = "SAMMissile" as const; +export const UT_SHELL = "Shell" as const; +export const UT_MIRV_WARHEAD = "MIRV Warhead" as const; +export const UT_TRAIN = "Train" as const; + +// Structures +export const UT_CITY = "City" as const; +export const UT_PORT = "Port" as const; +export const UT_FACTORY = "Factory" as const; +export const UT_DEFENSE_POST = "Defense Post" as const; +export const UT_SAM_LAUNCHER = "SAM Launcher" as const; +export const UT_MISSILE_SILO = "Missile Silo" as const; + +// --------------------------------------------------------------------------- +// Derived sets +// --------------------------------------------------------------------------- + +export const STRUCTURE_TYPES: ReadonlySet = new Set([ + UT_CITY, + UT_PORT, + UT_FACTORY, + UT_DEFENSE_POST, + UT_SAM_LAUNCHER, + UT_MISSILE_SILO, +]); + +export const NUKE_TYPES: ReadonlySet = new Set([ + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, +]); + +/** Blast radii (in tiles) matching upstream DefaultConfig.nukeMagnitudes(). */ +export const NUKE_MAGNITUDES: Readonly< + Record +> = { + [UT_ATOM_BOMB]: { inner: 12, outer: 30 }, + [UT_HYDROGEN_BOMB]: { inner: 80, outer: 100 }, + [UT_MIRV_WARHEAD]: { inner: 12, outer: 18 }, +}; + +// --------------------------------------------------------------------------- +// Ordered lists (atlas column order — used by GPU passes + header) +// --------------------------------------------------------------------------- + +/** All unit type strings in the canonical order used by RendererConfig.unitTypes. */ +export const ALL_UNIT_TYPES = [ + UT_TRANSPORT, + UT_TRADE_SHIP, + UT_WARSHIP, + UT_ATOM_BOMB, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_SAM_MISSILE, + UT_SHELL, + UT_MIRV_WARHEAD, + UT_CITY, + UT_PORT, + UT_FACTORY, + UT_DEFENSE_POST, + UT_SAM_LAUNCHER, + UT_MISSILE_SILO, + UT_TRAIN, +] as const; diff --git a/src/client/render/types/index.ts b/src/client/render/types/index.ts new file mode 100644 index 0000000000..ff724aa0a5 --- /dev/null +++ b/src/client/render/types/index.ts @@ -0,0 +1,108 @@ +// Renderer types (units, players, tiles, names, config) +export { PlayerTypeEnum, TrainType } from "./Renderer"; +export type { + AllianceData, + AttackData, + AttackRingInput, + ConquestFx, + DeadUnitFx, + EmojiData, + GhostPreviewData, + NameEntry, + NukeTelegraphData, + NukeTrajectoryData, + PlayerState, + PlayerStatic, + PlayerStatusData, + RendererConfig, + TilePair, + UnitState, +} from "./Renderer"; + +// Frame data — boundary contract between game integration and features +export type { FrameData } from "./FrameData"; + +// Frame events — per-frame ephemeral events (rendering FX + stats events) +export { EMPTY_FRAME_EVENTS } from "./FrameEvents"; +export type { + AllianceBrokenEvent, + AllianceExpiredEvent, + AllianceFormedEvent, + BonusEvent, + DisplayMessageEvent, + EmbargoEvent, + EmojiEvent, + FrameEvents, + NukeIncomingEvent, + TargetEvent, + WinEvent, +} from "./FrameEvents"; + +// Frame source — mode-agnostic subscription interface +export type { FrameSource, GameStartConfig } from "./FrameSource"; + +// Game update types +export type { GameStartInfo, GameUpdateViewData } from "./Game"; + +// Replay types (header, frames, codec helpers) +export type { + ChunkIndexEntry, + FrameSnapshot, + GridPlanRecord, + GzipFn, + InflateFn, + MotionPlanRecord, + RawDelta, + RawFrame, + RawKeyframe, + ReplayHeader, + StreamableReplayInfo, + TrainPlanRecord, +} from "./Replay"; + +// Game update type constants and event payloads (shared between shim + codec) +export { GameUpdateType, MessageType } from "./GameUpdates"; +export type { + AllianceExpiredUpdate, + AllianceReplyUpdate, + AttackEventUpdate, + BonusUpdate, + BrokeAllianceUpdate, + DisplayMessageUpdate, + EmbargoUpdate, + EmojiUpdate, + GamePausedUpdate, + PlayerEventUpdate, + PlayerType, + RailroadConstructionUpdate, + RailroadDestructionUpdate, + RailroadSnapUpdate, + TargetPlayerUpdate, + UnitEventUpdate, + UnitIncomingUpdate, + WinUpdate, +} from "./GameUpdates"; + +// Unit type string constants and derived sets +export { + ALL_UNIT_TYPES, + NUKE_MAGNITUDES, + NUKE_TYPES, + STRUCTURE_TYPES, + UT_ATOM_BOMB, + UT_CITY, + UT_DEFENSE_POST, + UT_FACTORY, + UT_HYDROGEN_BOMB, + UT_MIRV, + UT_MIRV_WARHEAD, + UT_MISSILE_SILO, + UT_PORT, + UT_SAM_LAUNCHER, + UT_SAM_MISSILE, + UT_SHELL, + UT_TRADE_SHIP, + UT_TRAIN, + UT_TRANSPORT, + UT_WARSHIP, +} from "./UnitType"; diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts new file mode 100644 index 0000000000..2832673f09 --- /dev/null +++ b/src/client/view/GameView.ts @@ -0,0 +1,1088 @@ +import { Config } from "../../core/configuration/Config"; +import { + Cell, + GameUpdates, + PlayerID, + TerrainType, + TerraNullius, + Tick, + Unit, + UnitInfo, + UnitType, +} from "../../core/game/Game"; +import { GameMap, TileRef } from "../../core/game/GameMap"; +import { + GameUpdateType, + GameUpdateViewData, + SpawnPhaseEndUpdate, +} from "../../core/game/GameUpdates"; +import { + MotionPlanRecord, + unpackMotionPlans, +} from "../../core/game/MotionPlans"; +import { TerrainMapData } from "../../core/game/TerrainMapLoader"; +import { TerraNulliusImpl } from "../../core/game/TerraNulliusImpl"; +import { UnitGrid, UnitPredicate } from "../../core/game/UnitGrid"; +import { ClientID, GameID, Player, PlayerCosmetics } from "../../core/Schemas"; +import { formatPlayerDisplayName } from "../../core/Util"; +import { WorkerClient } from "../../core/worker/WorkerClient"; +import { computeAllianceClusters } from "../render/frame/derive/AllianceClusters"; +import { extractAttackRings } from "../render/frame/derive/AttackRings"; +import { extractNukeTelegraphs } from "../render/frame/derive/NukeTelegraphs"; +import { computePlayerStatus } from "../render/frame/derive/PlayerStatus"; +import { buildRelationMatrix } from "../render/frame/derive/RelationMatrix"; +import { RailroadCache } from "../render/frame/RailroadCache"; +import { TrailManager } from "../render/frame/TrailManager"; +import type { FrameData, NameEntry, TilePair } from "../render/types"; +import { STRUCTURE_TYPES } from "../render/types"; +import { PlayerView } from "./PlayerView"; +import { UnitView } from "./UnitView"; + +const TRAIL_TYPES: ReadonlySet = new Set([ + UnitType.TransportShip, + UnitType.AtomBomb, + UnitType.HydrogenBomb, + UnitType.MIRV, + UnitType.MIRVWarhead, +]); + +type TrainPlanState = { + planId: number; + startTick: number; + speed: number; + spacing: number; + carUnitIds: Uint32Array; + path: Uint32Array; + cursor: number; + usedTilesBuf: Uint32Array; + usedHead: number; + usedLen: number; + lastAdvancedTick: Tick; +}; + +export class GameView implements GameMap { + private lastUpdate: GameUpdateViewData | null; + private startTick: Tick | null = null; + private smallIDToID = new Map(); + private _players = new Map(); + private _units = new Map(); + /** + * Long-lived state maps (renderer's plain-object shape). Each entry shares + * its identity with the corresponding PlayerView.state / UnitView.state, so + * mutations through either path are visible everywhere. + */ + private _playerStates = new Map< + number, + import("../render/types").PlayerState + >(); + private _unitStates = new Map(); + private updatedTiles: TileRef[] = []; + private updatedTerrainTiles: TileRef[] = []; + + // ── FrameData accumulators (renderer-bound state) ───────────────────── + private trailManager!: TrailManager; + private railroadCache!: RailroadCache; + /** Long-lived NameEntry map for the renderer's NamePass. */ + private _names = new Map(); + /** Reusable scratch buffers for per-tick deltas. */ + private readonly _changedTilesScratch: TilePair[] = []; + private readonly _trailIdsScratch: number[] = []; + /** + * The single long-lived FrameData object. Fields are mutated in place each + * tick by update(). Renderer reads this each frame via frameData(). + */ + private _frame: FrameData; + private _structuresDirty = false; + /** True until first populateFrame() — controls full-vs-delta tile upload. */ + private _firstPopulate = true; + + private _myPlayer: PlayerView | null = null; + + private unitGrid: UnitGrid; + private unitMotionPlans = new Map< + number, + { + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; + } + >(); + private trainMotionPlans = new Map(); + private trainUnitToEngine = new Map(); + + private toDelete = new Set(); + + private _cosmetics: Map = new Map(); + + private _map: GameMap; + + constructor( + public worker: WorkerClient, + private _config: Config, + private _mapData: TerrainMapData, + private _myClientID: ClientID | undefined, + private _myUsername: string, + private _myClanTag: string | null, + private _gameID: GameID, + humans: Player[], + ) { + this._map = this._mapData.gameMap; + this.lastUpdate = null; + this.unitGrid = new UnitGrid(this._map); + this._cosmetics = new Map( + humans.map((h) => [h.clientID, h.cosmetics ?? {}]), + ); + for (const nation of this._mapData.nations) { + // Nations don't have client ids, so we use their name as the key instead. + this._cosmetics.set(nation.name, { + flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined, + } satisfies PlayerCosmetics); + } + for (const extra of this._mapData.additionalNations) { + // Only set if not already provided by a manifest nation with the same name. + if (this._cosmetics.has(extra.name)) continue; + this._cosmetics.set(extra.name, { + flag: extra.flag ? `/flags/${extra.flag}.svg` : undefined, + } satisfies PlayerCosmetics); + } + + const mapW = this._map.width(); + const mapH = this._map.height(); + this.trailManager = new TrailManager(mapW, mapH); + this.railroadCache = new RailroadCache(mapW, mapH); + + // Long-lived FrameData. Most fields are mutable references to long-lived + // buffers (tileState, trailState, etc.); some (_changedTilesScratch, + // derived arrays) are reused each tick. Properties marked `readonly` on + // FrameData only prevent reassignment, not mutation through the reference. + // events: fresh arrays we own; cleared and repopulated each tick. (Don't + // spread EMPTY_FRAME_EVENTS — that would share the module-level arrays.) + this._frame = { + tick: 0, + inSpawnPhase: true, + tileState: this._map.tileStateBuffer(), + trailState: this.trailManager.getTrailState(), + railroadState: this.railroadCache.railroadState, + units: this._unitStates, + players: this._playerStates, + names: this._names, + events: { + deadUnits: [], + conquestEvents: [], + unitUpdates: [], + playerUpdates: [], + allianceFormed: [], + allianceBroken: [], + allianceExpired: [], + embargoEvents: [], + targetEvents: [], + bonusEvents: [], + nukeIncoming: [], + emojis: [], + displayMessages: [], + wins: [], + gamePaused: null, + }, + changedTiles: this._changedTilesScratch, + railroadDirty: false, + revealedRailTiles: this.railroadCache.revealedRailTiles, + trailDirtyRowMin: 0, + trailDirtyRowMax: -1, + // Derived data — populated each tick by populateFrame(). Empty defaults + // here so the type is satisfied before the first update(). + playerStatus: new Map(), + relationMatrix: new Uint8Array(0), + relationSize: 0, + allianceClusters: new Map(), + nukeTelegraphs: [], + attackRings: [], + structuresDirty: false, + tileMode: "live", + }; + } + + isOnEdgeOfMap(ref: TileRef): boolean { + return this._map.isOnEdgeOfMap(ref); + } + + public updatesSinceLastTick(): GameUpdates | null { + return this.lastUpdate?.updates ?? null; + } + + public motionPlans(): ReadonlyMap< + number, + { + planId: number; + startTick: number; + ticksPerStep: number; + path: Uint32Array; + } + > { + return this.unitMotionPlans; + } + + private motionPlannedUnitIdsCache: number[] = []; + private motionPlannedUnitIdsDirty = true; + + private markMotionPlannedUnitIdsDirty(): void { + this.motionPlannedUnitIdsDirty = true; + } + + private rebuildMotionPlannedUnitIdsCacheIfDirty(): void { + if (!this.motionPlannedUnitIdsDirty) { + return; + } + this.motionPlannedUnitIdsDirty = false; + + const out = this.motionPlannedUnitIdsCache; + out.length = 0; + + for (const unitId of this.unitMotionPlans.keys()) { + out.push(unitId); + } + for (const [engineId, plan] of this.trainMotionPlans) { + out.push(engineId); + for (let i = 0; i < plan.carUnitIds.length; i++) { + const id = plan.carUnitIds[i] >>> 0; + if (id !== 0) out.push(id); + } + } + } + + public motionPlannedUnitIds(): number[] { + this.rebuildMotionPlannedUnitIdsCacheIfDirty(); + return this.motionPlannedUnitIdsCache; + } + + public isCatchingUp(): boolean { + return (this.lastUpdate?.pendingTurns ?? 0) > 1; + } + + public update(gu: GameUpdateViewData) { + this.toDelete.forEach((id) => { + this._units.delete(id); + this._unitStates.delete(id); + }); + this.toDelete.clear(); + + this.lastUpdate = gu; + + this.updatedTiles = []; + this.updatedTerrainTiles = []; + const packed = this.lastUpdate.packedTileUpdates; + for (let i = 0; i + 1 < packed.length; i += 2) { + const tile = packed[i]; + const state = packed[i + 1]; + const terrainChanged = this.updateTile(tile, state); + this.updatedTiles.push(tile); + if (terrainChanged) { + this.updatedTerrainTiles.push(tile); + } + } + + if (gu.packedMotionPlans) { + const records = unpackMotionPlans(gu.packedMotionPlans); + this.applyMotionPlanRecords(records); + } + + if (gu.updates === null) { + throw new Error("lastUpdate.updates not initialized"); + } + + const spawnPhaseEndUpdate = gu.updates[GameUpdateType.SpawnPhaseEnd][0] as + | SpawnPhaseEndUpdate + | undefined; + if (spawnPhaseEndUpdate) { + this.startTick = spawnPhaseEndUpdate.startTick; + } + + const myDisplayName = formatPlayerDisplayName( + this._myUsername, + this._myClanTag, + ); + + // Pass 1: ensure every player exists with up-to-date PlayerState. We need + // all smallIDs registered before pass 2 can translate embargo PlayerIDs. + gu.updates[GameUpdateType.Player].forEach((pu) => { + // Replace the local player's name/displayName with their own stored values. + // This way the user does not know they are being censored. + if (pu.clientID === this._myClientID) { + pu.name = this._myUsername; + pu.displayName = myDisplayName; + } + + this.smallIDToID.set(pu.smallID, pu.id); + let player = this._players.get(pu.id); + if (player !== undefined) { + player.applyUpdate(pu); + const nextNameData = gu.playerNameViewData[pu.id]; + if (nextNameData !== undefined) { + player.nameData = nextNameData; + } + } else { + player = new PlayerView( + this, + pu, + gu.playerNameViewData[pu.id], + // First check human by clientID, then check nation by name. + this._cosmetics.get(pu.clientID ?? "") ?? + this._cosmetics.get(pu.name) ?? + {}, + ); + this._players.set(pu.id, player); + this._playerStates.set(pu.smallID, player.state); + } + }); + + // Pass 2: translate engine embargoes (Set) → renderer-format + // stringified smallIDs. We could do this only on changes, but embargo sets + // are typically small (<50 entries per player). Pass through all in case + // any pu in this tick referenced a player created in this same tick. + gu.updates[GameUpdateType.Player].forEach((pu) => { + const player = this._players.get(pu.id); + if (player === undefined) return; + const smallIDs: number[] = []; + for (const otherPlayerID of pu.embargoes) { + const otherPV = this._players.get(otherPlayerID); + if (otherPV !== undefined) { + smallIDs.push(otherPV.smallID()); + } + } + player.setEmbargoSmallIDs(smallIDs); + }); + + if (this._myClientID) { + this._myPlayer ??= this.playerByClientID(this._myClientID); + } + + for (const unit of this._units.values()) { + unit._wasUpdated = false; + unit.lastPos = unit.lastPos.slice(-1); + } + gu.updates[GameUpdateType.Unit].forEach((update) => { + let unit = this._units.get(update.id); + const isStructure = STRUCTURE_TYPES.has(update.unitType); + if (unit !== undefined) { + // Structure changes that affect rendering: level changed, became + // inactive, or finished construction (underConstruction → !underConstruction). + if ( + isStructure && + (unit.state.level !== update.level || + unit.state.isActive !== update.isActive || + (unit.state.underConstruction && + !(update.underConstruction ?? false))) + ) { + this._structuresDirty = true; + } + unit.update(update); + } else { + unit = new UnitView(this, update); + this._units.set(update.id, unit); + this._unitStates.set(update.id, unit.state); + this.unitGrid.addUnit(unit); + if (isStructure) this._structuresDirty = true; + } + if (!update.isActive) { + this.unitGrid.removeUnit(unit); + } else if (unit.tile() !== unit.lastTile()) { + this.unitGrid.updateUnitCell(unit); + } + if (!unit.isActive()) { + // Wait until next tick to delete the unit. + this.toDelete.add(unit.id()); + if (this.unitMotionPlans.delete(unit.id())) { + this.markMotionPlannedUnitIdsDirty(); + } + this.clearTrainPlanForUnit(unit.id()); + } + }); + + this.advanceMotionPlannedUnits(gu.tick); + this.rebuildMotionPlannedUnitIdsCacheIfDirty(); + + this.populateFrame(gu); + } + + // ── FrameData population ──────────────────────────────────────────────── + + /** + * Populate the long-lived FrameData from this tick's updates and current + * state. Runs at the end of update() once all engine-driven mutations are + * complete. Mutates _frame fields in place; never reassigns them. + */ + private populateFrame(gu: GameUpdateViewData): void { + // Reset trail dirty markers for this tick. The trailManager.update() pass + // below repaints rows and re-sets these as it goes. + this.trailManager.clearDirtyRows(); + + // Railroad events accumulate into the cache; revealedRailTiles is cleared + // at the start of apply(). + this.railroadCache.apply(gu); + + // Trail update: walk active trail-type units and stamp/decay. + this._trailIdsScratch.length = 0; + for (const u of this._units.values()) { + if (u.isActive() && TRAIL_TYPES.has(u.type())) { + this._trailIdsScratch.push(u.id()); + } + } + this.trailManager.update( + this._unitStates as Map, + this._trailIdsScratch, + ); + + // Changed-tile delta refs (zero-copy: state field unused in live mode). + this._changedTilesScratch.length = 0; + for (let i = 0; i < this.updatedTiles.length; i++) { + this._changedTilesScratch.push({ ref: this.updatedTiles[i], state: 0 }); + } + + // Names map — rebuilt every tick. Cheap (one entry per player, no big + // arrays). Entry order is irrelevant for the renderer. + this._names.clear(); + for (const p of this._players.values()) { + this._names.set(p.id(), { + playerID: p.id(), + x: p.nameData?.x ?? 0, + y: p.nameData?.y ?? 0, + size: p.nameData?.size ?? 0, + }); + } + + // FrameEvents — clear arrays, then re-populate from this tick's updates. + this.buildFrameEvents(gu); + + // Update FrameData fields. Derived data is computed once per tick and + // stored directly on _frame (no intermediate copy). The renderer's + // `readonly` modifier on FrameData is just an external API hint — + // not enforced at runtime; we cast off to assign here. + const f = this._frame as { + -readonly [K in keyof FrameData]: FrameData[K]; + }; + f.tick = gu.tick; + f.inSpawnPhase = this.startTick === null; + f.railroadDirty = this.railroadCache.railroadDirty; + f.trailDirtyRowMin = this.trailManager.dirtyRowMin; + f.trailDirtyRowMax = this.trailManager.dirtyRowMax; + f.playerStatus = computePlayerStatus(this._playerStates, this._unitStates, { + localPlayerID: this._myPlayer?.smallID() ?? 0, + tileState: this._map.tileStateBuffer(), + }); + const rel = buildRelationMatrix(this._playerStates); + f.relationMatrix = rel.matrix; + f.relationSize = rel.size; + f.allianceClusters = computeAllianceClusters(this._playerStates); + f.nukeTelegraphs = extractNukeTelegraphs( + this._unitStates, + this._map.width(), + ); + f.attackRings = extractAttackRings(this._unitStates, this._map.width()); + f.structuresDirty = this._structuresDirty; + + // First populate: signal "full upload required" by nulling changedTiles. + // uploadFrameData() treats null as "no delta info; do a full tile+trail + // upload" — needed because the renderer's GPU buffers are empty. + if (this._firstPopulate) { + f.changedTiles = null; + f.structuresDirty = true; // force initial structure upload + this._firstPopulate = false; + } else { + f.changedTiles = this._changedTilesScratch; + } + + // Reset transient flags for next tick. + this.railroadCache.clearDirty(); + this._structuresDirty = false; + } + + /** Clear and repopulate _frame.events arrays from this tick's gu.updates. */ + private buildFrameEvents(gu: GameUpdateViewData): void { + const ev = this._frame.events; + ev.deadUnits.length = 0; + ev.conquestEvents.length = 0; + ev.bonusEvents.length = 0; + + for (const u of gu.updates[GameUpdateType.Unit] ?? []) { + if (u.isActive) continue; + ev.deadUnits.push({ + unitType: u.unitType, + pos: u.pos, + reachedTarget: u.reachedTarget, + }); + } + for (const c of gu.updates[GameUpdateType.ConquestEvent] ?? []) { + const conquered = this._players.get(c.conqueredId); + if (conquered === undefined) continue; + const loc = conquered.nameLocation(); + ev.conquestEvents.push({ + x: loc.x, + y: loc.y, + gold: Number(c.gold), + }); + } + for (const b of gu.updates[GameUpdateType.BonusEvent] ?? []) { + const player = this._players.get(b.player); + if (player === undefined) continue; + ev.bonusEvents.push({ + playerID: b.player, + smallID: player.smallID(), + tile: b.tile, + gold: Number(b.gold), + troops: b.troops, + }); + } + } + + /** Public accessor: the renderer reads this and uploads to the GPU. */ + frameData(): FrameData { + return this._frame; + } + + private advanceMotionPlannedUnits(currentTick: Tick): void { + for (const [unitId, plan] of this.unitMotionPlans) { + const unit = this._units.get(unitId); + if (!unit || !unit.isActive()) { + if (this.unitMotionPlans.delete(unitId)) { + this.markMotionPlannedUnitIdsDirty(); + } + continue; + } + + const oldTile = unit.tile(); + const dt = currentTick - plan.startTick; + const stepIndex = + dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep)); + const lastIndex = plan.path.length - 1; + const idx = Math.max(0, Math.min(lastIndex, stepIndex)); + const newTile = plan.path[idx] as TileRef; + + if (newTile !== oldTile) { + unit.applyDerivedPosition(newTile); + this.unitGrid.updateUnitCell(unit); + continue; + } + + // Once a plan is past its final step, `newTile` remains clamped to the last path tile. + // Drop finished plans to avoid repeatedly marking static units as updated each tick. + if (dt > 0 && stepIndex >= lastIndex) { + if (this.unitMotionPlans.delete(unitId)) { + this.markMotionPlannedUnitIdsDirty(); + } + } + } + + this.advanceTrainMotionPlannedUnits(currentTick); + } + + private clearTrainPlanForUnit(unitId: number): void { + const engineId = + this.trainUnitToEngine.get(unitId) ?? + (this.trainMotionPlans.has(unitId) ? unitId : null); + if (engineId === null) { + return; + } + const plan = this.trainMotionPlans.get(engineId); + if (!plan) { + this.trainUnitToEngine.delete(unitId); + return; + } + if (this.trainMotionPlans.delete(engineId)) { + this.markMotionPlannedUnitIdsDirty(); + } + this.trainUnitToEngine.delete(engineId); + for (let i = 0; i < plan.carUnitIds.length; i++) { + const id = plan.carUnitIds[i] >>> 0; + if (id !== 0) this.trainUnitToEngine.delete(id); + } + } + + private advanceTrainMotionPlannedUnits(currentTick: Tick): void { + const staleEngineIds: number[] = []; + for (const [engineId, plan] of this.trainMotionPlans) { + const engine = this._units.get(engineId); + if (!engine || !engine.isActive()) { + staleEngineIds.push(engineId); + continue; + } + + const steps = currentTick - plan.lastAdvancedTick; + if (steps <= 0) { + continue; + } + + const path = plan.path; + const lastIndex = path.length - 1; + const cap = plan.usedTilesBuf.length; + + const pushUsed = (tile: TileRef) => { + if (cap === 0) return; + if (plan.usedLen < cap) { + const idx = (plan.usedHead + plan.usedLen) % cap; + plan.usedTilesBuf[idx] = tile >>> 0; + plan.usedLen++; + } else { + plan.usedTilesBuf[plan.usedHead] = tile >>> 0; + plan.usedHead = (plan.usedHead + 1) % cap; + plan.usedLen = cap; + } + }; + + const usedGet = (index: number): TileRef | null => { + if (index < 0 || index >= plan.usedLen || cap === 0) return null; + const idx = (plan.usedHead + index) % cap; + return plan.usedTilesBuf[idx] as TileRef; + }; + + let didMove = false; + for (let step = 0; step < steps; step++) { + const cursor = plan.cursor; + if (cursor >= lastIndex) { + break; + } + for (let i = 0; i < plan.speed && cursor + i < path.length; i++) { + pushUsed(path[cursor + i] as TileRef); + } + + plan.cursor = Math.min(lastIndex, cursor + plan.speed); + + for (let i = plan.carUnitIds.length - 1; i >= 0; --i) { + const carId = plan.carUnitIds[i] >>> 0; + if (carId === 0) continue; + const car = this._units.get(carId); + if (!car || !car.isActive()) { + continue; + } + const carTileIndex = (i + 1) * plan.spacing + 2; + const tile = usedGet(carTileIndex); + if (tile !== null) { + const oldTile = car.tile(); + if (tile !== oldTile) { + car.applyDerivedPosition(tile); + this.unitGrid.updateUnitCell(car); + didMove = true; + } + } + } + + const newEngineTile = path[plan.cursor] as TileRef; + const oldEngineTile = engine.tile(); + if (newEngineTile !== oldEngineTile) { + engine.applyDerivedPosition(newEngineTile); + this.unitGrid.updateUnitCell(engine); + didMove = true; + } + } + + plan.lastAdvancedTick = currentTick; + + // Preserve the final-step redraw (plan remains for the tick where motion ends), + // then clear once the train has settled and no longer moves. + // Note: trains are currently deleted at the end of TrainExecution, and the ensuing + // `Unit` update (isActive=false) also clears any associated motion plan records. + // This expiry is defensive to avoid keeping stale plans around if that behavior changes. + if (!didMove && plan.cursor >= lastIndex) { + staleEngineIds.push(engineId); + } + } + + for (const engineId of staleEngineIds) { + this.clearTrainPlanForUnit(engineId); + } + } + + private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void { + for (const record of records) { + switch (record.kind) { + case "grid": { + if (record.ticksPerStep < 1 || record.path.length < 1) { + break; + } + const existing = this.unitMotionPlans.get(record.unitId); + if (existing && record.planId <= existing.planId) { + break; + } + + const path = + record.path instanceof Uint32Array + ? record.path + : Uint32Array.from(record.path); + + this.unitMotionPlans.set(record.unitId, { + planId: record.planId, + startTick: record.startTick, + ticksPerStep: record.ticksPerStep, + path, + }); + this.markMotionPlannedUnitIdsDirty(); + break; + } + case "train": { + if (record.speed < 1 || record.path.length < 1) { + break; + } + const existing = this.trainMotionPlans.get(record.engineUnitId); + if (existing && record.planId <= existing.planId) { + break; + } + if (existing) { + this.clearTrainPlanForUnit(record.engineUnitId); + } + + const carUnitIds = + record.carUnitIds instanceof Uint32Array + ? record.carUnitIds + : Uint32Array.from(record.carUnitIds); + const path = + record.path instanceof Uint32Array + ? record.path + : Uint32Array.from(record.path); + + const usedCap = carUnitIds.length * record.spacing + 3; + const usedTilesBuf = new Uint32Array(Math.max(0, usedCap)); + + this.trainMotionPlans.set(record.engineUnitId, { + planId: record.planId, + startTick: record.startTick, + speed: record.speed, + spacing: record.spacing, + carUnitIds, + path, + cursor: 0, + usedTilesBuf, + usedHead: 0, + usedLen: 0, + lastAdvancedTick: record.startTick, + }); + this.markMotionPlannedUnitIdsDirty(); + + this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId); + for (let i = 0; i < carUnitIds.length; i++) { + const carId = carUnitIds[i] >>> 0; + if (carId !== 0) + this.trainUnitToEngine.set(carId, record.engineUnitId); + } + break; + } + } + } + } + + recentlyUpdatedTiles(): TileRef[] { + return this.updatedTiles; + } + + recentlyUpdatedTerrainTiles(): TileRef[] { + return this.updatedTerrainTiles; + } + + nearbyUnits( + tile: TileRef, + searchRange: number, + types: UnitType | readonly UnitType[], + predicate?: UnitPredicate, + ): Array<{ unit: UnitView; distSquared: number }> { + return this.unitGrid.nearbyUnits( + tile, + searchRange, + types, + predicate, + ) as Array<{ + unit: UnitView; + distSquared: number; + }>; + } + + hasUnitNearby( + tile: TileRef, + searchRange: number, + type: UnitType, + playerId?: PlayerID, + includeUnderConstruction?: boolean, + ) { + return this.unitGrid.hasUnitNearby( + tile, + searchRange, + type, + playerId, + includeUnderConstruction, + ); + } + + anyUnitNearby( + tile: TileRef, + searchRange: number, + types: readonly UnitType[], + predicate: (unit: UnitView) => boolean, + playerId?: PlayerID, + includeUnderConstruction?: boolean, + ): boolean { + return this.unitGrid.anyUnitNearby( + tile, + searchRange, + types, + predicate as (unit: Unit | UnitView) => boolean, + playerId, + includeUnderConstruction, + ); + } + + myClientID(): ClientID | undefined { + return this._myClientID; + } + + myPlayer(): PlayerView | null { + return this._myPlayer; + } + + player(id: PlayerID): PlayerView { + const player = this._players.get(id); + if (player === undefined) { + throw Error(`player id ${id} not found`); + } + return player; + } + + players(): PlayerView[] { + return Array.from(this._players.values()); + } + + playerBySmallID(id: number): PlayerView | TerraNullius { + if (id === 0) { + return new TerraNulliusImpl(); + } + const playerId = this.smallIDToID.get(id); + if (playerId === undefined) { + throw new Error(`small id ${id} not found`); + } + return this.player(playerId); + } + + playerByClientID(id: ClientID): PlayerView | null { + const player = + Array.from(this._players.values()).filter( + (p) => p.clientID() === id, + )[0] ?? null; + if (player === null) { + return null; + } + return player; + } + hasPlayer(id: PlayerID): boolean { + return false; + } + playerViews(): PlayerView[] { + return Array.from(this._players.values()); + } + + owner(tile: TileRef): PlayerView | TerraNullius { + return this.playerBySmallID(this.ownerID(tile)); + } + + ticks(): Tick { + if (this.lastUpdate === null) return 0; + return this.lastUpdate.tick; + } + inSpawnPhase(): boolean { + return this.startTick === null; + } + + isSpawnImmunityActive(): boolean { + return ( + this.inSpawnPhase() || + this.ticksSinceStart() < this._config.spawnImmunityDuration() + ); + } + isNationSpawnImmunityActive(): boolean { + return ( + this.inSpawnPhase() || + this.ticksSinceStart() < this._config.nationSpawnImmunityDuration() + ); + } + + elapsedGameSeconds(): number { + return this.ticksSinceStart() / 10; + } + + ticksSinceStart(): Tick { + if (this.inSpawnPhase()) { + return 0; + } + + return Math.max(0, this.ticks() - this.startTick!); + } + config(): Config { + return this._config; + } + units(...types: UnitType[]): UnitView[] { + if (types.length === 0) { + return Array.from(this._units.values()).filter((u) => u.isActive()); + } + return Array.from(this._units.values()).filter( + (u) => u.isActive() && types.includes(u.type()), + ); + } + unit(id: number): UnitView | undefined { + return this._units.get(id); + } + unitInfo(type: UnitType): UnitInfo { + return this._config.unitInfo(type); + } + + /** + * Long-lived map of UnitState records, keyed by unit ID. Mutated in place + * each tick by `update()`. Renderer code reads from this directly — the + * UnitView wrapping each entry shares the same UnitState reference. + * + * Includes inactive units; renderer filters by `state.isActive`. + */ + unitStates(): ReadonlyMap { + return this._unitStates; + } + + /** + * Long-lived map of PlayerState records, keyed by smallID. Mutated in place + * each tick by `update()`. Renderer code reads from this directly. + */ + playerStates(): ReadonlyMap { + return this._playerStates; + } + + ref(x: number, y: number): TileRef { + return this._map.ref(x, y); + } + isValidRef(ref: TileRef): boolean { + return this._map.isValidRef(ref); + } + x(ref: TileRef): number { + return this._map.x(ref); + } + y(ref: TileRef): number { + return this._map.y(ref); + } + cell(ref: TileRef): Cell { + return this._map.cell(ref); + } + width(): number { + return this._map.width(); + } + height(): number { + return this._map.height(); + } + numLandTiles(): number { + return this._map.numLandTiles(); + } + isValidCoord(x: number, y: number): boolean { + return this._map.isValidCoord(x, y); + } + isLand(ref: TileRef): boolean { + return this._map.isLand(ref); + } + isOceanShore(ref: TileRef): boolean { + return this._map.isOceanShore(ref); + } + isOcean(ref: TileRef): boolean { + return this._map.isOcean(ref); + } + isShoreline(ref: TileRef): boolean { + return this._map.isShoreline(ref); + } + magnitude(ref: TileRef): number { + return this._map.magnitude(ref); + } + terrainByte(ref: TileRef): number { + return this._map.terrainByte(ref); + } + setWater(ref: TileRef): void { + this._map.setWater(ref); + } + setShorelineBit(ref: TileRef): void { + this._map.setShorelineBit(ref); + } + clearShorelineBit(ref: TileRef): void { + this._map.clearShorelineBit(ref); + } + setOcean(ref: TileRef): void { + this._map.setOcean(ref); + } + setMagnitude(ref: TileRef, value: number): void { + this._map.setMagnitude(ref, value); + } + ownerID(ref: TileRef): number { + return this._map.ownerID(ref); + } + hasOwner(ref: TileRef): boolean { + return this._map.hasOwner(ref); + } + setOwnerID(ref: TileRef, playerId: number): void { + return this._map.setOwnerID(ref, playerId); + } + hasFallout(ref: TileRef): boolean { + return this._map.hasFallout(ref); + } + setFallout(ref: TileRef, value: boolean): void { + return this._map.setFallout(ref, value); + } + isBorder(ref: TileRef): boolean { + return this._map.isBorder(ref); + } + neighbors(ref: TileRef): TileRef[] { + return this._map.neighbors(ref); + } + isWater(ref: TileRef): boolean { + return this._map.isWater(ref); + } + isLake(ref: TileRef): boolean { + return this._map.isLake(ref); + } + isShore(ref: TileRef): boolean { + return this._map.isShore(ref); + } + cost(ref: TileRef): number { + return this._map.cost(ref); + } + terrainType(ref: TileRef): TerrainType { + return this._map.terrainType(ref); + } + forEachTile(fn: (tile: TileRef) => void): void { + return this._map.forEachTile(fn); + } + manhattanDist(c1: TileRef, c2: TileRef): number { + return this._map.manhattanDist(c1, c2); + } + euclideanDistSquared(c1: TileRef, c2: TileRef): number { + return this._map.euclideanDistSquared(c1, c2); + } + circleSearch( + tile: TileRef, + radius: number, + filter?: (tile: TileRef, d2: number) => boolean, + ): Set { + return this._map.circleSearch(tile, radius, filter); + } + bfs( + tile: TileRef, + filter: (gm: GameMap, tile: TileRef) => boolean, + ): Set { + return this._map.bfs(tile, filter); + } + tileState(tile: TileRef): number { + return this._map.tileState(tile); + } + tileStateBuffer(): Uint16Array { + return this._map.tileStateBuffer(); + } + updateTile(tile: TileRef, state: number): boolean { + return this._map.updateTile(tile, state); + } + numTilesWithFallout(): number { + return this._map.numTilesWithFallout(); + } + gameID(): GameID { + return this._gameID; + } + + focusedPlayer(): PlayerView | null { + return this.myPlayer(); + } +} diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts new file mode 100644 index 0000000000..2e49d9916c --- /dev/null +++ b/src/client/view/PlayerView.ts @@ -0,0 +1,575 @@ +import { Colord, colord } from "colord"; +import { base64url } from "jose"; +import { ColorPalette } from "../../core/CosmeticSchemas"; +import { PatternDecoder } from "../../core/PatternDecoder"; +import { ClientID, PlayerCosmetics } from "../../core/Schemas"; +import { createRandomName } from "../../core/Util"; +import { + BuildableUnit, + Cell, + EmojiMessage, + Gold, + NameViewData, + PlayerActions, + PlayerBorderTiles, + PlayerBuildableUnitType, + PlayerID, + PlayerProfile, + PlayerType, + Team, + Tick, + UnitType, +} from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { + AllianceView, + AttackUpdate, + PlayerUpdate, +} from "../../core/game/GameUpdates"; +import { UserSettings } from "../../core/game/UserSettings"; +import { PlayerState, PlayerStatic, PlayerTypeEnum } from "../render/types"; +import { GameView } from "./GameView"; +import { UnitView } from "./UnitView"; + +const userSettings: UserSettings = new UserSettings(); + +const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 }; +const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 }; +const BORDER_TINT_RATIO = 0.35; + +function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum { + switch (t) { + case PlayerType.Human: + return PlayerTypeEnum.Human; + case PlayerType.Bot: + return PlayerTypeEnum.Bot; + case PlayerType.Nation: + return PlayerTypeEnum.Nation; + default: + return PlayerTypeEnum.Bot; + } +} + +function staticFromUpdate(pu: PlayerUpdate): PlayerStatic { + return { + smallID: pu.smallID, + id: pu.id, + name: pu.name, + displayName: pu.displayName, + clientID: pu.clientID, + playerType: gamePlayerTypeToEnum(pu.playerType), + team: pu.team ?? null, + isLobbyCreator: pu.isLobbyCreator, + }; +} + +function stateFromUpdate(pu: PlayerUpdate): PlayerState { + // embargoes: Set on the wire, but the renderer stores + // smallIDs (numbers). GameView fills these in via setEmbargoes() because + // it has the PlayerID → smallID lookup table. + return { + smallID: pu.smallID, + isAlive: pu.isAlive, + isDisconnected: pu.isDisconnected, + tilesOwned: pu.tilesOwned, + gold: Number(pu.gold), + troops: pu.troops, + isTraitor: pu.isTraitor, + traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0), + betrayals: pu.betrayals, + hasSpawned: pu.hasSpawned, + lastDeleteUnitTick: pu.lastDeleteUnitTick, + allies: pu.allies.slice(), + embargoes: [], + targets: pu.targets.slice(), + outgoingAttacks: pu.outgoingAttacks, + incomingAttacks: pu.incomingAttacks, + outgoingAllianceRequests: pu.outgoingAllianceRequests.slice(), + alliances: pu.alliances, + outgoingEmojis: pu.outgoingEmojis, + }; +} + +function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void { + // smallID is identity — never changes for a given PlayerView. + target.isAlive = pu.isAlive; + target.isDisconnected = pu.isDisconnected; + target.tilesOwned = pu.tilesOwned; + target.gold = Number(pu.gold); + target.troops = pu.troops; + target.isTraitor = pu.isTraitor; + target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks ?? 0); + target.betrayals = pu.betrayals; + target.hasSpawned = pu.hasSpawned; + target.lastDeleteUnitTick = pu.lastDeleteUnitTick; + // Slice() to detach from the wire object — accumulated state mustn't share + // mutable arrays with per-tick update payloads. + target.allies = pu.allies.slice(); + target.targets = pu.targets.slice(); + target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice(); + target.outgoingAttacks = pu.outgoingAttacks; + target.incomingAttacks = pu.incomingAttacks; + target.alliances = pu.alliances; + target.outgoingEmojis = pu.outgoingEmojis; +} + +export class PlayerView { + public anonymousName: string | null = null; + private decoder?: PatternDecoder; + + /** Long-lived renderer state — mutated in place by applyUpdate(). */ + public state: PlayerState; + /** Static header data — set once at construction, never mutated. */ + public static: PlayerStatic; + + private _territoryColor: Colord; + private _borderColor: Colord; + // Update here to include structure light and dark colors + private _structureColors: { light: Colord; dark: Colord }; + + // Pre-computed border color variants + private _borderColorNeutral: Colord; + private _borderColorFriendly: Colord; + private _borderColorEmbargo: Colord; + private _borderColorDefendedNeutral: { light: Colord; dark: Colord }; + private _borderColorDefendedFriendly: { light: Colord; dark: Colord }; + private _borderColorDefendedEmbargo: { light: Colord; dark: Colord }; + + constructor( + private game: GameView, + data: PlayerUpdate, + public nameData: NameViewData, + public cosmetics: PlayerCosmetics, + ) { + this.state = stateFromUpdate(data); + this.static = staticFromUpdate(data); + + if (data.clientID === game.myClientID()) { + this.anonymousName = data.name; + } else { + this.anonymousName = createRandomName(data.name, data.playerType); + } + + const theme = this.game.config().theme(); + + const defaultTerritoryColor = theme.territoryColor(this); + const defaultBorderColor = theme.borderColor(defaultTerritoryColor); + + const pattern = userSettings.territoryPatterns() + ? this.cosmetics.pattern + : undefined; + if (pattern) { + pattern.colorPalette ??= { + name: "", + primaryColor: defaultTerritoryColor.toHex(), + secondaryColor: defaultBorderColor.toHex(), + } satisfies ColorPalette; + } + + if (this.team() === null) { + this._territoryColor = colord( + this.cosmetics.color?.color ?? + pattern?.colorPalette?.primaryColor ?? + defaultTerritoryColor.toHex(), + ); + } else { + this._territoryColor = defaultTerritoryColor; + } + + this._structureColors = theme.structureColors(this._territoryColor); + + const maybeFocusedBorderColor = + this.game.myClientID() === data.clientID + ? theme.focusedBorderColor() + : defaultBorderColor; + + this._borderColor = new Colord( + pattern?.colorPalette?.secondaryColor ?? + this.cosmetics.color?.color ?? + maybeFocusedBorderColor.toHex(), + ); + + const baseRgb = this._borderColor.toRgb(); + + this._borderColorNeutral = this._borderColor; + + this._borderColorFriendly = colord({ + r: Math.round( + baseRgb.r * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO, + ), + g: Math.round( + baseRgb.g * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO, + ), + b: Math.round( + baseRgb.b * (1 - BORDER_TINT_RATIO) + + FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO, + ), + a: baseRgb.a, + }); + + this._borderColorEmbargo = colord({ + r: Math.round( + baseRgb.r * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO, + ), + g: Math.round( + baseRgb.g * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO, + ), + b: Math.round( + baseRgb.b * (1 - BORDER_TINT_RATIO) + + EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO, + ), + a: baseRgb.a, + }); + + this._borderColorDefendedNeutral = theme.defendedBorderColors( + this._borderColorNeutral, + ); + this._borderColorDefendedFriendly = theme.defendedBorderColors( + this._borderColorFriendly, + ); + this._borderColorDefendedEmbargo = theme.defendedBorderColors( + this._borderColorEmbargo, + ); + + this.decoder = + pattern === undefined + ? undefined + : new PatternDecoder(pattern, base64url.decode); + } + + /** + * Update mutable state in place. Called by GameView.update() each tick the + * player appears in the PlayerUpdate stream. + */ + applyUpdate(pu: PlayerUpdate): void { + applyStateUpdate(this.state, pu); + } + + /** Set the renderer-format embargoes (smallIDs). */ + setEmbargoSmallIDs(smallIDs: number[]): void { + this.state.embargoes = smallIDs; + } + + territoryColor(tile?: TileRef): Colord { + if (tile === undefined || this.decoder === undefined) { + return this._territoryColor; + } + const isPrimary = this.decoder.isPrimary( + this.game.x(tile), + this.game.y(tile), + ); + return isPrimary ? this._territoryColor : this._borderColor; + } + + structureColors(): { light: Colord; dark: Colord } { + return this._structureColors; + } + + /** + * Border color for a tile: + * - Tints by neighbor relations (embargo → red, friendly → green, else neutral). + * - If defended, applies theme checkerboard to the tinted color. + */ + borderColor(tile?: TileRef, isDefended: boolean = false): Colord { + if (tile === undefined) { + return this._borderColor; + } + + const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile); + + let baseColor: Colord; + let defendedColors: { light: Colord; dark: Colord }; + + if (hasEmbargo) { + baseColor = this._borderColorEmbargo; + defendedColors = this._borderColorDefendedEmbargo; + } else if (hasFriendly) { + baseColor = this._borderColorFriendly; + defendedColors = this._borderColorDefendedFriendly; + } else { + baseColor = this._borderColorNeutral; + defendedColors = this._borderColorDefendedNeutral; + } + + if (!isDefended) { + return baseColor; + } + + const x = this.game.x(tile); + const y = this.game.y(tile); + const lightTile = + (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); + return lightTile ? defendedColors.light : defendedColors.dark; + } + + /** + * Border relation flags for a tile, used by both CPU and WebGL renderers. + */ + borderRelationFlags(tile: TileRef): { + hasEmbargo: boolean; + hasFriendly: boolean; + } { + const mySmallID = this.smallID(); + let hasEmbargo = false; + let hasFriendly = false; + + for (const n of this.game.neighbors(tile)) { + if (!this.game.hasOwner(n)) { + continue; + } + + const otherOwner = this.game.owner(n); + if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) { + continue; + } + + if (this.hasEmbargo(otherOwner)) { + hasEmbargo = true; + break; + } + + if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) { + hasFriendly = true; + } + } + return { hasEmbargo, hasFriendly }; + } + + async actions( + tile?: TileRef, + units?: readonly PlayerBuildableUnitType[] | null, + ): Promise { + return this.game.worker.playerInteraction( + this.id(), + tile && this.game.x(tile), + tile && this.game.y(tile), + units, + ); + } + + async buildables( + tile?: TileRef, + units?: readonly PlayerBuildableUnitType[], + ): Promise { + return this.game.worker.playerBuildables( + this.id(), + tile && this.game.x(tile), + tile && this.game.y(tile), + units, + ); + } + + async borderTiles(): Promise { + return this.game.worker.playerBorderTiles(this.id()); + } + + outgoingAttacks(): AttackUpdate[] { + return this.state.outgoingAttacks; + } + + incomingAttacks(): AttackUpdate[] { + return this.state.incomingAttacks; + } + + async attackClusteredPositions( + attackID?: string, + ): Promise<{ id: string; positions: Cell[] }[]> { + return this.game.worker.attackClusteredPositions(this.smallID(), attackID); + } + + units(...types: UnitType[]): UnitView[] { + return this.game + .units(...types) + .filter((u) => u.owner().smallID() === this.smallID()); + } + + nameLocation(): NameViewData { + return this.nameData; + } + + smallID(): number { + return this.state.smallID; + } + + name(): string { + return this.anonymousName !== null && userSettings.anonymousNames() + ? this.anonymousName + : this.static.name; + } + displayName(): string { + return this.anonymousName !== null && userSettings.anonymousNames() + ? this.anonymousName + : this.static.displayName; + } + + clientID(): ClientID | null { + return this.static.clientID; + } + id(): PlayerID { + return this.static.id; + } + team(): Team | null { + return this.static.team; + } + type(): PlayerType { + // Map PlayerStatic's numeric enum back to engine string enum. + switch (this.static.playerType) { + case PlayerTypeEnum.Human: + return PlayerType.Human; + case PlayerTypeEnum.Bot: + return PlayerType.Bot; + case PlayerTypeEnum.Nation: + return PlayerType.Nation; + default: + return PlayerType.Bot; + } + } + isAlive(): boolean { + return this.state.isAlive; + } + isPlayer(): this is PlayerView { + return true; + } + numTilesOwned(): number { + return this.state.tilesOwned; + } + allies(): PlayerView[] { + return this.state.allies.map( + (a) => this.game.playerBySmallID(a) as PlayerView, + ); + } + targets(): PlayerView[] { + return this.state.targets.map( + (id) => this.game.playerBySmallID(id) as PlayerView, + ); + } + gold(): Gold { + // Engine Gold is bigint; renderer state stores number. Convert back at the + // accessor for game-code that still expects bigint semantics. + return BigInt(this.state.gold); + } + + troops(): number { + return this.state.troops; + } + + totalUnitLevels(type: UnitType): number { + return this.units(type) + .filter((unit) => !unit.isUnderConstruction()) + .map((unit) => unit.level()) + .reduce((a, b) => a + b, 0); + } + + isMe(): boolean { + return this.smallID() === this.game.myPlayer()?.smallID(); + } + + isLobbyCreator(): boolean { + return this.static.isLobbyCreator; + } + + isAlliedWith(other: PlayerView): boolean { + return this.state.allies.some((n) => other.smallID() === n); + } + + isOnSameTeam(other: PlayerView): boolean { + return this.static.team !== null && this.static.team === other.static.team; + } + + isFriendly(other: PlayerView): boolean { + return this.isAlliedWith(other) || this.isOnSameTeam(other); + } + + isRequestingAllianceWith(other: PlayerView) { + return this.state.outgoingAllianceRequests.some((id) => other.id() === id); + } + + alliances(): AllianceView[] { + return this.state.alliances; + } + + hasEmbargoAgainst(other: PlayerView): boolean { + return this.state.embargoes.includes(other.smallID()); + } + + hasEmbargo(other: PlayerView): boolean { + return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this); + } + + profile(): Promise { + return this.game.worker.playerProfile(this.smallID()); + } + + bestTransportShipSpawn(targetTile: TileRef): Promise { + return this.game.worker.transportShipSpawn(this.id(), targetTile); + } + + transitiveTargets(): PlayerView[] { + const result: PlayerView[] = []; + + // Add own targets + for (const id of this.state.targets) { + result.push(this.game.playerBySmallID(id) as PlayerView); + } + + // Add allies' targets + for (const allyID of this.state.allies) { + const ally = this.game.playerBySmallID(allyID) as PlayerView; + for (const targetId of ally.state.targets) { + result.push(this.game.playerBySmallID(targetId) as PlayerView); + } + } + + // Add teammates' targets + const myTeam = this.static.team; + if (myTeam !== null) { + for (const p of this.game.playerViews()) { + if (p !== this && p.static.team === myTeam) { + for (const targetId of p.state.targets) { + result.push(this.game.playerBySmallID(targetId) as PlayerView); + } + } + } + } + + return result; + } + + isTraitor(): boolean { + return this.state.isTraitor; + } + getTraitorRemainingTicks(): number { + return this.state.traitorRemainingTicks; + } + betrayals(): number { + return this.state.betrayals; + } + outgoingEmojis(): EmojiMessage[] { + return this.state.outgoingEmojis; + } + + hasSpawned(): boolean { + return this.state.hasSpawned; + } + isDisconnected(): boolean { + return this.state.isDisconnected; + } + + lastDeleteUnitTick(): Tick { + return this.state.lastDeleteUnitTick; + } + + deleteUnitCooldown(): number { + return ( + Math.max( + 0, + this.game.config().deleteUnitCooldown() - + (this.game.ticks() + 1 - this.lastDeleteUnitTick()), + ) / 10 + ); + } +} diff --git a/src/client/view/UnitView.ts b/src/client/view/UnitView.ts new file mode 100644 index 0000000000..46b2c24b03 --- /dev/null +++ b/src/client/view/UnitView.ts @@ -0,0 +1,280 @@ +import { + Tick, + TrainType, + TransportShipState, + UnitType, + WarshipState, +} from "../../core/game/Game"; +import { TileRef } from "../../core/game/GameMap"; +import { UnitUpdate } from "../../core/game/GameUpdates"; +import type { UnitState } from "../render/types"; +import { TrainType as RendererTrainType } from "../render/types"; +import { GameView } from "./GameView"; +import { PlayerView } from "./PlayerView"; + +/** + * Convert engine TrainType (string enum) to renderer's numeric encoding. + * UnitState uses 0/1/2 so it can be uploaded to GPU buffers without lookup. + */ +function trainTypeToNum(t: TrainType | undefined): number | null { + switch (t) { + case TrainType.Engine: + return RendererTrainType.Engine; + case TrainType.TailEngine: + return RendererTrainType.TailEngine; + case TrainType.Carriage: + return RendererTrainType.Carriage; + default: + return null; + } +} + +function numToTrainType(n: number | null): TrainType | undefined { + switch (n) { + case RendererTrainType.Engine: + return TrainType.Engine; + case RendererTrainType.TailEngine: + return TrainType.TailEngine; + case RendererTrainType.Carriage: + return TrainType.Carriage; + default: + return undefined; + } +} + +/** Build a fresh UnitState from an incoming UnitUpdate. */ +function unitStateFromUpdate(u: UnitUpdate): UnitState { + return { + id: u.id, + unitType: u.unitType, + ownerID: u.ownerID, + lastOwnerID: u.lastOwnerID ?? null, + pos: u.pos, + lastPos: u.lastPos, + isActive: u.isActive, + reachedTarget: u.reachedTarget, + retreating: u.transportShipState?.isRetreating ?? false, + targetable: u.targetable, + markedForDeletion: u.markedForDeletion, + health: u.health ?? null, + underConstruction: u.underConstruction ?? false, + targetUnitId: u.targetUnitId ?? null, + targetTile: u.targetTile ?? null, + troops: u.troops, + missileTimerQueue: u.missileTimerQueue, + level: u.level, + hasTrainStation: u.hasTrainStation, + trainType: trainTypeToNum(u.trainType), + loaded: u.loaded ?? null, + constructionStartTick: null, // GameView fills in createdAt when underConstruction + }; +} + +/** Mutate `target` in place from a UnitUpdate, avoiding any allocation. */ +function applyUpdateInPlace(target: UnitState, u: UnitUpdate): void { + target.ownerID = u.ownerID; + target.unitType = u.unitType; + target.lastOwnerID = u.lastOwnerID ?? null; + target.pos = u.pos; + target.lastPos = u.lastPos; + target.isActive = u.isActive; + target.reachedTarget = u.reachedTarget; + target.retreating = u.transportShipState?.isRetreating ?? false; + target.targetable = u.targetable; + target.markedForDeletion = u.markedForDeletion; + target.health = u.health ?? null; + target.underConstruction = u.underConstruction ?? false; + target.targetUnitId = u.targetUnitId ?? null; + target.targetTile = u.targetTile ?? null; + target.troops = u.troops; + target.missileTimerQueue = u.missileTimerQueue; + target.level = u.level; + target.hasTrainStation = u.hasTrainStation; + target.trainType = trainTypeToNum(u.trainType); + target.loaded = u.loaded ?? null; +} + +export class UnitView { + public _wasUpdated = true; + public lastPos: TileRef[] = []; + /** Long-lived renderer state — mutated in place by update(). */ + public state: UnitState; + /** Engine-only fields not in UnitState. Use warshipState() / transportShipState() to read. */ + private _warshipState?: WarshipState; + private _transportShipState?: TransportShipState; + private _createdAt: Tick; + + constructor( + private gameView: GameView, + data: UnitUpdate, + ) { + this.state = unitStateFromUpdate(data); + this._warshipState = data.warshipState; + this._transportShipState = data.transportShipState; + this.lastPos.push(data.pos); + this._createdAt = this.gameView.ticks(); + if (this.state.underConstruction) { + this.state.constructionStartTick = this._createdAt; + } + } + + createdAt(): Tick { + return this._createdAt; + } + + wasUpdated(): boolean { + return this._wasUpdated; + } + + lastTiles(): TileRef[] { + return this.lastPos; + } + + lastTile(): TileRef { + if (this.lastPos.length === 0) { + return this.state.pos; + } + return this.lastPos[0]; + } + + update(data: UnitUpdate) { + this.lastPos.push(data.pos); + this._wasUpdated = true; + const wasUnderConstruction = this.state.underConstruction; + applyUpdateInPlace(this.state, data); + this._warshipState = data.warshipState; + this._transportShipState = data.transportShipState; + // constructionStartTick: set on transition into underConstruction. + if (this.state.underConstruction && !wasUnderConstruction) { + this.state.constructionStartTick = this.gameView.ticks(); + } else if (!this.state.underConstruction) { + this.state.constructionStartTick = null; + } + } + + applyDerivedPosition(pos: TileRef) { + const prev = this.state.pos; + this.lastPos.push(pos); + this._wasUpdated = true; + this.state.lastPos = prev; + this.state.pos = pos; + } + + id(): number { + return this.state.id; + } + + targetable(): boolean { + return this.state.targetable; + } + + markedForDeletion(): number | false { + return this.state.markedForDeletion; + } + + type(): UnitType { + return this.state.unitType as UnitType; + } + troops(): number { + return this.state.troops; + } + warshipState(): WarshipState { + if (this._warshipState === undefined) { + throw new Error("warshipState called on non-warship unit"); + } + return this._warshipState; + } + updateWarshipState(_update: Partial): void { + throw new Error("updateWarshipState is not supported on UnitView"); + } + isInCombat(): boolean { + return this._warshipState?.isInCombat ?? false; + } + touch(): void { + throw new Error("touch is not supported on UnitView"); + } + transportShipState(): TransportShipState { + return this._transportShipState ?? { isRetreating: false, troops: 0 }; + } + updateTransportShipState( + _update: Pick, + ): void { + throw new Error("updateTransportShipState is not supported on UnitView"); + } + tile(): TileRef { + return this.state.pos; + } + owner(): PlayerView { + return this.gameView.playerBySmallID(this.state.ownerID)! as PlayerView; + } + isActive(): boolean { + return this.state.isActive; + } + reachedTarget(): boolean { + return this.state.reachedTarget; + } + hasHealth(): boolean { + return this.state.health !== null; + } + health(): number { + return this.state.health ?? 0; + } + isUnderConstruction(): boolean { + return this.state.underConstruction; + } + targetUnitId(): number | undefined { + return this.state.targetUnitId ?? undefined; + } + targetTile(): TileRef | undefined { + return this.state.targetTile ?? undefined; + } + + // How "ready" this unit is from 0 to 1. + missileReadinesss(): number { + const maxMissiles = this.state.level; + const missilesReloading = this.state.missileTimerQueue.length; + + if (missilesReloading === 0) { + return 1; + } + + const missilesReady = maxMissiles - missilesReloading; + + if (missilesReady === 0 && maxMissiles > 1) { + // Unless we have just one missile (level 1), + // show 0% readiness so user knows no missiles are ready. + return 0; + } + + let readiness = missilesReady / maxMissiles; + + const cooldownDuration = + this.state.unitType === UnitType.SAMLauncher + ? this.gameView.config().SAMCooldown() + : this.gameView.config().SiloCooldown(); + + for (const cooldown of this.state.missileTimerQueue) { + const cooldownProgress = this.gameView.ticks() - cooldown; + const cooldownRatio = cooldownProgress / cooldownDuration; + const adjusted = cooldownRatio / maxMissiles; + readiness += adjusted; + } + return readiness; + } + + level(): number { + return this.state.level; + } + hasTrainStation(): boolean { + return this.state.hasTrainStation; + } + trainType(): TrainType | undefined { + return numToTrainType(this.state.trainType); + } + isLoaded(): boolean | undefined { + return this.state.loaded ?? undefined; + } + missileTimerQueue(): number[] { + return this.state.missileTimerQueue; + } +} diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 75435cda82..509ce5dd09 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1174,6 +1174,9 @@ export class GameImpl implements Game { tileState(tile: TileRef): number { return this._map.tileState(tile); } + tileStateBuffer(): Uint16Array { + return this._map.tileStateBuffer(); + } updateTile(tile: TileRef, state: number): boolean { return this._map.updateTile(tile, state); } diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 592d02ca40..be403dcf18 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -72,6 +72,20 @@ export interface GameMap { */ updateTile(tile: TileRef, state: number): boolean; + /** + * Direct access to the per-tile state buffer for zero-copy consumers + * (e.g. WebGL renderer uploading to a R16UI texture). + * + * The returned array is a live reference — it is mutated by `updateTile()` + * each tick. Callers must not write to it. + * + * The bit layout of each `uint16` matches the renderer's tile state: + * bits 0-11: ownerID + * bit 13: fallout + * bit 14: defense bonus + */ + tileStateBuffer(): Uint16Array; + numTilesWithFallout(): number; } @@ -401,6 +415,10 @@ export class GameMapImpl implements GameMap { return this.state[tile]; } + tileStateBuffer(): Uint16Array { + return this.state; + } + /** * Update a tile from a packed uint32: * bits 0-15: tile state (owner, fallout, etc.) diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 44bc83a855..da408cfac1 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -1,1414 +1,12 @@ -import { Colord, colord } from "colord"; -import { base64url } from "jose"; -import { Config } from "../configuration/Config"; -import { ColorPalette } from "../CosmeticSchemas"; -import { PatternDecoder } from "../PatternDecoder"; -import { ClientID, GameID, Player, PlayerCosmetics } from "../Schemas"; -import { createRandomName, formatPlayerDisplayName } from "../Util"; -import { WorkerClient } from "../worker/WorkerClient"; -import { - BuildableUnit, - Cell, - EmojiMessage, - GameUpdates, - Gold, - NameViewData, - PlayerActions, - PlayerBorderTiles, - PlayerBuildableUnitType, - PlayerID, - PlayerProfile, - PlayerType, - Team, - TerrainType, - TerraNullius, - Tick, - TrainType, - TransportShipState, - Unit, - UnitInfo, - UnitType, - WarshipState, -} from "./Game"; -import { GameMap, TileRef } from "./GameMap"; -import { - AllianceView, - AttackUpdate, - GameUpdateType, - GameUpdateViewData, - PlayerUpdate, - SpawnPhaseEndUpdate, - UnitUpdate, -} from "./GameUpdates"; -import { MotionPlanRecord, unpackMotionPlans } from "./MotionPlans"; -import { TerrainMapData } from "./TerrainMapLoader"; -import { TerraNulliusImpl } from "./TerraNulliusImpl"; -import { UnitGrid, UnitPredicate } from "./UnitGrid"; -import { UserSettings } from "./UserSettings"; - -const userSettings: UserSettings = new UserSettings(); - -const FRIENDLY_TINT_TARGET = { r: 0, g: 255, b: 0, a: 1 }; -const EMBARGO_TINT_TARGET = { r: 255, g: 0, b: 0, a: 1 }; -const BORDER_TINT_RATIO = 0.35; - -export class UnitView { - public _wasUpdated = true; - public lastPos: TileRef[] = []; - private _createdAt: Tick; - - constructor( - private gameView: GameView, - private data: UnitUpdate, - ) { - this.lastPos.push(data.pos); - this._createdAt = this.gameView.ticks(); - } - - createdAt(): Tick { - return this._createdAt; - } - - wasUpdated(): boolean { - return this._wasUpdated; - } - - lastTiles(): TileRef[] { - return this.lastPos; - } - - lastTile(): TileRef { - if (this.lastPos.length === 0) { - return this.data.pos; - } - return this.lastPos[0]; - } - - update(data: UnitUpdate) { - this.lastPos.push(data.pos); - this._wasUpdated = true; - this.data = data; - } - - applyDerivedPosition(pos: TileRef) { - const prev = this.data.pos; - this.lastPos.push(pos); - this._wasUpdated = true; - this.data = { - ...this.data, - lastPos: prev, - pos, - }; - } - - id(): number { - return this.data.id; - } - - targetable(): boolean { - return this.data.targetable; - } - - markedForDeletion(): number | false { - return this.data.markedForDeletion; - } - - type(): UnitType { - return this.data.unitType; - } - troops(): number { - return this.data.troops; - } - warshipState(): WarshipState { - if (this.data.warshipState === undefined) { - throw new Error("warshipState called on non-warship unit"); - } - return this.data.warshipState; - } - updateWarshipState(_update: Partial): void { - throw new Error("updateWarshipState is not supported on UnitView"); - } - isInCombat(): boolean { - return this.data.warshipState?.isInCombat ?? false; - } - touch(): void { - throw new Error("touch is not supported on UnitView"); - } - transportShipState(): TransportShipState { - return this.data.transportShipState ?? { isRetreating: false, troops: 0 }; - } - updateTransportShipState( - _update: Pick, - ): void { - throw new Error("updateTransportShipState is not supported on UnitView"); - } - tile(): TileRef { - return this.data.pos; - } - owner(): PlayerView { - return this.gameView.playerBySmallID(this.data.ownerID)! as PlayerView; - } - isActive(): boolean { - return this.data.isActive; - } - reachedTarget(): boolean { - return this.data.reachedTarget; - } - hasHealth(): boolean { - return this.data.health !== undefined; - } - health(): number { - return this.data.health ?? 0; - } - isUnderConstruction(): boolean { - return this.data.underConstruction === true; - } - targetUnitId(): number | undefined { - return this.data.targetUnitId; - } - targetTile(): TileRef | undefined { - return this.data.targetTile; - } - - // How "ready" this unit is from 0 to 1. - missileReadinesss(): number { - const maxMissiles = this.data.level; - const missilesReloading = this.data.missileTimerQueue.length; - - if (missilesReloading === 0) { - return 1; - } - - const missilesReady = maxMissiles - missilesReloading; - - if (missilesReady === 0 && maxMissiles > 1) { - // Unless we have just one missile (level 1), - // show 0% readiness so user knows no missiles are ready. - return 0; - } - - let readiness = missilesReady / maxMissiles; - - const cooldownDuration = - this.data.unitType === UnitType.SAMLauncher - ? this.gameView.config().SAMCooldown() - : this.gameView.config().SiloCooldown(); - - for (const cooldown of this.data.missileTimerQueue) { - const cooldownProgress = this.gameView.ticks() - cooldown; - const cooldownRatio = cooldownProgress / cooldownDuration; - const adjusted = cooldownRatio / maxMissiles; - readiness += adjusted; - } - return readiness; - } - - level(): number { - return this.data.level; - } - hasTrainStation(): boolean { - return this.data.hasTrainStation; - } - trainType(): TrainType | undefined { - return this.data.trainType; - } - isLoaded(): boolean | undefined { - return this.data.loaded; - } -} - -export class PlayerView { - public anonymousName: string | null = null; - private decoder?: PatternDecoder; - - private _territoryColor: Colord; - private _borderColor: Colord; - // Update here to include structure light and dark colors - private _structureColors: { light: Colord; dark: Colord }; - - // Pre-computed border color variants - private _borderColorNeutral: Colord; - private _borderColorFriendly: Colord; - private _borderColorEmbargo: Colord; - private _borderColorDefendedNeutral: { light: Colord; dark: Colord }; - private _borderColorDefendedFriendly: { light: Colord; dark: Colord }; - private _borderColorDefendedEmbargo: { light: Colord; dark: Colord }; - - constructor( - private game: GameView, - public data: PlayerUpdate, - public nameData: NameViewData, - public cosmetics: PlayerCosmetics, - ) { - if (data.clientID === game.myClientID()) { - this.anonymousName = this.data.name; - } else { - this.anonymousName = createRandomName( - this.data.name, - this.data.playerType, - ); - } - - const theme = this.game.config().theme(); - - const defaultTerritoryColor = theme.territoryColor(this); - const defaultBorderColor = theme.borderColor(defaultTerritoryColor); - - const pattern = userSettings.territoryPatterns() - ? this.cosmetics.pattern - : undefined; - if (pattern) { - pattern.colorPalette ??= { - name: "", - primaryColor: defaultTerritoryColor.toHex(), - secondaryColor: defaultBorderColor.toHex(), - } satisfies ColorPalette; - } - - if (this.team() === null) { - this._territoryColor = colord( - this.cosmetics.color?.color ?? - pattern?.colorPalette?.primaryColor ?? - defaultTerritoryColor.toHex(), - ); - } else { - this._territoryColor = defaultTerritoryColor; - } - - this._structureColors = theme.structureColors(this._territoryColor); - - const maybeFocusedBorderColor = - this.game.myClientID() === this.data.clientID - ? theme.focusedBorderColor() - : defaultBorderColor; - - this._borderColor = new Colord( - pattern?.colorPalette?.secondaryColor ?? - this.cosmetics.color?.color ?? - maybeFocusedBorderColor.toHex(), - ); - - // Pre-compute all border color variants once - const baseRgb = this._borderColor.toRgb(); - - // Neutral is just the base color - this._borderColorNeutral = this._borderColor; - - // Compute friendly tint - this._borderColorFriendly = colord({ - r: Math.round( - baseRgb.r * (1 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET.r * BORDER_TINT_RATIO, - ), - g: Math.round( - baseRgb.g * (1 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET.g * BORDER_TINT_RATIO, - ), - b: Math.round( - baseRgb.b * (1 - BORDER_TINT_RATIO) + - FRIENDLY_TINT_TARGET.b * BORDER_TINT_RATIO, - ), - a: baseRgb.a, - }); - - // Compute embargo tint - this._borderColorEmbargo = colord({ - r: Math.round( - baseRgb.r * (1 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET.r * BORDER_TINT_RATIO, - ), - g: Math.round( - baseRgb.g * (1 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET.g * BORDER_TINT_RATIO, - ), - b: Math.round( - baseRgb.b * (1 - BORDER_TINT_RATIO) + - EMBARGO_TINT_TARGET.b * BORDER_TINT_RATIO, - ), - a: baseRgb.a, - }); - - // Pre-compute defended variants - this._borderColorDefendedNeutral = theme.defendedBorderColors( - this._borderColorNeutral, - ); - this._borderColorDefendedFriendly = theme.defendedBorderColors( - this._borderColorFriendly, - ); - this._borderColorDefendedEmbargo = theme.defendedBorderColors( - this._borderColorEmbargo, - ); - - this.decoder = - pattern === undefined - ? undefined - : new PatternDecoder(pattern, base64url.decode); - } - - territoryColor(tile?: TileRef): Colord { - if (tile === undefined || this.decoder === undefined) { - return this._territoryColor; - } - const isPrimary = this.decoder.isPrimary( - this.game.x(tile), - this.game.y(tile), - ); - return isPrimary ? this._territoryColor : this._borderColor; - } - - structureColors(): { light: Colord; dark: Colord } { - return this._structureColors; - } - - /** - * Border color for a tile: - * - Tints by neighbor relations (embargo → red, friendly → green, else neutral). - * - If defended, applies theme checkerboard to the tinted color. - */ - borderColor(tile?: TileRef, isDefended: boolean = false): Colord { - if (tile === undefined) { - return this._borderColor; - } - - const { hasEmbargo, hasFriendly } = this.borderRelationFlags(tile); - - let baseColor: Colord; - let defendedColors: { light: Colord; dark: Colord }; - - if (hasEmbargo) { - baseColor = this._borderColorEmbargo; - defendedColors = this._borderColorDefendedEmbargo; - } else if (hasFriendly) { - baseColor = this._borderColorFriendly; - defendedColors = this._borderColorDefendedFriendly; - } else { - baseColor = this._borderColorNeutral; - defendedColors = this._borderColorDefendedNeutral; - } - - if (!isDefended) { - return baseColor; - } - - const x = this.game.x(tile); - const y = this.game.y(tile); - const lightTile = - (x % 2 === 0 && y % 2 === 0) || (y % 2 === 1 && x % 2 === 1); - return lightTile ? defendedColors.light : defendedColors.dark; - } - - /** - * Border relation flags for a tile, used by both CPU and WebGL renderers. - */ - borderRelationFlags(tile: TileRef): { - hasEmbargo: boolean; - hasFriendly: boolean; - } { - const mySmallID = this.smallID(); - let hasEmbargo = false; - let hasFriendly = false; - - for (const n of this.game.neighbors(tile)) { - if (!this.game.hasOwner(n)) { - continue; - } - - const otherOwner = this.game.owner(n); - if (!otherOwner.isPlayer() || otherOwner.smallID() === mySmallID) { - continue; - } - - if (this.hasEmbargo(otherOwner)) { - hasEmbargo = true; - break; - } - - if (this.isFriendly(otherOwner) || otherOwner.isFriendly(this)) { - hasFriendly = true; - } - } - return { hasEmbargo, hasFriendly }; - } - - async actions( - tile?: TileRef, - units?: readonly PlayerBuildableUnitType[] | null, - ): Promise { - return this.game.worker.playerInteraction( - this.id(), - tile && this.game.x(tile), - tile && this.game.y(tile), - units, - ); - } - - async buildables( - tile?: TileRef, - units?: readonly PlayerBuildableUnitType[], - ): Promise { - return this.game.worker.playerBuildables( - this.id(), - tile && this.game.x(tile), - tile && this.game.y(tile), - units, - ); - } - - async borderTiles(): Promise { - return this.game.worker.playerBorderTiles(this.id()); - } - - outgoingAttacks(): AttackUpdate[] { - return this.data.outgoingAttacks; - } - - incomingAttacks(): AttackUpdate[] { - return this.data.incomingAttacks; - } - - async attackClusteredPositions( - attackID?: string, - ): Promise<{ id: string; positions: Cell[] }[]> { - return this.game.worker.attackClusteredPositions(this.smallID(), attackID); - } - - units(...types: UnitType[]): UnitView[] { - return this.game - .units(...types) - .filter((u) => u.owner().smallID() === this.smallID()); - } - - nameLocation(): NameViewData { - return this.nameData; - } - - smallID(): number { - return this.data.smallID; - } - - name(): string { - return this.anonymousName !== null && userSettings.anonymousNames() - ? this.anonymousName - : this.data.name; - } - displayName(): string { - return this.anonymousName !== null && userSettings.anonymousNames() - ? this.anonymousName - : this.data.displayName; - } - - clientID(): ClientID | null { - return this.data.clientID; - } - id(): PlayerID { - return this.data.id; - } - team(): Team | null { - return this.data.team ?? null; - } - type(): PlayerType { - return this.data.playerType; - } - isAlive(): boolean { - return this.data.isAlive; - } - isPlayer(): this is PlayerView { - return true; - } - numTilesOwned(): number { - return this.data.tilesOwned; - } - allies(): PlayerView[] { - return this.data.allies.map( - (a) => this.game.playerBySmallID(a) as PlayerView, - ); - } - targets(): PlayerView[] { - return this.data.targets.map( - (id) => this.game.playerBySmallID(id) as PlayerView, - ); - } - gold(): Gold { - return this.data.gold; - } - - troops(): number { - return this.data.troops; - } - - totalUnitLevels(type: UnitType): number { - return this.units(type) - .filter((unit) => !unit.isUnderConstruction()) - .map((unit) => unit.level()) - .reduce((a, b) => a + b, 0); - } - - isMe(): boolean { - return this.smallID() === this.game.myPlayer()?.smallID(); - } - - isLobbyCreator(): boolean { - return this.data.isLobbyCreator; - } - - isAlliedWith(other: PlayerView): boolean { - return this.data.allies.some((n) => other.smallID() === n); - } - - isOnSameTeam(other: PlayerView): boolean { - return this.data.team !== undefined && this.data.team === other.data.team; - } - - isFriendly(other: PlayerView): boolean { - return this.isAlliedWith(other) || this.isOnSameTeam(other); - } - - isRequestingAllianceWith(other: PlayerView) { - return this.data.outgoingAllianceRequests.some((id) => other.id() === id); - } - - alliances(): AllianceView[] { - return this.data.alliances; - } - - hasEmbargoAgainst(other: PlayerView): boolean { - return this.data.embargoes.has(other.id()); - } - - hasEmbargo(other: PlayerView): boolean { - return this.hasEmbargoAgainst(other) || other.hasEmbargoAgainst(this); - } - - profile(): Promise { - return this.game.worker.playerProfile(this.smallID()); - } - - bestTransportShipSpawn(targetTile: TileRef): Promise { - return this.game.worker.transportShipSpawn(this.id(), targetTile); - } - - transitiveTargets(): PlayerView[] { - const result: PlayerView[] = []; - - // Add own targets - for (const id of this.data.targets) { - result.push(this.game.playerBySmallID(id) as PlayerView); - } - - // Add allies' targets - for (const allyID of this.data.allies) { - const ally = this.game.playerBySmallID(allyID) as PlayerView; - for (const targetId of ally.data.targets) { - result.push(this.game.playerBySmallID(targetId) as PlayerView); - } - } - - // Add teammates' targets - if (this.data.team !== undefined) { - for (const p of this.game.playerViews()) { - if (p !== this && p.data.team === this.data.team) { - for (const targetId of p.data.targets) { - result.push(this.game.playerBySmallID(targetId) as PlayerView); - } - } - } - } - - return result; - } - - isTraitor(): boolean { - return this.data.isTraitor; - } - getTraitorRemainingTicks(): number { - return Math.max(0, this.data.traitorRemainingTicks ?? 0); - } - outgoingEmojis(): EmojiMessage[] { - return this.data.outgoingEmojis; - } - - hasSpawned(): boolean { - return this.data.hasSpawned; - } - isDisconnected(): boolean { - return this.data.isDisconnected; - } - - lastDeleteUnitTick(): Tick { - return this.data.lastDeleteUnitTick; - } - - deleteUnitCooldown(): number { - return ( - Math.max( - 0, - this.game.config().deleteUnitCooldown() - - (this.game.ticks() + 1 - this.lastDeleteUnitTick()), - ) / 10 - ); - } -} - -type TrainPlanState = { - planId: number; - startTick: number; - speed: number; - spacing: number; - carUnitIds: Uint32Array; - path: Uint32Array; - cursor: number; - usedTilesBuf: Uint32Array; - usedHead: number; - usedLen: number; - lastAdvancedTick: Tick; -}; - -export class GameView implements GameMap { - private lastUpdate: GameUpdateViewData | null; - private startTick: Tick | null = null; - private smallIDToID = new Map(); - private _players = new Map(); - private _units = new Map(); - private updatedTiles: TileRef[] = []; - private updatedTerrainTiles: TileRef[] = []; - - private _myPlayer: PlayerView | null = null; - - private unitGrid: UnitGrid; - private unitMotionPlans = new Map< - number, - { - planId: number; - startTick: number; - ticksPerStep: number; - path: Uint32Array; - } - >(); - private trainMotionPlans = new Map(); - private trainUnitToEngine = new Map(); - - private toDelete = new Set(); - - private _cosmetics: Map = new Map(); - - private _map: GameMap; - - constructor( - public worker: WorkerClient, - private _config: Config, - private _mapData: TerrainMapData, - private _myClientID: ClientID | undefined, - private _myUsername: string, - private _myClanTag: string | null, - private _gameID: GameID, - humans: Player[], - ) { - this._map = this._mapData.gameMap; - this.lastUpdate = null; - this.unitGrid = new UnitGrid(this._map); - this._cosmetics = new Map( - humans.map((h) => [h.clientID, h.cosmetics ?? {}]), - ); - for (const nation of this._mapData.nations) { - // Nations don't have client ids, so we use their name as the key instead. - this._cosmetics.set(nation.name, { - flag: nation.flag ? `/flags/${nation.flag}.svg` : undefined, - } satisfies PlayerCosmetics); - } - for (const extra of this._mapData.additionalNations) { - // Only set if not already provided by a manifest nation with the same name. - if (this._cosmetics.has(extra.name)) continue; - this._cosmetics.set(extra.name, { - flag: extra.flag ? `/flags/${extra.flag}.svg` : undefined, - } satisfies PlayerCosmetics); - } - } - - isOnEdgeOfMap(ref: TileRef): boolean { - return this._map.isOnEdgeOfMap(ref); - } - - public updatesSinceLastTick(): GameUpdates | null { - return this.lastUpdate?.updates ?? null; - } - - public motionPlans(): ReadonlyMap< - number, - { - planId: number; - startTick: number; - ticksPerStep: number; - path: Uint32Array; - } - > { - return this.unitMotionPlans; - } - - private motionPlannedUnitIdsCache: number[] = []; - private motionPlannedUnitIdsDirty = true; - - private markMotionPlannedUnitIdsDirty(): void { - this.motionPlannedUnitIdsDirty = true; - } - - private rebuildMotionPlannedUnitIdsCacheIfDirty(): void { - if (!this.motionPlannedUnitIdsDirty) { - return; - } - this.motionPlannedUnitIdsDirty = false; - - const out = this.motionPlannedUnitIdsCache; - out.length = 0; - - for (const unitId of this.unitMotionPlans.keys()) { - out.push(unitId); - } - for (const [engineId, plan] of this.trainMotionPlans) { - out.push(engineId); - for (let i = 0; i < plan.carUnitIds.length; i++) { - const id = plan.carUnitIds[i] >>> 0; - if (id !== 0) out.push(id); - } - } - } - - public motionPlannedUnitIds(): number[] { - this.rebuildMotionPlannedUnitIdsCacheIfDirty(); - return this.motionPlannedUnitIdsCache; - } - - public isCatchingUp(): boolean { - return (this.lastUpdate?.pendingTurns ?? 0) > 1; - } - - public update(gu: GameUpdateViewData) { - this.toDelete.forEach((id) => this._units.delete(id)); - this.toDelete.clear(); - - this.lastUpdate = gu; - - this.updatedTiles = []; - this.updatedTerrainTiles = []; - const packed = this.lastUpdate.packedTileUpdates; - for (let i = 0; i + 1 < packed.length; i += 2) { - const tile = packed[i]; - const state = packed[i + 1]; - const terrainChanged = this.updateTile(tile, state); - this.updatedTiles.push(tile); - if (terrainChanged) { - this.updatedTerrainTiles.push(tile); - } - } - - if (gu.packedMotionPlans) { - const records = unpackMotionPlans(gu.packedMotionPlans); - this.applyMotionPlanRecords(records); - } - - if (gu.updates === null) { - throw new Error("lastUpdate.updates not initialized"); - } - - const spawnPhaseEndUpdate = gu.updates[GameUpdateType.SpawnPhaseEnd][0] as - | SpawnPhaseEndUpdate - | undefined; - if (spawnPhaseEndUpdate) { - this.startTick = spawnPhaseEndUpdate.startTick; - } - - const myDisplayName = formatPlayerDisplayName( - this._myUsername, - this._myClanTag, - ); - - gu.updates[GameUpdateType.Player].forEach((pu) => { - // Replace the local player's name/displayName with their own stored values. - // This way the user does not know they are being censored. - if (pu.clientID === this._myClientID) { - pu.name = this._myUsername; - pu.displayName = myDisplayName; - } - - this.smallIDToID.set(pu.smallID, pu.id); - let player = this._players.get(pu.id); - if (player !== undefined) { - player.data = pu; - const nextNameData = gu.playerNameViewData[pu.id]; - if (nextNameData !== undefined) { - player.nameData = nextNameData; - } - } else { - player = new PlayerView( - this, - pu, - gu.playerNameViewData[pu.id], - // First check human by clientID, then check nation by name. - this._cosmetics.get(pu.clientID ?? "") ?? - this._cosmetics.get(pu.name) ?? - {}, - ); - this._players.set(pu.id, player); - } - }); - - if (this._myClientID) { - this._myPlayer ??= this.playerByClientID(this._myClientID); - } - - for (const unit of this._units.values()) { - unit._wasUpdated = false; - unit.lastPos = unit.lastPos.slice(-1); - } - gu.updates[GameUpdateType.Unit].forEach((update) => { - let unit = this._units.get(update.id); - if (unit !== undefined) { - unit.update(update); - } else { - unit = new UnitView(this, update); - this._units.set(update.id, unit); - this.unitGrid.addUnit(unit); - } - if (!update.isActive) { - this.unitGrid.removeUnit(unit); - } else if (unit.tile() !== unit.lastTile()) { - this.unitGrid.updateUnitCell(unit); - } - if (!unit.isActive()) { - // Wait until next tick to delete the unit. - this.toDelete.add(unit.id()); - if (this.unitMotionPlans.delete(unit.id())) { - this.markMotionPlannedUnitIdsDirty(); - } - this.clearTrainPlanForUnit(unit.id()); - } - }); - - this.advanceMotionPlannedUnits(gu.tick); - this.rebuildMotionPlannedUnitIdsCacheIfDirty(); - } - - private advanceMotionPlannedUnits(currentTick: Tick): void { - for (const [unitId, plan] of this.unitMotionPlans) { - const unit = this._units.get(unitId); - if (!unit || !unit.isActive()) { - if (this.unitMotionPlans.delete(unitId)) { - this.markMotionPlannedUnitIdsDirty(); - } - continue; - } - - const oldTile = unit.tile(); - const dt = currentTick - plan.startTick; - const stepIndex = - dt <= 0 ? 0 : Math.floor(dt / Math.max(1, plan.ticksPerStep)); - const lastIndex = plan.path.length - 1; - const idx = Math.max(0, Math.min(lastIndex, stepIndex)); - const newTile = plan.path[idx] as TileRef; - - if (newTile !== oldTile) { - unit.applyDerivedPosition(newTile); - this.unitGrid.updateUnitCell(unit); - continue; - } - - // Once a plan is past its final step, `newTile` remains clamped to the last path tile. - // Drop finished plans to avoid repeatedly marking static units as updated each tick. - if (dt > 0 && stepIndex >= lastIndex) { - if (this.unitMotionPlans.delete(unitId)) { - this.markMotionPlannedUnitIdsDirty(); - } - } - } - - this.advanceTrainMotionPlannedUnits(currentTick); - } - - private clearTrainPlanForUnit(unitId: number): void { - const engineId = - this.trainUnitToEngine.get(unitId) ?? - (this.trainMotionPlans.has(unitId) ? unitId : null); - if (engineId === null) { - return; - } - const plan = this.trainMotionPlans.get(engineId); - if (!plan) { - this.trainUnitToEngine.delete(unitId); - return; - } - if (this.trainMotionPlans.delete(engineId)) { - this.markMotionPlannedUnitIdsDirty(); - } - this.trainUnitToEngine.delete(engineId); - for (let i = 0; i < plan.carUnitIds.length; i++) { - const id = plan.carUnitIds[i] >>> 0; - if (id !== 0) this.trainUnitToEngine.delete(id); - } - } - - private advanceTrainMotionPlannedUnits(currentTick: Tick): void { - const staleEngineIds: number[] = []; - for (const [engineId, plan] of this.trainMotionPlans) { - const engine = this._units.get(engineId); - if (!engine || !engine.isActive()) { - staleEngineIds.push(engineId); - continue; - } - - const steps = currentTick - plan.lastAdvancedTick; - if (steps <= 0) { - continue; - } - - const path = plan.path; - const lastIndex = path.length - 1; - const cap = plan.usedTilesBuf.length; - - const pushUsed = (tile: TileRef) => { - if (cap === 0) return; - if (plan.usedLen < cap) { - const idx = (plan.usedHead + plan.usedLen) % cap; - plan.usedTilesBuf[idx] = tile >>> 0; - plan.usedLen++; - } else { - plan.usedTilesBuf[plan.usedHead] = tile >>> 0; - plan.usedHead = (plan.usedHead + 1) % cap; - plan.usedLen = cap; - } - }; - - const usedGet = (index: number): TileRef | null => { - if (index < 0 || index >= plan.usedLen || cap === 0) return null; - const idx = (plan.usedHead + index) % cap; - return plan.usedTilesBuf[idx] as TileRef; - }; - - let didMove = false; - for (let step = 0; step < steps; step++) { - const cursor = plan.cursor; - if (cursor >= lastIndex) { - break; - } - for (let i = 0; i < plan.speed && cursor + i < path.length; i++) { - pushUsed(path[cursor + i] as TileRef); - } - - plan.cursor = Math.min(lastIndex, cursor + plan.speed); - - for (let i = plan.carUnitIds.length - 1; i >= 0; --i) { - const carId = plan.carUnitIds[i] >>> 0; - if (carId === 0) continue; - const car = this._units.get(carId); - if (!car || !car.isActive()) { - continue; - } - const carTileIndex = (i + 1) * plan.spacing + 2; - const tile = usedGet(carTileIndex); - if (tile !== null) { - const oldTile = car.tile(); - if (tile !== oldTile) { - car.applyDerivedPosition(tile); - this.unitGrid.updateUnitCell(car); - didMove = true; - } - } - } - - const newEngineTile = path[plan.cursor] as TileRef; - const oldEngineTile = engine.tile(); - if (newEngineTile !== oldEngineTile) { - engine.applyDerivedPosition(newEngineTile); - this.unitGrid.updateUnitCell(engine); - didMove = true; - } - } - - plan.lastAdvancedTick = currentTick; - - // Preserve the final-step redraw (plan remains for the tick where motion ends), - // then clear once the train has settled and no longer moves. - // Note: trains are currently deleted at the end of TrainExecution, and the ensuing - // `Unit` update (isActive=false) also clears any associated motion plan records. - // This expiry is defensive to avoid keeping stale plans around if that behavior changes. - if (!didMove && plan.cursor >= lastIndex) { - staleEngineIds.push(engineId); - } - } - - for (const engineId of staleEngineIds) { - this.clearTrainPlanForUnit(engineId); - } - } - - private applyMotionPlanRecords(records: readonly MotionPlanRecord[]): void { - for (const record of records) { - switch (record.kind) { - case "grid": { - if (record.ticksPerStep < 1 || record.path.length < 1) { - break; - } - const existing = this.unitMotionPlans.get(record.unitId); - if (existing && record.planId <= existing.planId) { - break; - } - - const path = - record.path instanceof Uint32Array - ? record.path - : Uint32Array.from(record.path); - - this.unitMotionPlans.set(record.unitId, { - planId: record.planId, - startTick: record.startTick, - ticksPerStep: record.ticksPerStep, - path, - }); - this.markMotionPlannedUnitIdsDirty(); - break; - } - case "train": { - if (record.speed < 1 || record.path.length < 1) { - break; - } - const existing = this.trainMotionPlans.get(record.engineUnitId); - if (existing && record.planId <= existing.planId) { - break; - } - if (existing) { - this.clearTrainPlanForUnit(record.engineUnitId); - } - - const carUnitIds = - record.carUnitIds instanceof Uint32Array - ? record.carUnitIds - : Uint32Array.from(record.carUnitIds); - const path = - record.path instanceof Uint32Array - ? record.path - : Uint32Array.from(record.path); - - const usedCap = carUnitIds.length * record.spacing + 3; - const usedTilesBuf = new Uint32Array(Math.max(0, usedCap)); - - this.trainMotionPlans.set(record.engineUnitId, { - planId: record.planId, - startTick: record.startTick, - speed: record.speed, - spacing: record.spacing, - carUnitIds, - path, - cursor: 0, - usedTilesBuf, - usedHead: 0, - usedLen: 0, - lastAdvancedTick: record.startTick, - }); - this.markMotionPlannedUnitIdsDirty(); - - this.trainUnitToEngine.set(record.engineUnitId, record.engineUnitId); - for (let i = 0; i < carUnitIds.length; i++) { - const carId = carUnitIds[i] >>> 0; - if (carId !== 0) - this.trainUnitToEngine.set(carId, record.engineUnitId); - } - break; - } - } - } - } - - recentlyUpdatedTiles(): TileRef[] { - return this.updatedTiles; - } - - recentlyUpdatedTerrainTiles(): TileRef[] { - return this.updatedTerrainTiles; - } - - nearbyUnits( - tile: TileRef, - searchRange: number, - types: UnitType | readonly UnitType[], - predicate?: UnitPredicate, - ): Array<{ unit: UnitView; distSquared: number }> { - return this.unitGrid.nearbyUnits( - tile, - searchRange, - types, - predicate, - ) as Array<{ - unit: UnitView; - distSquared: number; - }>; - } - - hasUnitNearby( - tile: TileRef, - searchRange: number, - type: UnitType, - playerId?: PlayerID, - includeUnderConstruction?: boolean, - ) { - return this.unitGrid.hasUnitNearby( - tile, - searchRange, - type, - playerId, - includeUnderConstruction, - ); - } - - anyUnitNearby( - tile: TileRef, - searchRange: number, - types: readonly UnitType[], - predicate: (unit: UnitView) => boolean, - playerId?: PlayerID, - includeUnderConstruction?: boolean, - ): boolean { - return this.unitGrid.anyUnitNearby( - tile, - searchRange, - types, - predicate as (unit: Unit | UnitView) => boolean, - playerId, - includeUnderConstruction, - ); - } - - myClientID(): ClientID | undefined { - return this._myClientID; - } - - myPlayer(): PlayerView | null { - return this._myPlayer; - } - - player(id: PlayerID): PlayerView { - const player = this._players.get(id); - if (player === undefined) { - throw Error(`player id ${id} not found`); - } - return player; - } - - players(): PlayerView[] { - return Array.from(this._players.values()); - } - - playerBySmallID(id: number): PlayerView | TerraNullius { - if (id === 0) { - return new TerraNulliusImpl(); - } - const playerId = this.smallIDToID.get(id); - if (playerId === undefined) { - throw new Error(`small id ${id} not found`); - } - return this.player(playerId); - } - - playerByClientID(id: ClientID): PlayerView | null { - const player = - Array.from(this._players.values()).filter( - (p) => p.clientID() === id, - )[0] ?? null; - if (player === null) { - return null; - } - return player; - } - hasPlayer(id: PlayerID): boolean { - return false; - } - playerViews(): PlayerView[] { - return Array.from(this._players.values()); - } - - owner(tile: TileRef): PlayerView | TerraNullius { - return this.playerBySmallID(this.ownerID(tile)); - } - - ticks(): Tick { - if (this.lastUpdate === null) return 0; - return this.lastUpdate.tick; - } - inSpawnPhase(): boolean { - return this.startTick === null; - } - - isSpawnImmunityActive(): boolean { - return ( - this.inSpawnPhase() || - this.ticksSinceStart() < this._config.spawnImmunityDuration() - ); - } - isNationSpawnImmunityActive(): boolean { - return ( - this.inSpawnPhase() || - this.ticksSinceStart() < this._config.nationSpawnImmunityDuration() - ); - } - - elapsedGameSeconds(): number { - return this.ticksSinceStart() / 10; - } - - ticksSinceStart(): Tick { - if (this.inSpawnPhase()) { - return 0; - } - - return Math.max(0, this.ticks() - this.startTick!); - } - config(): Config { - return this._config; - } - units(...types: UnitType[]): UnitView[] { - if (types.length === 0) { - return Array.from(this._units.values()).filter((u) => u.isActive()); - } - return Array.from(this._units.values()).filter( - (u) => u.isActive() && types.includes(u.type()), - ); - } - unit(id: number): UnitView | undefined { - return this._units.get(id); - } - unitInfo(type: UnitType): UnitInfo { - return this._config.unitInfo(type); - } - - ref(x: number, y: number): TileRef { - return this._map.ref(x, y); - } - isValidRef(ref: TileRef): boolean { - return this._map.isValidRef(ref); - } - x(ref: TileRef): number { - return this._map.x(ref); - } - y(ref: TileRef): number { - return this._map.y(ref); - } - cell(ref: TileRef): Cell { - return this._map.cell(ref); - } - width(): number { - return this._map.width(); - } - height(): number { - return this._map.height(); - } - numLandTiles(): number { - return this._map.numLandTiles(); - } - isValidCoord(x: number, y: number): boolean { - return this._map.isValidCoord(x, y); - } - isLand(ref: TileRef): boolean { - return this._map.isLand(ref); - } - isOceanShore(ref: TileRef): boolean { - return this._map.isOceanShore(ref); - } - isOcean(ref: TileRef): boolean { - return this._map.isOcean(ref); - } - isShoreline(ref: TileRef): boolean { - return this._map.isShoreline(ref); - } - magnitude(ref: TileRef): number { - return this._map.magnitude(ref); - } - terrainByte(ref: TileRef): number { - return this._map.terrainByte(ref); - } - setWater(ref: TileRef): void { - this._map.setWater(ref); - } - setShorelineBit(ref: TileRef): void { - this._map.setShorelineBit(ref); - } - clearShorelineBit(ref: TileRef): void { - this._map.clearShorelineBit(ref); - } - setOcean(ref: TileRef): void { - this._map.setOcean(ref); - } - setMagnitude(ref: TileRef, value: number): void { - this._map.setMagnitude(ref, value); - } - ownerID(ref: TileRef): number { - return this._map.ownerID(ref); - } - hasOwner(ref: TileRef): boolean { - return this._map.hasOwner(ref); - } - setOwnerID(ref: TileRef, playerId: number): void { - return this._map.setOwnerID(ref, playerId); - } - hasFallout(ref: TileRef): boolean { - return this._map.hasFallout(ref); - } - setFallout(ref: TileRef, value: boolean): void { - return this._map.setFallout(ref, value); - } - isBorder(ref: TileRef): boolean { - return this._map.isBorder(ref); - } - neighbors(ref: TileRef): TileRef[] { - return this._map.neighbors(ref); - } - isWater(ref: TileRef): boolean { - return this._map.isWater(ref); - } - isLake(ref: TileRef): boolean { - return this._map.isLake(ref); - } - isShore(ref: TileRef): boolean { - return this._map.isShore(ref); - } - cost(ref: TileRef): number { - return this._map.cost(ref); - } - terrainType(ref: TileRef): TerrainType { - return this._map.terrainType(ref); - } - forEachTile(fn: (tile: TileRef) => void): void { - return this._map.forEachTile(fn); - } - manhattanDist(c1: TileRef, c2: TileRef): number { - return this._map.manhattanDist(c1, c2); - } - euclideanDistSquared(c1: TileRef, c2: TileRef): number { - return this._map.euclideanDistSquared(c1, c2); - } - circleSearch( - tile: TileRef, - radius: number, - filter?: (tile: TileRef, d2: number) => boolean, - ): Set { - return this._map.circleSearch(tile, radius, filter); - } - bfs( - tile: TileRef, - filter: (gm: GameMap, tile: TileRef) => boolean, - ): Set { - return this._map.bfs(tile, filter); - } - tileState(tile: TileRef): number { - return this._map.tileState(tile); - } - updateTile(tile: TileRef, state: number): boolean { - return this._map.updateTile(tile, state); - } - numTilesWithFallout(): number { - return this._map.numTilesWithFallout(); - } - gameID(): GameID { - return this._gameID; - } - - focusedPlayer(): PlayerView | null { - return this.myPlayer(); - } -} +// Back-compat re-export shim. +// The view classes physically live in src/client/view/ — this re-export keeps +// the older `import { GameView } from "src/core/game/GameView"` path working. +// +// TODO: remove this shim once all 50+ importers have been updated to point at +// src/client/view/ directly, and the 6 core files that reference PlayerView / +// UnitView / GameView as union types (Player | PlayerView etc.) are refactored +// to use Player / Unit / Game interfaces instead. + +export { GameView } from "../../client/view/GameView"; +export { PlayerView } from "../../client/view/PlayerView"; +export { UnitView } from "../../client/view/UnitView"; diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index 0fc31b6a48..7e1f169f73 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -11,6 +11,7 @@ import { const HASHED_PUBLIC_ASSET_GLOBS = [ "changelog.md", "manifest.json", + "atlases/**/*", "cosmetics/**/*", "flags/**/*", "fonts/**/*", diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 3da402e6f2..f0a8f2e047 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -6,7 +6,7 @@ import { WarshipSelectionBoxCompleteEvent, WarshipSelectionBoxUpdateEvent, } from "../src/client/InputHandler"; -import { UIState } from "../src/client/graphics/UIState"; +import { UIState } from "../src/client/UIState"; import { EventBus } from "../src/core/EventBus"; import { UnitType } from "../src/core/game/Game"; import { GameView, PlayerView } from "../src/core/game/GameView"; @@ -928,13 +928,10 @@ describe("Warship box selection (Shift+drag)", () => { test("Shift keydown discards active ghostStructure", () => { uiState.ghostStructure = UnitType.Warship; - const emitSpy = vi.spyOn(eventBus, "emit"); window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" })); expect(uiState.ghostStructure).toBeNull(); - const types = emitSpy.mock.calls.map((c) => c[0].constructor.name); - expect(types).toContain("GhostStructureChangedEvent"); }); test("Shift+drag emits WarshipSelectionBoxUpdateEvent", () => { diff --git a/tests/client/graphics/layers/StructureIconsLayer.test.ts b/tests/client/controllers/BuildPreviewController.test.ts similarity index 70% rename from tests/client/graphics/layers/StructureIconsLayer.test.ts rename to tests/client/controllers/BuildPreviewController.test.ts index 7cb8b557cc..3dafc54695 100644 --- a/tests/client/graphics/layers/StructureIconsLayer.test.ts +++ b/tests/client/controllers/BuildPreviewController.test.ts @@ -1,14 +1,8 @@ import { describe, expect, test } from "vitest"; -import { shouldPreserveGhostAfterBuild } from "../../../../src/client/graphics/layers/StructureIconsLayer"; -import { UnitType } from "../../../../src/core/game/Game"; +import { shouldPreserveGhostAfterBuild } from "../../../src/client/controllers/BuildPreviewController"; +import { UnitType } from "../../../src/core/game/Game"; -/** - * Tests for StructureIconsLayer edge cases mentioned in comments: - * - Locked nuke / AtomBomb / HydrogenBomb: when confirming placement (Enter or key), - * the ghost is preserved so the user can place multiple nukes or keep the nuke - * selected. Other structure types clear the ghost after placement. - */ -describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", () => { +describe("BuildPreviewController ghost preservation (locked nuke / Enter confirm)", () => { describe("shouldPreserveGhostAfterBuild", () => { test("returns true for AtomBomb so ghost is not cleared after placement", () => { expect(shouldPreserveGhostAfterBuild(UnitType.AtomBomb)).toBe(true); diff --git a/tests/client/controllers/WarshipSelectionController.test.ts b/tests/client/controllers/WarshipSelectionController.test.ts new file mode 100644 index 0000000000..a28aef4a18 --- /dev/null +++ b/tests/client/controllers/WarshipSelectionController.test.ts @@ -0,0 +1,96 @@ +import { WarshipSelectionController } from "../../../src/client/controllers/WarshipSelectionController"; +import { UnitSelectionEvent } from "../../../src/client/InputHandler"; + +describe("WarshipSelectionController", () => { + let game: any; + let eventBus: any; + let transformHandler: any; + let view: any; + + beforeEach(() => { + game = { + width: () => 100, + height: () => 100, + config: () => ({ + theme: () => ({ + territoryColor: () => ({ + lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }), + }), + }), + }), + x: () => 10, + y: () => 10, + unitInfo: () => ({ maxHealth: 10, constructionDuration: 5 }), + myPlayer: () => ({ id: () => 1 }), + ticks: () => 1, + updatesSinceLastTick: () => undefined, + }; + eventBus = { on: vi.fn() }; + transformHandler = {}; + view = { setSelectedUnits: vi.fn() }; + }); + + it("tracks the selected unit on single-unit selection (rendering is WebGL)", () => { + const ui = new WarshipSelectionController( + game, + eventBus, + transformHandler, + view, + ); + const unit = { + id: () => 1, + type: () => "Warship", + isActive: () => true, + tile: () => ({}), + owner: () => ({}), + }; + const event = { isSelected: true, unit }; + ui["onUnitSelection"](event as UnitSelectionEvent); + // selectedUnit is held for game-logic callers (the click handlers). The + // visual selection box is drawn by WebGL SelectionBoxPass — wired from + // ClientGameRunner via view.setSelectedUnits([unit.id()]). + expect(ui["selectedUnit"]).toBe(unit); + }); + + it("clears selection on deselect", () => { + const ui = new WarshipSelectionController( + game, + eventBus, + transformHandler, + view, + ); + const unit = { + id: () => 1, + type: () => "Warship", + isActive: () => true, + tile: () => ({}), + owner: () => ({}), + }; + ui["onUnitSelection"]({ isSelected: true, unit } as UnitSelectionEvent); + ui["onUnitSelection"]({ + isSelected: false, + unit: null, + } as unknown as UnitSelectionEvent); + expect(ui["selectedUnit"]).toBeNull(); + }); + + it("tracks multi-selection list", () => { + const ui = new WarshipSelectionController( + game, + eventBus, + transformHandler, + view, + ); + const units = [ + { id: () => 1, isActive: () => true }, + { id: () => 2, isActive: () => true }, + ]; + ui["onUnitSelection"]({ + isSelected: true, + unit: null, + units, + } as unknown as UnitSelectionEvent); + expect(ui["multiSelectedWarships"]).toEqual(units); + expect(ui["selectedUnit"]).toBeNull(); + }); +}); diff --git a/tests/client/graphics/ProgressBar.test.ts b/tests/client/graphics/ProgressBar.test.ts deleted file mode 100644 index 5fc845fbfb..0000000000 --- a/tests/client/graphics/ProgressBar.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ProgressBar } from "../../../src/client/graphics/ProgressBar"; - -describe("ProgressBar", () => { - let ctx: CanvasRenderingContext2D; - let canvas: HTMLCanvasElement; - - beforeEach(() => { - canvas = document.createElement("canvas"); - canvas.width = 100; - canvas.height = 20; - ctx = canvas.getContext("2d")!; - }); - - it("should initialize and draw the background", () => { - const spyClearRect = vi.spyOn(ctx, "clearRect"); - const spyFillRect = vi.spyOn(ctx, "fillRect"); - const spyFillStyle = vi.spyOn(ctx, "fillStyle", "set"); - const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 0.5); - expect(spyClearRect).toHaveBeenCalledWith(0, 0, 82, 12); - expect(spyFillRect).toHaveBeenCalledWith(1, 1, 80, 10); - expect(spyFillStyle).toHaveBeenCalledWith("#00ff00"); - expect(bar.getX()).toBe(2); - expect(bar.getY()).toBe(2); - }); - - it("should set progress and draw the progress bar", () => { - const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10); - const spyFillRect = vi.spyOn(ctx, "fillRect"); - bar.setProgress(0.5); - expect(bar.getProgress()).toBe(0.5); - expect(spyFillRect).toHaveBeenCalledWith( - 2, - 2, - Math.floor(0.5 * (80 - 2)), - 8, - ); - expect(ctx.fillStyle).toBe("#00ff00"); - - bar.setProgress(0.1); - expect(ctx.fillStyle).toBe("#ff0000"); - }); - - it("should clamp progress between 0 and 1 on init", () => { - const bar = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, -1); - expect(bar.getProgress()).toBe(0); - const bar2 = new ProgressBar(["#ff0000", "#00ff00"], ctx, 2, 2, 80, 10, 2); - expect(bar2.getProgress()).toBe(1); - }); - - it("should handle empty colors array gracefully", () => { - const bar = new ProgressBar([], ctx, 2, 2, 80, 10, 0.5); - expect(() => bar.setProgress(0.5)).not.toThrow(); - expect(ctx.fillStyle).toBe("#808080"); - }); -}); diff --git a/tests/client/graphics/UILayer.test.ts b/tests/client/graphics/UILayer.test.ts deleted file mode 100644 index ab1300ab13..0000000000 --- a/tests/client/graphics/UILayer.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { UILayer } from "../../../src/client/graphics/layers/UILayer"; -import { UnitSelectionEvent } from "../../../src/client/InputHandler"; -import { UnitView } from "../../../src/core/game/GameView"; - -describe("UILayer", () => { - let game: any; - let eventBus: any; - let transformHandler: any; - - beforeEach(() => { - game = { - width: () => 100, - height: () => 100, - config: () => ({ - theme: () => ({ - territoryColor: () => ({ - lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }), - }), - }), - }), - x: () => 10, - y: () => 10, - unitInfo: () => ({ maxHealth: 10, constructionDuration: 5 }), - myPlayer: () => ({ id: () => 1 }), - ticks: () => 1, - updatesSinceLastTick: () => undefined, - }; - eventBus = { on: vi.fn() }; - transformHandler = {}; - }); - - it("should initialize and redraw canvas", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - expect(ui["canvas"].width).toBe(100); - expect(ui["canvas"].height).toBe(100); - expect(ui["context"]).not.toBeNull(); - }); - - it("should handle unit selection event", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - type: () => "Warship", - isActive: () => true, - tile: () => ({}), - owner: () => ({}), - }; - const event = { isSelected: true, unit }; - ui.drawSelectionBox = vi.fn(); - ui["onUnitSelection"](event as UnitSelectionEvent); - expect(ui.drawSelectionBox).toHaveBeenCalledWith(unit); - }); - - it("should add and clear health bars", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 1, - type: () => "Warship", - health: () => 5, - tile: () => ({}), - owner: () => ({}), - isActive: () => true, - createdAt: () => 1, - } as unknown as UnitView; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(true); - - // a full hp unit doesn't have a health bar - unit.health = () => 10; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(false); - - // a dead unit doesn't have a health bar - unit.health = () => 5; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(true); - unit.health = () => 0; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(false); - }); - - it("should remove health bars for inactive units", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 1, - type: () => "Warship", - health: () => 5, - tile: () => ({}), - owner: () => ({}), - isActive: () => true, - } as unknown as UnitView; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(true); - - // an inactive unit doesn't have a health bar - unit.isActive = () => false; - ui.drawHealthBar(unit); - expect(ui["allHealthBars"].has(1)).toBe(false); - }); - - it("should add loading bar for unit", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 2, - tile: () => ({}), - isActive: () => true, - } as unknown as UnitView; - ui.createLoadingBar(unit); - expect(ui["allProgressBars"].has(2)).toBe(true); - }); - - it("should remove loading bar for inactive unit", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 2, - type: () => "City", - isUnderConstruction: () => true, - owner: () => ({ id: () => 1 }), - tile: () => ({}), - isActive: () => true, - } as unknown as UnitView; - ui.onUnitEvent(unit); - expect(ui["allProgressBars"].has(2)).toBe(true); - - // an inactive unit should not have a loading bar - unit.isActive = () => false; - ui.tick(); - expect(ui["allProgressBars"].has(2)).toBe(false); - }); - - it("should remove loading bar for a finished progress bar", () => { - const ui = new UILayer(game, eventBus, transformHandler); - ui.redraw(); - const unit = { - id: () => 2, - type: () => "City", - isUnderConstruction: () => true, - owner: () => ({ id: () => 1 }), - tile: () => ({}), - isActive: () => true, - createdAt: () => 1, - markedForDeletion: () => false, - } as unknown as UnitView; - ui.onUnitEvent(unit); - expect(ui["allProgressBars"].has(2)).toBe(true); - - game.ticks = () => 6; // simulate enough ticks for completion - // simulate construction finished - (unit as any).isUnderConstruction = () => false; - ui.tick(); - expect(ui["allProgressBars"].has(2)).toBe(false); - }); -}); diff --git a/tests/client/render/frame/derive/player-status.test.ts b/tests/client/render/frame/derive/player-status.test.ts new file mode 100644 index 0000000000..16a4b7ed83 --- /dev/null +++ b/tests/client/render/frame/derive/player-status.test.ts @@ -0,0 +1,334 @@ +/** + * computePlayerStatus has two modes: + * + * - Replay mode (no localPlayerID): only crown / traitor / disconnected / + * nukeActive flags are populated. All relative flags are false. + * - Live mode (localPlayerID set): also fills alliance / target / embargo, + * and nukeTargetsMe if a tileState buffer is supplied. + * + * The function only emits an entry per player when at least one flag is true + * (the NamePass treats missing entries as "all flags off"). Tests assert + * both presence and absence of entries. + */ + +import { describe, expect, it } from "vitest"; +import { computePlayerStatus } from "../../../../../src/client/render/frame/derive/PlayerStatus"; +import type { + PlayerState, + UnitState, +} from "../../../../../src/client/render/types"; +import { + UT_ATOM_BOMB, + UT_WARSHIP, +} from "../../../../../src/client/render/types"; + +function ps(overrides: Partial = {}): PlayerState { + return { + smallID: 1, + isAlive: true, + isDisconnected: false, + tilesOwned: 0, + gold: 0, + troops: 0, + isTraitor: false, + traitorRemainingTicks: 0, + betrayals: 0, + hasSpawned: true, + lastDeleteUnitTick: 0, + allies: [], + embargoes: [], + targets: [], + outgoingAttacks: [], + incomingAttacks: [], + outgoingAllianceRequests: [], + alliances: [], + outgoingEmojis: [], + ...overrides, + }; +} + +function unit(overrides: Partial = {}): UnitState { + return { + id: 1, + unitType: UT_WARSHIP, + ownerID: 1, + lastOwnerID: null, + pos: 0, + lastPos: 0, + isActive: true, + reachedTarget: false, + retreating: false, + targetable: true, + markedForDeletion: false, + health: null, + underConstruction: false, + targetUnitId: null, + targetTile: null, + troops: 0, + missileTimerQueue: [], + level: 1, + hasTrainStation: false, + trainType: null, + loaded: null, + constructionStartTick: null, + ...overrides, + }; +} + +function playersMap(...players: PlayerState[]): Map { + return new Map(players.map((p) => [p.smallID, p])); +} + +function unitsMap(...us: UnitState[]): Map { + return new Map(us.map((u) => [u.id, u])); +} + +describe("computePlayerStatus — replay mode (no localPlayerID)", () => { + it("returns empty map when no flags are set", () => { + const players = playersMap(ps({ smallID: 1 })); + const status = computePlayerStatus(players, unitsMap()); + expect(status.size).toBe(0); + }); + + it("crown goes to the alive player with most tiles owned", () => { + const players = playersMap( + ps({ smallID: 1, tilesOwned: 100 }), + ps({ smallID: 2, tilesOwned: 500 }), // king + ps({ smallID: 3, tilesOwned: 250 }), + ); + const status = computePlayerStatus(players, unitsMap()); + expect(status.get(2)?.crown).toBe(true); + // Players 1 and 3 don't have crown and no other flags → no entry emitted. + expect(status.has(1)).toBe(false); + expect(status.has(3)).toBe(false); + }); + + it("dead players don't get the crown even if they had the most tiles", () => { + const players = playersMap( + ps({ smallID: 1, tilesOwned: 1000, isAlive: false }), + ps({ smallID: 2, tilesOwned: 100 }), + ); + const status = computePlayerStatus(players, unitsMap()); + expect(status.get(2)?.crown).toBe(true); + expect(status.has(1)).toBe(false); + }); + + it("traitor + traitorRemainingTicks flow through", () => { + const players = playersMap( + ps({ smallID: 1, isTraitor: true, traitorRemainingTicks: 42 }), + ); + const status = computePlayerStatus(players, unitsMap()); + expect(status.get(1)?.traitor).toBe(true); + expect(status.get(1)?.traitorRemainingTicks).toBe(42); + }); + + it("disconnected flag flows through", () => { + const players = playersMap(ps({ smallID: 1, isDisconnected: true })); + const status = computePlayerStatus(players, unitsMap()); + expect(status.get(1)?.disconnected).toBe(true); + }); + + it("nukeActive: any in-flight nuke marks its owner", () => { + const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 })); + const units = unitsMap( + unit({ id: 10, ownerID: 2, unitType: UT_ATOM_BOMB, isActive: true }), + ); + const status = computePlayerStatus(players, units); + expect(status.get(2)?.nukeActive).toBe(true); + expect(status.has(1)).toBe(false); + }); + + it("inactive nukes don't trigger nukeActive", () => { + const players = playersMap(ps({ smallID: 1 })); + const units = unitsMap( + unit({ id: 10, ownerID: 1, unitType: UT_ATOM_BOMB, isActive: false }), + ); + const status = computePlayerStatus(players, units); + expect(status.has(1)).toBe(false); + }); + + it("relative flags (alliance/target/embargo/nukeTargetsMe) are always false in replay mode", () => { + const players = playersMap( + ps({ smallID: 1, allies: [2], targets: [2], embargoes: [2] }), + ps({ smallID: 2, tilesOwned: 1 }), // crown so an entry exists + ); + const status = computePlayerStatus(players, unitsMap()); + expect(status.get(2)?.alliance).toBe(false); + expect(status.get(2)?.target).toBe(false); + expect(status.get(2)?.embargo).toBe(false); + expect(status.get(2)?.nukeTargetsMe).toBe(false); + }); +}); + +describe("computePlayerStatus — live mode (localPlayerID set)", () => { + it("alliance: local has them as ally → alliance true", () => { + const players = playersMap( + ps({ smallID: 1, allies: [2] }), // me + ps({ smallID: 2 }), + ); + const status = computePlayerStatus(players, unitsMap(), { + localPlayerID: 1, + }); + expect(status.get(2)?.alliance).toBe(true); + }); + + it("target: local has them in targets → target true", () => { + const players = playersMap( + ps({ smallID: 1, targets: [2] }), // me + ps({ smallID: 2 }), + ); + const status = computePlayerStatus(players, unitsMap(), { + localPlayerID: 1, + }); + expect(status.get(2)?.target).toBe(true); + }); + + it("embargo is bilateral: true if I embargo them OR they embargo me", () => { + // I embargo them. + let status = computePlayerStatus( + playersMap(ps({ smallID: 1, embargoes: [2] }), ps({ smallID: 2 })), + unitsMap(), + { localPlayerID: 1 }, + ); + expect(status.get(2)?.embargo).toBe(true); + + // They embargo me. + status = computePlayerStatus( + playersMap(ps({ smallID: 1 }), ps({ smallID: 2, embargoes: [1] })), + unitsMap(), + { localPlayerID: 1 }, + ); + expect(status.get(2)?.embargo).toBe(true); + + // Neither. + status = computePlayerStatus( + playersMap(ps({ smallID: 1 }), ps({ smallID: 2, tilesOwned: 1 })), + unitsMap(), + { localPlayerID: 1 }, + ); + // Player 2 only has crown — embargo should be false. + expect(status.get(2)?.embargo).toBe(false); + }); + + it("relative flags are NOT set for the local player itself (no self-relationships)", () => { + const players = playersMap( + ps({ + smallID: 1, + tilesOwned: 100, + allies: [1], + targets: [1], + embargoes: [1], + }), + ps({ smallID: 2 }), + ); + const status = computePlayerStatus(players, unitsMap(), { + localPlayerID: 1, + }); + // Player 1 (local) gets crown but no relative flags vs. self. + expect(status.get(1)?.crown).toBe(true); + expect(status.get(1)?.alliance).toBe(false); + expect(status.get(1)?.target).toBe(false); + expect(status.get(1)?.embargo).toBe(false); + }); + + it("nukeTargetsMe: requires tileState — without it, stays false", () => { + const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 })); + const units = unitsMap( + unit({ + id: 10, + ownerID: 2, + unitType: UT_ATOM_BOMB, + isActive: true, + targetTile: 5, + }), + ); + const status = computePlayerStatus(players, units, { localPlayerID: 1 }); + expect(status.get(2)?.nukeActive).toBe(true); + expect(status.get(2)?.nukeTargetsMe).toBe(false); + }); + + it("nukeTargetsMe: true when nuke targets a tile owned by local player", () => { + const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 })); + const units = unitsMap( + unit({ + id: 10, + ownerID: 2, + unitType: UT_ATOM_BOMB, + isActive: true, + targetTile: 5, + }), + ); + // tileState[5] low 12 bits = 1 (local player's smallID). + const tileState = new Uint16Array(16); + tileState[5] = 1; + + const status = computePlayerStatus(players, units, { + localPlayerID: 1, + tileState, + }); + expect(status.get(2)?.nukeTargetsMe).toBe(true); + }); + + it("nukeTargetsMe: false when nuke targets a tile owned by someone else", () => { + const players = playersMap( + ps({ smallID: 1 }), + ps({ smallID: 2 }), + ps({ smallID: 3 }), + ); + const units = unitsMap( + unit({ + id: 10, + ownerID: 2, + unitType: UT_ATOM_BOMB, + isActive: true, + targetTile: 5, + }), + ); + // tileState[5] = player 3, not me. + const tileState = new Uint16Array(16); + tileState[5] = 3; + + const status = computePlayerStatus(players, units, { + localPlayerID: 1, + tileState, + }); + expect(status.get(2)?.nukeTargetsMe).toBe(false); + }); + + it("entry is emitted when only a relative flag is true (even with no base flags)", () => { + const players = playersMap( + ps({ smallID: 1, allies: [2] }), // me + ps({ smallID: 2 }), // no other flags + ); + const status = computePlayerStatus(players, unitsMap(), { + localPlayerID: 1, + }); + // Without local-mode, player 2 wouldn't get an entry — alliance is the + // only reason it shows up here. + expect(status.get(2)).toBeDefined(); + expect(status.get(2)?.alliance).toBe(true); + }); + + it("localPlayerID = 0 (no local player) behaves like replay mode", () => { + const players = playersMap( + ps({ smallID: 1, allies: [2] }), + ps({ smallID: 2, tilesOwned: 1 }), + ); + const status = computePlayerStatus(players, unitsMap(), { + localPlayerID: 0, + }); + expect(status.get(2)?.alliance).toBe(false); + }); + + it("allianceReq and allianceFraction are not computed (deferred)", () => { + const players = playersMap( + ps({ smallID: 1, allies: [2] }), + ps({ smallID: 2 }), + ); + const status = computePlayerStatus(players, unitsMap(), { + localPlayerID: 1, + }); + expect(status.get(2)?.allianceReq).toBe(false); + expect(status.get(2)?.allianceFraction).toBe(0); + }); +}); diff --git a/tests/client/view/GameView.test.ts b/tests/client/view/GameView.test.ts new file mode 100644 index 0000000000..39347d4d89 --- /dev/null +++ b/tests/client/view/GameView.test.ts @@ -0,0 +1,474 @@ +/** + * GameView is the client-side simulation mirror — it accumulates player / + * unit / tile state from per-tick GameUpdateViewData. The FrameBuilder reads + * the same accessors (players(), units(), tileStateBuffer(), + * recentlyUpdatedTiles()) to translate state into FrameData each tick. + * + * These tests verify the update lifecycle: PlayerView reuse vs creation, + * UnitView lifecycle (create / mutate / mark for deletion / sweep next tick), + * smallID lookup, tick tracking, and tile delta accumulation. + */ + +import { describe, expect, it } from "vitest"; +import { UnitType } from "../../../src/core/game/Game"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import { + makeEmptyGu, + makeGameView, + makeNameViewData, + makePlayerUpdate, + makeUnitUpdate, +} from "../../util/viewStubs"; + +function withPlayers( + tick: number, + players: ReturnType[], + nameDataMap: Record> = {}, +) { + const gu = makeEmptyGu(tick); + gu.updates[GameUpdateType.Player] = players; + for (const p of players) { + gu.playerNameViewData[p.id] = nameDataMap[p.id] ?? makeNameViewData(); + } + return gu; +} + +describe("GameView.update — players", () => { + it("creates a PlayerView for each player in the first tick", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1, name: "Alice" }), + makePlayerUpdate({ id: "bob", smallID: 2, name: "Bob" }), + ]), + ); + expect(game.players().map((p) => p.id())).toEqual(["alice", "bob"]); + }); + + it("reuses an existing PlayerView on subsequent updates (in-place data swap)", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1, troops: 100 }), + ]), + ); + const first = game.player("alice"); + + game.update( + withPlayers(2, [ + makePlayerUpdate({ id: "alice", smallID: 1, troops: 250 }), + ]), + ); + const second = game.player("alice"); + + expect(second).toBe(first); // same PlayerView instance + expect(second.troops()).toBe(250); // data was swapped in + }); + + it("playerBySmallID resolves through the smallID → PlayerID map", () => { + const game = makeGameView(); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "alice", smallID: 1 }), + makePlayerUpdate({ id: "bob", smallID: 2 }), + ]), + ); + expect( + (game.playerBySmallID(1) as ReturnType).id(), + ).toBe("alice"); + expect( + (game.playerBySmallID(2) as ReturnType).id(), + ).toBe("bob"); + }); + + it("playerBySmallID(0) returns a TerraNullius (used as the unowned-tile owner)", () => { + const game = makeGameView(); + const terra = game.playerBySmallID(0); + expect(terra.isPlayer()).toBe(false); + }); + + it("myPlayer() is resolved once the local player update arrives", () => { + const game = makeGameView({ myClientID: "c-me" }); + expect(game.myPlayer()).toBeNull(); + + game.update( + withPlayers(1, [ + makePlayerUpdate({ + id: "me", + smallID: 1, + clientID: "c-me", + name: "Me", + }), + ]), + ); + expect(game.myPlayer()?.id()).toBe("me"); + }); + + it("myPlayer() is cached — does not change identity across updates", () => { + const game = makeGameView({ myClientID: "c-me" }); + game.update( + withPlayers(1, [ + makePlayerUpdate({ id: "me", smallID: 1, clientID: "c-me" }), + ]), + ); + const first = game.myPlayer(); + game.update( + withPlayers(2, [ + makePlayerUpdate({ id: "me", smallID: 1, clientID: "c-me" }), + ]), + ); + expect(game.myPlayer()).toBe(first); + }); + + it("local player's name is overridden with myUsername to bypass censorship", () => { + const game = makeGameView({ + myClientID: "c-me", + myUsername: "RealName", + }); + game.update( + withPlayers(1, [ + makePlayerUpdate({ + id: "me", + smallID: 1, + clientID: "c-me", + name: "ServerName", + displayName: "ServerName", + }), + ]), + ); + expect(game.myPlayer()?.name()).toBe("RealName"); + }); +}); + +describe("GameView.update — units", () => { + it("creates a UnitView on first sighting and reuses it after", () => { + const game = makeGameView(); + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 42, pos: 0 })]; + game.update(gu1); + const first = game.unit(42); + expect(first).toBeDefined(); + + const gu2 = makeEmptyGu(2); + gu2.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 42, pos: 1 })]; + game.update(gu2); + expect(game.unit(42)).toBe(first); // same instance + expect(game.unit(42)?.tile()).toBe(1); + }); + + it("units() filters by type and returns only active units", () => { + const game = makeGameView(); + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 1, unitType: UnitType.City, isActive: true }), + makeUnitUpdate({ id: 2, unitType: UnitType.Port, isActive: true }), + makeUnitUpdate({ id: 3, unitType: UnitType.City, isActive: false }), + ]; + game.update(gu); + + expect( + game + .units() + .map((u) => u.id()) + .sort(), + ).toEqual([1, 2]); + expect(game.units(UnitType.City).map((u) => u.id())).toEqual([1]); + // The inactive one is still present until the NEXT tick sweeps it. + expect(game.unit(3)).toBeDefined(); + }); + + it("inactive units are deleted on the following tick", () => { + const game = makeGameView(); + + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 7, isActive: true }), + ]; + game.update(gu1); + expect(game.unit(7)).toBeDefined(); + + const gu2 = makeEmptyGu(2); + gu2.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 7, isActive: false }), + ]; + game.update(gu2); + // Still present on the tick they died (renderer can see deadUnit FX). + expect(game.unit(7)).toBeDefined(); + + const gu3 = makeEmptyGu(3); + game.update(gu3); + // Swept on the next tick. + expect(game.unit(7)).toBeUndefined(); + }); + + it("_wasUpdated resets to false at start of tick, then flips back on update", () => { + const game = makeGameView(); + + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 5 })]; + game.update(gu1); + expect(game.unit(5)?.wasUpdated()).toBe(true); + + // Next tick — unit not in updates → wasUpdated should be false + game.update(makeEmptyGu(2)); + expect(game.unit(5)?.wasUpdated()).toBe(false); + + // Next tick — unit reappears → wasUpdated true again + const gu3 = makeEmptyGu(3); + gu3.updates[GameUpdateType.Unit] = [makeUnitUpdate({ id: 5 })]; + game.update(gu3); + expect(game.unit(5)?.wasUpdated()).toBe(true); + }); +}); + +describe("GameView.update — tile deltas", () => { + it("recentlyUpdatedTiles() reflects refs in packedTileUpdates", () => { + const game = makeGameView({ width: 4, height: 4 }); + const gu = makeEmptyGu(1); + // packedTileUpdates is [tileRef, packedState, tileRef, packedState, ...] + // packed state = (terrainByte << 16) | state — use 0 for both to keep tile + // terrain-stable; we're just exercising the delta accumulator. + gu.packedTileUpdates = new Uint32Array([2, 0, 5, 0, 9, 0]); + game.update(gu); + expect(game.recentlyUpdatedTiles().sort((a, b) => a - b)).toEqual([ + 2, 5, 9, + ]); + }); + + it("recentlyUpdatedTerrainTiles() only includes refs where terrain bytes changed", () => { + const game = makeGameView({ width: 4, height: 4 }); + // Tile 3 starts with terrain byte 0. Pack a new terrain byte (0x80 = land) + // for tile 3, and an unchanged terrain (0) for tile 7. + const gu = makeEmptyGu(1); + const TILE_3_PACKED = (0x80 << 16) | 0; // terrain changed + const TILE_7_PACKED = 0; // terrain unchanged + gu.packedTileUpdates = new Uint32Array([ + 3, + TILE_3_PACKED, + 7, + TILE_7_PACKED, + ]); + game.update(gu); + expect(game.recentlyUpdatedTiles().sort((a, b) => a - b)).toEqual([3, 7]); + expect(game.recentlyUpdatedTerrainTiles()).toEqual([3]); + }); + + it("resets deltas to empty arrays each tick", () => { + const game = makeGameView({ width: 4, height: 4 }); + const gu1 = makeEmptyGu(1); + gu1.packedTileUpdates = new Uint32Array([1, 0]); + game.update(gu1); + expect(game.recentlyUpdatedTiles().length).toBe(1); + + // Empty next tick → empty deltas + game.update(makeEmptyGu(2)); + expect(game.recentlyUpdatedTiles()).toEqual([]); + expect(game.recentlyUpdatedTerrainTiles()).toEqual([]); + }); +}); + +describe("GameView.update — tick & lifecycle", () => { + it("ticks() reflects the last update's tick", () => { + const game = makeGameView(); + expect(game.ticks()).toBe(0); // before any update + game.update(makeEmptyGu(42)); + expect(game.ticks()).toBe(42); + game.update(makeEmptyGu(43)); + expect(game.ticks()).toBe(43); + }); + + it("inSpawnPhase() is true until a SpawnPhaseEnd update flips it off", () => { + const game = makeGameView(); + expect(game.inSpawnPhase()).toBe(true); + game.update(makeEmptyGu(5)); + expect(game.inSpawnPhase()).toBe(true); + + const gu = makeEmptyGu(10); + gu.updates[GameUpdateType.SpawnPhaseEnd] = [ + { type: GameUpdateType.SpawnPhaseEnd, startTick: 10 } as ReturnType< + typeof makeEmptyGu + >["updates"][typeof GameUpdateType.SpawnPhaseEnd][number], + ]; + game.update(gu); + expect(game.inSpawnPhase()).toBe(false); + }); + + it("ticksSinceStart returns 0 during spawn phase, otherwise difference from startTick", () => { + const game = makeGameView(); + expect(game.ticksSinceStart()).toBe(0); // spawn phase + + const gu1 = makeEmptyGu(10); + gu1.updates[GameUpdateType.SpawnPhaseEnd] = [ + { type: GameUpdateType.SpawnPhaseEnd, startTick: 10 } as ReturnType< + typeof makeEmptyGu + >["updates"][typeof GameUpdateType.SpawnPhaseEnd][number], + ]; + game.update(gu1); + expect(game.ticksSinceStart()).toBe(0); // tick=10, start=10 + + game.update(makeEmptyGu(15)); + expect(game.ticksSinceStart()).toBe(5); + }); +}); + +describe("GameView — accessors used by FrameBuilder", () => { + it("width() / height() forward to the underlying map", () => { + const game = makeGameView({ width: 12, height: 8 }); + expect(game.width()).toBe(12); + expect(game.height()).toBe(8); + }); + + it("tileStateBuffer() returns a Uint16Array of width*height", () => { + const game = makeGameView({ width: 5, height: 4 }); + const buf = game.tileStateBuffer(); + expect(buf).toBeInstanceOf(Uint16Array); + expect(buf.length).toBe(20); + }); + + it("tileStateBuffer() is a live reference — mutated by update()", () => { + const game = makeGameView({ width: 4, height: 4 }); + const buf = game.tileStateBuffer(); + const gu = makeEmptyGu(1); + // Pack an owner ID into the low 12 bits of state for tile 6. + gu.packedTileUpdates = new Uint32Array([6, 0x123]); + game.update(gu); + expect(buf[6] & 0xfff).toBe(0x123); + }); + + it("player(id) throws for unknown players (matches FrameBuilder's expectation)", () => { + const game = makeGameView(); + expect(() => game.player("unknown")).toThrow(); + }); + + it("config() returns the same Config instance passed in", () => { + const game = makeGameView(); + expect(game.config()).toBe(game.config()); + }); +}); + +describe("GameView.frameData() — renderer contract", () => { + it("returns a stable object reference across ticks", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + const f1 = game.frameData(); + game.update(makeEmptyGu(2)); + const f2 = game.frameData(); + expect(f2).toBe(f1); + }); + + it("frame.tileState is === gameView.tileStateBuffer() (zero-copy)", () => { + const game = makeGameView({ width: 4, height: 4 }); + game.update(makeEmptyGu(1)); + expect(game.frameData().tileState).toBe(game.tileStateBuffer()); + }); + + it("frame.changedTiles is null on the first populate (signals full upload)", () => { + const game = makeGameView({ width: 4, height: 4 }); + const gu1 = makeEmptyGu(1); + gu1.packedTileUpdates = new Uint32Array([1, 0, 2, 0]); + game.update(gu1); + expect(game.frameData().changedTiles).toBeNull(); + }); + + it("frame.changedTiles becomes a delta array on subsequent populates", () => { + const game = makeGameView({ width: 4, height: 4 }); + game.update(makeEmptyGu(1)); + + const gu2 = makeEmptyGu(2); + gu2.packedTileUpdates = new Uint32Array([3, 0, 5, 0, 9, 0]); + game.update(gu2); + const ct = game.frameData().changedTiles; + expect(ct).not.toBeNull(); + expect(ct!.map((t) => t.ref).sort((a, b) => a - b)).toEqual([3, 5, 9]); + }); + + it("changedTiles scratch array is reused across ticks (no per-tick alloc)", () => { + const game = makeGameView({ width: 4, height: 4 }); + game.update(makeEmptyGu(1)); // first populate (changedTiles = null) + const gu2 = makeEmptyGu(2); + gu2.packedTileUpdates = new Uint32Array([1, 0]); + game.update(gu2); + const ct1 = game.frameData().changedTiles; + + const gu3 = makeEmptyGu(3); + gu3.packedTileUpdates = new Uint32Array([2, 0]); + game.update(gu3); + const ct2 = game.frameData().changedTiles; + + expect(ct2).toBe(ct1); // same array instance + }); + + it("frame.units is === gameView.unitStates() (same long-lived map)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + expect(game.frameData().units).toBe(game.unitStates()); + }); + + it("frame.players is === gameView.playerStates() (same long-lived map)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + expect(game.frameData().players).toBe(game.playerStates()); + }); + + it("frame.tick reflects the most recent gu.tick", () => { + const game = makeGameView(); + game.update(makeEmptyGu(42)); + expect(game.frameData().tick).toBe(42); + game.update(makeEmptyGu(43)); + expect(game.frameData().tick).toBe(43); + }); + + it("frame.events.deadUnits is populated from inactive Unit updates", () => { + const game = makeGameView(); + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 1, isActive: true, pos: 10 }), + makeUnitUpdate({ id: 2, isActive: false, pos: 20 }), + makeUnitUpdate({ id: 3, isActive: false, pos: 30 }), + ]; + game.update(gu); + const dead = game.frameData().events.deadUnits; + expect(dead.length).toBe(2); + expect(dead.map((d) => d.pos).sort((a, b) => a - b)).toEqual([20, 30]); + }); + + it("frame.events arrays are cleared each tick (no event leakage)", () => { + const game = makeGameView(); + const gu1 = makeEmptyGu(1); + gu1.updates[GameUpdateType.Unit] = [ + makeUnitUpdate({ id: 1, isActive: false }), + ]; + game.update(gu1); + expect(game.frameData().events.deadUnits.length).toBe(1); + + // Empty next tick → events cleared + game.update(makeEmptyGu(2)); + expect(game.frameData().events.deadUnits.length).toBe(0); + }); + + it("frame.events.deadUnits array is reused (same reference)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + const a1 = game.frameData().events.deadUnits; + game.update(makeEmptyGu(2)); + expect(game.frameData().events.deadUnits).toBe(a1); + }); + + it("frame.tileMode is 'live'", () => { + const game = makeGameView(); + expect(game.frameData().tileMode).toBe("live"); + }); + + it("frame.structuresDirty is true on first populate (force initial upload)", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + expect(game.frameData().structuresDirty).toBe(true); + }); + + it("frame.structuresDirty resets between ticks when no structure changes", () => { + const game = makeGameView(); + game.update(makeEmptyGu(1)); + game.update(makeEmptyGu(2)); + expect(game.frameData().structuresDirty).toBe(false); + }); +}); diff --git a/tests/client/view/PlayerView.test.ts b/tests/client/view/PlayerView.test.ts new file mode 100644 index 0000000000..c41b0d5be2 --- /dev/null +++ b/tests/client/view/PlayerView.test.ts @@ -0,0 +1,285 @@ +/** + * PlayerView is a thin accessor wrapping a PlayerUpdate record plus precomputed + * colors. Tests verify each accessor forwards the underlying data, that the + * color variants (neutral/friendly/embargo) are precomputed at construction, + * and that relation predicates (allied / same-team / friendly / embargo) match + * what the FrameBuilder relies on when populating PlayerState. + */ + +import { describe, expect, it } from "vitest"; +import { PlayerView } from "../../../src/client/view/PlayerView"; +import { PlayerType } from "../../../src/core/game/Game"; +import { GameUpdateType } from "../../../src/core/game/GameUpdates"; +import { + makeEmptyGu, + makeGameView, + makeNameViewData, + makePlayerUpdate, + makePlayerView, +} from "../../util/viewStubs"; + +describe("PlayerView accessors", () => { + it("forwards data fields", () => { + const p = makePlayerView({ + data: { + id: "player-a", + smallID: 7, + clientID: "client-a", + name: "Alice", + displayName: "Alice", + playerType: PlayerType.Human, + isAlive: true, + isDisconnected: false, + isLobbyCreator: true, + tilesOwned: 42, + gold: 999n, + troops: 250, + }, + }); + + expect(p.id()).toBe("player-a"); + expect(p.smallID()).toBe(7); + expect(p.clientID()).toBe("client-a"); + expect(p.name()).toBe("Alice"); + expect(p.displayName()).toBe("Alice"); + expect(p.type()).toBe(PlayerType.Human); + expect(p.isAlive()).toBe(true); + expect(p.isDisconnected()).toBe(false); + expect(p.isLobbyCreator()).toBe(true); + expect(p.numTilesOwned()).toBe(42); + expect(p.gold()).toBe(999n); + expect(p.troops()).toBe(250); + }); + + it("isPlayer() is always true", () => { + expect(makePlayerView().isPlayer()).toBe(true); + }); + + it("team() returns null when team is undefined on data", () => { + expect(makePlayerView({ data: { team: undefined } }).team()).toBeNull(); + }); + + it("team() forwards a set team", () => { + expect(makePlayerView({ data: { team: "red" } }).team()).toBe("red"); + }); + + it("isTraitor + getTraitorRemainingTicks forward, with min clamp at 0", () => { + const traitor = makePlayerView({ + data: { isTraitor: true, traitorRemainingTicks: 5 }, + }); + expect(traitor.isTraitor()).toBe(true); + expect(traitor.getTraitorRemainingTicks()).toBe(5); + + // Negative or missing → clamped to 0 + const expired = makePlayerView({ + data: { isTraitor: false, traitorRemainingTicks: -3 }, + }); + expect(expired.getTraitorRemainingTicks()).toBe(0); + + const missing = makePlayerView({ data: { isTraitor: false } }); + expect(missing.getTraitorRemainingTicks()).toBe(0); + }); + + it("nameLocation() returns nameData passed at construction", () => { + const nameData = makeNameViewData({ x: 12, y: 34, size: 20 }); + expect(makePlayerView({ nameData }).nameLocation()).toBe(nameData); + }); + + it("outgoingEmojis / outgoingAttacks / incomingAttacks / alliances forward arrays", () => { + const alliance = { + id: 1, + other: { id: "ally", smallID: 2 }, + createdAt: 0, + expiresAt: 100, + onlyOneAgreedToExtend: false, + } as unknown as ReturnType[number]; + const attack = { + attackerID: 1, + targetID: 0, + troops: 50, + id: "attack-a", + retreating: false, + } as unknown as ReturnType[number]; + const emoji = { + message: 0, + senderID: 1, + recipientID: 2, + createdAt: 0, + } as unknown as ReturnType[number]; + + const p = makePlayerView({ + data: { + alliances: [alliance], + outgoingAttacks: [attack], + incomingAttacks: [], + outgoingEmojis: [emoji], + }, + }); + + expect(p.alliances()).toEqual([alliance]); + expect(p.outgoingAttacks()).toEqual([attack]); + expect(p.incomingAttacks()).toEqual([]); + expect(p.outgoingEmojis()).toEqual([emoji]); + }); +}); + +describe("PlayerView colors", () => { + it("territoryColor() with no tile returns a Colord", () => { + const c = makePlayerView().territoryColor(); + expect(typeof c.toHex()).toBe("string"); + }); + + it("structureColors() returns precomputed light/dark", () => { + const colors = makePlayerView().structureColors(); + expect(colors).toHaveProperty("light"); + expect(colors).toHaveProperty("dark"); + }); + + it("borderColor() with no tile returns the base border color", () => { + const p = makePlayerView(); + const noTile = p.borderColor(); + // Same value should come back for repeat calls (cached). + expect(p.borderColor().toHex()).toBe(noTile.toHex()); + }); +}); + +describe("PlayerView relations", () => { + function pair( + aSmall: number, + bSmall: number, + opts: { + aAllies?: number[]; + aTeam?: string; + bTeam?: string; + // Embargoes are renderer-format: stringified smallIDs of the OTHER player. + aEmbargoSmallIDs?: number[]; + bEmbargoSmallIDs?: number[]; + aOutgoingReq?: string[]; + } = {}, + ) { + const a = makePlayerView({ + data: { + id: "a", + smallID: aSmall, + allies: opts.aAllies ?? [], + team: opts.aTeam, + outgoingAllianceRequests: opts.aOutgoingReq ?? [], + }, + }); + const b = makePlayerView({ + data: { + id: "b", + smallID: bSmall, + team: opts.bTeam, + }, + }); + if (opts.aEmbargoSmallIDs) a.setEmbargoSmallIDs(opts.aEmbargoSmallIDs); + if (opts.bEmbargoSmallIDs) b.setEmbargoSmallIDs(opts.bEmbargoSmallIDs); + return { a, b }; + } + + it("isAlliedWith() reflects ally smallIDs in data.allies", () => { + const { a, b } = pair(1, 2, { aAllies: [2] }); + expect(a.isAlliedWith(b)).toBe(true); + expect(b.isAlliedWith(a)).toBe(false); // b has no allies set + }); + + it("isOnSameTeam() compares data.team and treats undefined as no team", () => { + const same = pair(1, 2, { aTeam: "red", bTeam: "red" }); + const diff = pair(1, 2, { aTeam: "red", bTeam: "blue" }); + const noTeam = pair(1, 2); + expect(same.a.isOnSameTeam(same.b)).toBe(true); + expect(diff.a.isOnSameTeam(diff.b)).toBe(false); + // Two players with no team set should NOT count as same team. + expect(noTeam.a.isOnSameTeam(noTeam.b)).toBe(false); + }); + + it("isFriendly() = allied OR same team", () => { + const allied = pair(1, 2, { aAllies: [2] }); + expect(allied.a.isFriendly(allied.b)).toBe(true); + + const teammates = pair(1, 2, { aTeam: "red", bTeam: "red" }); + expect(teammates.a.isFriendly(teammates.b)).toBe(true); + + const strangers = pair(1, 2); + expect(strangers.a.isFriendly(strangers.b)).toBe(false); + }); + + it("hasEmbargoAgainst / hasEmbargo are symmetric on the second", () => { + // a embargoes b — by smallID (renderer format) + const aEmbargoesB = pair(1, 2, { aEmbargoSmallIDs: [2] }); + // One-way directional embargo from a + expect(aEmbargoesB.a.hasEmbargoAgainst(aEmbargoesB.b)).toBe(true); + expect(aEmbargoesB.b.hasEmbargoAgainst(aEmbargoesB.a)).toBe(false); + // Symmetric version is true from either side + expect(aEmbargoesB.a.hasEmbargo(aEmbargoesB.b)).toBe(true); + expect(aEmbargoesB.b.hasEmbargo(aEmbargoesB.a)).toBe(true); + }); + + it("isRequestingAllianceWith() reflects outgoingAllianceRequests", () => { + const { a, b } = pair(1, 2, { aOutgoingReq: ["b"] }); + expect(a.isRequestingAllianceWith(b)).toBe(true); + expect(b.isRequestingAllianceWith(a)).toBe(false); + }); +}); + +describe("PlayerView in a GameView context", () => { + it("allies() resolves smallIDs through the game's smallID → PlayerView map", () => { + // Build a GameView and feed it two players so allies() can resolve. + const game = makeGameView(); + const aliceUpdate = makePlayerUpdate({ + id: "alice", + smallID: 1, + clientID: "c-alice", + name: "Alice", + allies: [2], + }); + const bobUpdate = makePlayerUpdate({ + id: "bob", + smallID: 2, + clientID: "c-bob", + name: "Bob", + }); + + // Drive a tick through the GameView so it creates the PlayerViews and + // registers smallID lookups — that's the path FrameBuilder & PlayerView use. + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Player] = [aliceUpdate, bobUpdate]; + gu.playerNameViewData = { + alice: makeNameViewData(), + bob: makeNameViewData(), + }; + game.update(gu); + + const alice = game.player("alice"); + const bob = game.player("bob"); + expect(alice.allies()).toEqual([bob]); + }); + + it("isMe() is true only for the player matching myClientID", () => { + const game = makeGameView({ myClientID: "c-me" }); + const me = makePlayerUpdate({ + id: "me", + smallID: 1, + clientID: "c-me", + name: "Me", + }); + const other = makePlayerUpdate({ + id: "other", + smallID: 2, + clientID: "c-other", + name: "Other", + }); + + const gu = makeEmptyGu(1); + gu.updates[GameUpdateType.Player] = [me, other]; + gu.playerNameViewData = { + me: makeNameViewData(), + other: makeNameViewData(), + }; + game.update(gu); + + expect(game.player("me").isMe()).toBe(true); + expect(game.player("other").isMe()).toBe(false); + }); +}); diff --git a/tests/client/view/UnitView.test.ts b/tests/client/view/UnitView.test.ts new file mode 100644 index 0000000000..e5b8bf98c8 --- /dev/null +++ b/tests/client/view/UnitView.test.ts @@ -0,0 +1,258 @@ +/** + * UnitView is mostly a thin accessor over a UnitUpdate record. Tests verify + * each accessor returns the underlying data, that update() swaps the backing + * record, that lastPos tracking works as the simulation advances units, and + * that the trickier missile-readiness math is correct. + */ + +import { describe, expect, it } from "vitest"; +import { UnitView } from "../../../src/client/view/UnitView"; +import { + TrainType, + TransportShipState, + UnitType, + WarshipState, +} from "../../../src/core/game/Game"; +import { makeGameView, makeUnitUpdate, stubConfig } from "../../util/viewStubs"; + +describe("UnitView accessors", () => { + it("forwards data fields", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ + id: 42, + unitType: UnitType.City, + ownerID: 7, + pos: 100, + lastPos: 99, + troops: 250, + level: 3, + hasTrainStation: true, + targetable: false, + markedForDeletion: false, + isActive: true, + reachedTarget: false, + }), + ); + + expect(u.id()).toBe(42); + expect(u.type()).toBe(UnitType.City); + expect(u.troops()).toBe(250); + expect(u.level()).toBe(3); + expect(u.hasTrainStation()).toBe(true); + expect(u.targetable()).toBe(false); + expect(u.markedForDeletion()).toBe(false); + expect(u.isActive()).toBe(true); + expect(u.reachedTarget()).toBe(false); + expect(u.tile()).toBe(100); + }); + + it("tracks createdAt from the GameView's tick at construction", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(u.createdAt()).toBe(0); // GameView.ticks() returns 0 before any update + }); + + it("returns the latest data after update()", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ troops: 100, pos: 1 })); + u.update(makeUnitUpdate({ troops: 250, pos: 5 })); + expect(u.troops()).toBe(250); + expect(u.tile()).toBe(5); + }); + + it("update() pushes new pos into lastPos", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ pos: 1 })); + expect(u.lastTile()).toBe(1); + u.update(makeUnitUpdate({ pos: 2 })); + expect(u.lastTiles()).toEqual([1, 2]); + u.update(makeUnitUpdate({ pos: 3 })); + expect(u.lastTiles()).toEqual([1, 2, 3]); + }); + + it("lastTile() returns the first remembered pos", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ pos: 1 })); + u.update(makeUnitUpdate({ pos: 2 })); + u.update(makeUnitUpdate({ pos: 3 })); + expect(u.lastTile()).toBe(1); + }); + + it("applyDerivedPosition pushes a new pos and shifts lastPos in data", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate({ pos: 10, lastPos: 9 })); + u.applyDerivedPosition(11); + expect(u.tile()).toBe(11); + expect(u.lastTiles()).toEqual([10, 11]); + }); + + it("hasHealth() reflects whether health is set", () => { + const game = makeGameView(); + expect(new UnitView(game, makeUnitUpdate({ health: 50 })).hasHealth()).toBe( + true, + ); + expect(new UnitView(game, makeUnitUpdate()).hasHealth()).toBe(false); + }); + + it("health() returns 0 when unset", () => { + const game = makeGameView(); + expect(new UnitView(game, makeUnitUpdate()).health()).toBe(0); + expect(new UnitView(game, makeUnitUpdate({ health: 42 })).health()).toBe( + 42, + ); + }); + + it("isUnderConstruction reflects the explicit boolean", () => { + const game = makeGameView(); + expect( + new UnitView( + game, + makeUnitUpdate({ underConstruction: true }), + ).isUnderConstruction(), + ).toBe(true); + expect( + new UnitView( + game, + makeUnitUpdate({ underConstruction: false }), + ).isUnderConstruction(), + ).toBe(false); + // Undefined is treated as false (not under construction). + expect(new UnitView(game, makeUnitUpdate()).isUnderConstruction()).toBe( + false, + ); + }); + + it("trainType() / isLoaded() forward optional train fields", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ trainType: TrainType.Engine, loaded: true }), + ); + expect(u.trainType()).toBe(TrainType.Engine); + expect(u.isLoaded()).toBe(true); + }); + + it("transportShipState() returns a default when missing", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(u.transportShipState()).toEqual({ isRetreating: false, troops: 0 }); + }); + + it("transportShipState() forwards when set", () => { + const game = makeGameView(); + const state: TransportShipState = { isRetreating: true, troops: 50 }; + const u = new UnitView(game, makeUnitUpdate({ transportShipState: state })); + expect(u.transportShipState()).toBe(state); + }); + + it("warshipState() throws when not a warship state", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(() => u.warshipState()).toThrow(); + }); + + it("warshipState() forwards when present", () => { + const game = makeGameView(); + const state: WarshipState = { + isInCombat: false, + patrolTile: 0, + lastAttackTile: 0, + bossUnitId: null, + } as unknown as WarshipState; + const u = new UnitView(game, makeUnitUpdate({ warshipState: state })); + expect(u.warshipState()).toBe(state); + }); + + it("isInCombat() reflects warshipState.isInCombat (or false if missing)", () => { + const game = makeGameView(); + expect(new UnitView(game, makeUnitUpdate()).isInCombat()).toBe(false); + const combat = new UnitView( + game, + makeUnitUpdate({ + warshipState: { isInCombat: true } as unknown as WarshipState, + }), + ); + expect(combat.isInCombat()).toBe(true); + }); + + it("targetUnitId / targetTile pass through", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ targetUnitId: 99, targetTile: 12 }), + ); + expect(u.targetUnitId()).toBe(99); + expect(u.targetTile()).toBe(12); + }); + + it("missileTimerQueue() forwards the array", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ missileTimerQueue: [10, 20, 30] }), + ); + expect(u.missileTimerQueue()).toEqual([10, 20, 30]); + }); + + it("touch / updateWarshipState / updateTransportShipState throw on view", () => { + const game = makeGameView(); + const u = new UnitView(game, makeUnitUpdate()); + expect(() => u.touch()).toThrow(); + expect(() => u.updateWarshipState({})).toThrow(); + expect(() => u.updateTransportShipState({ isRetreating: false })).toThrow(); + }); + + describe("missileReadinesss", () => { + it("returns 1 when nothing is reloading", () => { + const game = makeGameView(); + const u = new UnitView( + game, + makeUnitUpdate({ level: 3, missileTimerQueue: [] }), + ); + expect(u.missileReadinesss()).toBe(1); + }); + + it("returns 0 when all missiles are reloading and level > 1", () => { + const game = makeGameView({ config: stubConfig() }); + const u = new UnitView( + game, + makeUnitUpdate({ + unitType: UnitType.SAMLauncher, + level: 2, + missileTimerQueue: [0, 0], // both reloading, started at tick 0 + }), + ); + // Just-launched: progress is 0, readiness 0/2. + expect(u.missileReadinesss()).toBe(0); + }); + + it("returns partial readiness when missiles are partway through cooldown", () => { + // SAMCooldown = 120 in stub. Half-way at tick 60. Level 2 with both reloading + // means readiness = 0/2 from ready missiles + 2 * (60/120) / 2 = 0.5. + // But game.ticks() returns 0 with no update. So progress = 0 - 0 = 0 → 0. + // Use a game with a tick number injected. + const config = stubConfig({ + SAMCooldown: () => 120, + SiloCooldown: () => 75, + } as unknown as Partial< + typeof stubConfig extends () => infer C ? C : never + >); + const game = makeGameView({ config }); + const u = new UnitView( + game, + makeUnitUpdate({ + unitType: UnitType.SAMLauncher, + level: 2, + missileTimerQueue: [0, 0], + }), + ); + // Without advancing game ticks, readiness = (2-2)/2 + 2*((0-0)/120)/2 = 0. + // We can't easily advance ticks without going through update(); just assert <=1. + const r = u.missileReadinesss(); + expect(r).toBeGreaterThanOrEqual(0); + expect(r).toBeLessThanOrEqual(1); + }); + }); +}); diff --git a/tests/core/game/GameMap.tileStateBuffer.test.ts b/tests/core/game/GameMap.tileStateBuffer.test.ts new file mode 100644 index 0000000000..5da53f05ab --- /dev/null +++ b/tests/core/game/GameMap.tileStateBuffer.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { GameMapImpl } from "../../../src/core/game/GameMap"; + +describe("GameMap.tileStateBuffer", () => { + it("returns a Uint16Array sized to width * height", () => { + const map = new GameMapImpl(10, 8, new Uint8Array(10 * 8), 0); + const buf = map.tileStateBuffer(); + expect(buf).toBeInstanceOf(Uint16Array); + expect(buf.length).toBe(80); + }); + + it("returns a live reference — updateTile() mutates the same buffer", () => { + const map = new GameMapImpl(4, 4, new Uint8Array(16), 0); + const buf = map.tileStateBuffer(); + // Writes go through updateTile (packed uint32: high 16 bits = terrain byte, low 16 = state). + map.updateTile(5, 0x00abcd); + expect(buf[5]).toBe(0xabcd); + }); + + it("returns the same array on every call (zero-copy)", () => { + const map = new GameMapImpl(4, 4, new Uint8Array(16), 0); + expect(map.tileStateBuffer()).toBe(map.tileStateBuffer()); + }); + + it("reflects ownerID writes in the low 12 bits of each cell", () => { + const map = new GameMapImpl(4, 4, new Uint8Array(16), 0); + map.setOwnerID(7, 0x123); + expect(map.tileStateBuffer()[7] & 0xfff).toBe(0x123); + }); +}); diff --git a/tests/util/viewStubs.ts b/tests/util/viewStubs.ts new file mode 100644 index 0000000000..d02bccced9 --- /dev/null +++ b/tests/util/viewStubs.ts @@ -0,0 +1,224 @@ +/** + * Stub builders for GameView/PlayerView/UnitView unit tests. + * + * These tests don't go through the full game setup (which creates a worker + * and runs the simulation) — they exercise the view classes directly with + * minimal stubs for their dependencies. + */ + +import { colord } from "colord"; +import { GameView } from "../../src/client/view/GameView"; +import { PlayerView } from "../../src/client/view/PlayerView"; +import { Config } from "../../src/core/configuration/Config"; +import { Theme } from "../../src/core/configuration/Theme"; +import { + NameViewData, + PlayerType, + Team, + UnitType, +} from "../../src/core/game/Game"; +import { GameMapImpl } from "../../src/core/game/GameMap"; +import { + GameUpdateType, + GameUpdateViewData, + PlayerUpdate, + UnitUpdate, +} from "../../src/core/game/GameUpdates"; +import { TerrainMapData } from "../../src/core/game/TerrainMapLoader"; +import { Player, PlayerCosmetics } from "../../src/core/Schemas"; +import { WorkerClient } from "../../src/core/worker/WorkerClient"; + +/** Theme stub — returns deterministic colors so PlayerView's color math works. */ +export function stubTheme(): Theme { + const white = colord("#ffffff"); + const grey = colord("#808080"); + const defended = { light: white, dark: grey }; + return { + teamColor: () => white, + territoryColor: () => white, + structureColors: () => defended, + borderColor: () => grey, + defendedBorderColors: () => defended, + focusedBorderColor: () => grey, + terrainColor: () => white, + backgroundColor: () => white, + falloutColor: () => white, + font: () => "Arial", + textColor: () => "#000000", + selfColor: () => white, + allyColor: () => white, + neutralColor: () => grey, + enemyColor: () => grey, + spawnHighlightColor: () => white, + spawnHighlightSelfColor: () => white, + spawnHighlightTeamColor: () => white, + spawnHighlightEnemyColor: () => white, + }; +} + +/** Minimum Config stub for view tests. Extend as test needs grow. */ +export function stubConfig(overrides: Partial = {}): Config { + const theme = stubTheme(); + const cfg = { + theme: () => theme, + SAMCooldown: () => 120, + SiloCooldown: () => 75, + deleteUnitCooldown: () => 0, + spawnImmunityDuration: () => 0, + nationSpawnImmunityDuration: () => 0, + unitInfo: () => ({ maxHealth: 100, constructionDuration: 20 }), + disableAlliances: () => false, + allianceDuration: () => 100, + deletionMarkDuration: () => 300, + nukeMagnitudes: () => ({ inner: 0, outer: 0 }), + nukeAllianceBreakThreshold: () => 0, + userSettings: () => ({}), + ...overrides, + } as unknown as Config; + return cfg; +} + +/** WorkerClient stub. View classes only call worker.* in async methods we don't exercise. */ +export function stubWorker(): WorkerClient { + return {} as unknown as WorkerClient; +} + +/** Build TerrainMapData wrapping a fresh GameMapImpl of the given size. */ +export function stubTerrainMap(width = 10, height = 10): TerrainMapData { + const terrain = new Uint8Array(width * height); + const gameMap = new GameMapImpl(width, height, terrain, 0); + return { + nations: [], + additionalNations: [], + gameMap, + miniGameMap: gameMap, + } as unknown as TerrainMapData; +} + +export interface GameViewStubOptions { + width?: number; + height?: number; + myClientID?: string; + myUsername?: string; + myClanTag?: string | null; + humans?: Player[]; + config?: Config; +} + +/** Construct a GameView with minimal dependencies. */ +export function makeGameView(opts: GameViewStubOptions = {}): GameView { + return new GameView( + stubWorker(), + opts.config ?? stubConfig(), + stubTerrainMap(opts.width ?? 10, opts.height ?? 10), + opts.myClientID, + opts.myUsername ?? "tester", + opts.myClanTag ?? null, + "test-game", + opts.humans ?? [], + ); +} + +// ── Synthetic update builders ── + +export function makePlayerUpdate( + overrides: Partial = {}, +): PlayerUpdate { + return { + type: GameUpdateType.Player, + clientID: "client-a", + name: "Alice", + displayName: "Alice", + id: "player-a", + smallID: 1, + playerType: PlayerType.Human, + isAlive: true, + isDisconnected: false, + tilesOwned: 0, + gold: 0n, + troops: 100, + allies: [], + embargoes: new Set(), + isTraitor: false, + targets: [], + outgoingEmojis: [], + outgoingAttacks: [], + incomingAttacks: [], + outgoingAllianceRequests: [], + alliances: [], + hasSpawned: true, + betrayals: 0, + lastDeleteUnitTick: 0, + isLobbyCreator: false, + ...overrides, + }; +} + +export function makeUnitUpdate( + overrides: Partial = {}, +): UnitUpdate { + return { + type: GameUpdateType.Unit, + unitType: UnitType.Warship, + troops: 0, + id: 1, + ownerID: 1, + pos: 0, + lastPos: 0, + isActive: true, + reachedTarget: false, + targetable: true, + markedForDeletion: false, + missileTimerQueue: [], + level: 1, + hasTrainStation: false, + ...overrides, + }; +} + +export function makeNameViewData( + overrides: Partial = {}, +): NameViewData { + return { x: 0, y: 0, size: 12, ...overrides }; +} + +export interface PlayerViewStubOptions { + game?: GameView; + data?: Partial; + nameData?: NameViewData; + cosmetics?: PlayerCosmetics; +} + +/** Construct a PlayerView with minimal dependencies. */ +export function makePlayerView(opts: PlayerViewStubOptions = {}): PlayerView { + return new PlayerView( + opts.game ?? makeGameView(), + makePlayerUpdate(opts.data), + opts.nameData ?? makeNameViewData(), + opts.cosmetics ?? {}, + ); +} + +/** + * Build a GameUpdateViewData with no updates and an empty packed tile delta. + * Caller can fill in updates[GameUpdateType.X] arrays as needed. + */ +export function makeEmptyGu( + tick: number, + overrides: Partial = {}, +): GameUpdateViewData { + const updates = Object.fromEntries( + Object.values(GameUpdateType) + .filter((v): v is number => typeof v === "number") + .map((k) => [k, []]), + ) as unknown as GameUpdateViewData["updates"]; + return { + tick, + updates, + packedTileUpdates: new Uint32Array(0), + playerNameViewData: {}, + ...overrides, + }; +} + +export { Team };