diff --git a/eslint.config.js b/eslint.config.js index 3168893d5..e75c2f38c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,6 +26,7 @@ export default [ allowDefaultProject: [ "__mocks__/fileMock.js", "eslint.config.js", + "scripts/build-namelayer-assets.mjs", "scripts/sync-assets.mjs", ], }, diff --git a/package.json b/package.json index 7ea868462..86b8fa1d9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "docs:map-generator": "cd map-generator && go doc -cmd -u -all", "tunnel": "npm run build-prod && npm run start:server", "test": "vitest run && vitest run tests/server", + "build:namelayer-assets": "node scripts/build-namelayer-assets.mjs", "perf": "npx tsx tests/perf/run-all.ts", "test:coverage": "vitest run --coverage", "format": "prettier --ignore-unknown --write .", diff --git a/resources/fonts/namelayer_overpass.png b/resources/fonts/namelayer_overpass.png new file mode 100644 index 000000000..fd0364875 Binary files /dev/null and b/resources/fonts/namelayer_overpass.png differ diff --git a/resources/fonts/namelayer_overpass.xml b/resources/fonts/namelayer_overpass.xml new file mode 100644 index 000000000..2b316fe9d --- /dev/null +++ b/resources/fonts/namelayer_overpass.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/fonts/overpass-OFL.txt b/resources/fonts/overpass-OFL.txt new file mode 100644 index 000000000..2739ed0cf --- /dev/null +++ b/resources/fonts/overpass-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Overpass Project Authors (https://github.com/RedHatOfficial/Overpass) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/resources/fonts/overpass-regular.otf b/resources/fonts/overpass-regular.otf new file mode 100644 index 000000000..3a7c095fa Binary files /dev/null and b/resources/fonts/overpass-regular.otf differ diff --git a/resources/images/namelayer-emojis.json b/resources/images/namelayer-emojis.json new file mode 100644 index 000000000..655ac5e79 --- /dev/null +++ b/resources/images/namelayer-emojis.json @@ -0,0 +1,1214 @@ +{ + "frames": { + "πŸ˜€": { + "frame": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "😊": { + "frame": { + "x": 64, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ₯°": { + "frame": { + "x": 128, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ˜‡": { + "frame": { + "x": 192, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "😎": { + "frame": { + "x": 256, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "😞": { + "frame": { + "x": 320, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ₯Ί": { + "frame": { + "x": 384, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "😭": { + "frame": { + "x": 448, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "😱": { + "frame": { + "x": 0, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "😑": { + "frame": { + "x": 64, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "😈": { + "frame": { + "x": 128, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🀑": { + "frame": { + "x": 192, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ₯±": { + "frame": { + "x": 256, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🫑": { + "frame": { + "x": 320, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ–•": { + "frame": { + "x": 384, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ‘‹": { + "frame": { + "x": 448, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ‘": { + "frame": { + "x": 0, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "βœ‹": { + "frame": { + "x": 64, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ™": { + "frame": { + "x": 128, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ’ͺ": { + "frame": { + "x": 192, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ‘": { + "frame": { + "x": 256, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ‘Ž": { + "frame": { + "x": 320, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🫴": { + "frame": { + "x": 384, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🀌": { + "frame": { + "x": 448, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ€¦β€β™‚οΈ": { + "frame": { + "x": 0, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🀝": { + "frame": { + "x": 64, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ†˜": { + "frame": { + "x": 128, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ•ŠοΈ": { + "frame": { + "x": 192, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🏳️": { + "frame": { + "x": 256, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "⏳": { + "frame": { + "x": 320, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ”₯": { + "frame": { + "x": 384, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ’₯": { + "frame": { + "x": 448, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ’€": { + "frame": { + "x": 0, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "☒️": { + "frame": { + "x": 64, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "⚠️": { + "frame": { + "x": 128, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "↖️": { + "frame": { + "x": 192, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "⬆️": { + "frame": { + "x": 256, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "↗️": { + "frame": { + "x": 320, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ‘‘": { + "frame": { + "x": 384, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ₯‡": { + "frame": { + "x": 448, + "y": 256, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "⬅️": { + "frame": { + "x": 0, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🎯": { + "frame": { + "x": 64, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "➑️": { + "frame": { + "x": 128, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ₯ˆ": { + "frame": { + "x": 192, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ₯‰": { + "frame": { + "x": 256, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "↙️": { + "frame": { + "x": 320, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "⬇️": { + "frame": { + "x": 384, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "β†˜οΈ": { + "frame": { + "x": 448, + "y": 320, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "❀️": { + "frame": { + "x": 0, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ’”": { + "frame": { + "x": 64, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ’°": { + "frame": { + "x": 128, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "βš“": { + "frame": { + "x": 192, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "β›΅": { + "frame": { + "x": 256, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🏑": { + "frame": { + "x": 320, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ›‘οΈ": { + "frame": { + "x": 384, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "🏭": { + "frame": { + "x": 448, + "y": 384, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸš‚": { + "frame": { + "x": 0, + "y": 448, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "❓": { + "frame": { + "x": 64, + "y": 448, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ”": { + "frame": { + "x": 128, + "y": 448, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "πŸ€": { + "frame": { + "x": 192, + "y": 448, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + } + }, + "meta": { + "app": "scripts/build-namelayer-assets.mjs", + "image": "namelayer-emojis.png", + "format": "RGBA8888", + "size": { + "w": 512, + "h": 512 + }, + "scale": "1" + } +} diff --git a/resources/images/namelayer-emojis.png b/resources/images/namelayer-emojis.png new file mode 100644 index 000000000..03e5276ac Binary files /dev/null and b/resources/images/namelayer-emojis.png differ diff --git a/resources/images/namelayer-icons.json b/resources/images/namelayer-icons.json new file mode 100644 index 000000000..91124e703 --- /dev/null +++ b/resources/images/namelayer-icons.json @@ -0,0 +1,274 @@ +{ + "frames": { + "AllianceIcon.svg": { + "frame": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "AllianceIconFaded.svg": { + "frame": { + "x": 64, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "AllianceRequestBlackIcon.svg": { + "frame": { + "x": 128, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "AllianceRequestWhiteIcon.svg": { + "frame": { + "x": 192, + "y": 0, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "CrownIcon.svg": { + "frame": { + "x": 0, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "DisconnectedIcon.svg": { + "frame": { + "x": 64, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "EmbargoBlackIcon.svg": { + "frame": { + "x": 128, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "EmbargoWhiteIcon.svg": { + "frame": { + "x": 192, + "y": 64, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "NukeIconRed.svg": { + "frame": { + "x": 0, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "NukeIconWhite.svg": { + "frame": { + "x": 64, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "QuestionMarkIcon.svg": { + "frame": { + "x": 128, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "TargetIcon.svg": { + "frame": { + "x": 192, + "y": 128, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + }, + "TraitorIcon.svg": { + "frame": { + "x": 0, + "y": 192, + "w": 64, + "h": 64 + }, + "rotated": false, + "trimmed": false, + "spriteSourceSize": { + "x": 0, + "y": 0, + "w": 64, + "h": 64 + }, + "sourceSize": { + "w": 64, + "h": 64 + } + } + }, + "meta": { + "app": "scripts/build-namelayer-assets.mjs", + "image": "namelayer-icons.png", + "format": "RGBA8888", + "size": { + "w": 256, + "h": 256 + }, + "scale": "1" + } +} diff --git a/resources/images/namelayer-icons.png b/resources/images/namelayer-icons.png new file mode 100644 index 000000000..8d85df2d1 Binary files /dev/null and b/resources/images/namelayer-icons.png differ diff --git a/scripts/build-namelayer-assets.mjs b/scripts/build-namelayer-assets.mjs new file mode 100644 index 000000000..8727a8a60 --- /dev/null +++ b/scripts/build-namelayer-assets.mjs @@ -0,0 +1,351 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); +const fontsDir = path.join(root, "resources", "fonts"); +const imagesDir = path.join(root, "resources", "images"); + +const fontPng = "namelayer_overpass.png"; +const fontXml = "namelayer_overpass.xml"; +const fontFace = "namelayer_overpass"; +const fontSourceCandidates = [ + "overpass-regular.otf", + "overpass-regular.ttf", + "overpass.otf", + "overpass.ttf", + "overpass.woff", +]; +const glyphs = Array.from( + new Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ üÜ.[]+-=(),':!?/@#$%&\"".split( + "", + ), + ), +); + +const iconSources = [ + "AllianceIcon.svg", + "AllianceIconFaded.svg", + "AllianceRequestBlackIcon.svg", + "AllianceRequestWhiteIcon.svg", + "CrownIcon.svg", + "DisconnectedIcon.svg", + "EmbargoBlackIcon.svg", + "EmbargoWhiteIcon.svg", + "NukeIconRed.svg", + "NukeIconWhite.svg", + "QuestionMarkIcon.svg", + "TargetIcon.svg", + "TraitorIcon.svg", +]; + +fs.mkdirSync(fontsDir, { recursive: true }); +fs.mkdirSync(imagesDir, { recursive: true }); + +const canvasApi = await loadCanvasApi(); + +await buildBitmapFont(); +await buildIconAtlas(); +await buildEmojiAtlas(); + +async function loadCanvasApi() { + try { + const api = await import("canvas"); + const fontPath = fontSourceCandidates + .map((fileName) => path.join(fontsDir, fileName)) + .find((candidate) => fs.existsSync(candidate)); + try { + if (!fontPath) { + throw new Error( + `No Overpass font source found. Tried: ${fontSourceCandidates.join( + ", ", + )}`, + ); + } + api.registerFont(fontPath, { + family: "OverpassNameLayer", + }); + } catch (error) { + console.warn( + "Could not register Overpass; using canvas fallback font", + error, + ); + } + return api; + } catch (error) { + console.warn( + "canvas native bindings are unavailable; writing deterministic fallback NameLayer assets", + error, + ); + return null; + } +} + +async function buildBitmapFont() { + if (!canvasApi) { + const fallbackXml = fs + .readFileSync(path.join(fontsDir, "round_6x6_modified.xml"), "utf8") + .replace(/face="round_6x6_modified"/g, `face="${fontFace}"`) + .replace(/file="round_6x6_modified\.png"/g, `file="${fontPng}"`); + fs.writeFileSync( + path.join(fontsDir, fontPng), + fs.readFileSync(path.join(fontsDir, "round_6x6_modified.png")), + ); + fs.writeFileSync(path.join(fontsDir, fontXml), fallbackXml); + return; + } + + const { createCanvas } = canvasApi; + const cell = 64; + const cols = 16; + const rows = Math.ceil(glyphs.length / cols); + const canvas = createCanvas(cols * cell, rows * cell); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = "#ffffff"; + ctx.textBaseline = "alphabetic"; + ctx.textAlign = "left"; + ctx.font = '48px "OverpassNameLayer", Arial, sans-serif'; + + const chars = []; + glyphs.forEach((glyph, index) => { + const col = index % cols; + const row = Math.floor(index / cols); + const x = col * cell; + const y = row * cell; + const metrics = ctx.measureText(glyph); + const advance = glyph === " " ? 16 : Math.max(16, Math.ceil(metrics.width)); + const drawX = x + 4; + const drawY = y + 48; + if (glyph !== " ") { + ctx.fillText(glyph, drawX, drawY); + } + chars.push({ + id: glyph.codePointAt(0), + x, + y, + width: cell, + height: cell, + xadvance: advance, + xoffset: 0, + yoffset: 0, + label: glyph, + }); + }); + + const xml = ` + + + + + + + +${chars + .map( + (char) => + ` `, + ) + .join("\n")} + + +`; + + fs.writeFileSync(path.join(fontsDir, fontPng), canvas.toBuffer("image/png")); + fs.writeFileSync(path.join(fontsDir, fontXml), xml); +} + +async function buildIconAtlas() { + if (!canvasApi) { + writeFallbackAtlas("namelayer-icons", iconSources); + return; + } + + const { createCanvas, loadImage } = canvasApi; + const cell = 64; + const cols = 4; + const rows = Math.ceil(iconSources.length / cols); + const canvas = createCanvas(cols * cell, rows * cell); + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const frames = {}; + + for (let i = 0; i < iconSources.length; i++) { + const source = iconSources[i]; + const col = i % cols; + const row = Math.floor(i / cols); + const x = col * cell; + const y = row * cell; + try { + const img = await loadIconImage(path.join(imagesDir, source), loadImage); + ctx.drawImage(img, x, y, cell, cell); + } catch (error) { + console.warn( + `Could not pack ${source}; leaving empty atlas frame`, + error, + ); + } + frames[source] = { + frame: { x, y, w: cell, h: cell }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: cell, h: cell }, + sourceSize: { w: cell, h: cell }, + }; + } + + fs.writeFileSync( + path.join(imagesDir, "namelayer-icons.png"), + canvas.toBuffer("image/png"), + ); + fs.writeFileSync( + path.join(imagesDir, "namelayer-icons.json"), + JSON.stringify( + { + frames, + meta: { + app: "scripts/build-namelayer-assets.mjs", + image: "namelayer-icons.png", + format: "RGBA8888", + size: { w: canvas.width, h: canvas.height }, + scale: "1", + }, + }, + null, + 2, + ), + ); +} + +async function loadIconImage(sourcePath, loadImage) { + if (path.extname(sourcePath).toLowerCase() !== ".svg") { + return loadImage(sourcePath); + } + + let svg = fs.readFileSync(sourcePath, "utf8"); + if (!/]*\swidth=/i.test(svg) || !/]*\sheight=/i.test(svg)) { + const [, , , width, height] = + svg.match( + /viewBox=["']\s*([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s*["']/i, + ) ?? []; + svg = svg.replace( + / { + const col = index % cols; + const row = Math.floor(index / cols); + const x = col * cell; + const y = row * cell; + ctx.fillText(emoji, x + cell / 2, y + cell / 2); + frames[emoji] = { + frame: { x, y, w: cell, h: cell }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: cell, h: cell }, + sourceSize: { w: cell, h: cell }, + }; + }); + + fs.writeFileSync( + path.join(imagesDir, "namelayer-emojis.png"), + canvas.toBuffer("image/png"), + ); + fs.writeFileSync( + path.join(imagesDir, "namelayer-emojis.json"), + JSON.stringify( + { + frames, + meta: { + app: "scripts/build-namelayer-assets.mjs", + image: "namelayer-emojis.png", + format: "RGBA8888", + size: { w: canvas.width, h: canvas.height }, + scale: "1", + }, + }, + null, + 2, + ), + ); +} + +function readEmojiTable() { + const utilSource = fs.readFileSync( + path.join(root, "src", "core", "Util.ts"), + "utf8", + ); + const tableSource = utilSource.match( + /export const emojiTable = \[([\s\S]*?)\] as const;/, + )?.[1]; + return tableSource + ? Array.from(tableSource.matchAll(/"([^"]+)"/g), (match) => match[1]) + : []; +} + +function writeFallbackAtlas(name, keys) { + const transparentPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lD6N7wAAAABJRU5ErkJggg==", + "base64", + ); + const frames = {}; + + for (const key of keys) { + frames[key] = { + frame: { x: 0, y: 0, w: 1, h: 1 }, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: 1, h: 1 }, + sourceSize: { w: 1, h: 1 }, + }; + } + + fs.writeFileSync(path.join(imagesDir, `${name}.png`), transparentPng); + fs.writeFileSync( + path.join(imagesDir, `${name}.json`), + JSON.stringify( + { + frames, + meta: { + app: "scripts/build-namelayer-assets.mjs", + image: `${name}.png`, + format: "RGBA8888", + size: { w: 1, h: 1 }, + scale: "1", + }, + }, + null, + 2, + ), + ); +} diff --git a/src/client/graphics/PlayerIcons.ts b/src/client/graphics/PlayerIcons.ts index 935de9388..36615e0e2 100644 --- a/src/client/graphics/PlayerIcons.ts +++ b/src/client/graphics/PlayerIcons.ts @@ -341,6 +341,10 @@ export function updateAllianceProgressIconRefs( } export function computeAllianceClipPath(fraction: number): string { - const topCut = 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40% + const topCut = computeAllianceTopCutPercent(fraction); return `inset(${topCut.toFixed(2)}% -2px 0 -2px)`; } + +export function computeAllianceTopCutPercent(fraction: number): number { + return 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40% +} diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 701e09c26..16b206299 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,4 +1,5 @@ -import { assetUrl } from "src/core/AssetUrls"; +import * as PIXI from "pixi.js"; +import { assetUrl } from "../../../core/AssetUrls"; import { EventBus } from "../../../core/EventBus"; import { PseudoRandom } from "../../../core/PseudoRandom"; import { Config, Theme } from "../../../core/configuration/Config"; @@ -9,8 +10,7 @@ import { AlternateViewEvent } from "../../InputHandler"; import { renderTroops } from "../../Utils"; import { ALLIANCE_ICON_ID, - AllianceProgressIconRefs, - createAllianceProgressIconRefs, + computeAllianceTopCutPercent, EMOJI_ICON_KIND, getFirstPlacePlayer, getPlayerIcons, @@ -18,58 +18,80 @@ import { PlayerIconDescriptor, PlayerIconId, TRAITOR_ICON_ID, - updateAllianceProgressIconRefs, } from "../PlayerIcons"; import { TransformHandler } from "../TransformHandler"; import { Layer } from "./Layer"; +import { NameLayerAssets } from "./NameLayerAssets"; +import { + computeNameLayerFontSize, + computeNameLayerLayout, + computeNameLayerScale, + computeNameLayerVisible, + computeTraitorFlashAlpha, + replaceUnsupportedNameGlyphs, +} from "./NameLayerLayout"; -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"; +const allianceIconFaded = assetUrl("images/AllianceIconFaded.svg"); +const questionMarkIcon = assetUrl("images/QuestionMarkIcon.svg"); + +type PixiRenderer = PIXI.Renderer | PIXI.WebGLRenderer | PIXI.WebGPURenderer; + +interface PixiIconRender { + container: PIXI.Container; + centered: boolean; + src?: string; + sprite?: PIXI.Sprite; + text?: PIXI.Text; + alliance?: { + base: PIXI.Sprite; + colored: PIXI.Sprite; + questionMark: PIXI.Sprite; + mask: PIXI.Graphics; + }; +} class RenderInfo { - public icons: Map = new Map(); - public allianceIconRefs: AllianceProgressIconRefs | null = null; + public icons: Map = new Map(); + public location: Cell | null = null; + public baseSize = 1; + public fontSize = 0; + public fontColor = ""; + public flagSrc = ""; + public flagSprite: PIXI.Sprite | null = null; + public lastDisplayName = ""; + public lastTroopsText = ""; 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 = "", + public container: PIXI.Container, + public nameText: PIXI.BitmapText, + public troopsText: PIXI.BitmapText, ) {} } 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 readonly renderCheckRate = 100; + private readonly renderRefreshRate = 500; + private readonly rand = new PseudoRandom(10); + private readonly renders: RenderInfo[] = []; + private readonly seenPlayers: Set = new Set(); + private readonly rootStage: PIXI.Container = new PIXI.Container(); + private readonly labelStage: PIXI.Container = new PIXI.Container(); + private readonly assets = new NameLayerAssets(); private theme: Theme; private userSettings: UserSettings = new UserSettings(); - private isVisible: boolean = true; + private isVisible = true; private firstPlace: PlayerView | null = null; private allianceDuration: number; - private alliancesDisabled: boolean = false; + private alliancesDisabled = false; private myPlayer: PlayerView | null = null; - private lastContainerTransform: string = ""; - private basePlayerTemplate: HTMLDivElement; - private iconTemplate: HTMLImageElement; - private iconCenterTemplate: HTMLImageElement; - private emojiTemplate: HTMLDivElement; + private pixicanvas: HTMLCanvasElement; + private renderer: PixiRenderer | null = null; + private rendererInitialized = false; + private rebuildPending = false; constructor( private game: GameView, @@ -81,86 +103,41 @@ export class NameLayer implements Layer { 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); - + async init() { 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.rootStage.addChild(this.labelStage); + this.rootStage.position.set(0, 0); this.eventBus.on(AlternateViewEvent, (e) => this.onAlternateViewChange(e)); + window.addEventListener("resize", () => this.resizeCanvas()); + + await this.setupRenderer(); + this.resizeCanvas(); } - 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()) { + async redraw() { + if (this.rebuildPending || this.rendererOrGLContextLost()) { 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; + this.rebuildPending = true; + try { + if (this.renderer?.name === "webgpu") { + this.rendererInitialized = false; + await this.setupRenderer(); + } + this.resizeCanvas(); + for (const render of this.renders) { + render.container.destroy({ children: true }); + } + this.renders.length = 0; + this.seenPlayers.clear(); + } finally { + this.rebuildPending = false; } } @@ -168,385 +145,526 @@ export class NameLayer implements Layer { return 1000; } - public tick() { - // Precompute the first-place player for performance + tick() { 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)); - } + if (player.isAlive() && !this.seenPlayers.has(player)) { + this.seenPlayers.add(player); + this.renders.push(this.createPlayerRender(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; + renderLayer(mainContext: CanvasRenderingContext2D) { + if (this.rendererOrGLContextLost()) { + return; } + this.myPlayer ??= this.game.myPlayer(); + this.updateTransformsAndVisibility(); + 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); + for (const render of [...this.renders]) { + this.renderPlayerInfo(render, transitiveTargets, now); } } + + this.renderer?.render(this.rootStage); + if (this.renderer) { + mainContext.drawImage(this.renderer.canvas, 0, 0); + } } - 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"; + private async setupRenderer() { + if (this.renderer) { + this.renderer.destroy(true); + this.labelStage.removeChildren(); } - const renderInfo = new RenderInfo( - player, - 0, - null, - 0, - "", - element, - nameDiv, - nameSpan, - troopsDiv, - flagImg, - iconsDiv, - ); + await this.assets.preload(); - this.container.appendChild(element); - return renderInfo; + this.pixicanvas = document.createElement("canvas"); + this.pixicanvas.width = window.innerWidth; + this.pixicanvas.height = window.innerHeight; + + 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 name layer`); + this.renderer = renderer; + + if (this.renderer.name === "webgpu") { + const gpuRenderer = this.renderer as PIXI.WebGPURenderer; + gpuRenderer.gpu.device.lost.then(() => { + this.redraw(); + }); + } + + if (this.renderer.name === "webgl") { + this.renderer.runners.contextChange.add({ + contextChange: () => { + requestAnimationFrame(() => { + this.redraw(); + }); + }, + }); + } + + this.rendererInitialized = true; } - renderPlayerInfo(render: RenderInfo, transitiveTargets: PlayerView[]) { + private rendererOrGLContextLost(): boolean { + if (!this.renderer || !this.rendererInitialized) return true; + if (this.renderer.name === "webgl") { + return (this.renderer as PIXI.WebGLRenderer).context?.isLost === true; + } + return false; + } + + private resizeCanvas() { + if (this.rendererOrGLContextLost()) { + return; + } + this.pixicanvas.width = window.innerWidth; + this.pixicanvas.height = window.innerHeight; + this.renderer?.resize(window.innerWidth, window.innerHeight, 1); + } + + private onAlternateViewChange(event: AlternateViewEvent) { + this.isVisible = !event.alternateView; + this.updateTransformsAndVisibility(); + } + + private createPlayerRender(player: PlayerView): RenderInfo { + const container = new PIXI.Container(); + container.visible = false; + + const nameText = this.createBitmapText(""); + const troopsText = this.createBitmapText(""); + + container.addChild(nameText, troopsText); + this.labelStage.addChild(container); + + const render = new RenderInfo(player, 0, container, nameText, troopsText); + this.updateFlag(render); + return render; + } + + private createBitmapText(text: string): PIXI.BitmapText { + const bitmapText = new PIXI.BitmapText({ + text, + style: { + fontFamily: this.assets.fontFamily, + fontSize: 12, + fill: "#ffffff", + }, + }); + bitmapText.anchor.set(0.5); + return bitmapText; + } + + private updateTransformsAndVisibility() { + const now = performance.now(); + for (const render of this.renders) { + const nameLocation = render.player.nameLocation(); + if (!nameLocation || !render.player.isAlive()) { + render.container.visible = false; + continue; + } + + render.baseSize = Math.max(1, Math.floor(nameLocation.size)); + render.location = new Cell(nameLocation.x, nameLocation.y); + const isOnScreen = this.transformHandler.isOnScreen(render.location); + render.container.visible = computeNameLayerVisible({ + isLayerVisible: this.isVisible, + transformScale: this.transformHandler.scale, + baseSize: render.baseSize, + isOnScreen, + }); + + if (!render.container.visible) { + continue; + } + + const screenPos = this.transformHandler.worldToCanvasCoordinates( + render.location, + ); + render.container.position.set( + Math.round(screenPos.x), + Math.round(screenPos.y), + ); + render.container.scale.set(computeNameLayerScale(render.baseSize)); + this.updateTraitorAlpha(render, now); + } + } + + private renderPlayerInfo( + render: RenderInfo, + transitiveTargets: PlayerView[], + now: number, + ) { if (!render.player.nameLocation()) { return; } if (!render.player.isAlive()) { - this.renders = this.renders.filter((r) => r !== render); - render.element.remove(); + this.deleteRender(render); 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") { + if (!render.container.visible) { 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.fontSize = computeNameLayerFontSize(render.baseSize); + this.updateText(render); + this.updateFlag(render); - 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, + darkMode: this.userSettings.darkMode(), alliancesDisabled: this.alliancesDisabled, - transitiveTargets: transitiveTargets, + transitiveTargets, }); - // Build a set of desired icon IDs - const desiredIconIds = new Set(icons.map((icon) => icon.id)); + this.updateIcons(render, icons, iconSize); + this.layoutRender(render, iconSize); + } - // 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); - } + private updateText(render: RenderInfo) { + const displayName = replaceUnsupportedNameGlyphs( + render.player.displayName(), + ); + const troopsText = replaceUnsupportedNameGlyphs( + renderTroops(render.player.troops()), + ); + const fontColor = this.theme.textColor(render.player); + + if ( + render.lastDisplayName !== displayName || + render.fontColor !== fontColor || + render.nameText.style.fontSize !== render.fontSize || + render.nameText.style.fontFamily !== this.assets.fontFamily + ) { + render.nameText.text = displayName; + render.nameText.style = { + fontFamily: this.assets.fontFamily, + fontSize: render.fontSize, + fill: fontColor, + }; + render.lastDisplayName = displayName; + } + + if ( + render.lastTroopsText !== troopsText || + render.fontColor !== fontColor || + render.troopsText.style.fontSize !== render.fontSize || + render.troopsText.style.fontFamily !== this.assets.fontFamily + ) { + render.troopsText.text = troopsText; + render.troopsText.style = { + fontFamily: this.assets.fontFamily, + fontSize: render.fontSize, + fill: fontColor, + }; + render.lastTroopsText = troopsText; + } + + render.fontColor = fontColor; + } + + private updateFlag(render: RenderInfo) { + const flag = render.player.cosmetics.flag; + const src = flag ? assetUrl(flag) : ""; + if (!src) { + render.flagSprite?.destroy(); + render.flagSprite = null; + render.flagSrc = ""; + return; + } + + if (src !== render.flagSrc) { + render.flagSprite?.destroy(); + render.flagSprite = null; + render.flagSrc = src; + } + + const texture = this.assets.getTexture(src); + if (!texture) { + if (render.flagSprite) { + render.flagSprite.visible = false; + } + return; + } + + if (!render.flagSprite) { + render.flagSprite = new PIXI.Sprite(texture); + render.flagSprite.anchor.set(0.5); + render.flagSprite.alpha = 0.8; + render.container.addChild(render.flagSprite); + } else if (render.flagSprite.texture !== texture) { + render.flagSprite.texture = texture; + } + + render.flagSprite.visible = true; + } + + private updateIcons( + render: RenderInfo, + icons: PlayerIconDescriptor[], + size: number, + ) { + const desiredIds = new Set(icons.map((icon) => icon.id)); + for (const [id, iconRender] of render.icons) { + if (!desiredIds.has(id)) { + iconRender.container.destroy({ children: true }); + 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; + if (icon.kind === EMOJI_ICON_KIND) { + this.updateEmojiIcon(render, icon, size); + } else if (icon.id === ALLIANCE_ICON_ID) { + this.updateAllianceIcon(render, icon, size); + } else if (icon.kind === IMAGE_ICON_KIND && icon.src) { + this.updateImageIcon(render, icon, size); } - // 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( + private updateImageIcon( 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); + const src = icon.src; + if (!src) { + return; } - emojiDiv.textContent = icon.text ?? ""; - emojiDiv.style.fontSize = `${size}px`; + let iconRender = render.icons.get(icon.id); + if (!iconRender || iconRender.src !== src || !iconRender.sprite) { + iconRender?.container.destroy({ children: true }); + const container = new PIXI.Container(); + container.alpha = 0.8; + const sprite = new PIXI.Sprite(); + sprite.anchor.set(0.5); + container.addChild(sprite); + render.container.addChild(container); + iconRender = { + container, + centered: icon.center ?? false, + src, + sprite, + }; + render.icons.set(icon.id, iconRender); + } + + iconRender.centered = icon.center ?? false; + const texture = this.assets.getTexture(src); + iconRender.container.visible = texture !== null; + if (!texture) { + return; + } + + iconRender.sprite!.texture = texture; + iconRender.sprite!.width = size; + iconRender.sprite!.height = size; } - private handleAllianceIcons( + private updateEmojiIcon( render: RenderInfo, + icon: PlayerIconDescriptor, size: number, - darkMode: string, ) { + let iconRender = render.icons.get(icon.id); + if (!iconRender || !iconRender.text) { + iconRender?.container.destroy({ children: true }); + const container = new PIXI.Container(); + container.alpha = 0.8; + const text = new PIXI.Text({ + text: icon.text ?? "", + style: { + fontFamily: "sans-serif", + fontSize: size, + fill: "#ffffff", + }, + }); + text.anchor.set(0.5); + container.addChild(text); + render.container.addChild(container); + iconRender = { container, centered: icon.center ?? false, text }; + render.icons.set(icon.id, iconRender); + } + + iconRender.centered = icon.center ?? false; + iconRender.text!.text = icon.text ?? ""; + iconRender.text!.style = { + fontFamily: "sans-serif", + fontSize: size, + fill: "#ffffff", + }; + iconRender.container.visible = true; + } + + private updateAllianceIcon( + render: RenderInfo, + icon: PlayerIconDescriptor, + size: number, + ) { + let iconRender = render.icons.get(icon.id); + if (!iconRender || !iconRender.alliance) { + iconRender?.container.destroy({ children: true }); + const container = new PIXI.Container(); + container.alpha = 0.8; + const base = new PIXI.Sprite(); + const colored = new PIXI.Sprite(); + const questionMark = new PIXI.Sprite(); + const mask = new PIXI.Graphics(); + for (const sprite of [base, colored, questionMark]) { + sprite.anchor.set(0.5); + container.addChild(sprite); + } + colored.mask = mask; + container.addChild(mask); + render.container.addChild(container); + iconRender = { + container, + centered: false, + src: icon.src, + alliance: { base, colored, questionMark, mask }, + }; + render.icons.set(icon.id, iconRender); + } + + const baseTexture = this.assets.getTexture(allianceIconFaded); + const coloredTexture = icon.src ? this.assets.getTexture(icon.src) : null; + const questionTexture = this.assets.getTexture(questionMarkIcon); + iconRender.container.visible = + baseTexture !== null && coloredTexture !== null; + if (!baseTexture || !coloredTexture) { + return; + } + + const refs = iconRender.alliance!; + refs.base.texture = baseTexture; + refs.colored.texture = coloredTexture; + refs.base.width = size; + refs.base.height = size; + refs.colored.width = size; + refs.colored.height = size; + this.myPlayer ??= this.game.myPlayer(); const allianceView = this.myPlayer ?.alliances() .find((a) => a.other === render.player.id()); + const remaining = allianceView + ? Math.max(0, allianceView.expiresAt - this.game.ticks()) + : 0; + const fraction = Math.max( + 0, + Math.min(1, remaining / this.allianceDuration), + ); + const topCut = (computeAllianceTopCutPercent(fraction) / 100) * size; + refs.mask.clear(); + refs.mask + .rect(-size / 2, -size / 2 + topCut, size, Math.max(0, size - topCut)) + .fill(0xffffff); - 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; + refs.questionMark.visible = + allianceView?.hasExtensionRequest === true && questionTexture !== null; + if (questionTexture) { + refs.questionMark.texture = questionTexture; + refs.questionMark.width = size; + refs.questionMark.height = size; } - - 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; + private layoutRender(render: RenderInfo, iconSize: number) { + const regularIcons = Array.from(render.icons.values()).filter( + (icon) => !icon.centered && icon.container.visible, + ); + const centeredIcons = Array.from(render.icons.values()).filter( + (icon) => icon.centered && icon.container.visible, + ); + const flagTexture = render.flagSprite?.visible + ? render.flagSprite.texture + : null; + const flagAspectRatio = + flagTexture && flagTexture.height > 0 + ? flagTexture.width / flagTexture.height + : 1; - if (!imgElement) { - imgElement = icon.center - ? (this.iconCenterTemplate.cloneNode(true) as HTMLImageElement) - : (this.iconTemplate.cloneNode(true) as HTMLImageElement); + const layout = computeNameLayerLayout({ + fontSize: render.fontSize, + iconSize, + iconCount: regularIcons.length, + centeredIconCount: centeredIcons.length, + hasFlag: render.flagSprite?.visible === true, + flagAspectRatio, + nameWidth: render.nameText.width, + troopWidth: render.troopsText.width, + }); - 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 ?? ""; - } + regularIcons.forEach((icon, index) => { + const pos = layout.iconPositions[index]; + icon.container.position.set(pos.x, pos.y); + }); + centeredIcons.forEach((icon, index) => { + const pos = layout.centeredIconPositions[index]; + icon.container.position.set(pos.x, pos.y); + }); - imgElement.style.width = `${size}px`; - imgElement.style.height = `${size}px`; - imgElement.setAttribute("dark-mode", darkMode); + if (render.flagSprite && layout.flag) { + render.flagSprite.position.set(layout.flag.x, layout.flag.y); + render.flagSprite.width = layout.flag.width; + render.flagSprite.height = layout.flag.height; + render.flagSprite.visible = true; + } else if (render.flagSprite) { + render.flagSprite.visible = false; } - return imgElement; + + render.nameText.position.set(layout.nameText.x, layout.nameText.y); + render.troopsText.position.set(layout.troopText.x, layout.troopText.y); } - 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"; + private updateTraitorAlpha(render: RenderInfo, nowMs: number) { + const traitorIcon = render.icons.get(TRAITOR_ICON_ID); + if (!traitorIcon) { + return; } + traitorIcon.container.alpha = + computeTraitorFlashAlpha( + render.player.getTraitorRemainingTicks(), + nowMs, + ) * 0.8; + } + + private deleteRender(render: RenderInfo) { + const index = this.renders.indexOf(render); + if (index >= 0) { + this.renders.splice(index, 1); + } + this.seenPlayers.delete(render.player); + render.container.destroy({ children: true }); } } diff --git a/src/client/graphics/layers/NameLayerAssets.ts b/src/client/graphics/layers/NameLayerAssets.ts new file mode 100644 index 000000000..a9dbfb56b --- /dev/null +++ b/src/client/graphics/layers/NameLayerAssets.ts @@ -0,0 +1,104 @@ +import * as PIXI from "pixi.js"; +import { assetUrl } from "../../../core/AssetUrls"; + +const nameLayerFont = assetUrl("fonts/namelayer_overpass.xml"); +const fallbackFont = assetUrl("fonts/round_6x6_modified.xml"); +const iconAtlas = assetUrl("images/namelayer-icons.json"); +const emojiAtlas = assetUrl("images/namelayer-emojis.json"); + +export const NAME_LAYER_FONT_FAMILY = "namelayer_overpass"; +export const NAME_LAYER_FALLBACK_FONT_FAMILY = "round_6x6_modified"; + +export class NameLayerAssets { + public fontFamily = NAME_LAYER_FONT_FAMILY; + + private readonly textures = new Map(); + private readonly pendingTextures = new Map>(); + private readonly warnedTextureFailures = new Set(); + private preloadPromise: Promise | null = null; + + preload(): Promise { + this.preloadPromise ??= this.loadBaseAssets(); + return this.preloadPromise; + } + + getTexture(src: string): PIXI.Texture | null { + const cached = this.textures.get(src); + if (cached !== undefined) { + return cached; + } + + if (!this.pendingTextures.has(src)) { + this.pendingTextures.set( + src, + PIXI.Assets.load(src) + .then((texture: PIXI.Texture) => { + this.textures.set(src, texture); + }) + .catch((error) => { + this.textures.set(src, null); + this.warnTextureFailure(src, error); + }) + .finally(() => { + this.pendingTextures.delete(src); + }), + ); + } + + return null; + } + + preloadTextures(srcs: Iterable): void { + for (const src of srcs) { + this.getTexture(src); + } + } + + resetWarningsForTests(): void { + this.warnedTextureFailures.clear(); + } + + private async loadBaseAssets(): Promise { + await this.loadFont(); + await Promise.all([ + this.loadOptionalAtlas(iconAtlas, "static icon atlas"), + this.loadOptionalAtlas(emojiAtlas, "emoji atlas"), + ]); + } + + private async loadFont(): Promise { + try { + await PIXI.Assets.load(nameLayerFont); + this.fontFamily = NAME_LAYER_FONT_FAMILY; + return; + } catch (error) { + console.warn( + "NameLayer generated bitmap font unavailable; using fallback font", + error, + ); + } + + try { + await PIXI.Assets.load(fallbackFont); + this.fontFamily = NAME_LAYER_FALLBACK_FONT_FAMILY; + } catch (error) { + console.error("NameLayer failed to load bitmap font", error); + } + } + + private async loadOptionalAtlas(src: string, label: string): Promise { + try { + await PIXI.Assets.load(src); + } catch (error) { + console.warn(`NameLayer ${label} unavailable`, error); + } + } + + private warnTextureFailure(src: string, error: unknown): void { + if (this.warnedTextureFailures.has(src)) { + return; + } + this.warnedTextureFailures.add(src); + console.warn(`NameLayer texture omitted after load failure: ${src}`, error); + } +} diff --git a/src/client/graphics/layers/NameLayerLayout.ts b/src/client/graphics/layers/NameLayerLayout.ts new file mode 100644 index 000000000..6515ab07c --- /dev/null +++ b/src/client/graphics/layers/NameLayerLayout.ts @@ -0,0 +1,195 @@ +export const NAME_LAYER_ICON_GAP = 4; +export const NAME_LAYER_MAX_ZOOM_SCALE = 17; +export const NAME_LAYER_TROOP_MARGIN_RATIO = -0.05; + +export interface NameLayerVisibilityInput { + isLayerVisible: boolean; + transformScale: number; + baseSize: number; + isOnScreen: boolean; +} + +export interface NameLayerLayoutInput { + fontSize: number; + iconSize: number; + iconCount: number; + centeredIconCount: number; + hasFlag: boolean; + flagAspectRatio: number; + nameWidth: number; + troopWidth: number; +} + +export interface NameLayerLayout { + flag: { x: number; y: number; width: number; height: number } | null; + nameText: { x: number; y: number }; + troopText: { x: number; y: number }; + iconPositions: { x: number; y: number }[]; + centeredIconPositions: { x: number; y: number }[]; + height: number; + width: number; + rows: { iconsY: number | null; nameY: number; troopsY: number }; +} + +const SUPPORTED_TEXT_CHARS = new Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_ üÜ.[]+-=(),':!?/@#$%&\"".split( + "", + ), +); + +const warnedUnsupportedGlyphs = new Set(); + +export function computeNameLayerVisible({ + isLayerVisible, + transformScale, + baseSize, + isOnScreen, +}: NameLayerVisibilityInput): boolean { + const size = transformScale * baseSize; + return ( + isLayerVisible && + size >= 7 && + !(transformScale > NAME_LAYER_MAX_ZOOM_SCALE && size > 100) && + isOnScreen + ); +} + +export function computeNameLayerScale(baseSize: number): number { + return Math.min(baseSize * 0.25, 3); +} + +export function computeNameLayerFontSize(baseSize: number): number { + return Math.max(4, Math.floor(baseSize * 0.4)); +} + +export function computeNameLayerLayout({ + fontSize, + iconSize, + iconCount, + centeredIconCount, + hasFlag, + flagAspectRatio, + nameWidth, + troopWidth, +}: NameLayerLayoutInput): NameLayerLayout { + const visibleIconCount = Math.max(0, iconCount); + const iconRowHeight = visibleIconCount > 0 ? iconSize : 0; + const iconRowWidth = + visibleIconCount > 0 + ? visibleIconCount * iconSize + + (visibleIconCount - 1) * NAME_LAYER_ICON_GAP + : 0; + const flagHeight = hasFlag ? fontSize : 0; + const flagWidth = hasFlag ? Math.max(0, flagHeight * flagAspectRatio) : 0; + const nameRowHeight = fontSize; + const troopMargin = fontSize * NAME_LAYER_TROOP_MARGIN_RATIO; + const troopHeight = fontSize; + const nameRowWidth = flagWidth + nameWidth; + const totalHeight = iconRowHeight + nameRowHeight + troopMargin + troopHeight; + const width = Math.max(iconRowWidth, nameRowWidth, troopWidth); + + let cursorY = -totalHeight / 2; + const iconsY = visibleIconCount > 0 ? cursorY + iconRowHeight / 2 : null; + cursorY += iconRowHeight; + const nameY = cursorY + nameRowHeight / 2; + cursorY += nameRowHeight + troopMargin; + const troopsY = cursorY + troopHeight / 2; + + const iconPositions: { x: number; y: number }[] = []; + if (visibleIconCount > 0 && iconsY !== null) { + const startX = -iconRowWidth / 2 + iconSize / 2; + for (let i = 0; i < visibleIconCount; i++) { + iconPositions.push({ + x: startX + i * (iconSize + NAME_LAYER_ICON_GAP), + y: iconsY, + }); + } + } + + const nameStartX = -nameRowWidth / 2; + const flag = hasFlag + ? { + x: nameStartX + flagWidth / 2, + y: nameY, + width: flagWidth, + height: flagHeight, + } + : null; + const nameTextX = nameStartX + flagWidth + nameWidth / 2; + const centeredIconPositions = Array.from( + { length: centeredIconCount }, + () => ({ + x: 0, + y: nameY, + }), + ); + + return { + flag, + nameText: { x: nameTextX, y: nameY }, + troopText: { x: 0, y: troopsY }, + iconPositions, + centeredIconPositions, + height: totalHeight, + width, + rows: { iconsY, nameY, troopsY }, + }; +} + +export function computeTraitorFlashDurationSeconds( + remainingTicks: number, +): number | null { + const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2; + if (remainingSeconds > 15) { + return null; + } + + const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds)); + const normalizedTime = clampedSeconds / 15; + const easedProgress = 1 - Math.pow(1 - normalizedTime, 3); + return 0.2 + (1.0 - 0.2) * easedProgress; +} + +export function computeTraitorFlashAlpha( + remainingTicks: number, + nowMs: number, +): number { + const duration = computeTraitorFlashDurationSeconds(remainingTicks); + if (duration === null) { + return 1; + } + + const durationMs = Math.max(1, duration * 1000); + const phase = (nowMs % durationMs) / durationMs; + const wave = phase < 0.5 ? phase / 0.5 : (1 - phase) / 0.5; + const eased = 0.5 - Math.cos(wave * Math.PI) / 2; + return 1 - eased * 0.7; +} + +export function replaceUnsupportedNameGlyphs( + value: string, + warn: (message: string) => void = console.warn, +): string { + let changed = false; + let result = ""; + + for (const char of value) { + if (SUPPORTED_TEXT_CHARS.has(char)) { + result += char; + continue; + } + + changed = true; + result += "?"; + if (!warnedUnsupportedGlyphs.has(char)) { + warnedUnsupportedGlyphs.add(char); + warn(`NameLayer unsupported glyph replaced with ?: ${char}`); + } + } + + return changed ? result : value; +} + +export function resetNameLayerGlyphWarningsForTests(): void { + warnedUnsupportedGlyphs.clear(); +} diff --git a/tests/NameLayer.test.ts b/tests/NameLayer.test.ts index 2337e78a1..8473198e9 100644 --- a/tests/NameLayer.test.ts +++ b/tests/NameLayer.test.ts @@ -1,4 +1,14 @@ -import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons"; +import { + computeAllianceClipPath, + computeAllianceTopCutPercent, +} from "../src/client/graphics/PlayerIcons"; +import { + computeNameLayerLayout, + computeTraitorFlashAlpha, + computeTraitorFlashDurationSeconds, + replaceUnsupportedNameGlyphs, + resetNameLayerGlyphWarningsForTests, +} from "../src/client/graphics/layers/NameLayerLayout"; describe("PlayerIcons", () => { describe("computeAllianceClipPath", () => { @@ -37,5 +47,69 @@ describe("PlayerIcons", () => { expect(result).toContain("-2px"); expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right) }); + + test("shares numeric top-cut helper with Pixi masks", () => { + expect(computeAllianceTopCutPercent(1.0)).toBeCloseTo(20); + expect(computeAllianceTopCutPercent(0.5)).toBeCloseTo(51.2); + expect(computeAllianceTopCutPercent(0.0)).toBeCloseTo(82.4); + }); + }); +}); + +describe("NameLayerLayout", () => { + test("computes DOM-compatible local row positions with flag and icon gaps", () => { + const layout = computeNameLayerLayout({ + fontSize: 10, + iconSize: 15, + iconCount: 2, + centeredIconCount: 1, + hasFlag: true, + flagAspectRatio: 2, + nameWidth: 40, + troopWidth: 30, + }); + + expect(layout.iconPositions).toEqual([ + { x: -9.5, y: -9.75 }, + { x: 9.5, y: -9.75 }, + ]); + expect(layout.flag).toEqual({ x: -20, y: 2.75, width: 20, height: 10 }); + expect(layout.nameText).toEqual({ x: 10, y: 2.75 }); + expect(layout.troopText).toEqual({ x: 0, y: 12.25 }); + expect(layout.centeredIconPositions).toEqual([{ x: 0, y: 2.75 }]); + }); + + test("keeps no-flag names centered on the text width", () => { + const layout = computeNameLayerLayout({ + fontSize: 12, + iconSize: 18, + iconCount: 0, + centeredIconCount: 0, + hasFlag: false, + flagAspectRatio: 1, + nameWidth: 60, + troopWidth: 24, + }); + + expect(layout.flag).toBeNull(); + expect(layout.nameText.x).toBe(0); + expect(layout.width).toBe(60); + }); + + test("matches traitor flash duration thresholds and alpha extrema", () => { + expect(computeTraitorFlashDurationSeconds(156)).toBeNull(); + expect(computeTraitorFlashDurationSeconds(150)).toBeCloseTo(1); + expect(computeTraitorFlashDurationSeconds(0)).toBeCloseTo(0.2); + expect(computeTraitorFlashAlpha(150, 0)).toBeCloseTo(1); + expect(computeTraitorFlashAlpha(150, 500)).toBeCloseTo(0.3); + }); + + test("replaces unsupported glyphs once per glyph", () => { + resetNameLayerGlyphWarningsForTests(); + const warn = vi.fn(); + + expect(replaceUnsupportedNameGlyphs("AπŸ™‚πŸ™‚B", warn)).toBe("A??B"); + expect(replaceUnsupportedNameGlyphs("πŸ™‚", warn)).toBe("?"); + expect(warn).toHaveBeenCalledTimes(1); }); });