mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-24 04:58:01 +00:00
Merge branch 'main' into local-attack
This commit is contained in:
+18
-17
@@ -133,14 +133,14 @@
|
||||
|
||||
<div
|
||||
id="mobile-menu-backdrop"
|
||||
class="lg:!hidden [.in-game_&]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
|
||||
class="lg:hidden! in-[.in-game]:hidden hidden pointer-events-none [&.open]:block [&.open]:pointer-events-auto [&.open]:fixed [&.open]:inset-0 [&.open]:bg-black/60 [&.open]:z-[40000] transition-opacity"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<mobile-nav-bar
|
||||
id="sidebar-menu"
|
||||
class="peer [.in-game_&]:hidden z-[40001] fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
|
||||
class="peer in-[.in-game]:hidden z-40001 fixed left-0 top-0 h-full flex flex-col justify-start overflow-visible bg-black/60 backdrop-blur-md transition-transform duration-500 ease-out transform -translate-x-full w-[80%] [&.open]:translate-x-0 lg:hidden"
|
||||
role="dialog"
|
||||
data-i18n-aria-label="main.menu"
|
||||
aria-hidden="true"
|
||||
@@ -148,14 +148,14 @@
|
||||
|
||||
<!-- MAIN CONTENT AREA -->
|
||||
<div
|
||||
class="[.in-game_&]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
|
||||
class="in-[.in-game]:hidden flex-1 relative overflow-hidden h-full transition-[margin] duration-500 ease-out will-change-[margin-left] flex flex-col"
|
||||
>
|
||||
<!-- Desktop Top Bar -->
|
||||
<desktop-nav-bar></desktop-nav-bar>
|
||||
|
||||
<div
|
||||
id="turnstile-container"
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-[99999]"
|
||||
class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-99999"
|
||||
></div>
|
||||
|
||||
<gutter-ads></gutter-ads>
|
||||
@@ -179,11 +179,11 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></host-lobby-modal>
|
||||
<join-private-lobby-modal
|
||||
id="page-join-private-lobby"
|
||||
<join-lobby-modal
|
||||
id="page-join-lobby"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></join-private-lobby-modal>
|
||||
></join-lobby-modal>
|
||||
<territory-patterns-modal
|
||||
id="page-item-store"
|
||||
inline
|
||||
@@ -194,16 +194,16 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></user-setting>
|
||||
<leaderboard-modal
|
||||
id="page-leaderboard"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></leaderboard-modal>
|
||||
<troubleshooting-modal
|
||||
id="page-troubleshooting"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></troubleshooting-modal>
|
||||
<stats-modal
|
||||
id="page-stats"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></stats-modal>
|
||||
<account-modal
|
||||
id="page-account"
|
||||
inline
|
||||
@@ -239,17 +239,17 @@
|
||||
<div id="app"></div>
|
||||
|
||||
<div
|
||||
class="left-0 bottom-0 sm:left-4 sm:bottom-4 w-full flex-col-reverse sm:flex-row z-50 md:w-[320px] fixed pointer-events-none"
|
||||
class="fixed left-0 bottom-0 min-[1200px]:left-4 min-[1200px]:bottom-4 w-full flex flex-col sm:flex-row sm:items-end z-50 pointer-events-none"
|
||||
>
|
||||
<div class="order-2 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto">
|
||||
<control-panel></control-panel>
|
||||
</div>
|
||||
<div
|
||||
class="w-full md:w-2/3 md:fixed sm:right-0 md:bottom-0 md:flex flex-col items-end pointer-events-none"
|
||||
class="order-1 sm:order-none w-full sm:w-1/2 min-[1200px]:w-auto min-[1200px]:fixed min-[1200px]:right-0 min-[1200px]:bottom-0 flex flex-col sm:items-end pointer-events-none"
|
||||
>
|
||||
<chat-display></chat-display>
|
||||
<events-display></events-display>
|
||||
</div>
|
||||
<div>
|
||||
<control-panel></control-panel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game modals and overlays -->
|
||||
@@ -267,6 +267,7 @@
|
||||
<spawn-timer></spawn-timer>
|
||||
<immunity-timer></immunity-timer>
|
||||
<in-game-header-ad></in-game-header-ad>
|
||||
<spawn-video-ad></spawn-video-ad>
|
||||
<game-info-modal></game-info-modal>
|
||||
<alert-frame></alert-frame>
|
||||
<chat-modal></chat-modal>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "TheBox",
|
||||
"nations": [
|
||||
{ "coordinates": [10, 10], "flag": "", "name": "King of the Corner" },
|
||||
{ "coordinates": [1024, 300], "flag": "", "name": "Suspicious Ally" },
|
||||
{ "coordinates": [1650, 400], "flag": "", "name": "Evan The Dev" },
|
||||
{ "coordinates": [1024, 1024], "flag": "", "name": "Middle Defender" },
|
||||
{ "coordinates": [350, 1024], "flag": "", "name": "Punch Merchant" },
|
||||
{ "coordinates": [1700, 1024], "flag": "", "name": "Nuke Thrower" },
|
||||
{ "coordinates": [400, 1650], "flag": "", "name": "Fullsender" },
|
||||
{ "coordinates": [1024, 1750], "flag": "", "name": "Factory Builder" },
|
||||
{ "coordinates": [1650, 1650], "flag": "", "name": "Front Manager" },
|
||||
{ "coordinates": [700, 700], "flag": "", "name": "Box Fighter" },
|
||||
{ "coordinates": [1350, 700], "flag": "", "name": "Cage Liberator" },
|
||||
{ "coordinates": [700, 1350], "flag": "", "name": "Train Trader" },
|
||||
{ "coordinates": [1350, 1350], "flag": "", "name": "Non-peaceful Bot" }
|
||||
]
|
||||
}
|
||||
@@ -63,6 +63,7 @@ var maps = []struct {
|
||||
{Name: "world"},
|
||||
{Name: "lemnos"},
|
||||
{Name: "twolakes"},
|
||||
{Name: "thebox"},
|
||||
{Name: "didier"},
|
||||
{Name: "didierfrance"},
|
||||
{Name: "amazonriver"},
|
||||
|
||||
Generated
+32
-6
@@ -7,6 +7,7 @@
|
||||
"name": "openfront-client",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@lit-labs/virtualizer": "^2.1.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
|
||||
@@ -1188,6 +1189,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -1231,6 +1233,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2100,14 +2103,22 @@
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.3.0.tgz",
|
||||
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@lit-labs/virtualizer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@lit-labs/virtualizer/-/virtualizer-2.1.1.tgz",
|
||||
"integrity": "sha512-JWxMwnlouLdwpw8spLTuax53WMnSP3xt0dCyxAS7GJr5Otda9MGgR/ghAdfwhSY75TmjbE1T2TqChwoGCw3ggw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"lit": "^3.2.0",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@lit/reactive-element": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.0.tgz",
|
||||
"integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.2.0"
|
||||
@@ -2156,6 +2167,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -4550,6 +4562,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.32.tgz",
|
||||
"integrity": "sha512-3jigKqgSjsH6gYZv2nEsqdXfZqIFGAV36XYYjf9KGZ3PSG+IhLecqPnI310RvjutyMwifE2hhhNEklOUrvx/wA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4657,7 +4670,6 @@
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
@@ -4716,6 +4728,7 @@
|
||||
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.34.1",
|
||||
"@typescript-eslint/types": "8.34.1",
|
||||
@@ -5195,6 +5208,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5594,6 +5608,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001718",
|
||||
"electron-to-chromium": "^1.5.160",
|
||||
@@ -5747,6 +5762,7 @@
|
||||
"integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
@@ -6668,6 +6684,7 @@
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -7237,6 +7254,7 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8571,6 +8589,7 @@
|
||||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
@@ -9114,7 +9133,6 @@
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
|
||||
"integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit/reactive-element": "^2.1.0",
|
||||
@@ -9126,7 +9144,6 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.0.tgz",
|
||||
"integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@lit-labs/ssr-dom-shim": "^1.2.0",
|
||||
@@ -9138,7 +9155,6 @@
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.0.tgz",
|
||||
"integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "^2.0.2"
|
||||
@@ -10169,6 +10185,7 @@
|
||||
"integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pixi/colord": "^2.9.6",
|
||||
"@types/css-font-loading-module": "^0.0.12",
|
||||
@@ -10213,6 +10230,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -10315,6 +10333,7 @@
|
||||
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -11137,6 +11156,7 @@
|
||||
"integrity": "sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@sinonjs/commons": "^3.0.1",
|
||||
"@sinonjs/fake-timers": "^15.1.0",
|
||||
@@ -11558,6 +11578,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -11800,6 +11821,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz",
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -11868,6 +11890,7 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12014,6 +12037,7 @@
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12763,6 +12787,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12776,6 +12801,7 @@
|
||||
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.16",
|
||||
"@vitest/mocker": "4.0.16",
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.758.0",
|
||||
"@lit-labs/virtualizer": "^2.1.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.200.0",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path fill="#fff" d="M90.9 17.1c-3.9-3.9-10.2-3.9-14.1 0L37.5 56.4 23.2 42.1c-3.9-3.9-10.2-3.9-14.1 0s-3.9 10.2 0 14.1l21.4 21.4c1.9 1.9 4.4 2.9 7.1 2.9s5.1-1 7.1-2.9l46.4-46.4c3.8-3.9 3.8-10.2-.2-14.1z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 278 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 125" xmlns:v="https://vecta.io/nano"><path d="M86.946 70.119l-2.417-15.098c-.286-1.419-1.511-2.495-2.982-2.495-.899 0-1.687.385-2.268.997L68.104 63.997c-.176.156-.352.338-.515.56-1.049 1.433-.749 3.465.684 4.527.273.215.573.345.866.462l2.808.931c-2.697 7.641-9.313 13.45-17.417 15.059l.014-45.638h5.992c1.889 0 3.412-1.538 3.412-3.426 0-1.875-1.523-3.413-3.412-3.413h-5.992v-6.78c3.979-1.687 6.721-5.634 6.721-10.206C61.264 9.95 56.314 5 50.192 5c-6.097 0-11.073 4.95-11.073 11.073 0 4.578 2.847 8.52 6.8 10.206l.007 6.78h-6.468c-1.882 0-3.413 1.538-3.413 3.413 0 1.889 1.531 3.426 3.413 3.426h6.468l.021 45.71c-8.292-1.485-15.15-7.354-17.926-15.131l2.827-.931c.299-.117.6-.247.879-.456 1.427-1.067 1.753-3.1.671-4.533-.155-.222-.313-.404-.508-.554L20.706 53.524c-.553-.612-1.361-.997-2.246-.997-1.486 0-2.743 1.075-3.009 2.495L13.027 70.12c-.053.241-.064.508-.064.762 0 1.791 1.446 3.236 3.229 3.236.339 0 .658-.032.959-.137l2.624-.853C23.956 85.816 35.901 95 49.997 95s26.053-9.184 30.221-21.891l2.619.872c.299.105.618.137.963.137 1.785 0 3.237-1.446 3.237-3.236 0-.255-.025-.522-.091-.763M45.659 16.073c0-2.521 2.044-4.553 4.533-4.553 2.527 0 4.552 2.032 4.552 4.553 0 2.514-2.024 4.559-4.552 4.559-2.489 0-4.533-2.046-4.533-4.559"/><path d="M46.821306 94.770829c-9.672125-.972647-18.52814-6.513856-23.771656-14.873922-.868866-1.385288-2.385574-4.487112-2.870545-5.870561l-.343618-.96102c-.0095-.01668-.705224.192731-1.545948.46535s-1.804312.494255-2.141309.492526c-1.598809-.0082-3.122304-1.547171-3.115278-3.146913.000864-.196879.574575-3.918388 1.274912-8.270022 1.419295-8.818969 1.428076-8.850837 2.627499-9.536632.834583-.477189 1.483957-.563323 2.337029-.309989.644845.191499 1.336272.793564 6.866372 5.978939 4.549564 4.26596 6.233135 5.932201 6.471488 6.40487.603214 1.196211.325525 2.693184-.677921 3.654548-.288018.275937-1.002559.607386-2.183926 1.013042-2.007091.68919-1.921853.529349-1.189152 2.229934 2.313522 5.369652 7.064022 9.988932 12.577448 12.230032 1.263767.513697 3.384288 1.159414 4.275471 1.30192l.550027.08795V62.733077 39.805269H42.37799c-3.958889 0-4.236614-.04887-5.126792-.902159-.76147-.729916-1.085823-1.473414-1.081006-2.477941.0071-1.483668.947583-2.699422 2.43431-3.146861.49772-.149791 1.545965-.202707 4.01562-.202707h3.342077v-3.423483-3.423485l-1.109679-.604511c-.793899-.432485-1.482722-.979822-2.420583-1.923389-1.146297-1.15327-1.401076-1.505996-2.029023-2.809061-.858345-1.781165-1.170382-3.086543-1.170382-4.896174 0-6.4184795 5.839733-11.6186738 12.164919-10.8326779 2.656895.3301574 4.668838 1.3296881 6.615711 3.286675 1.070143 1.0757025 1.339007 1.4513238 1.957431 2.7346549.865772 1.796619 1.169155 3.085051 1.169155 4.965255 0 4.023175-2.191435 7.679165-5.755536 9.602011l-.969271.522925-.0024 3.389446-.0024 3.389446 3.472222.04702c3.946037.05344 4.168331.108713 5.177124 1.287261 1.316051 1.53751.926562 3.821223-.837885 4.912821l-.700444.433339-3.555509.04279-3.555508.0428v22.902508l.107388 22.902148c.332248-.0011 2.935079-.780331 4.075018-1.219944 5.752163-2.218302 10.721311-7.144396 13.059805-12.946652.204466-.507318.322918-.966456.263226-1.020307s-.65621-.274921-1.325597-.491268c-1.73484-.560706-2.51001-1.013168-2.998016-1.749923-.680235-1.026966-.727607-2.0483-.14813-3.19361.309132-.610985 12.085183-11.683542 12.854449-12.08653 1.199252-.628243 2.81487-.181005 3.651625 1.010847.392869.559594.481176 1.003292 1.695561 8.519473.875547 5.419011 1.264717 8.190005 1.229335 8.753186-.06941 1.104804-.613524 1.897274-1.670778 2.433394-1.027413.520988-1.635513.502208-3.442467-.106313-.816705-.275039-1.512424-.472563-1.546044-.438944s-.379938.854657-.769596 1.824527c-2.13365 5.310705-5.61865 9.924534-10.123033 13.401993-6.338429 4.893379-14.56767 7.254819-22.501513 6.456977zm4.86827-74.330261c1.140586-.346728 2.230937-1.344337 2.770928-2.535241.21803-.480845.29704-.95557.301498-1.811516.0055-1.049248-.04177-1.253646-.495106-2.142377-1.123467-2.202486-3.642798-3.073429-5.980881-2.067613-.987511.424816-2.068224 1.59611-2.409954 2.611949-1.248434 3.711132 2.053637 7.087771 5.813515 5.944798z" fill="#fff"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" xmlns:v="https://vecta.io/nano"><path d="M86.946 70.119l-2.417-15.098c-.286-1.419-1.511-2.495-2.982-2.495-.899 0-1.687.385-2.268.997L68.104 63.997c-.176.156-.352.338-.515.56-1.049 1.433-.749 3.465.684 4.527.273.215.573.345.866.462l2.808.931c-2.697 7.641-9.313 13.45-17.417 15.059l.014-45.638h5.992c1.889 0 3.412-1.538 3.412-3.426 0-1.875-1.523-3.413-3.412-3.413h-5.992v-6.78c3.979-1.687 6.721-5.634 6.721-10.206C61.264 9.95 56.314 5 50.192 5c-6.097 0-11.073 4.95-11.073 11.073 0 4.578 2.847 8.52 6.8 10.206l.007 6.78h-6.468c-1.882 0-3.413 1.538-3.413 3.413 0 1.889 1.531 3.426 3.413 3.426h6.468l.021 45.71c-8.292-1.485-15.15-7.354-17.926-15.131l2.827-.931c.299-.117.6-.247.879-.456 1.427-1.067 1.753-3.1.671-4.533-.155-.222-.313-.404-.508-.554L20.706 53.524c-.553-.612-1.361-.997-2.246-.997-1.486 0-2.743 1.075-3.009 2.495L13.027 70.12c-.053.241-.064.508-.064.762 0 1.791 1.446 3.236 3.229 3.236.339 0 .658-.032.959-.137l2.624-.853C23.956 85.816 35.901 95 49.997 95s26.053-9.184 30.221-21.891l2.619.872c.299.105.618.137.963.137 1.785 0 3.237-1.446 3.237-3.236 0-.255-.025-.522-.091-.763M45.659 16.073c0-2.521 2.044-4.553 4.533-4.553 2.527 0 4.552 2.032 4.552 4.553 0 2.514-2.024 4.559-4.552 4.559-2.489 0-4.533-2.046-4.533-4.559"/><path d="M46.821306 94.770829c-9.672125-.972647-18.52814-6.513856-23.771656-14.873922-.868866-1.385288-2.385574-4.487112-2.870545-5.870561l-.343618-.96102c-.0095-.01668-.705224.192731-1.545948.46535s-1.804312.494255-2.141309.492526c-1.598809-.0082-3.122304-1.547171-3.115278-3.146913.000864-.196879.574575-3.918388 1.274912-8.270022 1.419295-8.818969 1.428076-8.850837 2.627499-9.536632.834583-.477189 1.483957-.563323 2.337029-.309989.644845.191499 1.336272.793564 6.866372 5.978939 4.549564 4.26596 6.233135 5.932201 6.471488 6.40487.603214 1.196211.325525 2.693184-.677921 3.654548-.288018.275937-1.002559.607386-2.183926 1.013042-2.007091.68919-1.921853.529349-1.189152 2.229934 2.313522 5.369652 7.064022 9.988932 12.577448 12.230032 1.263767.513697 3.384288 1.159414 4.275471 1.30192l.550027.08795V62.733077 39.805269H42.37799c-3.958889 0-4.236614-.04887-5.126792-.902159-.76147-.729916-1.085823-1.473414-1.081006-2.477941.0071-1.483668.947583-2.699422 2.43431-3.146861.49772-.149791 1.545965-.202707 4.01562-.202707h3.342077v-3.423483-3.423485l-1.109679-.604511c-.793899-.432485-1.482722-.979822-2.420583-1.923389-1.146297-1.15327-1.401076-1.505996-2.029023-2.809061-.858345-1.781165-1.170382-3.086543-1.170382-4.896174 0-6.4184795 5.839733-11.6186738 12.164919-10.8326779 2.656895.3301574 4.668838 1.3296881 6.615711 3.286675 1.070143 1.0757025 1.339007 1.4513238 1.957431 2.7346549.865772 1.796619 1.169155 3.085051 1.169155 4.965255 0 4.023175-2.191435 7.679165-5.755536 9.602011l-.969271.522925-.0024 3.389446-.0024 3.389446 3.472222.04702c3.946037.05344 4.168331.108713 5.177124 1.287261 1.316051 1.53751.926562 3.821223-.837885 4.912821l-.700444.433339-3.555509.04279-3.555508.0428v22.902508l.107388 22.902148c.332248-.0011 2.935079-.780331 4.075018-1.219944 5.752163-2.218302 10.721311-7.144396 13.059805-12.946652.204466-.507318.322918-.966456.263226-1.020307s-.65621-.274921-1.325597-.491268c-1.73484-.560706-2.51001-1.013168-2.998016-1.749923-.680235-1.026966-.727607-2.0483-.14813-3.19361.309132-.610985 12.085183-11.683542 12.854449-12.08653 1.199252-.628243 2.81487-.181005 3.651625 1.010847.392869.559594.481176 1.003292 1.695561 8.519473.875547 5.419011 1.264717 8.190005 1.229335 8.753186-.06941 1.104804-.613524 1.897274-1.670778 2.433394-1.027413.520988-1.635513.502208-3.442467-.106313-.816705-.275039-1.512424-.472563-1.546044-.438944s-.379938.854657-.769596 1.824527c-2.13365 5.310705-5.61865 9.924534-10.123033 13.401993-6.338429 4.893379-14.56767 7.254819-22.501513 6.456977zm4.86827-74.330261c1.140586-.346728 2.230937-1.344337 2.770928-2.535241.21803-.480845.29704-.95557.301498-1.811516.0055-1.049248-.04177-1.253646-.495106-2.142377-1.123467-2.202486-3.642798-3.073429-5.980881-2.067613-.987511.424816-2.068224 1.59611-2.409954 2.611949-1.248434 3.711132 2.053637 7.087771 5.813515 5.944798z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="m417.86 237.12v-237.12h-40.664v432.45h78.738v-195.34z"/>
|
||||
<path d="m708.33 392.05h302.69v69.617l38.762-0.14453-0.046875-5.3555s-0.5-48.523-0.5-68.785c0-113.14-85.332-204.45-189.74-204.45-104.5 0-190.45 93.094-190.45 206.26 0 20.477-0.5 70.215-0.5 70.238l-0.046875 2.0703h39.809z"/>
|
||||
<path d="m735.59 418.45c-0.26172 2.9062-0.66797 5.7852-0.66797 8.7383 0 68.832 55.809 124.57 124.59 124.57 68.785 0 124.55-55.762 124.55-124.57 0-2.9766-0.45312-5.8555-0.64453-8.7383z"/>
|
||||
<path d="m1030.2 573.67h-331.57c-96.855 0-169.98 56.047-169.98 154.98v409.93c0 33.191 26.953 60.023 60.094 60.023 33.191 0 60.047-26.883 60.047-60.023l0.046875-367.07h37.738v428.5h355.53v-428.52h37.547l0.19141 367.07c0 33.191 26.953 60.023 60.023 60.023 33.238 0 60.094-26.883 60.094-60.023v-409.95c0.046875-98.926-72.953-154.93-169.76-154.93zm-47.5 207-48.785-25.668-48.762 25.668 9.3086-54.309-39.43-38.43 54.5-7.9297 24.383-49.406 24.355 49.406 54.523 7.9297-39.453 38.43z"/>
|
||||
<path d="m489.07 1162-0.90625-396.02-30.668 0.070312-0.71094-311.24-78.523 0.19141 0.71484 293.79s-24.262 41.57-100.19 41.57c-69.332 0-225.52-90.809-225.52-90.809l-53.262 131.71s112.19 55.883 173.67 65.43c23 3.5938 47.617 5.1172 71.191 5.4766-13.883 15.453-22.547 35.668-22.547 58.023 0 28.168 13.617 52.953 34.383 68.953l-133.91 67.977 55.07 97.906 136.24-93.93s13.93-9.8555 26.285-3.332c11.117 5.8828 12.617 20.57 12.617 20.57l0.11719 44.047 46.07-0.30859-0.57031 37.191h53.309l-0.023437-37.262zm-180-159.05c-23.43 0-42.406-19-42.406-42.406s18.977-42.406 42.406-42.406c18.023 0 33.332 11.309 39.477 27.168h-43.57v33.477h42.238c-6.832 14.238-21.262 24.168-38.145 24.168z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
+27
-31
@@ -21,7 +21,8 @@
|
||||
"target_dead_note": "You can't send resources to an eliminated player.",
|
||||
"none": "None",
|
||||
"copied": "Copied!",
|
||||
"click_to_copy": "Click to copy"
|
||||
"click_to_copy": "Click to copy",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
"main": {
|
||||
"title": "OpenFront (ALPHA)",
|
||||
@@ -50,6 +51,7 @@
|
||||
"settings": "Settings",
|
||||
"keys": "Keys",
|
||||
"stats": "Stats",
|
||||
"leaderboard": "Leaderboard",
|
||||
"account": "Account",
|
||||
"help": "Help",
|
||||
"menu": "Menu",
|
||||
@@ -91,6 +93,8 @@
|
||||
"canvas_2d_no_gpu": "Canvas 2D (no GPU)"
|
||||
},
|
||||
"help_modal": {
|
||||
"video_tutorial": "Video Tutorial",
|
||||
"video_tutorial_title": "OpenFront.io Tutorial",
|
||||
"hotkeys": "Hotkeys",
|
||||
"table_key": "Key",
|
||||
"table_action": "Action",
|
||||
@@ -207,6 +211,7 @@
|
||||
"disable_nukes": "Disable Nukes",
|
||||
"enables_title": "Enable Settings",
|
||||
"start": "Start Game",
|
||||
"options_changed_no_achievements": "Custom settings – achievements disabled",
|
||||
"gold_multiplier": "Gold multiplier",
|
||||
"gold_multiplier_placeholder": "2.0x",
|
||||
"starting_gold": "Starting gold",
|
||||
@@ -236,15 +241,21 @@
|
||||
"enter_email_address": "Please enter an email address",
|
||||
"personal_player_id": "Personal Player ID:"
|
||||
},
|
||||
"stats_modal": {
|
||||
"title": "Stats",
|
||||
"leaderboard_modal": {
|
||||
"title": "Leaderboard",
|
||||
"title_plural": "Leaderboards",
|
||||
"clan_stats": "Clan Stats",
|
||||
"player_stats": "1v1 Ranked Stats",
|
||||
"ranked_tab": "1v1 Ranked",
|
||||
"clans_tab": "Clans",
|
||||
"loading": "Loading...",
|
||||
"error": "Error loading clan stats",
|
||||
"no_stats": "No clan stats available",
|
||||
"error": "Error loading leaderboard",
|
||||
"no_stats": "No stats available",
|
||||
"no_data_yet": "No Data Yet",
|
||||
"clan": "Clan",
|
||||
"player": "Player",
|
||||
"games": "Games",
|
||||
"elo": "ELO",
|
||||
"win_score": "Win Score",
|
||||
"win_score_tooltip": "Weighted wins based on clan participation and match difficulty",
|
||||
"loss_score": "Loss Score",
|
||||
@@ -252,7 +263,8 @@
|
||||
"win_loss_ratio": "Win/Loss",
|
||||
"ratio": "Ratio",
|
||||
"rank": "Rank",
|
||||
"try_again": "Try Again"
|
||||
"try_again": "Try Again",
|
||||
"your_ranking": "Your Ranking"
|
||||
},
|
||||
"game_info_modal": {
|
||||
"title": "Game info",
|
||||
@@ -326,6 +338,7 @@
|
||||
"manicouagan": "Manicouagan",
|
||||
"lemnos": "Lemnos",
|
||||
"sierpinski": "Sierpinski",
|
||||
"thebox": "The Box",
|
||||
"twolakes": "Two Lakes",
|
||||
"straitofhormuz": "Strait of Hormuz",
|
||||
"surrounded": "Surrounded",
|
||||
@@ -359,18 +372,23 @@
|
||||
"disabled_units": "Disabled Units"
|
||||
},
|
||||
"public_lobby": {
|
||||
"title": "Waiting for Game Start...",
|
||||
"join": "Join next Game",
|
||||
"waiting": "players waiting",
|
||||
"teams_Duos": "{team_count} teams of 2 (Duos)",
|
||||
"teams_Trios": "{team_count} teams of 3 (Trios)",
|
||||
"teams_Quads": "{team_count} teams of 4 (Quads)",
|
||||
"waiting_for_players": "Waiting for players",
|
||||
"connecting": "Connecting to lobby...",
|
||||
"starting_in": "Starting in {time}",
|
||||
"starting_game": "Starting game…",
|
||||
"teams_hvn": "Humans vs Nations",
|
||||
"teams_hvn_detailed": "{num} Humans vs {num} Nations",
|
||||
"teams": "{num} teams",
|
||||
"players_per_team": "of {num}",
|
||||
"started": "Started"
|
||||
"started": "Started",
|
||||
"status": "Status",
|
||||
"join_timeout": "You didn't enter the game in time."
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "1v1 Ranked Matchmaking (ALPHA)",
|
||||
@@ -455,7 +473,8 @@
|
||||
},
|
||||
"game_mode": {
|
||||
"ffa": "Free for All",
|
||||
"teams": "Teams"
|
||||
"teams": "Teams",
|
||||
"humans_vs_nations": "Humans vs Nations"
|
||||
},
|
||||
"public_game_modifier": {
|
||||
"random_spawn": "Random Spawn",
|
||||
@@ -723,29 +742,6 @@
|
||||
"show_control": "Show Control",
|
||||
"show_units": "Show Units"
|
||||
},
|
||||
"player_info_overlay": {
|
||||
"type": "Type",
|
||||
"bot": "Bot",
|
||||
"nation": "Nation",
|
||||
"player": "Player",
|
||||
"team": "Team",
|
||||
"alliance_timeout": "Alliance ends in",
|
||||
"troops": "Troops",
|
||||
"maxtroops": "Max troops",
|
||||
"a_troops": "Attacking troops",
|
||||
"gold": "Gold",
|
||||
"ports": "Ports",
|
||||
"cities": "Cities",
|
||||
"factories": "Factories",
|
||||
"missile_launchers": "Missile launchers",
|
||||
"sams": "SAMs",
|
||||
"warships": "Warships",
|
||||
"health": "Health",
|
||||
"attitude": "Attitude",
|
||||
"levels": "Levels",
|
||||
"wilderness_title": "Wilderness",
|
||||
"irradiated_wilderness_title": "Irradiated Wilderness"
|
||||
},
|
||||
"events_display": {
|
||||
"retreating": "retreating",
|
||||
"retaliate": "Retaliate",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 2048,
|
||||
"num_land_tiles": 4194304,
|
||||
"width": 2048
|
||||
},
|
||||
"map16x": {
|
||||
"height": 512,
|
||||
"num_land_tiles": 262144,
|
||||
"width": 512
|
||||
},
|
||||
"map4x": {
|
||||
"height": 1024,
|
||||
"num_land_tiles": 1048576,
|
||||
"width": 1024
|
||||
},
|
||||
"name": "The Box",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [10, 10],
|
||||
"flag": "",
|
||||
"name": "King of the Corner"
|
||||
},
|
||||
{
|
||||
"coordinates": [1024, 300],
|
||||
"flag": "",
|
||||
"name": "Suspicious Ally"
|
||||
},
|
||||
{
|
||||
"coordinates": [1650, 400],
|
||||
"flag": "",
|
||||
"name": "Evan The Dev"
|
||||
},
|
||||
{
|
||||
"coordinates": [1024, 1024],
|
||||
"flag": "",
|
||||
"name": "Middle Defender"
|
||||
},
|
||||
{
|
||||
"coordinates": [350, 1024],
|
||||
"flag": "",
|
||||
"name": "Punch Merchant"
|
||||
},
|
||||
{
|
||||
"coordinates": [1700, 1024],
|
||||
"flag": "",
|
||||
"name": "Nuke Thrower"
|
||||
},
|
||||
{
|
||||
"coordinates": [400, 1650],
|
||||
"flag": "",
|
||||
"name": "Fullsender"
|
||||
},
|
||||
{
|
||||
"coordinates": [1024, 1750],
|
||||
"flag": "",
|
||||
"name": "Factory Builder"
|
||||
},
|
||||
{
|
||||
"coordinates": [1650, 1650],
|
||||
"flag": "",
|
||||
"name": "Front Manager"
|
||||
},
|
||||
{
|
||||
"coordinates": [700, 700],
|
||||
"flag": "",
|
||||
"name": "Box Fighter"
|
||||
},
|
||||
{
|
||||
"coordinates": [1350, 700],
|
||||
"flag": "",
|
||||
"name": "Cage Liberator"
|
||||
},
|
||||
{
|
||||
"coordinates": [700, 1350],
|
||||
"flag": "",
|
||||
"name": "Train Trader"
|
||||
},
|
||||
{
|
||||
"coordinates": [1350, 1350],
|
||||
"flag": "",
|
||||
"name": "Non-peaceful Bot"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 536 B |
+115
-26
@@ -1,7 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
PlayerProfile,
|
||||
PlayerProfileSchema,
|
||||
RankedLeaderboardResponse,
|
||||
RankedLeaderboardResponseSchema,
|
||||
UserMeResponse,
|
||||
UserMeResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
@@ -47,34 +51,42 @@ export async function fetchPlayerById(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
try {
|
||||
const userAuthResult = await userAuth();
|
||||
if (!userAuthResult) return false;
|
||||
const { jwt } = userAuthResult;
|
||||
|
||||
// Get the user object
|
||||
const response = await fetch(getApiBase() + "/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
await logOut();
|
||||
return false;
|
||||
}
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = UserMeResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
const error = z.prettifyError(result.error);
|
||||
console.error("Invalid response", error);
|
||||
return false;
|
||||
}
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
return false;
|
||||
let __userMe: Promise<UserMeResponse | false> | null = null;
|
||||
export async function getUserMe(): Promise<UserMeResponse | false> {
|
||||
if (__userMe !== null) {
|
||||
return __userMe;
|
||||
}
|
||||
__userMe = (async () => {
|
||||
try {
|
||||
const userAuthResult = await userAuth();
|
||||
if (!userAuthResult) return false;
|
||||
const { jwt } = userAuthResult;
|
||||
|
||||
// Get the user object
|
||||
const response = await fetch(getApiBase() + "/users/@me", {
|
||||
headers: {
|
||||
authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
await logOut();
|
||||
return false;
|
||||
}
|
||||
if (response.status !== 200) return false;
|
||||
const body = await response.json();
|
||||
const result = UserMeResponseSchema.safeParse(body);
|
||||
if (!result.success) {
|
||||
const error = z.prettifyError(result.error);
|
||||
console.error("Invalid response", error);
|
||||
return false;
|
||||
}
|
||||
return result.data;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
return __userMe;
|
||||
}
|
||||
|
||||
export async function createCheckoutSession(
|
||||
@@ -177,3 +189,80 @@ export async function fetchGameById(
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClanLeaderboard(): Promise<
|
||||
ClanLeaderboardResponse | false
|
||||
> {
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(
|
||||
"fetchClanLeaderboard: unexpected status",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"fetchClanLeaderboard: Zod validation failed",
|
||||
parsed.error.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (err) {
|
||||
console.warn("fetchClanLeaderboard: request failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPlayerLeaderboard(
|
||||
page: number,
|
||||
): Promise<RankedLeaderboardResponse | "reached_limit" | false> {
|
||||
try {
|
||||
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
|
||||
url.searchParams.set("page", String(page));
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Handle "Page must be between X and Y" error as end of list
|
||||
if (res.status === 400) {
|
||||
const errorJson = await res.json().catch(() => null);
|
||||
if (errorJson?.message?.includes("Page must be between")) {
|
||||
return "reached_limit";
|
||||
}
|
||||
}
|
||||
console.warn(
|
||||
"fetchPlayerLeaderboard: unexpected status",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = RankedLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"fetchPlayerLeaderboard: Zod validation failed",
|
||||
parsed.error.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
} catch (err) {
|
||||
console.error("fetchPlayerLeaderboard: request failed", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GameID,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
LobbyInfoEvent,
|
||||
PlayerCosmeticRefs,
|
||||
PlayerRecord,
|
||||
ServerMessage,
|
||||
@@ -97,6 +98,10 @@ export function joinLobby(
|
||||
let terrainLoad: Promise<TerrainMapData> | null = null;
|
||||
|
||||
const onmessage = (message: ServerMessage) => {
|
||||
if (message.type === "lobby_info") {
|
||||
eventBus.emit(new LobbyInfoEvent(message.lobby));
|
||||
return;
|
||||
}
|
||||
if (message.type === "prestart") {
|
||||
console.log(
|
||||
`lobby: game prestarting: ${JSON.stringify(message, replacer)}`,
|
||||
|
||||
@@ -30,6 +30,18 @@ export async function handlePurchase(
|
||||
}
|
||||
|
||||
let __cosmetics: Promise<Cosmetics | null> | null = null;
|
||||
let __cosmeticsHash: string | null = null;
|
||||
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
if (__cosmetics !== null) {
|
||||
return __cosmetics;
|
||||
@@ -46,6 +58,11 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
console.error(`Invalid cosmetics: ${result.error.message}`);
|
||||
return null;
|
||||
}
|
||||
const patternKeys = Object.keys(result.data.patterns).sort();
|
||||
const hashInput = patternKeys
|
||||
.map((k) => k + (result.data.patterns[k].product ? "sale" : ""))
|
||||
.join(",");
|
||||
__cosmeticsHash = simpleHash(hashInput);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
console.error("Error getting cosmetics:", error);
|
||||
@@ -55,6 +72,11 @@ export async function fetchCosmetics(): Promise<Cosmetics | null> {
|
||||
return __cosmetics;
|
||||
}
|
||||
|
||||
export async function getCosmeticsHash(): Promise<string | null> {
|
||||
await fetchCosmetics();
|
||||
return __cosmeticsHash;
|
||||
}
|
||||
|
||||
export function patternRelationship(
|
||||
pattern: Pattern,
|
||||
colorPalette: { name: string; isArchived?: boolean } | null,
|
||||
|
||||
+55
-2
@@ -1,6 +1,6 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText, TUTORIAL_VIDEO_URL } from "../client/Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
@@ -9,6 +9,7 @@ import { TroubleshootingModal } from "./TroubleshootingModal";
|
||||
@customElement("help-modal")
|
||||
export class HelpModal extends BaseModal {
|
||||
@state() private keybinds: Record<string, string> = this.getKeybinds();
|
||||
@query("#tutorial-video-iframe") private videoIframe?: HTMLIFrameElement;
|
||||
|
||||
private isKeybindObject(v: unknown): v is { value: string } {
|
||||
return (
|
||||
@@ -121,6 +122,47 @@ export class HelpModal extends BaseModal {
|
||||
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
|
||||
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
|
||||
>
|
||||
<!-- Video Tutorial Section -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="text-blue-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-xl font-bold uppercase tracking-widest text-white/90"
|
||||
>
|
||||
${translateText("help_modal.video_tutorial")}
|
||||
</h3>
|
||||
<div
|
||||
class="flex-1 h-px bg-gradient-to-r from-blue-500/50 to-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<section
|
||||
class="bg-white/5 rounded-xl border border-white/10 overflow-hidden mb-8"
|
||||
>
|
||||
<div class="relative w-full h-0 pb-[56.25%]">
|
||||
<iframe
|
||||
id="tutorial-video-iframe"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
src="${TUTORIAL_VIDEO_URL}"
|
||||
title="${translateText("help_modal.video_tutorial_title")}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Troubleshooting Section -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="text-blue-400">
|
||||
@@ -1201,5 +1243,16 @@ export class HelpModal extends BaseModal {
|
||||
|
||||
protected onOpen(): void {
|
||||
this.keybinds = this.getKeybinds();
|
||||
// Restore the video src when modal opens
|
||||
if (this.videoIframe) {
|
||||
this.videoIframe.src = TUTORIAL_VIDEO_URL;
|
||||
}
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
// Clear the iframe src to stop video playback
|
||||
if (this.videoIframe) {
|
||||
this.videoIframe.src = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,848 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import {
|
||||
getActiveModifiers,
|
||||
getGameModeLabel,
|
||||
normaliseMapKey,
|
||||
renderDuration,
|
||||
renderNumber,
|
||||
translateText,
|
||||
} from "../client/Utils";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import {
|
||||
ClientInfo,
|
||||
GAME_ID_REGEX,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
GameRecordSchema,
|
||||
LobbyInfoEvent,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import {
|
||||
GameMapSize,
|
||||
GameMode,
|
||||
GameType,
|
||||
HumansVsNations,
|
||||
} from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import "./components/LobbyConfigItem";
|
||||
import "./components/LobbyPlayerView";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
|
||||
@customElement("join-lobby-modal")
|
||||
export class JoinLobbyModal extends BaseModal {
|
||||
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
|
||||
|
||||
@property({ attribute: false }) eventBus: EventBus | null = null;
|
||||
|
||||
@state() private players: ClientInfo[] = [];
|
||||
@state() private playerCount: number = 0;
|
||||
@state() private gameConfig: GameConfig | null = null;
|
||||
@state() private currentLobbyId: string = "";
|
||||
@state() private currentClientID: string = "";
|
||||
@state() private nationCount: number = 0;
|
||||
@state() private lobbyStartAt: number | null = null;
|
||||
@state() private isConnecting: boolean = true;
|
||||
@state() private lobbyCreatorClientID: string | null = null;
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
private countdownTimerId: number | null = null;
|
||||
private handledJoinTimeout = false;
|
||||
|
||||
private readonly handleLobbyInfo = (event: LobbyInfoEvent) => {
|
||||
const lobby = event.lobby;
|
||||
if (!this.currentLobbyId || lobby.gameID !== this.currentLobbyId) {
|
||||
return;
|
||||
}
|
||||
// Only stop showing spinner when we have player info
|
||||
if (this.isConnecting && lobby.clients) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
this.updateFromLobby({
|
||||
...lobby,
|
||||
msUntilStart: lobby.msUntilStart ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
// Pre-join state: show lobby ID input form
|
||||
if (!this.currentLobbyId) {
|
||||
return this.renderJoinForm();
|
||||
}
|
||||
|
||||
// Post-join state: show lobby info (identical for public & private)
|
||||
const secondsRemaining =
|
||||
this.lobbyStartAt !== null
|
||||
? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000))
|
||||
: null;
|
||||
const statusLabel =
|
||||
secondsRemaining === null
|
||||
? translateText("public_lobby.waiting_for_players")
|
||||
: secondsRemaining > 0
|
||||
? translateText("public_lobby.starting_in", {
|
||||
time: renderDuration(secondsRemaining),
|
||||
})
|
||||
: translateText("public_lobby.started");
|
||||
const maxPlayers = this.gameConfig?.maxPlayers ?? 0;
|
||||
const playerCount = this.playerCount;
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("public_lobby.title"),
|
||||
onBack: () => this.closeAndLeave(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
rightContent:
|
||||
this.currentLobbyId &&
|
||||
this.gameConfig?.gameType === GameType.Private
|
||||
? html`
|
||||
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
|
||||
`
|
||||
: undefined,
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
${this.isConnecting
|
||||
? html`
|
||||
<div
|
||||
class="min-h-[240px] flex flex-col items-center justify-center gap-4"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-white/20 border-t-white rounded-full animate-spin"
|
||||
></div>
|
||||
<p class="text-center text-white/80 text-sm">
|
||||
${translateText("public_lobby.connecting")}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
${this.gameConfig ? this.renderGameConfig() : html``}
|
||||
${this.players.length > 0
|
||||
? html`
|
||||
<lobby-player-view
|
||||
class="mt-6"
|
||||
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
|
||||
.clients=${this.players}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.currentClientID=${this.currentClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
.nationCount=${this.nationCount}
|
||||
.disableNations=${this.gameConfig?.disableNations ??
|
||||
false}
|
||||
.isCompactMap=${this.gameConfig?.gameMapSize ===
|
||||
GameMapSize.Compact}
|
||||
></lobby-player-view>
|
||||
`
|
||||
: ""}
|
||||
`}
|
||||
</div>
|
||||
|
||||
${this.gameConfig?.gameType === GameType.Private
|
||||
? html`
|
||||
<div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<button
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
disabled
|
||||
>
|
||||
${translateText("private_lobby.joined_waiting")}
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-full px-4 py-3 rounded-xl border border-white/10 bg-white/5 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-widest text-white/40"
|
||||
>${translateText("public_lobby.status")}</span
|
||||
>
|
||||
<span class="text-sm font-bold text-white"
|
||||
>${statusLabel}</span
|
||||
>
|
||||
</div>
|
||||
${maxPlayers > 0
|
||||
? html`
|
||||
<div
|
||||
class="flex items-center gap-2 text-white/80 text-xs font-bold uppercase tracking-widest"
|
||||
>
|
||||
<span>${playerCount}/${maxPlayers}</span>
|
||||
<svg
|
||||
class="w-4 h-4 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.972 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderJoinForm() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("private_lobby.title"),
|
||||
onBack: () => this.closeAndLeave(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="lobbyIdInput"
|
||||
placeholder=${translateText("private_lobby.enter_id")}
|
||||
@keyup=${this.handleChange}
|
||||
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
|
||||
/>
|
||||
<button
|
||||
@click=${this.pasteFromClipboard}
|
||||
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
|
||||
title=${translateText("common.paste")}
|
||||
>
|
||||
<svg
|
||||
class="text-white/60 group-hover:text-white transition-colors"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 32 32"
|
||||
height="18px"
|
||||
width="18px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<o-button
|
||||
title=${translateText("private_lobby.join_lobby")}
|
||||
block
|
||||
@click=${this.joinLobbyFromInput}
|
||||
></o-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
public open(lobbyId: string = "", lobbyInfo?: GameInfo) {
|
||||
super.open();
|
||||
if (lobbyId) {
|
||||
this.startTrackingLobby(lobbyId, lobbyInfo);
|
||||
// If opened with lobbyInfo (public lobby case), auto-join the lobby
|
||||
if (lobbyInfo) {
|
||||
this.joinPublicLobby(lobbyId, lobbyInfo);
|
||||
} else {
|
||||
// If opened with lobbyId but no lobbyInfo (URL join case), check if active and join
|
||||
this.handleUrlJoin(lobbyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUrlJoin(lobbyId: string): Promise<void> {
|
||||
try {
|
||||
const gameExists = await this.checkActiveLobby(lobbyId);
|
||||
if (gameExists) return;
|
||||
|
||||
// Active lobby not found, check if it's an archived game
|
||||
switch (await this.checkArchivedGame(lobbyId)) {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(
|
||||
translateText("private_lobby.version_mismatch"),
|
||||
"red",
|
||||
);
|
||||
return;
|
||||
case "error":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby from URL:", error);
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
}
|
||||
}
|
||||
|
||||
private joinPublicLobby(lobbyId: string, lobbyInfo: GameInfo) {
|
||||
// Dispatch join-lobby event to actually connect to the lobby
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
publicLobbyInfo: lobbyInfo,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private startTrackingLobby(lobbyId: string, lobbyInfo?: GameInfo) {
|
||||
this.currentLobbyId = lobbyId;
|
||||
this.currentClientID = generateID();
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.playerCount = 0;
|
||||
this.nationCount = 0;
|
||||
this.lobbyStartAt = null;
|
||||
this.lobbyCreatorClientID = null;
|
||||
this.isConnecting = true;
|
||||
this.handledJoinTimeout = false;
|
||||
this.startLobbyUpdates();
|
||||
if (lobbyInfo) {
|
||||
this.updateFromLobby(lobbyInfo);
|
||||
// Only stop showing spinner when we have player info
|
||||
if (lobbyInfo.clients) {
|
||||
this.isConnecting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resetTrackingState() {
|
||||
this.stopLobbyUpdates();
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.isConnecting = false;
|
||||
}
|
||||
|
||||
private leaveLobby() {
|
||||
if (!this.currentLobbyId) {
|
||||
return;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: this.currentLobbyId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.clearCountdownTimer();
|
||||
this.stopLobbyUpdates();
|
||||
|
||||
if (this.leaveLobbyOnClose) {
|
||||
this.leaveLobby();
|
||||
this.updateHistory("/");
|
||||
}
|
||||
|
||||
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
this.playerCount = 0;
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.nationCount = 0;
|
||||
this.lobbyStartAt = null;
|
||||
this.lobbyCreatorClientID = null;
|
||||
this.isConnecting = true;
|
||||
this.leaveLobbyOnClose = true;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.clearCountdownTimer();
|
||||
this.stopLobbyUpdates();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public closeAndLeave() {
|
||||
this.leaveLobby();
|
||||
try {
|
||||
this.updateHistory("/");
|
||||
} catch (error) {
|
||||
console.warn("Failed to restore URL on leave:", error);
|
||||
}
|
||||
this.leaveLobbyOnClose = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
public closeWithoutLeaving() {
|
||||
this.leaveLobbyOnClose = false;
|
||||
this.close();
|
||||
}
|
||||
|
||||
private updateHistory(url: string): void {
|
||||
if (!crazyGamesSDK.isOnCrazyGames()) {
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Game config rendering ---
|
||||
|
||||
private renderGameConfig(): TemplateResult {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
const c = this.gameConfig;
|
||||
const mapName = translateText("map." + normaliseMapKey(c.gameMap));
|
||||
const modeName = getGameModeLabel(c);
|
||||
const modifiers = getActiveModifiers(c.publicGameModifiers);
|
||||
|
||||
return html`
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<lobby-config-item
|
||||
.label=${translateText("map.map")}
|
||||
.value=${mapName}
|
||||
></lobby-config-item>
|
||||
<lobby-config-item
|
||||
.label=${translateText("host_modal.mode")}
|
||||
.value=${modeName}
|
||||
></lobby-config-item>
|
||||
${modifiers.map(
|
||||
(m) => html`
|
||||
<lobby-config-item
|
||||
.label=${translateText(m.labelKey)}
|
||||
.value=${m.value !== undefined
|
||||
? renderNumber(m.value)
|
||||
: translateText("common.enabled")}
|
||||
></lobby-config-item>
|
||||
`,
|
||||
)}
|
||||
${c.gameMode !== GameMode.FFA &&
|
||||
c.playerTeams &&
|
||||
c.playerTeams !== HumansVsNations
|
||||
? html`
|
||||
<lobby-config-item
|
||||
.label=${typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.team_type")
|
||||
: translateText("host_modal.team_count")}
|
||||
.value=${typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.teams_" + c.playerTeams)
|
||||
: c.playerTeams.toString()}
|
||||
></lobby-config-item>
|
||||
`
|
||||
: html``}
|
||||
</div>
|
||||
${this.renderDisabledUnits()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDisabledUnits(): TemplateResult {
|
||||
if (
|
||||
!this.gameConfig ||
|
||||
!this.gameConfig.disabledUnits ||
|
||||
this.gameConfig.disabledUnits.length === 0
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const unitKeys: Record<string, string> = {
|
||||
City: "unit_type.city",
|
||||
Port: "unit_type.port",
|
||||
"Defense Post": "unit_type.defense_post",
|
||||
"SAM Launcher": "unit_type.sam_launcher",
|
||||
"Missile Silo": "unit_type.missile_silo",
|
||||
Warship: "unit_type.warship",
|
||||
Factory: "unit_type.factory",
|
||||
"Atom Bomb": "unit_type.atom_bomb",
|
||||
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
|
||||
MIRV: "unit_type.mirv",
|
||||
"Trade Ship": "player_stats_table.unit.trade",
|
||||
Transport: "player_stats_table.unit.trans",
|
||||
"MIRV Warhead": "player_stats_table.unit.mirvw",
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div
|
||||
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
|
||||
>
|
||||
${translateText("private_lobby.disabled_units")}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${this.gameConfig.disabledUnits.map((unit) => {
|
||||
const key = unitKeys[unit];
|
||||
const name = key ? translateText(key) : unit;
|
||||
return html`
|
||||
<span
|
||||
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
|
||||
>
|
||||
${name}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Lobby event handling ---
|
||||
|
||||
private updateFromLobby(lobby: GameInfo) {
|
||||
if (lobby.clients) {
|
||||
this.players = lobby.clients;
|
||||
this.playerCount = lobby.clients.length;
|
||||
this.lobbyCreatorClientID = lobby.clients[0]?.clientID ?? null;
|
||||
} else {
|
||||
this.players = [];
|
||||
this.playerCount = lobby.numClients ?? 0;
|
||||
}
|
||||
if (lobby.msUntilStart !== undefined) {
|
||||
this.lobbyStartAt = lobby.msUntilStart + Date.now();
|
||||
} else {
|
||||
this.lobbyStartAt = null;
|
||||
}
|
||||
this.syncCountdownTimer();
|
||||
if (lobby.gameConfig) {
|
||||
const mapChanged = this.gameConfig?.gameMap !== lobby.gameConfig.gameMap;
|
||||
this.gameConfig = lobby.gameConfig;
|
||||
if (mapChanged) {
|
||||
this.loadNationCount();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startLobbyUpdates() {
|
||||
this.stopLobbyUpdates();
|
||||
if (!this.eventBus) {
|
||||
console.warn(
|
||||
"JoinLobbyModal: eventBus not set, cannot subscribe to lobby updates",
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.eventBus.on(LobbyInfoEvent, this.handleLobbyInfo);
|
||||
}
|
||||
|
||||
private stopLobbyUpdates() {
|
||||
this.eventBus?.off(LobbyInfoEvent, this.handleLobbyInfo);
|
||||
}
|
||||
|
||||
// --- Countdown timer ---
|
||||
|
||||
private syncCountdownTimer() {
|
||||
if (this.lobbyStartAt === null) {
|
||||
this.clearCountdownTimer();
|
||||
return;
|
||||
}
|
||||
if (this.countdownTimerId !== null) {
|
||||
return;
|
||||
}
|
||||
this.countdownTimerId = window.setInterval(() => {
|
||||
this.checkForJoinTimeout();
|
||||
this.requestUpdate();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private clearCountdownTimer() {
|
||||
if (this.countdownTimerId === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this.countdownTimerId);
|
||||
this.countdownTimerId = null;
|
||||
}
|
||||
|
||||
private checkForJoinTimeout() {
|
||||
if (
|
||||
this.handledJoinTimeout ||
|
||||
!this.isConnecting ||
|
||||
this.lobbyStartAt === null ||
|
||||
!this.isModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() < this.lobbyStartAt) {
|
||||
return;
|
||||
}
|
||||
this.handledJoinTimeout = true;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: translateText("public_lobby.join_timeout"),
|
||||
color: "red",
|
||||
duration: 3500,
|
||||
},
|
||||
}),
|
||||
);
|
||||
this.closeAndLeave();
|
||||
}
|
||||
|
||||
// --- Nation count ---
|
||||
|
||||
private async loadNationCount() {
|
||||
if (!this.gameConfig) {
|
||||
this.nationCount = 0;
|
||||
return;
|
||||
}
|
||||
const currentMap = this.gameConfig.gameMap;
|
||||
try {
|
||||
const mapData = terrainMapFileLoader.getMapData(currentMap);
|
||||
const manifest = await mapData.manifest();
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = manifest.nations.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load nation count", error);
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private lobby join flow (lobby ID input) ---
|
||||
|
||||
private isValidLobbyId(value: string): boolean {
|
||||
return GAME_ID_REGEX.test(value);
|
||||
}
|
||||
|
||||
private normalizeLobbyId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const extracted = this.extractLobbyIdFromUrl(trimmed).trim();
|
||||
if (!this.isValidLobbyId(extracted)) return null;
|
||||
return extracted;
|
||||
}
|
||||
|
||||
private sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\r\n]/g, "");
|
||||
}
|
||||
|
||||
private extractLobbyIdFromUrl(input: string): string {
|
||||
if (!input.startsWith("http")) {
|
||||
return input;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
const match = url.pathname.match(/game\/([^/]+)/);
|
||||
const candidate = match?.[1];
|
||||
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
|
||||
|
||||
return input;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse lobby URL", error);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
private setLobbyId(id: string) {
|
||||
if (this.lobbyIdInput) {
|
||||
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
|
||||
}
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
this.setLobbyId(value);
|
||||
}
|
||||
|
||||
private async pasteFromClipboard() {
|
||||
try {
|
||||
const clipText = await navigator.clipboard.readText();
|
||||
this.setLobbyId(clipText);
|
||||
} catch (err) {
|
||||
console.error("Failed to read clipboard contents: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async joinLobbyFromInput(): Promise<void> {
|
||||
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
||||
if (!lobbyId) {
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lobbyIdInput.value = lobbyId;
|
||||
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
|
||||
|
||||
// Initialize tracking state before checking/joining
|
||||
this.startTrackingLobby(lobbyId);
|
||||
|
||||
try {
|
||||
const gameExists = await this.checkActiveLobby(lobbyId);
|
||||
if (gameExists) return;
|
||||
|
||||
switch (await this.checkArchivedGame(lobbyId)) {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(
|
||||
translateText("private_lobby.version_mismatch"),
|
||||
"red",
|
||||
);
|
||||
return;
|
||||
case "error":
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby existence:", error);
|
||||
this.resetTrackingState();
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
}
|
||||
}
|
||||
|
||||
private showMessage(message: string, color: "green" | "red" = "green") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: { message, duration: 3000, color },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
|
||||
const config = await getServerConfigFromClient();
|
||||
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let gameInfo: { exists?: boolean };
|
||||
try {
|
||||
gameInfo = await response.json();
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse active lobby response", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (gameInfo.exists) {
|
||||
this.showMessage(translateText("private_lobby.joined_waiting"));
|
||||
|
||||
// Use the clientID that was already set by startTrackingLobby in open()
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Event tracking is already started by open() -> startTrackingLobby()
|
||||
// LobbyInfoEvents will update the UI as they arrive
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkArchivedGame(
|
||||
lobbyId: string,
|
||||
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
|
||||
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (archiveResponse.status === 404) {
|
||||
return "not_found";
|
||||
}
|
||||
if (archiveResponse.status !== 200) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const archiveData = await archiveResponse.json();
|
||||
const parsed = GameRecordSchema.safeParse(archiveData);
|
||||
if (!parsed.success) {
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
if (
|
||||
window.GIT_COMMIT !== "DEV" &&
|
||||
parsed.data.gitCommit !== window.GIT_COMMIT
|
||||
) {
|
||||
const safeLobbyId = this.sanitizeForLog(lobbyId);
|
||||
console.warn(
|
||||
`Git commit hash mismatch for game ${safeLobbyId}`,
|
||||
archiveData.details,
|
||||
);
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
// If the modal closes as part of joining the replay, do not leave/reset URL
|
||||
this.leaveLobbyOnClose = false;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return "success";
|
||||
}
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import {
|
||||
ClientInfo,
|
||||
GAME_ID_REGEX,
|
||||
GameConfig,
|
||||
GameInfo,
|
||||
GameRecordSchema,
|
||||
} from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameMapSize, GameMode } from "../core/game/Game";
|
||||
import { getApiBase } from "./Api";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/CopyButton";
|
||||
import "./components/Difficulties";
|
||||
import "./components/LobbyPlayerView";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
@customElement("join-private-lobby-modal")
|
||||
export class JoinPrivateLobbyModal extends BaseModal {
|
||||
@query("#lobbyIdInput") private lobbyIdInput!: HTMLInputElement;
|
||||
@state() private message: string = "";
|
||||
@state() private hasJoined = false;
|
||||
@state() private players: ClientInfo[] = [];
|
||||
@state() private gameConfig: GameConfig | null = null;
|
||||
@state() private lobbyCreatorClientID: string | null = null;
|
||||
@state() private currentLobbyId: string = "";
|
||||
@state() private currentClientID: string = "";
|
||||
@state() private nationCount: number = 0;
|
||||
|
||||
private playersInterval: NodeJS.Timeout | null = null;
|
||||
private mapLoader = terrainMapFileLoader;
|
||||
|
||||
private leaveLobbyOnClose = true;
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden select-none"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("private_lobby.title"),
|
||||
onBack: this.closeAndLeave,
|
||||
ariaLabel: translateText("common.close"),
|
||||
rightContent: this.hasJoined
|
||||
? html`
|
||||
<copy-button .lobbyId=${this.currentLobbyId}></copy-button>
|
||||
`
|
||||
: undefined,
|
||||
})}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-4 mr-1">
|
||||
${!this.hasJoined
|
||||
? html`<div class="flex flex-col gap-3">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="lobbyIdInput"
|
||||
placeholder=${translateText("private_lobby.enter_id")}
|
||||
@keyup=${this.handleChange}
|
||||
class="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm tracking-wider"
|
||||
/>
|
||||
<button
|
||||
@click=${this.pasteFromClipboard}
|
||||
class="px-4 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 rounded-xl transition-all group"
|
||||
title=${translateText("common.paste")}
|
||||
>
|
||||
<svg
|
||||
class="text-white/60 group-hover:text-white transition-colors"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 32 32"
|
||||
height="18px"
|
||||
width="18px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M 15 3 C 13.742188 3 12.847656 3.890625 12.40625 5 L 5 5 L 5 28 L 13 28 L 13 30 L 27 30 L 27 14 L 25 14 L 25 5 L 17.59375 5 C 17.152344 3.890625 16.257813 3 15 3 Z M 15 5 C 15.554688 5 16 5.445313 16 6 L 16 7 L 19 7 L 19 9 L 11 9 L 11 7 L 14 7 L 14 6 C 14 5.445313 14.445313 5 15 5 Z M 7 7 L 9 7 L 9 11 L 21 11 L 21 7 L 23 7 L 23 14 L 13 14 L 13 26 L 7 26 Z M 15 16 L 25 16 L 25 28 L 15 28 Z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<o-button
|
||||
title=${translateText("private_lobby.join_lobby")}
|
||||
block
|
||||
@click=${this.joinLobby}
|
||||
></o-button>
|
||||
</div>`
|
||||
: ""}
|
||||
${this.renderGameConfig()}
|
||||
${this.hasJoined && this.players.length > 0
|
||||
? html`
|
||||
<lobby-player-view
|
||||
class="mt-6"
|
||||
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
|
||||
.clients=${this.players}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.currentClientID=${this.currentClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
.nationCount=${this.nationCount}
|
||||
.disableNations=${this.gameConfig?.disableNations ?? false}
|
||||
.isCompactMap=${this.gameConfig?.gameMapSize ===
|
||||
GameMapSize.Compact}
|
||||
></lobby-player-view>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
|
||||
${this.hasJoined && this.players.length > 0
|
||||
? html` <div
|
||||
class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0"
|
||||
>
|
||||
<button
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
|
||||
disabled
|
||||
>
|
||||
${translateText("private_lobby.joined_waiting")}
|
||||
</button>
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderConfigItem(
|
||||
label: string,
|
||||
value: string | TemplateResult,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 border border-white/10 rounded-lg p-3 flex flex-col items-center justify-center gap-1 text-center min-w-[100px]"
|
||||
>
|
||||
<span
|
||||
class="text-white/40 text-[10px] font-bold uppercase tracking-wider"
|
||||
>${label}</span
|
||||
>
|
||||
<span
|
||||
class="text-white font-bold text-sm w-full break-words hyphens-auto"
|
||||
>${value}</span
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderGameConfig(): TemplateResult {
|
||||
if (!this.gameConfig) return html``;
|
||||
|
||||
const c = this.gameConfig;
|
||||
const mapName = translateText(
|
||||
"map." + c.gameMap.toLowerCase().replace(/ /g, ""),
|
||||
);
|
||||
const modeName =
|
||||
c.gameMode === "Free For All"
|
||||
? translateText("game_mode.ffa")
|
||||
: translateText("game_mode.teams");
|
||||
const diffName = translateText(
|
||||
"difficulty." + c.difficulty.toLowerCase().replace(/ /g, ""),
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
${this.renderConfigItem(translateText("map.map"), mapName)}
|
||||
${this.renderConfigItem(translateText("host_modal.mode"), modeName)}
|
||||
${this.renderConfigItem(
|
||||
translateText("difficulty.difficulty"),
|
||||
diffName,
|
||||
)}
|
||||
${this.renderConfigItem(
|
||||
translateText("host_modal.bots"),
|
||||
c.bots.toString(),
|
||||
)}
|
||||
${c.gameMode !== "Free For All" && c.playerTeams
|
||||
? this.renderConfigItem(
|
||||
typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.team_type")
|
||||
: translateText("host_modal.team_count"),
|
||||
typeof c.playerTeams === "string"
|
||||
? translateText("host_modal.teams_" + c.playerTeams)
|
||||
: c.playerTeams.toString(),
|
||||
)
|
||||
: html``}
|
||||
</div>
|
||||
${this.renderDisabledUnits()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDisabledUnits(): TemplateResult {
|
||||
if (
|
||||
!this.gameConfig ||
|
||||
!this.gameConfig.disabledUnits ||
|
||||
this.gameConfig.disabledUnits.length === 0
|
||||
) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const unitKeys: Record<string, string> = {
|
||||
City: "unit_type.city",
|
||||
Port: "unit_type.port",
|
||||
"Defense Post": "unit_type.defense_post",
|
||||
"SAM Launcher": "unit_type.sam_launcher",
|
||||
"Missile Silo": "unit_type.missile_silo",
|
||||
Warship: "unit_type.warship",
|
||||
Factory: "unit_type.factory",
|
||||
"Atom Bomb": "unit_type.atom_bomb",
|
||||
"Hydrogen Bomb": "unit_type.hydrogen_bomb",
|
||||
MIRV: "unit_type.mirv",
|
||||
"Trade Ship": "stats_modal.unit.trade",
|
||||
Transport: "stats_modal.unit.trans",
|
||||
"MIRV Warhead": "stats_modal.unit.mirvw",
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div
|
||||
class="text-xs font-bold text-red-400 uppercase tracking-widest mb-2"
|
||||
>
|
||||
${translateText("private_lobby.disabled_units")}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${this.gameConfig.disabledUnits.map((unit) => {
|
||||
const key = unitKeys[unit];
|
||||
const name = key ? translateText(key) : unit;
|
||||
return html`
|
||||
<span
|
||||
class="px-2 py-1 bg-red-500/20 text-red-200 text-xs rounded font-bold border border-red-500/30"
|
||||
>
|
||||
${name}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public open(id: string = "") {
|
||||
super.open();
|
||||
if (id) {
|
||||
this.setLobbyId(id);
|
||||
this.joinLobby();
|
||||
}
|
||||
}
|
||||
|
||||
private leaveLobby() {
|
||||
if (!this.currentLobbyId || !this.hasJoined) {
|
||||
return;
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: this.currentLobbyId },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
if (this.lobbyIdInput) this.lobbyIdInput.value = "";
|
||||
this.gameConfig = null;
|
||||
this.players = [];
|
||||
if (this.playersInterval) {
|
||||
clearInterval(this.playersInterval);
|
||||
this.playersInterval = null;
|
||||
}
|
||||
if (this.leaveLobbyOnClose) {
|
||||
this.leaveLobby();
|
||||
// Reset URL to base when modal closes
|
||||
history.replaceState(null, "", window.location.origin + "/");
|
||||
}
|
||||
|
||||
this.hasJoined = false;
|
||||
this.message = "";
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.nationCount = 0;
|
||||
|
||||
this.leaveLobbyOnClose = true;
|
||||
}
|
||||
|
||||
public closeAndLeave() {
|
||||
this.leaveLobbyOnClose = true;
|
||||
this.close();
|
||||
}
|
||||
|
||||
private isValidLobbyId(value: string): boolean {
|
||||
return GAME_ID_REGEX.test(value);
|
||||
}
|
||||
|
||||
private normalizeLobbyId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
const extracted = this.extractLobbyIdFromUrl(trimmed).trim();
|
||||
if (!this.isValidLobbyId(extracted)) return null;
|
||||
return extracted;
|
||||
}
|
||||
|
||||
private sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\r\n]/g, "");
|
||||
}
|
||||
|
||||
private extractLobbyIdFromUrl(input: string): string {
|
||||
if (!input.startsWith("http")) {
|
||||
return input;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
const match = url.pathname.match(/game\/([^/]+)/);
|
||||
const candidate = match?.[1];
|
||||
if (candidate && GAME_ID_REGEX.test(candidate)) return candidate;
|
||||
|
||||
return input;
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse lobby URL", error);
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
private setLobbyId(id: string) {
|
||||
this.lobbyIdInput.value = this.extractLobbyIdFromUrl(id);
|
||||
}
|
||||
|
||||
private handleChange(e: Event) {
|
||||
const value = (e.target as HTMLInputElement).value.trim();
|
||||
this.setLobbyId(value);
|
||||
}
|
||||
|
||||
private async pasteFromClipboard() {
|
||||
try {
|
||||
const clipText = await navigator.clipboard.readText();
|
||||
this.setLobbyId(clipText);
|
||||
} catch (err) {
|
||||
console.error("Failed to read clipboard contents: ", err);
|
||||
}
|
||||
}
|
||||
|
||||
private async joinLobby(): Promise<void> {
|
||||
const lobbyId = this.normalizeLobbyId(this.lobbyIdInput.value);
|
||||
if (!lobbyId) {
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
return;
|
||||
}
|
||||
|
||||
this.lobbyIdInput.value = lobbyId;
|
||||
this.currentLobbyId = lobbyId;
|
||||
console.log(`Joining lobby with ID: ${this.sanitizeForLog(lobbyId)}`);
|
||||
|
||||
try {
|
||||
// First, check if the game exists in active lobbies
|
||||
const gameExists = await this.checkActiveLobby(lobbyId);
|
||||
if (gameExists) return;
|
||||
|
||||
// If not active, check archived games
|
||||
switch (await this.checkArchivedGame(lobbyId)) {
|
||||
case "success":
|
||||
return;
|
||||
case "not_found":
|
||||
this.showMessage(translateText("private_lobby.not_found"), "red");
|
||||
this.message = "";
|
||||
return;
|
||||
case "version_mismatch":
|
||||
this.showMessage(
|
||||
translateText("private_lobby.version_mismatch"),
|
||||
"red",
|
||||
);
|
||||
this.message = "";
|
||||
return;
|
||||
case "error":
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
this.message = "";
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking lobby existence:", error);
|
||||
this.showMessage(translateText("private_lobby.error"), "red");
|
||||
this.message = "";
|
||||
}
|
||||
}
|
||||
|
||||
private showMessage(message: string, color: "green" | "red" = "green") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: { message, duration: 3000, color },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkActiveLobby(lobbyId: string): Promise<boolean> {
|
||||
const config = await getServerConfigFromClient();
|
||||
const url = `/${config.workerPath(lobbyId)}/api/game/${lobbyId}/exists`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const gameInfo = await response.json();
|
||||
|
||||
if (gameInfo.exists) {
|
||||
this.showMessage(translateText("private_lobby.joined_waiting"));
|
||||
this.message = "";
|
||||
this.hasJoined = true;
|
||||
this.currentClientID = generateID();
|
||||
|
||||
// If the modal closes as part of joining the game, do not leave the lobby
|
||||
this.leaveLobbyOnClose = false;
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.pollPlayers();
|
||||
this.playersInterval = setInterval(() => this.pollPlayers(), 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async checkArchivedGame(
|
||||
lobbyId: string,
|
||||
): Promise<"success" | "not_found" | "version_mismatch" | "error"> {
|
||||
const archiveResponse = await fetch(`${getApiBase()}/game/${lobbyId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (archiveResponse.status === 404) {
|
||||
return "not_found";
|
||||
}
|
||||
if (archiveResponse.status !== 200) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
const archiveData = await archiveResponse.json();
|
||||
const parsed = GameRecordSchema.safeParse(archiveData);
|
||||
if (!parsed.success) {
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
// Allow DEV to join games created with a different version for debugging.
|
||||
if (
|
||||
window.GIT_COMMIT !== "DEV" &&
|
||||
parsed.data.gitCommit !== window.GIT_COMMIT
|
||||
) {
|
||||
const safeLobbyId = this.sanitizeForLog(lobbyId);
|
||||
console.warn(
|
||||
`Git commit hash mismatch for game ${safeLobbyId}`,
|
||||
archiveData.details,
|
||||
);
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
this.currentClientID = generateID();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
return "success";
|
||||
}
|
||||
|
||||
private async pollPlayers() {
|
||||
const lobbyId = this.currentLobbyId;
|
||||
if (!lobbyId) return;
|
||||
const config = await getServerConfigFromClient();
|
||||
|
||||
fetch(`/${config.workerPath(lobbyId)}/api/game/${lobbyId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data: GameInfo) => {
|
||||
this.lobbyCreatorClientID = data.clients?.[0]?.clientID ?? null;
|
||||
this.players = data.clients ?? [];
|
||||
if (data.gameConfig) {
|
||||
const mapChanged =
|
||||
this.gameConfig?.gameMap !== data.gameConfig.gameMap;
|
||||
this.gameConfig = data.gameConfig;
|
||||
if (mapChanged) {
|
||||
this.loadNationCount();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error polling players:", error);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadNationCount() {
|
||||
if (!this.gameConfig) {
|
||||
this.nationCount = 0;
|
||||
return;
|
||||
}
|
||||
const currentMap = this.gameConfig.gameMap;
|
||||
try {
|
||||
const mapData = this.mapLoader.getMapData(currentMap);
|
||||
const manifest = await mapData.manifest();
|
||||
// Only update if the map hasn't changed
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = manifest.nations.length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load nation count", error);
|
||||
// Only update if the map hasn't changed
|
||||
if (this.gameConfig?.gameMap === currentMap) {
|
||||
this.nationCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,9 +203,12 @@ export class LangSelector extends LitElement {
|
||||
const components = [
|
||||
"single-player-modal",
|
||||
"host-lobby-modal",
|
||||
"join-private-lobby-modal",
|
||||
"join-lobby-modal",
|
||||
"emoji-table",
|
||||
"leader-board",
|
||||
"leaderboard-tabs",
|
||||
"leaderboard-player-list",
|
||||
"leaderboard-clan-table",
|
||||
"build-menu",
|
||||
"win-modal",
|
||||
"game-starting-modal",
|
||||
@@ -225,7 +228,7 @@ export class LangSelector extends LitElement {
|
||||
"news-modal",
|
||||
"news-button",
|
||||
"account-modal",
|
||||
"stats-modal",
|
||||
"leaderboard-modal",
|
||||
"flag-input-modal",
|
||||
"flag-input",
|
||||
"matchmaking-button",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/leaderboard/LeaderboardClanTable";
|
||||
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
|
||||
import "./components/leaderboard/LeaderboardPlayerList";
|
||||
import type { LeaderboardPlayerList } from "./components/leaderboard/LeaderboardPlayerList";
|
||||
import "./components/leaderboard/LeaderboardTabs";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("leaderboard-modal")
|
||||
export class LeaderboardModal extends BaseModal {
|
||||
@state() private activeTab: "players" | "clans" = "players";
|
||||
@state()
|
||||
private clanDateRange: { start: string; end: string } | null = null;
|
||||
|
||||
@query("leaderboard-player-list")
|
||||
private playerList?: LeaderboardPlayerList;
|
||||
@query("leaderboard-clan-table")
|
||||
private clanTable?: LeaderboardClanTable;
|
||||
|
||||
private loadToken = 0;
|
||||
|
||||
protected onOpen(): void {
|
||||
this.loadActiveTabData();
|
||||
}
|
||||
|
||||
private loadActiveTabData() {
|
||||
const token = ++this.loadToken;
|
||||
|
||||
const run = async () => {
|
||||
if (token !== this.loadToken) return;
|
||||
|
||||
if (this.activeTab === "players") {
|
||||
await this.playerList?.ensureLoaded();
|
||||
if (token !== this.loadToken) return;
|
||||
this.playerList?.handleTabActivated();
|
||||
} else {
|
||||
await this.clanTable?.ensureLoaded();
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (token !== this.loadToken) return;
|
||||
if (this.activeTab === "players") void this.clanTable?.ensureLoaded();
|
||||
else void this.playerList?.ensureLoaded();
|
||||
});
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
if (!(this.activeTab === "players" ? this.playerList : this.clanTable)) {
|
||||
await this.updateComplete;
|
||||
}
|
||||
await run();
|
||||
})();
|
||||
}
|
||||
|
||||
private handleTabChange(tab: "clans" | "players") {
|
||||
this.activeTab = tab;
|
||||
this.loadActiveTabData();
|
||||
}
|
||||
|
||||
private handleClanDateRangeChange(
|
||||
event: CustomEvent<{ start: string; end: string }>,
|
||||
) {
|
||||
this.clanDateRange = event.detail;
|
||||
}
|
||||
|
||||
render() {
|
||||
let dateRange = html``;
|
||||
if (this.clanDateRange) {
|
||||
const start = new Date(this.clanDateRange.start).toLocaleDateString();
|
||||
const end = new Date(this.clanDateRange.end).toLocaleDateString();
|
||||
dateRange = html`<span
|
||||
class="text-sm font-normal text-white/40 ml-2 wrap-break-words"
|
||||
>(${start} - ${end})</span
|
||||
>`;
|
||||
}
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/80 backdrop-blur-xl rounded-2xl border border-white/10 overflow-hidden shadow-2xl"
|
||||
>
|
||||
${modalHeader({
|
||||
titleContent: html`
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
|
||||
>
|
||||
${translateText("leaderboard_modal.title")}
|
||||
</span>
|
||||
${this.activeTab === "clans" ? dateRange : ""}
|
||||
</div>
|
||||
`,
|
||||
onBack: this.close,
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<leaderboard-tabs
|
||||
.activeTab=${this.activeTab}
|
||||
@tab-change=${(event: CustomEvent<"players" | "clans">) =>
|
||||
this.handleTabChange(event.detail)}
|
||||
></leaderboard-tabs>
|
||||
<div class="flex-1 min-h-0">
|
||||
<leaderboard-player-list
|
||||
class=${this.activeTab === "players" ? "h-full" : "hidden"}
|
||||
></leaderboard-player-list>
|
||||
<leaderboard-clan-table
|
||||
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
|
||||
@date-range-change=${(
|
||||
event: CustomEvent<{ start: string; end: string }>,
|
||||
) => this.handleClanDateRangeChange(event)}
|
||||
></leaderboard-clan-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) return content;
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
id="leaderboard-modal"
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+62
-18
@@ -1,7 +1,12 @@
|
||||
import version from "resources/version.txt?raw";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { GAME_ID_REGEX, GameRecord, GameStartInfo } from "../core/Schemas";
|
||||
import {
|
||||
GAME_ID_REGEX,
|
||||
GameInfo,
|
||||
GameRecord,
|
||||
GameStartInfo,
|
||||
} from "../core/Schemas";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromClient } from "../core/configuration/ConfigLoader";
|
||||
import { GameType } from "../core/game/Game";
|
||||
@@ -22,19 +27,19 @@ import "./GoogleAdElement";
|
||||
import { GutterAds } from "./GutterAds";
|
||||
import { HelpModal } from "./HelpModal";
|
||||
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
|
||||
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
|
||||
import { JoinLobbyModal } from "./JoinLobbyModal";
|
||||
import "./LangSelector";
|
||||
import { LangSelector } from "./LangSelector";
|
||||
import { initLayout } from "./Layout";
|
||||
import "./LeaderboardModal";
|
||||
import "./Matchmaking";
|
||||
import { MatchmakingModal } from "./Matchmaking";
|
||||
import { initNavigation } from "./Navigation";
|
||||
import "./NewsModal";
|
||||
import "./PatternInput";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { PublicLobby, ShowPublicLobbyModalEvent } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import "./StatsModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import {
|
||||
@@ -178,6 +183,24 @@ declare global {
|
||||
slots?: any;
|
||||
};
|
||||
spaNewPage: (url?: string) => void;
|
||||
// Video ad methods
|
||||
onPlayerReady: (() => void) | null;
|
||||
addUnits: (units: Array<{ type: string }>) => Promise<void>;
|
||||
displayUnits: () => void;
|
||||
};
|
||||
Bolt: {
|
||||
on: (unitType: string, event: string, callback: () => void) => void;
|
||||
BOLT_AD_REQUEST_START: string;
|
||||
BOLT_AD_IMPRESSION: string;
|
||||
BOLT_AD_STARTED: string;
|
||||
BOLT_FIRST_QUARTILE: string;
|
||||
BOLT_MIDPOINT: string;
|
||||
BOLT_THIRD_QUARTILE: string;
|
||||
BOLT_AD_COMPLETE: string;
|
||||
BOLT_AD_ERROR: string;
|
||||
BOLT_AD_PAUSED: string;
|
||||
BOLT_AD_CLICKED: string;
|
||||
SHOW_HIDDEN_CONTAINER: string;
|
||||
};
|
||||
showPage?: (pageId: string) => void;
|
||||
}
|
||||
@@ -185,6 +208,7 @@ declare global {
|
||||
// Extend the global interfaces to include your custom events
|
||||
interface DocumentEventMap {
|
||||
"join-lobby": CustomEvent<JoinLobbyEvent>;
|
||||
"show-public-lobby-modal": CustomEvent<ShowPublicLobbyModalEvent>;
|
||||
"kick-player": CustomEvent;
|
||||
"join-changed": CustomEvent;
|
||||
}
|
||||
@@ -198,6 +222,8 @@ export interface JoinLobbyEvent {
|
||||
gameStartInfo?: GameStartInfo;
|
||||
// GameRecord exists when replaying an archived game.
|
||||
gameRecord?: GameRecord;
|
||||
source?: "public" | "private" | "host" | "matchmaking" | "singleplayer";
|
||||
publicLobbyInfo?: GameInfo;
|
||||
}
|
||||
|
||||
class Client {
|
||||
@@ -210,7 +236,7 @@ class Client {
|
||||
private flagInput: FlagInput | null = null;
|
||||
|
||||
private hostModal: HostPrivateLobbyModal;
|
||||
private joinModal: JoinPrivateLobbyModal;
|
||||
private joinModal: JoinLobbyModal;
|
||||
private publicLobby: PublicLobby;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private patternsModal: TerritoryPatternsModal;
|
||||
@@ -284,6 +310,10 @@ class Client {
|
||||
this.gutterAds = gutterAds;
|
||||
|
||||
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
|
||||
document.addEventListener(
|
||||
"show-public-lobby-modal",
|
||||
this.handleShowPublicLobbyModal.bind(this),
|
||||
);
|
||||
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
|
||||
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
|
||||
document.addEventListener(
|
||||
@@ -486,7 +516,6 @@ class Client {
|
||||
hostLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-host-lobby");
|
||||
this.publicLobby.leaveLobby();
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
@@ -501,10 +530,12 @@ class Client {
|
||||
});
|
||||
|
||||
this.joinModal = document.querySelector(
|
||||
"join-private-lobby-modal",
|
||||
) as JoinPrivateLobbyModal;
|
||||
if (!this.joinModal || !(this.joinModal instanceof JoinPrivateLobbyModal)) {
|
||||
console.warn("Join private lobby modal element not found");
|
||||
"join-lobby-modal",
|
||||
) as JoinLobbyModal;
|
||||
if (!this.joinModal || !(this.joinModal instanceof JoinLobbyModal)) {
|
||||
console.warn("Join lobby modal element not found");
|
||||
} else {
|
||||
this.joinModal.eventBus = this.eventBus;
|
||||
}
|
||||
const joinPrivateLobbyButton = document.getElementById(
|
||||
"join-private-lobby-button",
|
||||
@@ -513,7 +544,7 @@ class Client {
|
||||
throw new Error("Missing join-private-lobby-button");
|
||||
joinPrivateLobbyButton.addEventListener("click", () => {
|
||||
if (this.usernameInput?.isValid()) {
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
window.showPage?.("page-join-lobby");
|
||||
} else {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
@@ -613,7 +644,7 @@ class Client {
|
||||
private async handleUrl() {
|
||||
// Wait for modal custom elements to be defined
|
||||
await Promise.all([
|
||||
customElements.whenDefined("join-private-lobby-modal"),
|
||||
customElements.whenDefined("join-lobby-modal"),
|
||||
customElements.whenDefined("host-lobby-modal"),
|
||||
]);
|
||||
|
||||
@@ -626,7 +657,7 @@ class Client {
|
||||
// Wait 2 seconds to ensure all elements are actually loaded,
|
||||
// On low end-chromebooks the join modal was not registered in time.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal?.open(lobbyId);
|
||||
console.log(`CrazyGames: joining lobby ${lobbyId} from invite param`);
|
||||
return;
|
||||
@@ -715,7 +746,7 @@ class Client {
|
||||
const lobbyId =
|
||||
pathMatch && GAME_ID_REGEX.test(pathMatch[1]) ? pathMatch[1] : null;
|
||||
if (lobbyId) {
|
||||
window.showPage?.("page-join-private-lobby");
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal.open(lobbyId);
|
||||
console.log(`joining lobby ${lobbyId}`);
|
||||
return;
|
||||
@@ -741,7 +772,10 @@ class Client {
|
||||
document.body.classList.remove("in-game");
|
||||
}
|
||||
const config = await getServerConfigFromClient();
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
// Only update URL immediately for private lobbies, not public ones
|
||||
if (!lobby.publicLobbyInfo && lobby.source !== "public") {
|
||||
this.updateJoinUrlForShare(lobby.gameID, config);
|
||||
}
|
||||
|
||||
const pattern = this.userSettings.getSelectedPatternName(
|
||||
await fetchCosmetics(),
|
||||
@@ -778,10 +812,10 @@ class Client {
|
||||
document
|
||||
.getElementById("username-validation-error")
|
||||
?.classList.add("hidden");
|
||||
this.joinModal?.closeWithoutLeaving();
|
||||
[
|
||||
"single-player-modal",
|
||||
"host-lobby-modal",
|
||||
"join-private-lobby-modal",
|
||||
"game-starting-modal",
|
||||
"game-top-bar",
|
||||
"help-modal",
|
||||
@@ -792,7 +826,7 @@ class Client {
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
"account-button",
|
||||
"stats-button",
|
||||
"leaderboard-button",
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
"lang-selector",
|
||||
@@ -866,6 +900,17 @@ class Client {
|
||||
}
|
||||
}
|
||||
|
||||
private handleShowPublicLobbyModal(
|
||||
event: CustomEvent<ShowPublicLobbyModalEvent>,
|
||||
) {
|
||||
const { lobby } = event.detail;
|
||||
console.log(`Opening JoinLobbyModal for public lobby ${lobby.gameID}`);
|
||||
|
||||
// Open the join lobby modal page and pass the lobby info
|
||||
window.showPage?.("page-join-lobby");
|
||||
this.joinModal?.open(lobby.gameID, lobby);
|
||||
}
|
||||
|
||||
private async handleLeaveLobby(/* event: CustomEvent */) {
|
||||
if (this.gameStop === null) {
|
||||
return;
|
||||
@@ -884,7 +929,6 @@ class Client {
|
||||
document.body.classList.remove("in-game");
|
||||
|
||||
crazyGamesSDK.gameplayStop();
|
||||
this.publicLobby.leaveLobby();
|
||||
}
|
||||
|
||||
private handleKickPlayer(event: CustomEvent) {
|
||||
|
||||
+26
-24
@@ -15,24 +15,15 @@ import { translateText } from "./Utils";
|
||||
@customElement("matchmaking-modal")
|
||||
export class MatchmakingModal extends BaseModal {
|
||||
private gameCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
@state() private connected = false;
|
||||
@state() private socket: WebSocket | null = null;
|
||||
@state() private gameID: string | null = null;
|
||||
private elo = "unknown";
|
||||
private elo: number | "unknown" = "unknown";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = "page-matchmaking";
|
||||
document.addEventListener("userMeResponse", (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
if (customEvent.detail) {
|
||||
const userMeResponse = customEvent.detail as UserMeResponse;
|
||||
this.elo =
|
||||
userMeResponse.player?.leaderboard?.oneVone?.elo?.toString() ??
|
||||
"unknown";
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
@@ -125,18 +116,24 @@ export class MatchmakingModal extends BaseModal {
|
||||
);
|
||||
this.socket.onopen = async () => {
|
||||
console.log("Connected to matchmaking server");
|
||||
setTimeout(() => {
|
||||
this.connectTimeout = setTimeout(async () => {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||
console.warn("[Matchmaking] socket not ready");
|
||||
return;
|
||||
}
|
||||
// Set a delay so the user can see the "connecting" message,
|
||||
// otherwise the "searching" message will be shown immediately.
|
||||
// Also wait so people who back out immediately aren't added
|
||||
// to the matchmaking queue.
|
||||
this.socket.send(
|
||||
JSON.stringify({
|
||||
type: "join",
|
||||
jwt: await getPlayToken(),
|
||||
}),
|
||||
);
|
||||
this.connected = true;
|
||||
this.requestUpdate();
|
||||
}, 1000);
|
||||
this.socket?.send(
|
||||
JSON.stringify({
|
||||
type: "join",
|
||||
jwt: await getPlayToken(),
|
||||
}),
|
||||
);
|
||||
}, 2000);
|
||||
};
|
||||
this.socket.onmessage = (event) => {
|
||||
console.log(event.data);
|
||||
@@ -145,6 +142,7 @@ export class MatchmakingModal extends BaseModal {
|
||||
this.socket?.close();
|
||||
console.log(`matchmaking: got game ID: ${data.gameId}`);
|
||||
this.gameID = data.gameId;
|
||||
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
|
||||
}
|
||||
};
|
||||
this.socket.onerror = (event: ErrorEvent) => {
|
||||
@@ -157,7 +155,6 @@ export class MatchmakingModal extends BaseModal {
|
||||
|
||||
protected async onOpen(): Promise<void> {
|
||||
const userMe = await getUserMe();
|
||||
|
||||
// Early return if modal was closed during async operation
|
||||
if (!this.isModalOpen) {
|
||||
return;
|
||||
@@ -180,15 +177,21 @@ export class MatchmakingModal extends BaseModal {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
this.elo = userMe.player.leaderboard?.oneVone?.elo ?? "unknown";
|
||||
|
||||
this.connected = false;
|
||||
this.gameID = null;
|
||||
this.connect();
|
||||
this.gameCheckInterval = setInterval(() => this.checkGame(), 1000);
|
||||
}
|
||||
|
||||
protected onClose(): void {
|
||||
this.connected = false;
|
||||
this.socket?.close();
|
||||
if (this.connectTimeout) {
|
||||
clearTimeout(this.connectTimeout);
|
||||
this.connectTimeout = null;
|
||||
}
|
||||
if (this.gameCheckInterval) {
|
||||
clearInterval(this.gameCheckInterval);
|
||||
this.gameCheckInterval = null;
|
||||
@@ -263,7 +266,7 @@ export class MatchmakingButton extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
const button = this.isLoggedIn
|
||||
return this.isLoggedIn
|
||||
? html`
|
||||
<button
|
||||
@click="${this.handleLoggedInClick}"
|
||||
@@ -279,6 +282,7 @@ export class MatchmakingButton extends LitElement {
|
||||
${translateText("matchmaking_button.description")}
|
||||
</span>
|
||||
</button>
|
||||
<matchmaking-modal></matchmaking-modal>
|
||||
`
|
||||
: html`
|
||||
<button
|
||||
@@ -290,8 +294,6 @@ export class MatchmakingButton extends LitElement {
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
return html` ${button} <matchmaking-modal></matchmaking-modal> `;
|
||||
}
|
||||
|
||||
private handleLoggedInClick() {
|
||||
|
||||
+46
-253
@@ -1,32 +1,25 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { renderDuration, translateText } from "../client/Utils";
|
||||
import {
|
||||
Duos,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
import {
|
||||
getGameModeLabel,
|
||||
getModifierLabels,
|
||||
normaliseMapKey,
|
||||
renderDuration,
|
||||
translateText,
|
||||
} from "./Utils";
|
||||
|
||||
export interface ShowPublicLobbyModalEvent {
|
||||
lobby: GameInfo;
|
||||
}
|
||||
|
||||
@customElement("public-lobby")
|
||||
export class PublicLobby extends LitElement {
|
||||
@state() private lobbies: GameInfo[] = [];
|
||||
@state() public isLobbyHighlighted: boolean = false;
|
||||
@state() private isButtonDebounced: boolean = false;
|
||||
@state() private mapImages: Map<GameID, string> = new Map();
|
||||
@state() private joiningDotIndex: number = 0;
|
||||
|
||||
private joiningInterval: number | null = null;
|
||||
private currLobby: GameInfo | null = null;
|
||||
private debounceDelay: number = 150;
|
||||
private lobbyIDToStart = new Map<GameID, number>();
|
||||
private lobbySocket = new PublicLobbySocket((lobbies) =>
|
||||
this.handleLobbiesUpdate(lobbies),
|
||||
@@ -44,7 +37,6 @@ export class PublicLobby extends LitElement {
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.lobbySocket.stop();
|
||||
this.stopJoiningAnimation();
|
||||
}
|
||||
|
||||
private handleLobbiesUpdate(lobbies: GameInfo[]) {
|
||||
@@ -84,52 +76,16 @@ export class PublicLobby extends LitElement {
|
||||
const isStarting = timeRemaining <= 2;
|
||||
const timeDisplay = renderDuration(timeRemaining);
|
||||
|
||||
const teamCount =
|
||||
lobby.gameConfig.gameMode === GameMode.Team
|
||||
? (lobby.gameConfig.playerTeams ?? 0)
|
||||
: null;
|
||||
|
||||
const maxPlayers = lobby.gameConfig.maxPlayers ?? 0;
|
||||
const teamSize = this.getTeamSize(teamCount, maxPlayers);
|
||||
const teamTotal = this.getTeamTotal(teamCount, teamSize, maxPlayers);
|
||||
const modeLabel = this.getModeLabel(
|
||||
lobby.gameConfig.gameMode,
|
||||
teamCount,
|
||||
teamTotal,
|
||||
teamSize,
|
||||
);
|
||||
// True when the detail label already includes the full mode text.
|
||||
const { label: teamDetailLabel, isFullLabel: isTeamDetailFullLabel } =
|
||||
this.getTeamDetailLabel(
|
||||
lobby.gameConfig.gameMode,
|
||||
teamCount,
|
||||
teamTotal,
|
||||
teamSize,
|
||||
);
|
||||
|
||||
let fullModeLabel = modeLabel;
|
||||
if (teamDetailLabel) {
|
||||
fullModeLabel = isTeamDetailFullLabel
|
||||
? teamDetailLabel
|
||||
: `${modeLabel} ${teamDetailLabel}`;
|
||||
}
|
||||
|
||||
const modifierLabel = this.getModifierLabels(
|
||||
const modeLabel = getGameModeLabel(lobby.gameConfig);
|
||||
const modifierLabels = getModifierLabels(
|
||||
lobby.gameConfig.publicGameModifiers,
|
||||
);
|
||||
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
?disabled=${this.isButtonDebounced}
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] ${this
|
||||
.isLobbyHighlighted
|
||||
? "ring-2 ring-blue-600 scale-[1.01] opacity-70"
|
||||
: "hover:scale-[1.01]"} active:scale-[0.98] ${this.isButtonDebounced
|
||||
? "cursor-not-allowed"
|
||||
: ""}"
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] hover:scale-[1.01] active:scale-[0.98] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
|
||||
>
|
||||
<div class="font-sans w-full h-full flex flex-col">
|
||||
<!-- Main card gradient - stops before text -->
|
||||
@@ -149,11 +105,11 @@ export class PublicLobby extends LitElement {
|
||||
</div>
|
||||
|
||||
<!-- Mode Badge in top left -->
|
||||
${fullModeLabel
|
||||
${modeLabel
|
||||
? html`<span
|
||||
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
|
||||
>
|
||||
${fullModeLabel}
|
||||
${modeLabel}
|
||||
</span>`
|
||||
: ""}
|
||||
|
||||
@@ -175,11 +131,11 @@ export class PublicLobby extends LitElement {
|
||||
<!-- Content Banner -->
|
||||
<div class="absolute bottom-0 left-0 right-0 z-20">
|
||||
<!-- Modifier badges placed just above the gradient overlay -->
|
||||
${modifierLabel.length > 0
|
||||
${modifierLabels.length > 0
|
||||
? html`<div
|
||||
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
|
||||
>
|
||||
${modifierLabel.map(
|
||||
${modifierLabels.map(
|
||||
(label) => html`
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
|
||||
@@ -200,19 +156,10 @@ export class PublicLobby extends LitElement {
|
||||
<!-- Header row: Status/Join on left, Player Count on right -->
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-base uppercase tracking-widest text-white">
|
||||
${this.currLobby
|
||||
? isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText("public_lobby.starting_game")}</span
|
||||
>`
|
||||
: html`<span class="text-orange-400"
|
||||
>${translateText("public_lobby.waiting_for_players")}
|
||||
${[0, 1, 2]
|
||||
.map((i) =>
|
||||
i === this.joiningDotIndex ? "•" : "·",
|
||||
)
|
||||
.join("")}</span
|
||||
>`
|
||||
${isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText("public_lobby.starting_game")}</span
|
||||
>`
|
||||
: html`${translateText("public_lobby.join")}`}
|
||||
</div>
|
||||
|
||||
@@ -237,7 +184,7 @@ export class PublicLobby extends LitElement {
|
||||
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
|
||||
>
|
||||
${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
|
||||
`map.${normaliseMapKey(lobby.gameConfig.gameMap)}`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -249,190 +196,36 @@ export class PublicLobby extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
leaveLobby() {
|
||||
this.isLobbyHighlighted = false;
|
||||
this.currLobby = null;
|
||||
this.stopJoiningAnimation();
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.lobbySocket.stop();
|
||||
this.isLobbyHighlighted = false;
|
||||
this.currLobby = null;
|
||||
this.stopJoiningAnimation();
|
||||
}
|
||||
|
||||
private startJoiningAnimation() {
|
||||
if (this.joiningInterval !== null) return;
|
||||
|
||||
this.joiningDotIndex = 0;
|
||||
this.joiningInterval = window.setInterval(() => {
|
||||
this.joiningDotIndex = (this.joiningDotIndex + 1) % 3;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private stopJoiningAnimation() {
|
||||
if (this.joiningInterval !== null) {
|
||||
clearInterval(this.joiningInterval);
|
||||
this.joiningInterval = null;
|
||||
}
|
||||
this.joiningDotIndex = 0;
|
||||
}
|
||||
|
||||
private getTeamSize(
|
||||
teamCount: number | string | null,
|
||||
maxPlayers: number,
|
||||
): number | undefined {
|
||||
if (typeof teamCount === "string") {
|
||||
if (teamCount === Duos) return 2;
|
||||
if (teamCount === Trios) return 3;
|
||||
if (teamCount === Quads) return 4;
|
||||
if (teamCount === HumansVsNations) return maxPlayers;
|
||||
return undefined;
|
||||
}
|
||||
if (typeof teamCount === "number" && teamCount > 0) {
|
||||
return Math.floor(maxPlayers / teamCount);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getTeamTotal(
|
||||
teamCount: number | string | null,
|
||||
teamSize: number | undefined,
|
||||
maxPlayers: number,
|
||||
): number | undefined {
|
||||
if (typeof teamCount === "number") return teamCount;
|
||||
if (teamCount === HumansVsNations) return 2;
|
||||
if (teamSize && teamSize > 0) return Math.floor(maxPlayers / teamSize);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getModeLabel(
|
||||
gameMode: GameMode,
|
||||
teamCount: number | string | null,
|
||||
teamTotal: number | undefined,
|
||||
teamSize: number | undefined,
|
||||
): string {
|
||||
if (gameMode !== GameMode.Team) return translateText("game_mode.ffa");
|
||||
if (teamCount === HumansVsNations && teamSize !== undefined)
|
||||
return translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: teamSize,
|
||||
});
|
||||
const totalTeams =
|
||||
teamTotal ?? (typeof teamCount === "number" ? teamCount : 0);
|
||||
return translateText("public_lobby.teams", { num: totalTeams });
|
||||
}
|
||||
|
||||
private getTeamDetailLabel(
|
||||
gameMode: GameMode,
|
||||
teamCount: number | string | null,
|
||||
teamTotal: number | undefined,
|
||||
teamSize: number | undefined,
|
||||
): { label: string | null; isFullLabel: boolean } {
|
||||
if (gameMode !== GameMode.Team) {
|
||||
return { label: null, isFullLabel: false };
|
||||
}
|
||||
|
||||
if (typeof teamCount === "string" && teamCount === HumansVsNations) {
|
||||
return { label: null, isFullLabel: false };
|
||||
}
|
||||
|
||||
if (typeof teamCount === "string") {
|
||||
const teamKey = `public_lobby.teams_${teamCount}`;
|
||||
// translateText returns the key when a translation is missing.
|
||||
const maybeTranslated = translateText(teamKey, {
|
||||
team_count: teamTotal ?? 0,
|
||||
});
|
||||
if (maybeTranslated !== teamKey) {
|
||||
return { label: maybeTranslated, isFullLabel: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (teamTotal !== undefined && teamSize !== undefined) {
|
||||
// Fallback when there's no specific team label translation.
|
||||
return {
|
||||
label: translateText("public_lobby.players_per_team", {
|
||||
num: teamSize,
|
||||
}),
|
||||
isFullLabel: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { label: null, isFullLabel: false };
|
||||
}
|
||||
|
||||
private getModifierLabels(
|
||||
publicGameModifiers: PublicGameModifiers | undefined,
|
||||
): string[] {
|
||||
if (!publicGameModifiers) {
|
||||
return [];
|
||||
}
|
||||
const labels: string[] = [];
|
||||
if (publicGameModifiers.isRandomSpawn) {
|
||||
labels.push(translateText("public_game_modifier.random_spawn"));
|
||||
}
|
||||
if (publicGameModifiers.isCompact) {
|
||||
labels.push(translateText("public_game_modifier.compact_map"));
|
||||
}
|
||||
if (publicGameModifiers.isCrowded) {
|
||||
labels.push(translateText("public_game_modifier.crowded"));
|
||||
}
|
||||
if (publicGameModifiers.startingGold) {
|
||||
labels.push(translateText("public_game_modifier.starting_gold"));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
private lobbyClicked(lobby: GameInfo) {
|
||||
if (this.isButtonDebounced) return;
|
||||
|
||||
this.isButtonDebounced = true;
|
||||
setTimeout(() => {
|
||||
this.isButtonDebounced = false;
|
||||
}, this.debounceDelay);
|
||||
|
||||
if (this.currLobby === null) {
|
||||
// Validate username only when joining a new lobby
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (
|
||||
usernameInput &&
|
||||
typeof usernameInput.isValid === "function" &&
|
||||
!usernameInput.isValid()
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: usernameInput.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLobbyHighlighted = true;
|
||||
this.currLobby = lobby;
|
||||
this.startJoiningAnimation();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
// Validate username before opening the modal
|
||||
const usernameInput = document.querySelector("username-input") as any;
|
||||
if (
|
||||
usernameInput &&
|
||||
typeof usernameInput.isValid === "function" &&
|
||||
!usernameInput.isValid()
|
||||
) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
gameID: lobby.gameID,
|
||||
clientID: generateID(),
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
message: usernameInput.validationError,
|
||||
color: "red",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("leave-lobby", {
|
||||
detail: { lobby: this.currLobby },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
this.leaveLobby();
|
||||
return;
|
||||
}
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("show-public-lobby-modal", {
|
||||
detail: { lobby } as ShowPublicLobbyModalEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,31 +36,59 @@ import {
|
||||
} from "./utilities/RenderToggleInputCard";
|
||||
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
selectedMap: GameMapType.World,
|
||||
selectedDifficulty: Difficulty.Easy,
|
||||
disableNations: false,
|
||||
bots: 400,
|
||||
infiniteGold: false,
|
||||
infiniteTroops: false,
|
||||
compactMap: false,
|
||||
maxTimer: false,
|
||||
maxTimerValue: undefined as number | undefined,
|
||||
instantBuild: false,
|
||||
randomSpawn: false,
|
||||
useRandomMap: false,
|
||||
gameMode: GameMode.FFA,
|
||||
teamCount: 2 as TeamCountConfig,
|
||||
goldMultiplier: false,
|
||||
goldMultiplierValue: undefined as number | undefined,
|
||||
startingGold: false,
|
||||
startingGoldValue: undefined as number | undefined,
|
||||
disabledUnits: [] as UnitType[],
|
||||
} as const;
|
||||
|
||||
@customElement("single-player-modal")
|
||||
export class SinglePlayerModal extends BaseModal {
|
||||
@state() private selectedMap: GameMapType = GameMapType.World;
|
||||
@state() private selectedDifficulty: Difficulty = Difficulty.Easy;
|
||||
@state() private disableNations: boolean = false;
|
||||
@state() private bots: number = 400;
|
||||
@state() private infiniteGold: boolean = false;
|
||||
@state() private infiniteTroops: boolean = false;
|
||||
@state() private compactMap: boolean = false;
|
||||
@state() private maxTimer: boolean = false;
|
||||
@state() private maxTimerValue: number | undefined = undefined;
|
||||
@state() private instantBuild: boolean = false;
|
||||
@state() private randomSpawn: boolean = false;
|
||||
@state() private useRandomMap: boolean = false;
|
||||
@state() private gameMode: GameMode = GameMode.FFA;
|
||||
@state() private teamCount: TeamCountConfig = 2;
|
||||
@state() private selectedMap: GameMapType = DEFAULT_OPTIONS.selectedMap;
|
||||
@state() private selectedDifficulty: Difficulty =
|
||||
DEFAULT_OPTIONS.selectedDifficulty;
|
||||
@state() private disableNations: boolean = DEFAULT_OPTIONS.disableNations;
|
||||
@state() private bots: number = DEFAULT_OPTIONS.bots;
|
||||
@state() private infiniteGold: boolean = DEFAULT_OPTIONS.infiniteGold;
|
||||
@state() private infiniteTroops: boolean = DEFAULT_OPTIONS.infiniteTroops;
|
||||
@state() private compactMap: boolean = DEFAULT_OPTIONS.compactMap;
|
||||
@state() private maxTimer: boolean = DEFAULT_OPTIONS.maxTimer;
|
||||
@state() private maxTimerValue: number | undefined =
|
||||
DEFAULT_OPTIONS.maxTimerValue;
|
||||
@state() private instantBuild: boolean = DEFAULT_OPTIONS.instantBuild;
|
||||
@state() private randomSpawn: boolean = DEFAULT_OPTIONS.randomSpawn;
|
||||
@state() private useRandomMap: boolean = DEFAULT_OPTIONS.useRandomMap;
|
||||
@state() private gameMode: GameMode = DEFAULT_OPTIONS.gameMode;
|
||||
@state() private teamCount: TeamCountConfig = DEFAULT_OPTIONS.teamCount;
|
||||
@state() private showAchievements: boolean = false;
|
||||
@state() private mapWins: Map<GameMapType, Set<Difficulty>> = new Map();
|
||||
@state() private userMeResponse: UserMeResponse | false = false;
|
||||
@state() private goldMultiplier: boolean = false;
|
||||
@state() private goldMultiplierValue: number | undefined = undefined;
|
||||
@state() private startingGold: boolean = false;
|
||||
@state() private startingGoldValue: number | undefined = undefined;
|
||||
@state() private goldMultiplier: boolean = DEFAULT_OPTIONS.goldMultiplier;
|
||||
@state() private goldMultiplierValue: number | undefined =
|
||||
DEFAULT_OPTIONS.goldMultiplierValue;
|
||||
@state() private startingGold: boolean = DEFAULT_OPTIONS.startingGold;
|
||||
@state() private startingGoldValue: number | undefined =
|
||||
DEFAULT_OPTIONS.startingGoldValue;
|
||||
|
||||
@state() private disabledUnits: UnitType[] = [];
|
||||
@state() private disabledUnits: UnitType[] = [
|
||||
...DEFAULT_OPTIONS.disabledUnits,
|
||||
];
|
||||
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
@@ -617,7 +645,14 @@ export class SinglePlayerModal extends BaseModal {
|
||||
</div>
|
||||
|
||||
<!-- Footer Action -->
|
||||
<div class="p-6 pt-4 border-t border-white/10 bg-black/20">
|
||||
<div class="p-6 border-t border-white/10 bg-black/20">
|
||||
${hasLinkedAccount(this.userMeResponse) && this.hasOptionsChanged()
|
||||
? html`<div
|
||||
class="mb-4 px-4 py-3 rounded-xl bg-yellow-500/20 border border-yellow-500/30 text-yellow-400 text-xs font-bold uppercase tracking-wider text-center"
|
||||
>
|
||||
${translateText("single_modal.options_changed_no_achievements")}
|
||||
</div>`
|
||||
: null}
|
||||
<button
|
||||
@click=${this.startGame}
|
||||
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0"
|
||||
@@ -645,6 +680,24 @@ export class SinglePlayerModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
// Check if any options other than map and difficulty have been changed from defaults
|
||||
private hasOptionsChanged(): boolean {
|
||||
return (
|
||||
this.disableNations !== DEFAULT_OPTIONS.disableNations ||
|
||||
this.bots !== DEFAULT_OPTIONS.bots ||
|
||||
this.infiniteGold !== DEFAULT_OPTIONS.infiniteGold ||
|
||||
this.infiniteTroops !== DEFAULT_OPTIONS.infiniteTroops ||
|
||||
this.compactMap !== DEFAULT_OPTIONS.compactMap ||
|
||||
this.maxTimer !== DEFAULT_OPTIONS.maxTimer ||
|
||||
this.instantBuild !== DEFAULT_OPTIONS.instantBuild ||
|
||||
this.randomSpawn !== DEFAULT_OPTIONS.randomSpawn ||
|
||||
this.gameMode !== DEFAULT_OPTIONS.gameMode ||
|
||||
this.goldMultiplier !== DEFAULT_OPTIONS.goldMultiplier ||
|
||||
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
|
||||
this.disabledUnits.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// Helper for consistent option buttons
|
||||
private renderOptionToggle(
|
||||
labelKey: string,
|
||||
@@ -674,25 +727,25 @@ export class SinglePlayerModal extends BaseModal {
|
||||
|
||||
protected onClose(): void {
|
||||
// Reset all transient form state to ensure clean slate
|
||||
this.selectedMap = GameMapType.World;
|
||||
this.selectedDifficulty = Difficulty.Easy;
|
||||
this.gameMode = GameMode.FFA;
|
||||
this.useRandomMap = false;
|
||||
this.disableNations = false;
|
||||
this.bots = 400;
|
||||
this.infiniteGold = false;
|
||||
this.infiniteTroops = false;
|
||||
this.compactMap = false;
|
||||
this.maxTimer = false;
|
||||
this.maxTimerValue = undefined;
|
||||
this.instantBuild = false;
|
||||
this.randomSpawn = false;
|
||||
this.teamCount = 2;
|
||||
this.disabledUnits = [];
|
||||
this.goldMultiplier = false;
|
||||
this.goldMultiplierValue = undefined;
|
||||
this.startingGold = false;
|
||||
this.startingGoldValue = undefined;
|
||||
this.selectedMap = DEFAULT_OPTIONS.selectedMap;
|
||||
this.selectedDifficulty = DEFAULT_OPTIONS.selectedDifficulty;
|
||||
this.gameMode = DEFAULT_OPTIONS.gameMode;
|
||||
this.useRandomMap = DEFAULT_OPTIONS.useRandomMap;
|
||||
this.disableNations = DEFAULT_OPTIONS.disableNations;
|
||||
this.bots = DEFAULT_OPTIONS.bots;
|
||||
this.infiniteGold = DEFAULT_OPTIONS.infiniteGold;
|
||||
this.infiniteTroops = DEFAULT_OPTIONS.infiniteTroops;
|
||||
this.compactMap = DEFAULT_OPTIONS.compactMap;
|
||||
this.maxTimer = DEFAULT_OPTIONS.maxTimer;
|
||||
this.maxTimerValue = DEFAULT_OPTIONS.maxTimerValue;
|
||||
this.instantBuild = DEFAULT_OPTIONS.instantBuild;
|
||||
this.randomSpawn = DEFAULT_OPTIONS.randomSpawn;
|
||||
this.teamCount = DEFAULT_OPTIONS.teamCount;
|
||||
this.disabledUnits = [...DEFAULT_OPTIONS.disabledUnits];
|
||||
this.goldMultiplier = DEFAULT_OPTIONS.goldMultiplier;
|
||||
this.goldMultiplierValue = DEFAULT_OPTIONS.goldMultiplierValue;
|
||||
this.startingGold = DEFAULT_OPTIONS.startingGold;
|
||||
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
|
||||
}
|
||||
|
||||
private handleSelectRandomMap() {
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
ClanLeaderboardEntry,
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
import { getApiBase } from "./Api";
|
||||
import { translateText } from "./Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
|
||||
@customElement("stats-modal")
|
||||
export class StatsModal extends BaseModal {
|
||||
@state() private isLoading: boolean = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private data: ClanLeaderboardResponse | null = null;
|
||||
@state() private sortBy: "rank" | "games" | "wins" | "losses" | "ratio" =
|
||||
"rank";
|
||||
@state() private sortOrder: "asc" | "desc" = "asc";
|
||||
|
||||
private hasLoaded = false;
|
||||
|
||||
private handleSort(column: "rank" | "games" | "wins" | "losses" | "ratio") {
|
||||
if (this.sortBy === column) {
|
||||
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this.sortBy = column;
|
||||
this.sortOrder = column === "rank" ? "asc" : "desc";
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private getSortedClans(clans: ClanLeaderboardEntry[]) {
|
||||
const sorted = [...clans];
|
||||
sorted.sort((a, b) => {
|
||||
let aVal: number, bVal: number;
|
||||
|
||||
switch (this.sortBy) {
|
||||
case "games":
|
||||
aVal = a.games;
|
||||
bVal = b.games;
|
||||
break;
|
||||
case "wins":
|
||||
aVal = a.weightedWins;
|
||||
bVal = b.weightedWins;
|
||||
break;
|
||||
case "losses":
|
||||
aVal = a.weightedLosses;
|
||||
bVal = b.weightedLosses;
|
||||
break;
|
||||
case "ratio":
|
||||
aVal = a.weightedWLRatio;
|
||||
bVal = b.weightedWLRatio;
|
||||
break;
|
||||
case "rank":
|
||||
default:
|
||||
// Original order
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
if (!this.hasLoaded && !this.isLoading) {
|
||||
void this.loadLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadLeaderboard() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"ClanLeaderboardModal: invalid response schema",
|
||||
parsed.error,
|
||||
);
|
||||
throw new Error("Invalid response format");
|
||||
}
|
||||
|
||||
this.data = parsed.data;
|
||||
this.hasLoaded = true;
|
||||
} catch (err) {
|
||||
console.warn("ClanLeaderboardModal: failed to load leaderboard", err);
|
||||
this.error = translateText("stats_modal.error");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private renderBody() {
|
||||
if (this.isLoading) {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
|
||||
></div>
|
||||
<p
|
||||
class="text-blue-200/80 text-sm font-bold tracking-[0.2em] uppercase"
|
||||
>
|
||||
${translateText("stats_modal.loading")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-[0_0_30px_rgba(239,68,68,0.2)]"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-8 text-center text-red-100/80 max-w-xs font-medium">
|
||||
${this.error}
|
||||
</p>
|
||||
<button
|
||||
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 hover:border-red-500/50 text-red-200 rounded-xl text-sm font-bold uppercase tracking-wider transition-all cursor-pointer hover:shadow-lg hover:shadow-red-500/10 active:scale-95"
|
||||
@click=${() => this.loadLeaderboard()}
|
||||
>
|
||||
${translateText("stats_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.data || this.data.clans.length === 0) {
|
||||
return html`
|
||||
<div
|
||||
class="p-12 text-center text-white/40 flex flex-col items-center h-full justify-center"
|
||||
>
|
||||
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 text-white/20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white/60 mb-2">
|
||||
${translateText("stats_modal.no_data_yet")}
|
||||
</h3>
|
||||
<p class="text-white/30 text-sm max-w-[200px]">
|
||||
${translateText("stats_modal.no_stats")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { clans } = this.data;
|
||||
const maxGames = Math.max(...clans.map((c) => c.games), 1);
|
||||
|
||||
return html`
|
||||
<div class="w-full pt-6">
|
||||
<div
|
||||
class="overflow-x-auto rounded-xl border border-white/5 bg-black/20"
|
||||
>
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
class="text-white/40 text-xs uppercase tracking-wider border-b border-white/5 bg-white/[0.02]"
|
||||
>
|
||||
<th class="py-4 px-4 text-center font-bold w-16">
|
||||
${translateText("stats_modal.rank")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-left font-bold">
|
||||
${translateText("stats_modal.clan")}
|
||||
</th>
|
||||
<th
|
||||
@click=${() => this.handleSort("games")}
|
||||
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.games")}
|
||||
${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
@click=${() => this.handleSort("wins")}
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
title=${translateText("stats_modal.win_score_tooltip")}
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.win_score")}
|
||||
${this.sortBy === "wins"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
@click=${() => this.handleSort("losses")}
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
title=${translateText("stats_modal.loss_score_tooltip")}
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.loss_score")}
|
||||
${this.sortBy === "losses"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
@click=${() => this.handleSort("ratio")}
|
||||
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors select-none"
|
||||
>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? html`<span class="text-blue-400">↑</span>`
|
||||
: html`<span class="text-blue-400">↓</span>`
|
||||
: html`<span class="text-white/20">↕</span>`}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${this.getSortedClans(clans).map((clan, index) => {
|
||||
const rankColor =
|
||||
index === 0
|
||||
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
|
||||
: index === 1
|
||||
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
|
||||
: index === 2
|
||||
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
|
||||
: "text-white/40 bg-white/5";
|
||||
|
||||
const rankIcon =
|
||||
index === 0
|
||||
? "👑"
|
||||
: index === 1
|
||||
? "🥈"
|
||||
: index === 2
|
||||
? "🥉"
|
||||
: String(index + 1);
|
||||
|
||||
return html`
|
||||
<tr
|
||||
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
|
||||
>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-blue-300 font-bold text-xs tracking-wide group-hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
${clan.clanTag}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-white font-mono font-medium"
|
||||
>${clan.games.toLocaleString()}</span
|
||||
>
|
||||
<div
|
||||
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500/50 rounded-full"
|
||||
style="width: ${(clan.games / maxGames) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedWins}
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedLosses}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right pr-6">
|
||||
<div class="inline-flex flex-col items-end">
|
||||
<span
|
||||
class="font-mono font-bold ${Number(
|
||||
clan.weightedWLRatio,
|
||||
) >= 1
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>
|
||||
${clan.weightedWLRatio}
|
||||
</span>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("stats_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
let dateRange = html``;
|
||||
if (this.data) {
|
||||
const start = new Date(this.data.start).toLocaleDateString();
|
||||
const end = new Date(this.data.end).toLocaleDateString();
|
||||
dateRange = html`<span
|
||||
class="text-sm font-normal text-white/40 ml-2 break-words"
|
||||
>(${start} - ${end})</span
|
||||
>`;
|
||||
}
|
||||
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full flex flex-col bg-black/60 backdrop-blur-md rounded-2xl border border-white/10 overflow-hidden"
|
||||
>
|
||||
${modalHeader({
|
||||
titleContent: html`
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
${translateText("stats_modal.clan_stats")}
|
||||
</span>
|
||||
${dateRange}
|
||||
</div>
|
||||
`,
|
||||
onBack: this.close,
|
||||
ariaLabel: translateText("common.close"),
|
||||
leftClassName: "flex flex-wrap items-center gap-4 flex-1",
|
||||
})}
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
|
||||
>
|
||||
${this.renderBody()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
id="stats-modal"
|
||||
title="${translateText("stats_modal.clan_stats")}"
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
+128
-1
@@ -1,7 +1,134 @@
|
||||
import IntlMessageFormat from "intl-messageformat";
|
||||
import { MessageType } from "../core/game/Game";
|
||||
import {
|
||||
Duos,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
MessageType,
|
||||
PublicGameModifiers,
|
||||
Quads,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { GameConfig } from "../core/Schemas";
|
||||
import type { LangSelector } from "./LangSelector";
|
||||
|
||||
export const TUTORIAL_VIDEO_URL = "https://www.youtube.com/embed/EN2oOog3pSs";
|
||||
|
||||
export function normaliseMapKey(mapName: string): string {
|
||||
return mapName.toLowerCase().replace(/[\s.]+/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a display label for the game mode (e.g. "FFA", "4 Teams", "Duos").
|
||||
*/
|
||||
export function getGameModeLabel(gameConfig: GameConfig): string {
|
||||
const { gameMode, playerTeams, maxPlayers } = gameConfig;
|
||||
|
||||
if (gameMode !== GameMode.Team) {
|
||||
return translateText("game_mode.ffa");
|
||||
}
|
||||
|
||||
// Humans vs Nations
|
||||
if (playerTeams === HumansVsNations) {
|
||||
return translateText("public_lobby.teams_hvn_detailed", {
|
||||
num: maxPlayers ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Named team types (Duos, Trios, Quads)
|
||||
if (typeof playerTeams === "string") {
|
||||
const teamKey = `public_lobby.teams_${playerTeams}`;
|
||||
const teamCount = getTeamCount(playerTeams, maxPlayers ?? 0);
|
||||
const translated = translateText(teamKey, { team_count: teamCount });
|
||||
if (translated !== teamKey) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
|
||||
// Numeric team count
|
||||
const teamCount =
|
||||
typeof playerTeams === "number"
|
||||
? playerTeams
|
||||
: getTeamCount(playerTeams, maxPlayers ?? 0);
|
||||
return translateText("public_lobby.teams", { num: teamCount });
|
||||
}
|
||||
|
||||
function getTeamCount(
|
||||
playerTeams: string | number | undefined,
|
||||
maxPlayers: number,
|
||||
): number {
|
||||
if (typeof playerTeams === "number") return playerTeams;
|
||||
const teamSize = getTeamSize(playerTeams, maxPlayers);
|
||||
return teamSize > 0 ? Math.floor(maxPlayers / teamSize) : 0;
|
||||
}
|
||||
|
||||
function getTeamSize(
|
||||
playerTeams: string | number | undefined,
|
||||
maxPlayers: number,
|
||||
): number {
|
||||
if (playerTeams === Duos) return 2;
|
||||
if (playerTeams === Trios) return 3;
|
||||
if (playerTeams === Quads) return 4;
|
||||
if (playerTeams === HumansVsNations) return maxPlayers;
|
||||
if (typeof playerTeams === "number" && playerTeams > 0) {
|
||||
return Math.floor(maxPlayers / playerTeams);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface ModifierInfo {
|
||||
/** Translation key for detailed label (e.g. "host_modal.random_spawn") */
|
||||
labelKey: string;
|
||||
/** Translation key for badge/short label (e.g. "public_game_modifier.random_spawn") */
|
||||
badgeKey: string;
|
||||
/** The raw value if applicable (e.g. startingGold amount) */
|
||||
value?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns structured modifier info for both detailed config display and badges.
|
||||
*/
|
||||
export function getActiveModifiers(
|
||||
modifiers: PublicGameModifiers | undefined,
|
||||
): ModifierInfo[] {
|
||||
if (!modifiers) return [];
|
||||
const result: ModifierInfo[] = [];
|
||||
if (modifiers.isRandomSpawn) {
|
||||
result.push({
|
||||
labelKey: "host_modal.random_spawn",
|
||||
badgeKey: "public_game_modifier.random_spawn",
|
||||
});
|
||||
}
|
||||
if (modifiers.isCompact) {
|
||||
result.push({
|
||||
labelKey: "host_modal.compact_map",
|
||||
badgeKey: "public_game_modifier.compact_map",
|
||||
});
|
||||
}
|
||||
if (modifiers.isCrowded) {
|
||||
result.push({
|
||||
labelKey: "host_modal.crowded",
|
||||
badgeKey: "public_game_modifier.crowded",
|
||||
});
|
||||
}
|
||||
if (modifiers.startingGold) {
|
||||
result.push({
|
||||
labelKey: "host_modal.starting_gold",
|
||||
badgeKey: "public_game_modifier.starting_gold",
|
||||
value: modifiers.startingGold,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of translated modifier labels for badge display.
|
||||
*/
|
||||
export function getModifierLabels(
|
||||
modifiers: PublicGameModifiers | undefined,
|
||||
): string[] {
|
||||
return getActiveModifiers(modifiers).map((m) => translateText(m.badgeKey));
|
||||
}
|
||||
|
||||
export function renderDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return "0s";
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
|
||||
@@ -25,6 +25,16 @@ export abstract class BaseModal extends LitElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
if (this.modalEl) {
|
||||
this.modalEl.onClose = () => {
|
||||
if (this.isModalOpen) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.unregisterEscapeHandler();
|
||||
super.disconnectedCallback();
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { getCosmeticsHash } from "../Cosmetics";
|
||||
import { getGamesPlayed } from "../Utils";
|
||||
|
||||
const HELP_SEEN_KEY = "helpSeen";
|
||||
const STORE_SEEN_HASH_KEY = "storeSeenHash";
|
||||
|
||||
@customElement("desktop-nav-bar")
|
||||
export class DesktopNavBar extends LitElement {
|
||||
@state() private _helpSeen = localStorage.getItem(HELP_SEEN_KEY) === "true";
|
||||
@state() private _hasNewCosmetics = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -18,6 +26,12 @@ export class DesktopNavBar extends LitElement {
|
||||
this._updateActiveState(current);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if cosmetics have changed
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
const seenHash = localStorage.getItem(STORE_SEEN_HASH_KEY);
|
||||
this._hasNewCosmetics = hash !== null && hash !== seenHash;
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -40,6 +54,30 @@ export class DesktopNavBar extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private showHelpDot(): boolean {
|
||||
// Only show one dot at a time to prevent
|
||||
// overwhelming users.
|
||||
return getGamesPlayed() < 10 && !this._helpSeen;
|
||||
}
|
||||
|
||||
private showStoreDot(): boolean {
|
||||
return this._hasNewCosmetics && !this.showHelpDot();
|
||||
}
|
||||
|
||||
private onHelpClick = () => {
|
||||
localStorage.setItem(HELP_SEEN_KEY, "true");
|
||||
this._helpSeen = true;
|
||||
};
|
||||
|
||||
private onStoreClick = () => {
|
||||
this._hasNewCosmetics = false;
|
||||
getCosmeticsHash().then((hash: string | null) => {
|
||||
if (hash !== null) {
|
||||
localStorage.setItem(STORE_SEEN_HASH_KEY, hash);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<nav
|
||||
@@ -109,11 +147,18 @@ export class DesktopNavBar extends LitElement {
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-item-store"
|
||||
data-i18n="main.store"
|
||||
@click=${this.onStoreClick}
|
||||
></button>
|
||||
<span
|
||||
class="absolute -top-3 -right-2 bg-gradient-to-br from-red-600 to-red-700 text-white text-[9px] font-black tracking-wider px-2 py-0.5 rounded rotate-12 shadow-lg shadow-red-600/50 animate-pulse pointer-events-none"
|
||||
data-i18n="main.store_new_badge"
|
||||
></span>
|
||||
${this.showStoreDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full animate-ping"
|
||||
></span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full"
|
||||
></span>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
@@ -122,14 +167,27 @@ export class DesktopNavBar extends LitElement {
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-stats"
|
||||
data-i18n="main.stats"
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
data-page="page-leaderboard"
|
||||
data-i18n="main.leaderboard"
|
||||
></button>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
|
||||
data-page="page-help"
|
||||
data-i18n="main.help"
|
||||
@click=${this.onHelpClick}
|
||||
></button>
|
||||
${this.showHelpDot()
|
||||
? html`
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-ping"
|
||||
></span>
|
||||
<span
|
||||
class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full"
|
||||
></span>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
<lang-selector></lang-selector>
|
||||
<button
|
||||
id="nav-account-button"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@customElement("lobby-config-item")
|
||||
export class LobbyConfigItem extends LitElement {
|
||||
@property({ type: String }) label = "";
|
||||
@property({ attribute: false }) value: string | TemplateResult = "";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
class="bg-white/5 border border-white/10 rounded-lg p-3 flex flex-col items-center justify-center gap-1 text-center min-w-[100px]"
|
||||
>
|
||||
<span
|
||||
class="text-white/40 text-[10px] font-bold uppercase tracking-wider"
|
||||
>${this.label}</span
|
||||
>
|
||||
<span
|
||||
class="text-white font-bold text-sm w-full break-words hyphens-auto"
|
||||
>${this.value}</span
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -121,8 +121,8 @@ export class MobileNavBar extends LitElement {
|
||||
></button>
|
||||
<button
|
||||
class="nav-menu-item block w-full text-left font-bold uppercase tracking-[0.05em] text-white/70 transition-all duration-200 cursor-pointer hover:text-blue-600 hover:translate-x-2.5 hover:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] [&.active]:text-blue-600 [&.active]:translate-x-2.5 [&.active]:drop-shadow-[0_0_20px_rgba(37,99,235,0.5)] text-[clamp(18px,2.8vh,32px)] py-[clamp(0.2rem,0.8vh,0.75rem)]"
|
||||
data-page="page-stats"
|
||||
data-i18n="main.stats"
|
||||
data-page="page-leaderboard"
|
||||
data-i18n="main.leaderboard"
|
||||
></button>
|
||||
<div class="relative no-crazygames">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
const VIDEO_AD_UNIT_TYPE = "precontent_ad_video";
|
||||
|
||||
@customElement("video-ad")
|
||||
export class VideoAd extends LitElement {
|
||||
@state()
|
||||
private isVisible: boolean = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
onComplete?: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
onMidpoint?: () => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
onAdBlocked?: () => void;
|
||||
|
||||
private adLoadTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private rampCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private rampWaitTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private adStarted = false;
|
||||
|
||||
// How long to wait for ad to start before assuming it's blocked
|
||||
private static readonly AD_LOAD_TIMEOUT_MS = 8000;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Set dimensions on the custom element itself (required by Playwire)
|
||||
// Playwire requires explicit pixel dimensions, use max-width for responsiveness
|
||||
this.style.display = "block";
|
||||
this.style.width = "100%";
|
||||
this.style.maxWidth = "800px";
|
||||
this.style.aspectRatio = "16/9";
|
||||
this.showVideoAd();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// Clean up timeout if component is removed
|
||||
if (this.adLoadTimeout) {
|
||||
clearTimeout(this.adLoadTimeout);
|
||||
this.adLoadTimeout = null;
|
||||
}
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
if (this.rampWaitTimeout) {
|
||||
clearTimeout(this.rampWaitTimeout);
|
||||
this.rampWaitTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
public showVideoAd(): void {
|
||||
if (!window.ramp) {
|
||||
// Wait for ramp to be available, but give up after timeout
|
||||
this.rampCheckInterval = setInterval(() => {
|
||||
if (window.ramp && window.ramp.que) {
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
if (this.rampWaitTimeout) {
|
||||
clearTimeout(this.rampWaitTimeout);
|
||||
this.rampWaitTimeout = null;
|
||||
}
|
||||
this.loadVideoAd();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Stop polling after timeout (e.g. adblocker preventing ramp from loading)
|
||||
this.rampWaitTimeout = setTimeout(() => {
|
||||
if (this.rampCheckInterval) {
|
||||
clearInterval(this.rampCheckInterval);
|
||||
this.rampCheckInterval = null;
|
||||
}
|
||||
console.log("[VideoAd] Ramp SDK never loaded - possible adblocker");
|
||||
this.handleAdBlocked();
|
||||
}, VideoAd.AD_LOAD_TIMEOUT_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadVideoAd();
|
||||
}
|
||||
|
||||
private loadVideoAd(): void {
|
||||
// Start timeout to detect if ad doesn't load (e.g., due to adblocker)
|
||||
this.adLoadTimeout = setTimeout(() => {
|
||||
if (!this.adStarted) {
|
||||
console.log("[VideoAd] Ad load timeout - possible adblocker detected");
|
||||
this.handleAdBlocked();
|
||||
}
|
||||
}, VideoAd.AD_LOAD_TIMEOUT_MS);
|
||||
|
||||
// Set up event listeners when player is ready, chaining any existing handler
|
||||
const prevOnPlayerReady = window.ramp.onPlayerReady;
|
||||
window.ramp.onPlayerReady = () => {
|
||||
if (prevOnPlayerReady) prevOnPlayerReady();
|
||||
if (window.Bolt) {
|
||||
// Listen for ad start to know ad is loading successfully
|
||||
window.Bolt.on(
|
||||
VIDEO_AD_UNIT_TYPE,
|
||||
window.Bolt.BOLT_AD_STARTED ?? "boltAdStarted",
|
||||
() => {
|
||||
console.log("[VideoAd] Ad started");
|
||||
this.adStarted = true;
|
||||
// Clear the timeout since ad is playing
|
||||
if (this.adLoadTimeout) {
|
||||
clearTimeout(this.adLoadTimeout);
|
||||
this.adLoadTimeout = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_COMPLETE, () => {
|
||||
console.log("[VideoAd] Ad completed");
|
||||
this.hideElement();
|
||||
});
|
||||
|
||||
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_AD_ERROR, () => {
|
||||
console.log("[VideoAd] Ad error/no fill");
|
||||
this.handleAdBlocked();
|
||||
});
|
||||
|
||||
window.Bolt.on(VIDEO_AD_UNIT_TYPE, window.Bolt.BOLT_MIDPOINT, () => {
|
||||
console.log("[VideoAd] Ad midpoint");
|
||||
if (this.onMidpoint) {
|
||||
this.onMidpoint();
|
||||
}
|
||||
});
|
||||
|
||||
window.Bolt.on(
|
||||
VIDEO_AD_UNIT_TYPE,
|
||||
window.Bolt.SHOW_HIDDEN_CONTAINER ?? "showHiddenContainer",
|
||||
() => {
|
||||
console.log("[VideoAd] Ad finished");
|
||||
this.hideElement();
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Queue the video ad initialization
|
||||
window.ramp.que.push(() => {
|
||||
const pwUnits = [{ type: VIDEO_AD_UNIT_TYPE }];
|
||||
|
||||
window.ramp
|
||||
.addUnits(pwUnits)
|
||||
.then(() => {
|
||||
window.ramp.displayUnits();
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.error("[VideoAd] Error adding units:", e);
|
||||
window.ramp.displayUnits();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleAdBlocked(): void {
|
||||
// Clear timeout if still pending
|
||||
if (this.adLoadTimeout) {
|
||||
clearTimeout(this.adLoadTimeout);
|
||||
this.adLoadTimeout = null;
|
||||
}
|
||||
|
||||
// Call the callback if provided
|
||||
if (this.onAdBlocked) {
|
||||
this.onAdBlocked();
|
||||
}
|
||||
}
|
||||
|
||||
private hideElement(): void {
|
||||
this.style.display = "none";
|
||||
this.isVisible = false;
|
||||
// Call the callback if provided
|
||||
if (this.onComplete) {
|
||||
this.onComplete();
|
||||
}
|
||||
// Also dispatch event for backwards compatibility
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("ad-complete", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
// Provide a container for the Playwire video player to render into
|
||||
// Structure matches Playwire example: wrapper > game-video-ad > precontent-video-location
|
||||
return html`
|
||||
<div
|
||||
class="game-video-ad"
|
||||
style="width: 100%; height: 100%; overflow: hidden;"
|
||||
>
|
||||
<div
|
||||
id="precontent-video-location"
|
||||
style="width: 100%; height: 100%;"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
ClanLeaderboardEntry,
|
||||
ClanLeaderboardResponse,
|
||||
} from "../../../core/ApiSchemas";
|
||||
import { fetchClanLeaderboard } from "../../Api";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
export type ClanSortColumn =
|
||||
| "rank"
|
||||
| "games"
|
||||
| "winScore"
|
||||
| "lossScore"
|
||||
| "ratio";
|
||||
export type ClanSortOrder = "asc" | "desc";
|
||||
|
||||
@customElement("leaderboard-clan-table")
|
||||
export class LeaderboardClanTable extends LitElement {
|
||||
@state() private clanData: ClanLeaderboardResponse | null = null;
|
||||
@state() private isLoading = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private sortBy: ClanSortColumn = "rank";
|
||||
@state() private sortOrder: ClanSortOrder = "asc";
|
||||
|
||||
private hasLoaded = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public async ensureLoaded() {
|
||||
if (this.hasLoaded || this.isLoading) return;
|
||||
await this.loadClanLeaderboard();
|
||||
}
|
||||
|
||||
public async loadClanLeaderboard() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const data = await fetchClanLeaderboard();
|
||||
if (!data) throw new Error("Failed to load clan leaderboard");
|
||||
|
||||
this.clanData = data;
|
||||
this.hasLoaded = true;
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<{ start: string; end: string }>("date-range-change", {
|
||||
detail: { start: data.start, end: data.end },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("loadClanLeaderboard: request failed", error);
|
||||
this.error = translateText("leaderboard_modal.error");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSort(column: ClanSortColumn) {
|
||||
if (this.sortBy === column) {
|
||||
this.sortOrder = this.sortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
this.sortBy = column;
|
||||
this.sortOrder = column === "rank" ? "asc" : "desc";
|
||||
}
|
||||
}
|
||||
|
||||
private getSortedClans(clans: ClanLeaderboardEntry[]) {
|
||||
if (this.sortBy === "rank") {
|
||||
const base = [...clans];
|
||||
return this.sortOrder === "asc" ? base : base.reverse();
|
||||
}
|
||||
|
||||
const sorted = [...clans];
|
||||
sorted.sort((a, b) => {
|
||||
let aVal: number, bVal: number;
|
||||
switch (this.sortBy) {
|
||||
case "games":
|
||||
aVal = a.games;
|
||||
bVal = b.games;
|
||||
break;
|
||||
case "winScore":
|
||||
aVal = a.weightedWins;
|
||||
bVal = b.weightedWins;
|
||||
break;
|
||||
case "lossScore":
|
||||
aVal = a.weightedLosses;
|
||||
bVal = b.weightedLosses;
|
||||
break;
|
||||
case "ratio":
|
||||
aVal = a.weightedWLRatio;
|
||||
bVal = b.weightedWLRatio;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
return this.sortOrder === "asc" ? aVal - bVal : bVal - aVal;
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private renderLoading() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
|
||||
></div>
|
||||
<p class="text-blue-200/80 text-sm font-bold tracking-widest uppercase">
|
||||
${translateText("leaderboard_modal.loading")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderError() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-lg shadow-red-500/10"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-8 text-center text-red-100/80 font-medium">
|
||||
${this.error ?? translateText("leaderboard_modal.error")}
|
||||
</p>
|
||||
<button
|
||||
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-sm font-bold uppercase transition-all active:scale-95"
|
||||
@click=${() => this.loadClanLeaderboard()}
|
||||
>
|
||||
${translateText("leaderboard_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderNoData() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white/40 h-full"
|
||||
>
|
||||
<div class="bg-white/5 p-6 rounded-full mb-6 border border-white/5">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 text-white/20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white/60 mb-2">
|
||||
${translateText("leaderboard_modal.no_data_yet")}
|
||||
</h3>
|
||||
<p class="text-white/30 text-sm">
|
||||
${translateText("leaderboard_modal.no_stats")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.isLoading) return this.renderLoading();
|
||||
if (this.error) return this.renderError();
|
||||
if (!this.clanData || this.clanData.clans.length === 0)
|
||||
return this.renderNoData();
|
||||
|
||||
const { clans } = this.clanData;
|
||||
const sorted = this.getSortedClans(clans);
|
||||
const maxGames = Math.max(...clans.map((c) => c.games), 1);
|
||||
|
||||
return html`
|
||||
<div class="h-full px-6 pb-6">
|
||||
<div
|
||||
class="h-full overflow-y-auto overflow-x-auto rounded-xl border border-white/5 bg-black/20"
|
||||
>
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr
|
||||
class="text-white/40 text-[10px] uppercase tracking-wider border-b border-white/5 bg-white/2"
|
||||
>
|
||||
<th class="py-4 px-4 text-center font-bold w-16">
|
||||
${translateText("leaderboard_modal.rank")}
|
||||
</th>
|
||||
<th class="py-4 px-4 text-left font-bold">
|
||||
${translateText("leaderboard_modal.clan")}
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold w-32 cursor-pointer hover:text-white/60 transition-colors"
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("games")}
|
||||
aria-sort=${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.games")}
|
||||
${this.sortBy === "games"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
|
||||
title=${translateText("leaderboard_modal.win_score_tooltip")}
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("winScore")}
|
||||
aria-sort=${this.sortBy === "winScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.win_score")}
|
||||
${this.sortBy === "winScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold hidden md:table-cell cursor-pointer hover:text-white/60 transition-colors"
|
||||
title=${translateText("leaderboard_modal.loss_score_tooltip")}
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("lossScore")}
|
||||
aria-sort=${this.sortBy === "lossScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.loss_score")}
|
||||
${this.sortBy === "lossScore"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
<th
|
||||
class="py-4 px-4 text-right font-bold pr-6 cursor-pointer hover:text-white/60 transition-colors"
|
||||
>
|
||||
<button
|
||||
@click=${() => this.handleSort("ratio")}
|
||||
aria-sort=${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"}
|
||||
>
|
||||
${translateText("leaderboard_modal.win_loss_ratio")}
|
||||
${this.sortBy === "ratio"
|
||||
? this.sortOrder === "asc"
|
||||
? "↑"
|
||||
: "↓"
|
||||
: "↕"}
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${sorted.map((clan, index) => {
|
||||
const displayRank = index + 1;
|
||||
const rankColor =
|
||||
displayRank === 1
|
||||
? "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20"
|
||||
: displayRank === 2
|
||||
? "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20"
|
||||
: displayRank === 3
|
||||
? "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20"
|
||||
: "text-white/40 bg-white/5";
|
||||
const rankIcon =
|
||||
displayRank === 1
|
||||
? "👑"
|
||||
: displayRank === 2
|
||||
? "🥈"
|
||||
: displayRank === 3
|
||||
? "🥉"
|
||||
: String(displayRank);
|
||||
|
||||
return html`
|
||||
<tr
|
||||
class="border-b border-white/5 hover:bg-white/[0.07] transition-colors group"
|
||||
>
|
||||
<td class="py-3 px-4 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 font-bold text-blue-300">
|
||||
<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 inline-block"
|
||||
>
|
||||
${clan.clanTag}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right">
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<span class="text-white font-mono font-medium"
|
||||
>${clan.games.toLocaleString()}</span
|
||||
>
|
||||
<div
|
||||
class="w-24 h-1 bg-white/10 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500/50 rounded-full"
|
||||
style="width: ${(clan.games / maxGames) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-green-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedWins.toLocaleString("fullwide", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</td>
|
||||
<td
|
||||
class="py-3 px-4 text-right font-mono text-red-400/90 hidden md:table-cell"
|
||||
>
|
||||
${clan.weightedLosses.toLocaleString("fullwide", {
|
||||
maximumFractionDigits: 1,
|
||||
})}
|
||||
</td>
|
||||
<td class="py-3 px-4 text-right pr-6">
|
||||
<div class="inline-flex flex-col items-end">
|
||||
<span
|
||||
class="font-mono font-bold ${clan.weightedWLRatio >= 1
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>${clan.weightedWLRatio.toLocaleString("fullwide", {
|
||||
maximumFractionDigits: 2,
|
||||
})}</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("leaderboard_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
import { virtualize } from "@lit-labs/virtualizer/virtualize.js";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import { PlayerLeaderboardEntry } from "../../../core/ApiSchemas";
|
||||
import { fetchPlayerLeaderboard, getUserMe } from "../../Api";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
@customElement("leaderboard-player-list")
|
||||
export class LeaderboardPlayerList extends LitElement {
|
||||
@state() private playerData: PlayerLeaderboardEntry[] = [];
|
||||
@state() private currentUserEntry: PlayerLeaderboardEntry | null = null;
|
||||
@state() private showStickyUser = false;
|
||||
@state() private isLoading = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private isLoadingMore = false;
|
||||
@state() private loadMoreError: string | null = null;
|
||||
@state() private playerHasMore = true;
|
||||
|
||||
private hasLoadedPlayers = false;
|
||||
private readonly playerPageSize = 50;
|
||||
private currentPage = 1;
|
||||
private currentUserId: string | null = null;
|
||||
private currentUserIdLoaded = false;
|
||||
|
||||
@query(".virtualizer-container") private virtualizerContainer?: HTMLElement;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public async ensureLoaded() {
|
||||
if (this.hasLoadedPlayers || this.isLoading) return;
|
||||
await this.loadPlayerLeaderboard(true);
|
||||
}
|
||||
|
||||
public async loadPlayerLeaderboard(reset = false) {
|
||||
if (reset) {
|
||||
this.currentPage = 1;
|
||||
this.playerHasMore = true;
|
||||
this.loadMoreError = null;
|
||||
this.playerData = [];
|
||||
this.currentUserEntry = null;
|
||||
this.showStickyUser = false;
|
||||
} else if (!this.playerHasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isLoading || this.isLoadingMore) return;
|
||||
|
||||
if (reset) {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
} else {
|
||||
this.isLoadingMore = true;
|
||||
this.loadMoreError = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchPlayerLeaderboard(this.currentPage);
|
||||
|
||||
if (result === false) {
|
||||
throw new Error("Failed to load player leaderboard");
|
||||
}
|
||||
|
||||
if (result === "reached_limit") {
|
||||
this.playerHasMore = false;
|
||||
this.hasLoadedPlayers = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPlayers: PlayerLeaderboardEntry[] = result["1v1"].map(
|
||||
(entry) => ({
|
||||
rank: entry.rank,
|
||||
playerId: entry.public_id,
|
||||
username: entry.username,
|
||||
clanTag: entry.clanTag ?? undefined,
|
||||
elo: entry.elo,
|
||||
games: entry.total,
|
||||
wins: entry.wins,
|
||||
losses: entry.losses,
|
||||
winRate: entry.total > 0 ? entry.wins / entry.total : 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const receivedCount = nextPlayers.length;
|
||||
if (reset) {
|
||||
this.playerData = nextPlayers;
|
||||
} else {
|
||||
const existingIds = new Set(
|
||||
this.playerData.map((player) => player.playerId),
|
||||
);
|
||||
const deduped = nextPlayers.filter(
|
||||
(player) => !existingIds.has(player.playerId),
|
||||
);
|
||||
this.playerData = [...this.playerData, ...deduped];
|
||||
}
|
||||
|
||||
if (receivedCount > 0) {
|
||||
this.currentPage++;
|
||||
}
|
||||
|
||||
if (receivedCount < this.playerPageSize) {
|
||||
this.playerHasMore = false;
|
||||
}
|
||||
|
||||
if (reset && !this.currentUserIdLoaded) {
|
||||
this.currentUserIdLoaded = true;
|
||||
const userMe = await getUserMe();
|
||||
this.currentUserId = userMe ? userMe.player.publicId : null;
|
||||
}
|
||||
|
||||
if (this.currentUserId && !this.currentUserEntry) {
|
||||
this.currentUserEntry =
|
||||
nextPlayers.find(
|
||||
(player) => player.playerId === this.currentUserId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
this.hasLoadedPlayers = true;
|
||||
this.scheduleStickyVisibilityCheck();
|
||||
this.schedulePlayerFillCheck();
|
||||
} catch (err) {
|
||||
console.error("loadPlayerLeaderboard: request failed", err);
|
||||
if (reset) {
|
||||
this.error = translateText("leaderboard_modal.error");
|
||||
} else {
|
||||
this.loadMoreError = translateText("leaderboard_modal.error");
|
||||
}
|
||||
} finally {
|
||||
if (reset) {
|
||||
this.isLoading = false;
|
||||
} else {
|
||||
this.isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public handleTabActivated() {
|
||||
this.scheduleStickyVisibilityCheck();
|
||||
this.schedulePlayerFillCheck();
|
||||
}
|
||||
|
||||
// TODO: consider IntersectionObserver for better visibility detection?
|
||||
private isVisible() {
|
||||
return this.isConnected && this.getClientRects().length > 0;
|
||||
}
|
||||
|
||||
private updateStickyVisibility() {
|
||||
if (!this.currentUserEntry) {
|
||||
this.showStickyUser = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.virtualizerContainer || !this.isVisible()) {
|
||||
this.showStickyUser = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRow = this.virtualizerContainer.querySelector(
|
||||
'[data-current-user="true"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
if (!currentRow) {
|
||||
this.showStickyUser = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = this.virtualizerContainer.getBoundingClientRect();
|
||||
const rowRect = currentRow.getBoundingClientRect();
|
||||
const isVisible =
|
||||
rowRect.top >= containerRect.top &&
|
||||
rowRect.bottom <= containerRect.bottom;
|
||||
this.showStickyUser = !isVisible;
|
||||
}
|
||||
|
||||
private scheduleStickyVisibilityCheck() {
|
||||
void this.updateComplete.then(() => {
|
||||
requestAnimationFrame(() => this.updateStickyVisibility());
|
||||
});
|
||||
}
|
||||
|
||||
private handleScroll() {
|
||||
this.updateStickyVisibility();
|
||||
this.maybeLoadMorePlayers();
|
||||
}
|
||||
|
||||
private maybeLoadMorePlayers() {
|
||||
if (this.isLoading || this.isLoadingMore) return;
|
||||
if (!this.playerHasMore || this.error || this.loadMoreError) return;
|
||||
if (!this.virtualizerContainer || !this.isVisible()) return;
|
||||
|
||||
const threshold = 64 * 3;
|
||||
const scrollTop = this.virtualizerContainer.scrollTop;
|
||||
const containerHeight = this.virtualizerContainer.clientHeight;
|
||||
const scrollHeight = this.virtualizerContainer.scrollHeight;
|
||||
const nearBottom = scrollTop + containerHeight >= scrollHeight - threshold;
|
||||
|
||||
if (containerHeight === 0 || scrollHeight === 0) return; // guard
|
||||
|
||||
if (nearBottom) {
|
||||
void this.loadPlayerLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
private schedulePlayerFillCheck() {
|
||||
if (!this.playerHasMore || this.error || this.loadMoreError) return;
|
||||
void this.updateComplete.then(() => this.maybeLoadMorePlayers());
|
||||
}
|
||||
|
||||
private renderPlayerRow(player: PlayerLeaderboardEntry) {
|
||||
const isCurrentUser = this.currentUserEntry?.playerId === player.playerId;
|
||||
const displayRank = player.rank;
|
||||
const winRate = player.games > 0 ? player.wins / player.games : 0;
|
||||
|
||||
const rankColor =
|
||||
{
|
||||
1: "text-yellow-400 bg-yellow-400/10 ring-1 ring-yellow-400/20",
|
||||
2: "text-slate-300 bg-slate-400/10 ring-1 ring-slate-400/20",
|
||||
3: "text-amber-600 bg-amber-600/10 ring-1 ring-amber-600/20",
|
||||
}?.[displayRank] ?? "text-white/40 bg-white/5";
|
||||
|
||||
const rankIcon =
|
||||
{
|
||||
1: "👑",
|
||||
2: "🥈",
|
||||
3: "🥉",
|
||||
}?.[displayRank] ?? String(displayRank);
|
||||
|
||||
return html`
|
||||
<div
|
||||
data-current-user=${isCurrentUser ? "true" : "false"}
|
||||
class="flex items-center border-b border-white/5 py-3 px-6 hover:bg-white/[0.07] transition-colors w-full ${isCurrentUser
|
||||
? "bg-blue-500/15 border-l-4 border-l-blue-500 pl-5"
|
||||
: ""}"
|
||||
>
|
||||
<div class="w-16 shrink-0 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg ${rankColor}"
|
||||
>
|
||||
${rankIcon}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center gap-3 overflow-hidden ml-4">
|
||||
<span class="font-bold text-blue-300 truncate text-base"
|
||||
>${player.username}</span
|
||||
>
|
||||
${player.clanTag
|
||||
? html`<div
|
||||
class="px-2.5 py-1 rounded bg-blue-500/10 border border-blue-500/20 text-[10px] font-bold text-blue-300 shrink-0"
|
||||
>
|
||||
${player.clanTag}
|
||||
</div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 w-32">
|
||||
<div class="text-right font-mono text-white font-medium">
|
||||
${player.elo}
|
||||
<span class="text-[10px] text-white/30 truncate"
|
||||
>${translateText("leaderboard_modal.elo")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-col items-end gap-1 w-32 hidden md:flex">
|
||||
<div class="text-right font-mono text-white font-medium">
|
||||
${player.games}
|
||||
<span class="text-[10px] text-white/30 uppercase"
|
||||
>${translateText("leaderboard_modal.games")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-flex flex-col items-end pr-6 w-32">
|
||||
<span
|
||||
class="font-mono font-bold ${winRate >= 0.5
|
||||
? "text-green-400"
|
||||
: "text-red-400"}"
|
||||
>${(winRate * 100).toFixed(1)}%</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] uppercase text-white/30 font-bold tracking-wider"
|
||||
>${translateText("leaderboard_modal.ratio")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPlayerFooter() {
|
||||
if (this.isLoadingMore) {
|
||||
return html`
|
||||
<div class="flex items-center justify-center py-4 text-white/50">
|
||||
<div
|
||||
class="w-4 h-4 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mr-2"
|
||||
></div>
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest">
|
||||
${translateText("leaderboard_modal.loading")}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.loadMoreError) {
|
||||
return html`
|
||||
<div class="flex items-center justify-center py-4">
|
||||
<button
|
||||
class="px-6 py-2 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-xs font-bold uppercase transition-all active:scale-95"
|
||||
@click=${() => this.loadPlayerLeaderboard()}
|
||||
>
|
||||
${translateText("leaderboard_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private renderLoading() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mb-6"
|
||||
></div>
|
||||
<p class="text-blue-200/80 text-sm font-bold tracking-widest uppercase">
|
||||
${translateText("leaderboard_modal.loading")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderError() {
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-12 text-white h-full"
|
||||
>
|
||||
<div
|
||||
class="bg-red-500/10 p-6 rounded-full mb-6 border border-red-500/20 shadow-lg shadow-red-500/10"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-12 w-12 text-red-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="mb-8 text-center text-red-100/80 font-medium">
|
||||
${this.error ?? translateText("leaderboard_modal.error")}
|
||||
</p>
|
||||
<button
|
||||
class="px-8 py-3 bg-red-500/10 hover:bg-red-500/20 border border-red-500/30 rounded-xl text-sm font-bold uppercase transition-all active:scale-95"
|
||||
@click=${() => this.loadPlayerLeaderboard(true)}
|
||||
>
|
||||
${translateText("leaderboard_modal.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.isLoading && this.playerData.length === 0)
|
||||
return this.renderLoading();
|
||||
if (this.error) return this.renderError();
|
||||
|
||||
return html`
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<div
|
||||
class="flex items-center text-[10px] uppercase tracking-wider text-white/40 font-bold px-6 py-4 border-b border-white/5 bg-white/2"
|
||||
>
|
||||
<div class="w-16 text-center">
|
||||
${translateText("leaderboard_modal.rank")}
|
||||
</div>
|
||||
<div class="flex-1 ml-4">
|
||||
${translateText("leaderboard_modal.player")}
|
||||
</div>
|
||||
<div class="w-32 text-right">
|
||||
${translateText("leaderboard_modal.elo")}
|
||||
</div>
|
||||
<div class="w-32 text-right hidden md:block">
|
||||
${translateText("leaderboard_modal.games")}
|
||||
</div>
|
||||
<div class="w-32 text-right pr-6">
|
||||
${translateText("leaderboard_modal.win_loss_ratio")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<div
|
||||
class="virtualizer-container h-full overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 ${this
|
||||
.showStickyUser
|
||||
? "pb-20"
|
||||
: "pb-0"}"
|
||||
@scroll=${() => this.handleScroll()}
|
||||
>
|
||||
${virtualize({
|
||||
items: this.playerData,
|
||||
renderItem: (player) => this.renderPlayerRow(player),
|
||||
scroller: true,
|
||||
})}
|
||||
${this.renderPlayerFooter()}
|
||||
</div>
|
||||
${this.currentUserEntry
|
||||
? html`
|
||||
<div class="absolute inset-x-0 bottom-0">
|
||||
<div
|
||||
class="bg-blue-600/90 backdrop-blur-md border-t border-blue-400/30 py-4 px-6 shadow-2xl flex items-center transition-all duration-200 ${this
|
||||
.showStickyUser
|
||||
? "opacity-100 translate-y-0"
|
||||
: "opacity-0 translate-y-3 pointer-events-none"}"
|
||||
aria-hidden=${this.showStickyUser ? "false" : "true"}
|
||||
>
|
||||
<div class="w-16 text-center">
|
||||
<div
|
||||
class="w-10 h-10 mx-auto flex items-center justify-center rounded-lg font-bold font-mono text-lg bg-white/20 text-white"
|
||||
>
|
||||
${this.currentUserEntry.rank}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col ml-4">
|
||||
<span
|
||||
class="text-[10px] uppercase font-bold text-blue-200/60 leading-tight"
|
||||
>${translateText(
|
||||
"leaderboard_modal.your_ranking",
|
||||
)}</span
|
||||
>
|
||||
<span class="font-bold text-white text-base"
|
||||
>${this.currentUserEntry.username}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col items-end w-32">
|
||||
<div class="font-mono text-white font-bold text-lg">
|
||||
${this.currentUserEntry.elo}
|
||||
<span class="text-[10px] text-white/60"
|
||||
>${translateText("leaderboard_modal.elo")}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
export type LeaderboardTab = "players" | "clans";
|
||||
|
||||
@customElement("leaderboard-tabs")
|
||||
export class LeaderboardTabs extends LitElement {
|
||||
@property({ type: String }) activeTab: LeaderboardTab = "players";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private baseTabClass =
|
||||
"px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none";
|
||||
private activeTabClass = "bg-blue-600 text-white";
|
||||
private inactiveTabClass =
|
||||
"text-white/40 hover:text-white/60 hover:bg-white/5";
|
||||
|
||||
private getTabClass(active: boolean) {
|
||||
return [
|
||||
this.baseTabClass,
|
||||
active ? this.activeTabClass : this.inactiveTabClass,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@state()
|
||||
private playerClass = this.getTabClass(this.activeTab === "players");
|
||||
@state()
|
||||
private clanClass = this.getTabClass(this.activeTab === "clans");
|
||||
|
||||
private handleTabChange(tab: LeaderboardTab) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<LeaderboardTab>("tab-change", {
|
||||
detail: tab,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.playerClass = this.getTabClass(tab === "players");
|
||||
this.clanClass = this.getTabClass(tab === "clans");
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-6 w-fit mx-auto mt-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="${this.playerClass}"
|
||||
@click=${() => this.handleTabChange("players")}
|
||||
id="player-leaderboard-tab"
|
||||
aria-selected=${this.activeTab === "players"}
|
||||
>
|
||||
${translateText("leaderboard_modal.ranked_tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="${this.clanClass}"
|
||||
@click=${() => this.handleTabChange("clans")}
|
||||
id="clan-leaderboard-tab"
|
||||
aria-selected=${this.activeTab === "clans"}
|
||||
>
|
||||
${translateText("leaderboard_modal.clans_tab")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export class MapDisplay extends LitElement {
|
||||
@state() private mapWebpPath: string | null = null;
|
||||
@state() private mapName: string | null = null;
|
||||
@state() private isLoading = true;
|
||||
@state() private hasNations = true;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -32,7 +33,10 @@ export class MapDisplay extends LitElement {
|
||||
const mapValue = GameMapType[this.mapKey as keyof typeof GameMapType];
|
||||
const data = terrainMapFileLoader.getMapData(mapValue);
|
||||
this.mapWebpPath = await data.webpPath();
|
||||
this.mapName = (await data.manifest()).name;
|
||||
const manifest = await data.manifest();
|
||||
this.mapName = manifest.name;
|
||||
this.hasNations =
|
||||
Array.isArray(manifest.nations) && manifest.nations.length > 0;
|
||||
} catch (error) {
|
||||
console.error("Failed to load map data:", error);
|
||||
} finally {
|
||||
@@ -85,7 +89,7 @@ export class MapDisplay extends LitElement {
|
||||
>
|
||||
${translateText("map_component.error")}
|
||||
</div>`}
|
||||
${this.showMedals
|
||||
${this.showMedals && this.hasNations
|
||||
? html`<div class="flex gap-1 justify-center w-full">
|
||||
${this.renderMedals()}
|
||||
</div>`
|
||||
|
||||
@@ -34,6 +34,7 @@ import { ReplayPanel } from "./layers/ReplayPanel";
|
||||
import { SAMRadiusLayer } from "./layers/SAMRadiusLayer";
|
||||
import { SettingsModal } from "./layers/SettingsModal";
|
||||
import { SpawnTimer } from "./layers/SpawnTimer";
|
||||
import { SpawnVideoAd } from "./layers/SpawnVideoReward";
|
||||
import { StructureIconsLayer } from "./layers/StructureIconsLayer";
|
||||
import { StructureLayer } from "./layers/StructureLayer";
|
||||
import { TeamStats } from "./layers/TeamStats";
|
||||
@@ -253,6 +254,12 @@ export function createRenderer(
|
||||
}
|
||||
inGameHeaderAd.game = game;
|
||||
|
||||
const spawnVideoAd = document.querySelector("spawn-video-ad") as SpawnVideoAd;
|
||||
if (!(spawnVideoAd instanceof SpawnVideoAd)) {
|
||||
console.error("spawn video ad not found");
|
||||
}
|
||||
spawnVideoAd.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().
|
||||
@@ -297,6 +304,7 @@ export function createRenderer(
|
||||
headsUpMessage,
|
||||
multiTabModal,
|
||||
inGameHeaderAd,
|
||||
spawnVideoAd,
|
||||
alertFrame,
|
||||
performanceOverlay,
|
||||
];
|
||||
|
||||
@@ -142,6 +142,9 @@ export class ChatModal extends LitElement {
|
||||
player
|
||||
? "selected"
|
||||
: ""}"
|
||||
style="border: 2px solid ${player
|
||||
.territoryColor()
|
||||
.toHex()};"
|
||||
@click=${() => this.selectPlayer(player)}
|
||||
>
|
||||
${player.name()}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../../client/Utils";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Gold } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
@@ -9,6 +8,9 @@ import { AttackRatioEvent } from "../../InputHandler";
|
||||
import { renderNumber, renderTroops } from "../../Utils";
|
||||
import { UIState } from "../UIState";
|
||||
import { Layer } from "./Layer";
|
||||
import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
|
||||
import soldierIcon from "/images/SoldierIcon.svg?url";
|
||||
import swordIcon from "/images/SwordIcon.svg?url";
|
||||
|
||||
@customElement("control-panel")
|
||||
export class ControlPanel extends LitElement implements Layer {
|
||||
@@ -35,6 +37,12 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
@state()
|
||||
private _gold: Gold;
|
||||
|
||||
@state()
|
||||
private _attackingTroops: number = 0;
|
||||
|
||||
@state()
|
||||
private _touchDragging = false;
|
||||
|
||||
private _troopRateIsIncreasing: boolean = true;
|
||||
|
||||
private _lastTroopIncreaseRate: number;
|
||||
@@ -49,12 +57,7 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
);
|
||||
this.uiState.attackRatio = this.attackRatio;
|
||||
this.eventBus.on(AttackRatioEvent, (event) => {
|
||||
let newAttackRatio =
|
||||
(parseInt(
|
||||
(document.getElementById("attack-ratio") as HTMLInputElement).value,
|
||||
) +
|
||||
event.attackRatio) /
|
||||
100;
|
||||
let newAttackRatio = this.attackRatio + event.attackRatio / 100;
|
||||
|
||||
if (newAttackRatio < 0.01) {
|
||||
newAttackRatio = 0.01;
|
||||
@@ -90,6 +93,10 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
this._maxTroops = this.game.config().maxTroops(player);
|
||||
this._gold = player.gold();
|
||||
this._troops = player.troops();
|
||||
this._attackingTroops = player
|
||||
.outgoingAttacks()
|
||||
.map((a) => a.troops)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
this.troopRate = this.game.config().troopIncreaseRate(player) * 10;
|
||||
this.requestUpdate();
|
||||
}
|
||||
@@ -120,119 +127,255 @@ export class ControlPanel extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _outsideTouchHandler: ((ev: Event) => void) | null = null;
|
||||
|
||||
private handleAttackTouchStart(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (this._touchDragging) {
|
||||
this.closeAttackBar();
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchDragging = true;
|
||||
|
||||
setTimeout(() => {
|
||||
this._outsideTouchHandler = () => {
|
||||
this.closeAttackBar();
|
||||
};
|
||||
document.addEventListener("touchstart", this._outsideTouchHandler);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private closeAttackBar() {
|
||||
this._touchDragging = false;
|
||||
if (this._outsideTouchHandler) {
|
||||
document.removeEventListener("touchstart", this._outsideTouchHandler);
|
||||
this._outsideTouchHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleBarTouch(e: TouchEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.setRatioFromTouch(e.touches[0]);
|
||||
|
||||
const onMove = (ev: TouchEvent) => {
|
||||
ev.preventDefault();
|
||||
this.setRatioFromTouch(ev.touches[0]);
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
document.removeEventListener("touchmove", onMove);
|
||||
document.removeEventListener("touchend", onEnd);
|
||||
};
|
||||
|
||||
document.addEventListener("touchmove", onMove, { passive: false });
|
||||
document.addEventListener("touchend", onEnd);
|
||||
}
|
||||
|
||||
private setRatioFromTouch(touch: Touch) {
|
||||
const barEl = this.querySelector(".attack-drag-bar");
|
||||
if (!barEl) return;
|
||||
|
||||
const rect = barEl.getBoundingClientRect();
|
||||
const ratio = (rect.bottom - touch.clientY) / (rect.bottom - rect.top);
|
||||
this.attackRatio =
|
||||
Math.round(Math.max(1, Math.min(100, ratio * 100))) / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}
|
||||
|
||||
private handleRatioSliderInput(e: Event) {
|
||||
const value = Number((e.target as HTMLInputElement).value);
|
||||
this.attackRatio = value / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}
|
||||
|
||||
private renderTroopBar() {
|
||||
const base = Math.max(this._maxTroops, 1);
|
||||
const greenPercentRaw = (this._troops / base) * 100;
|
||||
const orangePercentRaw = (this._attackingTroops / base) * 100;
|
||||
|
||||
const greenPercent = Math.max(0, Math.min(100, greenPercentRaw));
|
||||
const orangePercent = Math.max(
|
||||
0,
|
||||
Math.min(100 - greenPercent, orangePercentRaw),
|
||||
);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="w-full h-6 lg:h-8 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
||||
>
|
||||
<div class="h-full flex">
|
||||
${greenPercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-green-500 transition-[width] duration-200"
|
||||
style="width: ${greenPercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
${orangePercent > 0
|
||||
? html`<div
|
||||
class="h-full bg-orange-400 transition-[width] duration-200"
|
||||
style="width: ${orangePercent}%;"
|
||||
></div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-between px-1.5 lg:px-2 text-xs lg:text-sm font-bold leading-none pointer-events-none"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._troops)}</span
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(this._maxTroops)}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center gap-0.5 pointer-events-none"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${soldierIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="12"
|
||||
height="12"
|
||||
class="lg:w-4 lg:h-4 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
/>
|
||||
<span
|
||||
class="text-[10px] lg:text-xs font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
|
||||
._troopRateIsIncreasing
|
||||
? "text-green-400"
|
||||
: "text-orange-400"}"
|
||||
>+${renderTroops(this.troopRate)}/s</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<style>
|
||||
input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.targetTroopRatio::-webkit-slider-thumb {
|
||||
border-color: rgb(59 130 246);
|
||||
}
|
||||
.targetTroopRatio::-moz-range-thumb {
|
||||
border-color: rgb(59 130 246);
|
||||
}
|
||||
.attackRatio::-webkit-slider-thumb {
|
||||
border-color: rgb(239 68 68);
|
||||
}
|
||||
.attackRatio::-moz-range-thumb {
|
||||
border-color: rgb(239 68 68);
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="pointer-events-auto ${this._isVisible
|
||||
? "w-full sm:max-w-[320px] text-sm sm:text-base bg-gray-800/70 p-2 pr-3 sm:p-4 shadow-lg sm:rounded-lg backdrop-blur-sm"
|
||||
? "relative z-[60] w-full max-lg:landscape:fixed max-lg:landscape:bottom-0 max-lg:landscape:left-0 max-lg:landscape:w-1/2 max-lg:landscape:z-50 lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg lg:rounded-xl backdrop-blur-sm"
|
||||
: "hidden"}"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div class="block bg-black/30 text-white mb-4 p-2 rounded-sm">
|
||||
<div class="flex justify-between mb-1">
|
||||
<span class="font-bold"
|
||||
>${translateText("control_panel.troops")}:</span
|
||||
>
|
||||
<span translate="no"
|
||||
>${renderTroops(this._troops)} / ${renderTroops(this._maxTroops)}
|
||||
<span
|
||||
class="${this._troopRateIsIncreasing
|
||||
? "text-green-500"
|
||||
: "text-yellow-500"}"
|
||||
<div class="flex gap-2 lg:gap-3 items-center">
|
||||
<!-- Gold: 1/4 -->
|
||||
<div
|
||||
class="flex items-center justify-center p-1 lg:p-1.5 lg:gap-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs lg:text-sm w-1/5 lg:w-auto shrink-0"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${goldCoinIcon}
|
||||
width="13"
|
||||
height="13"
|
||||
class="lg:w-4 lg:h-4"
|
||||
/>
|
||||
<span class="px-0.5">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
<!-- Troop bar: 2/4 -->
|
||||
<div class="w-3/5 lg:flex-1">${this.renderTroopBar()}</div>
|
||||
<!-- Attack ratio: 1/4 -->
|
||||
<div
|
||||
class="relative w-1/5 shrink-0 flex items-center justify-center gap-1 cursor-pointer lg:hidden"
|
||||
@touchstart=${(e: TouchEvent) => this.handleAttackTouchStart(e)}
|
||||
>
|
||||
<div class="flex flex-col items-center w-10 shrink-0">
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-white text-xs font-bold tabular-nums"
|
||||
translate="no"
|
||||
>(+${renderTroops(this.troopRate)})</span
|
||||
></span
|
||||
>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="font-bold"
|
||||
>${translateText("control_panel.gold")}:</span
|
||||
>
|
||||
<span translate="no">${renderNumber(this._gold)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative mb-0 sm:mb-4">
|
||||
<label class="block text-white mb-1">
|
||||
${translateText("control_panel.attack_ratio")} :
|
||||
<span
|
||||
class="inline-flex items-center gap-1 [unicode-bidi:isolate]"
|
||||
dir="ltr"
|
||||
translate="no"
|
||||
>
|
||||
<span>${(this.attackRatio * 100).toFixed(0)}%</span>
|
||||
<span>
|
||||
>
|
||||
<img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="10"
|
||||
height="10"
|
||||
class="brightness-0 invert sepia saturate-[10000%] hue-rotate-[0deg]"
|
||||
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
|
||||
/>
|
||||
${(this.attackRatio * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div class="text-[10px] text-red-400 tabular-nums" translate="no">
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative h-8">
|
||||
<!-- Background track -->
|
||||
<div
|
||||
class="absolute left-0 right-0 top-3 h-2 bg-white/20 rounded-sm"
|
||||
></div>
|
||||
<!-- Fill track -->
|
||||
<div
|
||||
class="absolute left-0 top-3 h-2 bg-red-500/60 rounded-sm transition-all duration-300 w-(--width)"
|
||||
style="--width: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
<!-- Range input - exactly overlaying the visual elements -->
|
||||
<input
|
||||
id="attack-ratio"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${(this.attackRatio * 100).toString()}
|
||||
@input=${(e: Event) => {
|
||||
this.attackRatio =
|
||||
parseInt((e.target as HTMLInputElement).value) / 100;
|
||||
this.onAttackRatioChange(this.attackRatio);
|
||||
}}
|
||||
class="absolute left-0 right-0 top-2 m-0 h-4 cursor-pointer attackRatio"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Small red vertical bar indicator -->
|
||||
<div class="relative shrink-0">
|
||||
<div
|
||||
class="w-1.5 h-8 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full transition-all duration-200"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
${this._touchDragging
|
||||
? html`
|
||||
<div
|
||||
class="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/80 backdrop-blur-sm rounded-lg p-2 w-12"
|
||||
style="height: 50vh;"
|
||||
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
|
||||
>
|
||||
<span
|
||||
class="text-red-400 text-sm font-bold mb-1"
|
||||
translate="no"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%</span
|
||||
>
|
||||
<div
|
||||
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-red-500 rounded-full"
|
||||
style="height: ${this.attackRatio * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Attack ratio bar (desktop, always visible) -->
|
||||
<div class="hidden lg:block mt-2">
|
||||
<div
|
||||
class="flex items-center justify-between text-sm font-bold mb-1"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white flex items-center gap-1"
|
||||
><img
|
||||
src=${swordIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="14"
|
||||
height="14"
|
||||
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
|
||||
/>Attack Ratio</span
|
||||
>
|
||||
<span class="text-white tabular-nums"
|
||||
>${(this.attackRatio * 100).toFixed(0)}%
|
||||
(${renderTroops(
|
||||
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
|
||||
)})</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
.value=${String(Math.round(this.attackRatio * 100))}
|
||||
@input=${(e: Event) => this.handleRatioSliderInput(e)}
|
||||
class="w-full h-2 accent-red-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1013,7 +1013,9 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
<!-- Events Toggle (when hidden) -->
|
||||
${this._hidden
|
||||
? html`
|
||||
<div class="relative w-fit lg:bottom-4 lg:right-4 z-50">
|
||||
<div
|
||||
class="relative w-fit min-[1200px]:bottom-4 min-[1200px]:right-4 z-50"
|
||||
>
|
||||
${this.renderButton({
|
||||
content: html`
|
||||
Events
|
||||
@@ -1033,7 +1035,7 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
: html`
|
||||
<!-- Main Events Display -->
|
||||
<div
|
||||
class="relative w-full sm:bottom-4 sm:right-4 z-50 sm:w-96 backdrop-blur-sm"
|
||||
class="relative w-full min-[1200px]:bottom-4 min-[1200px]:right-4 z-50 min-[1200px]:w-96 backdrop-blur-sm"
|
||||
>
|
||||
<!-- Button Bar -->
|
||||
<div class="w-full p-2 lg:p-3 bg-gray-800/70 rounded-t-lg">
|
||||
|
||||
@@ -21,7 +21,8 @@ export class InGameHeaderAd extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.showHeaderAd();
|
||||
// TODO: move ad and re-enable.
|
||||
// this.showHeaderAd();
|
||||
}
|
||||
|
||||
private showHeaderAd(): void {
|
||||
|
||||
@@ -32,6 +32,7 @@ import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
|
||||
import missileSiloIcon from "/images/MissileSiloIconWhite.svg?url";
|
||||
import portIcon from "/images/PortIcon.svg?url";
|
||||
import samLauncherIcon from "/images/SamLauncherIconWhite.svg?url";
|
||||
import soldierIcon from "/images/SoldierIcon.svg?url";
|
||||
|
||||
function euclideanDistWorld(
|
||||
coord: { x: number; y: number },
|
||||
@@ -73,12 +74,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
@state()
|
||||
private unit: UnitView | null = null;
|
||||
|
||||
@state()
|
||||
private isWilderness: boolean = false;
|
||||
|
||||
@state()
|
||||
private isIrradiatedWilderness: boolean = false;
|
||||
|
||||
@state()
|
||||
private _isInfoVisible: boolean = false;
|
||||
|
||||
@@ -86,8 +81,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
private lastMouseUpdate = 0;
|
||||
|
||||
private showDetails = true;
|
||||
|
||||
init() {
|
||||
this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) =>
|
||||
this.onMouseEvent(e),
|
||||
@@ -112,8 +105,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
this.setVisible(false);
|
||||
this.unit = null;
|
||||
this.player = null;
|
||||
this.isWilderness = false;
|
||||
this.isIrradiatedWilderness = false;
|
||||
}
|
||||
|
||||
public maybeShow(x: number, y: number) {
|
||||
@@ -134,13 +125,6 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
this.playerProfile = p;
|
||||
});
|
||||
this.setVisible(true);
|
||||
} else if (owner && !owner.isPlayer() && this.game.isLand(tile)) {
|
||||
if (this.game.hasFallout(tile)) {
|
||||
this.isIrradiatedWilderness = true;
|
||||
} else {
|
||||
this.isWilderness = true;
|
||||
}
|
||||
this.setVisible(true);
|
||||
} else if (!this.game.isLand(tile)) {
|
||||
const units = this.game
|
||||
.units(UnitType.Warship, UnitType.TradeShip, UnitType.TransportShip)
|
||||
@@ -201,28 +185,17 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
private displayUnitCount(
|
||||
player: PlayerView,
|
||||
type: UnitType,
|
||||
icon: string,
|
||||
description: string,
|
||||
) {
|
||||
private displayUnitCount(player: PlayerView, type: UnitType, icon: string) {
|
||||
return !this.game.config().isUnitDisabled(type)
|
||||
? html`<div
|
||||
class="flex p-1 w-[calc(50%-0.13rem)] border rounded-md border-gray-500
|
||||
items-center gap-2 text-sm"
|
||||
class="flex items-center justify-center gap-0.5 lg:gap-1 p-0.5 lg:p-1 border rounded-md border-gray-500 text-[10px] lg:text-xs w-9 lg:w-12 h-6 lg:h-7"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${icon}
|
||||
width="20"
|
||||
height="20"
|
||||
alt="${translateText(description)}"
|
||||
class="align-middle"
|
||||
class="w-3 h-3 lg:w-4 lg:h-4 object-contain shrink-0"
|
||||
/>
|
||||
<span class="w-full text-right p-1"
|
||||
>${player.totalUnitLevels(type)}</span
|
||||
>
|
||||
<span>${player.totalUnitLevels(type)}</span>
|
||||
</div>`
|
||||
: "";
|
||||
}
|
||||
@@ -268,7 +241,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const isFriendly = myPlayer?.isFriendly(player);
|
||||
const isAllied = myPlayer?.isAlliedWith(player);
|
||||
let relationHtml: TemplateResult | null = null;
|
||||
let allianceHtml: TemplateResult | null = null;
|
||||
const maxTroops = this.game.config().maxTroops(player);
|
||||
const attackingTroops = player
|
||||
.outgoingAttacks()
|
||||
@@ -276,34 +249,17 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
.reduce((a, b) => a + b, 0);
|
||||
const totalTroops = player.troops();
|
||||
|
||||
if (player.type() === PlayerType.Nation && myPlayer !== null && !isAllied) {
|
||||
const relation =
|
||||
this.playerProfile?.relations[myPlayer.smallID()] ?? Relation.Neutral;
|
||||
const relationClass = this.getRelationClass(relation);
|
||||
const relationName = this.getRelationName(relation);
|
||||
|
||||
relationHtml = html`
|
||||
<span class="ml-auto mr-0 ${relationClass}">${relationName}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
if (isAllied) {
|
||||
const alliance = myPlayer
|
||||
?.alliances()
|
||||
.find((alliance) => alliance.other === player.id());
|
||||
if (alliance !== undefined) {
|
||||
relationHtml = html` <span
|
||||
class="flex gap-2 ml-auto mr-0 text-sm font-bold"
|
||||
allianceHtml = html` <div
|
||||
class="flex flex-col items-center ml-auto mr-0 text-sm font-bold leading-tight"
|
||||
>
|
||||
<img
|
||||
src=${allianceIcon}
|
||||
alt=${translateText("player_info_overlay.alliance_timeout")}
|
||||
width="20"
|
||||
height="20"
|
||||
class="align-middle"
|
||||
/>
|
||||
<img src=${allianceIcon} width="20" height="20" />
|
||||
${this.allianceExpirationText(alliance)}
|
||||
</span>`;
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
let playerType = "";
|
||||
@@ -320,128 +276,83 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="p-2">
|
||||
<button
|
||||
class="items-center text-bold text-sm lg:text-lg font-bold mb-1 inline-flex break-all ${isFriendly
|
||||
? "text-green-500"
|
||||
: "text-white"}"
|
||||
@click=${() => {
|
||||
this.showDetails = !this.showDetails;
|
||||
this.requestUpdate?.();
|
||||
}}
|
||||
>
|
||||
${player.cosmetics.flag
|
||||
? player.cosmetics.flag!.startsWith("!")
|
||||
? html`<div
|
||||
class="h-8 mr-1 aspect-3/4 player-flag"
|
||||
${ref((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
requestAnimationFrame(() => {
|
||||
renderPlayerFlag(player.cosmetics.flag!, el);
|
||||
});
|
||||
}
|
||||
})}
|
||||
></div>`
|
||||
: html`<img
|
||||
class="h-8 mr-1 aspect-3/4"
|
||||
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
|
||||
/>`
|
||||
: html``}
|
||||
<span>${player.name()}</span>
|
||||
${this.renderPlayerNameIcons(player)}
|
||||
</button>
|
||||
|
||||
<!-- Collapsible section -->
|
||||
${this.showDetails
|
||||
? html`
|
||||
${player.team() !== null
|
||||
? html`<div class="text-sm">
|
||||
${translateText("player_info_overlay.team")}:
|
||||
${player.team()}
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="flex text-sm">${playerType} ${relationHtml}</div>
|
||||
${player.troops() >= 1
|
||||
? html`<div class="flex gap-2 text-sm" translate="no">
|
||||
${translateText("player_info_overlay.troops")}
|
||||
<span class="ml-auto mr-0 font-bold">
|
||||
${renderTroops(player.troops())}
|
||||
</span>
|
||||
</div>`
|
||||
: ""}
|
||||
${maxTroops >= 1
|
||||
? html`<div class="flex gap-2 text-sm" translate="no">
|
||||
${translateText("player_info_overlay.maxtroops")}
|
||||
<span class="ml-auto mr-0 font-bold">
|
||||
${renderTroops(maxTroops)}
|
||||
</span>
|
||||
</div>`
|
||||
: ""}
|
||||
${attackingTroops >= 1
|
||||
? html`<div class="flex gap-2 text-sm" translate="no">
|
||||
${translateText("player_info_overlay.a_troops")}
|
||||
<span class="ml-auto mr-0 text-red-400 font-bold">
|
||||
${renderTroops(attackingTroops)}
|
||||
</span>
|
||||
</div>`
|
||||
: ""}
|
||||
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
|
||||
<div
|
||||
class="flex p-1 mb-1 mt-1 w-full border rounded-md border-yellow-400
|
||||
font-bold text-yellow-400 text-sm"
|
||||
translate="no"
|
||||
>
|
||||
<img
|
||||
src=${goldCoinIcon}
|
||||
alt=${translateText("player_info_overlay.gold")}
|
||||
width="15"
|
||||
height="15"
|
||||
class="align-middle"
|
||||
/>
|
||||
<span class="w-full text-center"
|
||||
>${renderNumber(player.gold())}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-wrap max-w-3xl gap-1">
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.City,
|
||||
cityIcon,
|
||||
"player_info_overlay.cities",
|
||||
)}
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.Factory,
|
||||
factoryIcon,
|
||||
"player_info_overlay.factories",
|
||||
)}
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.Port,
|
||||
portIcon,
|
||||
"player_info_overlay.ports",
|
||||
)}
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.MissileSilo,
|
||||
missileSiloIcon,
|
||||
"player_info_overlay.missile_launchers",
|
||||
)}
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.SAMLauncher,
|
||||
samLauncherIcon,
|
||||
"player_info_overlay.sams",
|
||||
)}
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.Warship,
|
||||
warshipIcon,
|
||||
"player_info_overlay.warships",
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<div class="flex items-start gap-2 lg:gap-3 p-1.5 lg:p-2">
|
||||
<!-- Left: Gold & Troop bar -->
|
||||
<div class="flex flex-col gap-1 shrink-0 w-28">
|
||||
<div
|
||||
class="flex items-center justify-center p-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-28"
|
||||
translate="no"
|
||||
>
|
||||
<img src=${goldCoinIcon} width="13" height="13" />
|
||||
<span class="px-0.5">${renderNumber(player.gold())}</span>
|
||||
</div>
|
||||
<div class="w-28" translate="no">
|
||||
${this.renderTroopBar(totalTroops, attackingTroops, maxTroops)}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Player identity + Units below -->
|
||||
<div class="flex flex-col justify-between self-stretch">
|
||||
<div
|
||||
class="flex items-center gap-2 font-bold text-sm lg:text-lg ${isFriendly
|
||||
? "text-green-500"
|
||||
: "text-white"}"
|
||||
>
|
||||
${player.cosmetics.flag
|
||||
? player.cosmetics.flag!.startsWith("!")
|
||||
? html`<div
|
||||
class="h-6 aspect-3/4 player-flag"
|
||||
${ref((el) => {
|
||||
if (el instanceof HTMLElement) {
|
||||
requestAnimationFrame(() => {
|
||||
renderPlayerFlag(player.cosmetics.flag!, el);
|
||||
});
|
||||
}
|
||||
})}
|
||||
></div>`
|
||||
: html`<img
|
||||
class="h-6 aspect-3/4"
|
||||
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
|
||||
/>`
|
||||
: html``}
|
||||
<span>${player.name()}</span>
|
||||
${player.team() !== null && player.type() !== PlayerType.Bot
|
||||
? html`<div class="flex flex-col leading-tight">
|
||||
<span class="text-gray-400 text-xs font-normal"
|
||||
>${playerType}</span
|
||||
>
|
||||
<span class="text-xs font-normal text-gray-400"
|
||||
>[<span
|
||||
style="color: ${this.game
|
||||
.config()
|
||||
.theme()
|
||||
.teamColor(player.team()!)
|
||||
.toHex()}"
|
||||
>${player.team()}</span
|
||||
>]</span
|
||||
>
|
||||
</div>`
|
||||
: html`<span class="text-gray-400 text-xs font-normal"
|
||||
>${playerType}</span
|
||||
>`}
|
||||
${this.renderPlayerNameIcons(player)} ${allianceHtml ?? ""}
|
||||
</div>
|
||||
<div class="flex gap-0.5 lg:gap-1 items-center mt-1">
|
||||
${this.displayUnitCount(player, UnitType.City, cityIcon)}
|
||||
${this.displayUnitCount(player, UnitType.Factory, factoryIcon)}
|
||||
${this.displayUnitCount(player, UnitType.Port, portIcon)}
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.MissileSilo,
|
||||
missileSiloIcon,
|
||||
)}
|
||||
${this.displayUnitCount(
|
||||
player,
|
||||
UnitType.SAMLauncher,
|
||||
samLauncherIcon,
|
||||
)}
|
||||
${this.displayUnitCount(player, UnitType.Warship, warshipIcon)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -463,7 +374,7 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="w-full mt-2 mb-2 h-5 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden"
|
||||
class="w-full mt-1 lg:mt-2 h-5 lg:h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
|
||||
>
|
||||
<div class="h-full flex">
|
||||
${greenPercent > 0
|
||||
@@ -479,6 +390,25 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
></div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
|
||||
translate="no"
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(totalTroops)}</span
|
||||
>
|
||||
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
|
||||
>${renderTroops(maxTroops)}</span
|
||||
>
|
||||
</div>
|
||||
<img
|
||||
src=${soldierIcon}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
width="14"
|
||||
height="14"
|
||||
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -497,18 +427,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
<div class="mt-1">
|
||||
<div class="text-sm opacity-80">${unit.type()}</div>
|
||||
${unit.hasHealth()
|
||||
? html`
|
||||
<div class="text-sm">
|
||||
${translateText("player_info_overlay.health")}:
|
||||
${unit.health()}
|
||||
</div>
|
||||
`
|
||||
? html` <div class="text-sm">Health: ${unit.health()}</div> `
|
||||
: ""}
|
||||
${unit.type() === UnitType.TransportShip
|
||||
? html`
|
||||
<div class="text-sm">
|
||||
${translateText("player_info_overlay.troops")}:
|
||||
${renderTroops(unit.troops())}
|
||||
Troops: ${renderTroops(unit.troops())}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
@@ -528,21 +452,12 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="block lg:flex fixed top-37.5 right-4 w-full z-50 flex-col max-w-45"
|
||||
class="fixed top-0 lg:top-[10px] left-0 right-0 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 z-[1001]"
|
||||
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
class="bg-gray-800/70 backdrop-blur-xs shadow-xs rounded-lg shadow-lg transition-all duration-300 text-white text-lg md:text-base ${containerClasses}"
|
||||
class="bg-gray-800/70 backdrop-blur-xs shadow-xs lg:rounded-lg shadow-lg transition-all duration-300 text-white text-lg lg:text-base w-full sm:w-auto sm:min-w-[400px] overflow-hidden ${containerClasses}"
|
||||
>
|
||||
${this.isWilderness || this.isIrradiatedWilderness
|
||||
? html`<div class="p-2 font-bold">
|
||||
${translateText(
|
||||
this.isIrradiatedWilderness
|
||||
? "player_info_overlay.irradiated_wilderness_title"
|
||||
: "player_info_overlay.wilderness_title",
|
||||
)}
|
||||
</div>`
|
||||
: ""}
|
||||
${this.player !== null ? this.renderPlayerInfo(this.player) : ""}
|
||||
${this.unit !== null ? this.renderUnitInfo(this.unit) : ""}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url";
|
||||
import boatIcon from "/images/BoatIconWhite.svg?url";
|
||||
import buildIcon from "/images/BuildIconWhite.svg?url";
|
||||
import chatIcon from "/images/ChatIconWhite.svg?url";
|
||||
import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url";
|
||||
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
|
||||
import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url";
|
||||
import emojiIcon from "/images/EmojiIconWhite.svg?url";
|
||||
@@ -220,15 +219,6 @@ const allyBreakElement: MenuElement = {
|
||||
!!params.playerActions?.interaction?.canBreakAlliance,
|
||||
color: COLORS.breakAlly,
|
||||
icon: traitorIcon,
|
||||
subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement],
|
||||
};
|
||||
|
||||
const allyBreakConfirmElement: MenuElement = {
|
||||
id: "ally_break_confirm",
|
||||
name: "confirm",
|
||||
disabled: () => false,
|
||||
color: COLORS.breakAlly,
|
||||
icon: checkmarkIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.playerActionHandler.handleBreakAlliance(
|
||||
params.myPlayer,
|
||||
@@ -238,17 +228,6 @@ const allyBreakConfirmElement: MenuElement = {
|
||||
},
|
||||
};
|
||||
|
||||
const allyBreakCancelElement: MenuElement = {
|
||||
id: "ally_break_cancel",
|
||||
name: "cancel",
|
||||
disabled: () => false,
|
||||
color: COLORS.info,
|
||||
icon: xIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.closeMenu();
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const allyDonateGoldElement: MenuElement = {
|
||||
id: "ally_donate_gold",
|
||||
@@ -650,10 +629,7 @@ export const rootMenuElement: MenuElement = {
|
||||
icon: infoIcon,
|
||||
color: COLORS.info,
|
||||
subMenu: (params: MenuElementParams) => {
|
||||
let ally = allyRequestElement;
|
||||
if (params.selected?.isAlliedWith(params.myPlayer)) {
|
||||
ally = allyBreakElement;
|
||||
}
|
||||
const isAllied = params.selected?.isAlliedWith(params.myPlayer);
|
||||
|
||||
const tileOwner = params.game.owner(params.tile);
|
||||
const isOwnTerritory =
|
||||
@@ -663,10 +639,10 @@ export const rootMenuElement: MenuElement = {
|
||||
const menuItems: (MenuElement | null)[] = [
|
||||
infoMenuElement,
|
||||
...(isOwnTerritory
|
||||
? [deleteUnitElement, ally, buildMenuElement]
|
||||
? [deleteUnitElement, allyRequestElement, buildMenuElement]
|
||||
: [
|
||||
boatMenuElement,
|
||||
ally,
|
||||
isAllied ? allyBreakElement : boatMenuElement,
|
||||
allyRequestElement,
|
||||
isFriendlyTarget(params) && !isDisconnectedTarget(params)
|
||||
? donateGoldRadialElement
|
||||
: attackMenuElement,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { crazyGamesSDK } from "src/client/CrazyGamesSDK";
|
||||
import { getGamesPlayed } from "src/client/Utils";
|
||||
import { GameType } from "src/core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import "../../components/VideoReward";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("spawn-video-ad")
|
||||
export class SpawnVideoAd extends LitElement implements Layer {
|
||||
public game: GameView;
|
||||
|
||||
@state() private shouldShow = false;
|
||||
@state() private adComplete = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
init() {
|
||||
if (
|
||||
!window.adsEnabled ||
|
||||
window.innerWidth < 768 ||
|
||||
crazyGamesSDK.isOnCrazyGames() ||
|
||||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
|
||||
getGamesPlayed() < 3 || // Don't show to new players
|
||||
getGamesPlayed() % 3 !== 0 // Only show 1 in 3 times
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.shouldShow = true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (this.adComplete) return;
|
||||
// Hide when spawn phase ends
|
||||
if (this.shouldShow && !this.game.inSpawnPhase()) {
|
||||
this.shouldShow = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private handleComplete = () => {
|
||||
this.adComplete = true;
|
||||
this.shouldShow = false;
|
||||
};
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shouldShow || this.adComplete) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed bottom-0 left-0 z-[9999] pointer-events-auto">
|
||||
<video-ad
|
||||
style="width: 400px; max-width: 400px; height: 225px; aspect-ratio: auto;"
|
||||
.onComplete="${this.handleComplete}"
|
||||
></video-ad>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { Layer } from "./Layer";
|
||||
import warshipIcon from "/images/BattleshipIconWhite.svg?url";
|
||||
import cityIcon from "/images/CityIconWhite.svg?url";
|
||||
import factoryIcon from "/images/FactoryIconWhite.svg?url";
|
||||
import goldCoinIcon from "/images/GoldCoinIcon.svg?url";
|
||||
import mirvIcon from "/images/MIRVIcon.svg?url";
|
||||
import missileSiloIcon from "/images/MissileSiloIconWhite.svg?url";
|
||||
import hydrogenBombIcon from "/images/MushroomCloudIconWhite.svg?url";
|
||||
@@ -256,11 +257,11 @@ export class UnitDisplay extends LitElement implements Layer {
|
||||
<div class="p-2">
|
||||
${translateText("build_menu.desc." + structureKey)}
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<img src=${goldCoinIcon} width="13" height="13" />
|
||||
<span class="text-yellow-300"
|
||||
>${renderNumber(this.cost(unitType))}</span
|
||||
>
|
||||
${translateText("player_info_overlay.gold")}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { LitElement, TemplateResult, html } from "lit";
|
||||
import { html, LitElement, TemplateResult } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import {
|
||||
getGamesPlayed,
|
||||
isInIframe,
|
||||
translateText,
|
||||
TUTORIAL_VIDEO_URL,
|
||||
} from "../../../client/Utils";
|
||||
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
@@ -131,9 +132,7 @@ export class WinModal extends LitElement implements Layer {
|
||||
<div class="relative w-full pb-[56.25%]">
|
||||
<iframe
|
||||
class="absolute top-0 left-0 w-full h-full rounded-sm"
|
||||
src="${this.isVisible
|
||||
? "https://www.youtube.com/embed/EN2oOog3pSs"
|
||||
: ""}"
|
||||
src="${this.isVisible ? TUTORIAL_VIDEO_URL : ""}"
|
||||
title="YouTube video player"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
|
||||
@@ -133,3 +133,49 @@ export const ClanLeaderboardResponseSchema = z.object({
|
||||
export type ClanLeaderboardResponse = z.infer<
|
||||
typeof ClanLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const PlayerLeaderboardEntrySchema = z.object({
|
||||
rank: z.number(),
|
||||
playerId: z.string(),
|
||||
username: z.string(),
|
||||
clanTag: z.string().optional(),
|
||||
flag: z.string().optional(),
|
||||
elo: z.number(),
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
winRate: z.number(),
|
||||
});
|
||||
export type PlayerLeaderboardEntry = z.infer<
|
||||
typeof PlayerLeaderboardEntrySchema
|
||||
>;
|
||||
|
||||
export const PlayerLeaderboardResponseSchema = z.object({
|
||||
players: PlayerLeaderboardEntrySchema.array(),
|
||||
});
|
||||
export type PlayerLeaderboardResponse = z.infer<
|
||||
typeof PlayerLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const RankedLeaderboardEntrySchema = z.object({
|
||||
rank: z.number(),
|
||||
elo: z.number(),
|
||||
peakElo: z.number().nullable(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
total: z.number(),
|
||||
public_id: z.string(),
|
||||
user: DiscordUserSchema.nullable().optional(),
|
||||
username: z.string(),
|
||||
clanTag: z.string().nullable().optional(),
|
||||
});
|
||||
export type RankedLeaderboardEntry = z.infer<
|
||||
typeof RankedLeaderboardEntrySchema
|
||||
>;
|
||||
|
||||
export const RankedLeaderboardResponseSchema = z.object({
|
||||
"1v1": RankedLeaderboardEntrySchema.array(),
|
||||
});
|
||||
export type RankedLeaderboardResponse = z.infer<
|
||||
typeof RankedLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
+18
-2
@@ -6,6 +6,7 @@ import {
|
||||
PatternDataSchema,
|
||||
PatternNameSchema,
|
||||
} from "./CosmeticSchemas";
|
||||
import type { GameEvent } from "./EventBus";
|
||||
import {
|
||||
AllPlayers,
|
||||
Difficulty,
|
||||
@@ -105,7 +106,8 @@ export type ServerMessage =
|
||||
| ServerPingMessage
|
||||
| ServerDesyncMessage
|
||||
| ServerPrestartMessage
|
||||
| ServerErrorMessage;
|
||||
| ServerErrorMessage
|
||||
| ServerLobbyInfoMessage;
|
||||
|
||||
export type ServerTurnMessage = z.infer<typeof ServerTurnMessageSchema>;
|
||||
export type ServerStartGameMessage = z.infer<
|
||||
@@ -115,6 +117,9 @@ export type ServerPingMessage = z.infer<typeof ServerPingMessageSchema>;
|
||||
export type ServerDesyncMessage = z.infer<typeof ServerDesyncSchema>;
|
||||
export type ServerPrestartMessage = z.infer<typeof ServerPrestartMessageSchema>;
|
||||
export type ServerErrorMessage = z.infer<typeof ServerErrorSchema>;
|
||||
export type ServerLobbyInfoMessage = z.infer<
|
||||
typeof ServerLobbyInfoMessageSchema
|
||||
>;
|
||||
export type ClientSendWinnerMessage = z.infer<typeof ClientSendWinnerSchema>;
|
||||
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>;
|
||||
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>;
|
||||
@@ -152,6 +157,11 @@ export interface GameInfo {
|
||||
msUntilStart?: number;
|
||||
gameConfig?: GameConfig;
|
||||
}
|
||||
|
||||
export class LobbyInfoEvent implements GameEvent {
|
||||
constructor(public lobby: GameInfo) {}
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
clientID: ClientID;
|
||||
username: string;
|
||||
@@ -212,7 +222,7 @@ export const GameConfigSchema = z.object({
|
||||
|
||||
export const TeamSchema = z.string();
|
||||
|
||||
const SafeString = z
|
||||
export const SafeString = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=[\]{}|;:"'/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|[üÜ])*$/u,
|
||||
@@ -540,6 +550,11 @@ export const ServerErrorSchema = z.object({
|
||||
message: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ServerLobbyInfoMessageSchema = z.object({
|
||||
type: z.literal("lobby_info"),
|
||||
lobby: GameInfoSchema,
|
||||
});
|
||||
|
||||
export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
ServerTurnMessageSchema,
|
||||
ServerPrestartMessageSchema,
|
||||
@@ -547,6 +562,7 @@ export const ServerMessageSchema = z.discriminatedUnion("type", [
|
||||
ServerPingMessageSchema,
|
||||
ServerDesyncSchema,
|
||||
ServerErrorSchema,
|
||||
ServerLobbyInfoMessageSchema,
|
||||
]);
|
||||
|
||||
//
|
||||
|
||||
@@ -728,7 +728,7 @@ export class DefaultConfig implements Config {
|
||||
if (playerInfo.playerType === PlayerType.Nation) {
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 18_750;
|
||||
return 12_500;
|
||||
case Difficulty.Medium:
|
||||
return 25_000; // Like humans
|
||||
case Difficulty.Hard:
|
||||
@@ -763,7 +763,7 @@ export class DefaultConfig implements Config {
|
||||
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return maxTroops * 0.75;
|
||||
return maxTroops * 0.5;
|
||||
case Difficulty.Medium:
|
||||
return maxTroops * 1; // Like humans
|
||||
case Difficulty.Hard:
|
||||
@@ -790,7 +790,7 @@ export class DefaultConfig implements Config {
|
||||
if (player.type() === PlayerType.Nation) {
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
toAdd *= 0.95;
|
||||
toAdd *= 0.9;
|
||||
break;
|
||||
case Difficulty.Medium:
|
||||
toAdd *= 1; // Like humans
|
||||
|
||||
@@ -360,7 +360,15 @@ export class NationExecution implements Execution {
|
||||
player.addEmbargo(other, false);
|
||||
} else if (
|
||||
player.relation(other) >= Relation.Neutral &&
|
||||
player.hasEmbargoAgainst(other)
|
||||
player.hasEmbargoAgainst(other) &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Hard &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
|
||||
) {
|
||||
player.stopEmbargo(other);
|
||||
} else if (
|
||||
player.relation(other) >= Relation.Friendly &&
|
||||
player.hasEmbargoAgainst(other) &&
|
||||
this.mg.config().gameConfig().difficulty !== Difficulty.Impossible
|
||||
) {
|
||||
player.stopEmbargo(other);
|
||||
}
|
||||
|
||||
@@ -143,10 +143,16 @@ export class NationAllianceBehavior {
|
||||
return false;
|
||||
}
|
||||
|
||||
const totalPlayers = this.game.players().length;
|
||||
const totalPlayers = this.game
|
||||
.players()
|
||||
.filter((p) => p.type() !== PlayerType.Bot).length;
|
||||
const otherPlayerAlliances = otherPlayer.alliances().length;
|
||||
|
||||
return otherPlayerAlliances >= totalPlayers * 0.5;
|
||||
if (difficulty !== Difficulty.Hard) {
|
||||
return otherPlayerAlliances >= totalPlayers * 0.5;
|
||||
} else {
|
||||
return otherPlayerAlliances >= totalPlayers * 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
private isConfused(): boolean {
|
||||
|
||||
@@ -42,6 +42,7 @@ export const EMOJI_BORED = (["🥱"] as const).map(emojiId);
|
||||
export const EMOJI_HANDSHAKE = (["🤝"] as const).map(emojiId);
|
||||
export const EMOJI_DONATION_OK = (["👍"] as const).map(emojiId);
|
||||
export const EMOJI_DONATION_TOO_SMALL = (["❓", "🥱"] as const).map(emojiId);
|
||||
export const EMOJI_GREET = (["👋"] as const).map(emojiId);
|
||||
|
||||
export class NationEmojiBehavior {
|
||||
private readonly lastEmojiSent = new Map<Player, Tick>();
|
||||
@@ -63,6 +64,7 @@ export class NationEmojiBehavior {
|
||||
this.charmAllies();
|
||||
this.annoyTraitors();
|
||||
this.findRat();
|
||||
this.greetNearbyPlayers();
|
||||
}
|
||||
|
||||
private checkOverwhelmedByAttacks(): void {
|
||||
@@ -203,6 +205,22 @@ export class NationEmojiBehavior {
|
||||
this.sendEmoji(smallPlayer, EMOJI_RAT);
|
||||
}
|
||||
|
||||
private greetNearbyPlayers(): void {
|
||||
if (this.game.ticks() > 600) return; // Only in the first minute
|
||||
if (!this.random.chance(250)) return;
|
||||
|
||||
const nearbyHumans = this.player
|
||||
.neighbors()
|
||||
.filter(
|
||||
(p): p is Player => p.isPlayer() && p.type() === PlayerType.Human,
|
||||
);
|
||||
|
||||
if (nearbyHumans.length === 0) return;
|
||||
|
||||
const neighbor = this.random.randElement(nearbyHumans);
|
||||
this.sendEmoji(neighbor, EMOJI_GREET);
|
||||
}
|
||||
|
||||
maybeSendEmoji(
|
||||
otherPlayer: Player | typeof AllPlayers,
|
||||
emojisList: number[],
|
||||
|
||||
@@ -99,7 +99,11 @@ export class NationWarshipBehavior {
|
||||
if (!ship.isActive()) {
|
||||
// Distinguish between arrival/retreat and enemy destruction
|
||||
if (ship.wasDestroyedByEnemy() && ship.destroyer() !== undefined) {
|
||||
this.maybeRetaliateWithWarship(ship.tile(), ship.destroyer()!);
|
||||
this.maybeRetaliateWithWarship(
|
||||
ship.tile(),
|
||||
ship.destroyer()!,
|
||||
"transport",
|
||||
);
|
||||
}
|
||||
this.trackedTransportShips.delete(ship);
|
||||
}
|
||||
@@ -121,13 +125,17 @@ export class NationWarshipBehavior {
|
||||
}
|
||||
if (ship.owner().id() !== this.player.id()) {
|
||||
// Ship was ours and is now owned by someone else -> captured
|
||||
this.maybeRetaliateWithWarship(ship.tile(), ship.owner());
|
||||
this.maybeRetaliateWithWarship(ship.tile(), ship.owner(), "trade");
|
||||
this.trackedTradeShips.delete(ship);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private maybeRetaliateWithWarship(tile: TileRef, enemy: Player): void {
|
||||
private maybeRetaliateWithWarship(
|
||||
tile: TileRef,
|
||||
enemy: Player,
|
||||
reason: "trade" | "transport",
|
||||
): void {
|
||||
// Don't send too many warships
|
||||
if (this.player.units(UnitType.Warship).length >= 10) {
|
||||
return;
|
||||
@@ -148,6 +156,7 @@ export class NationWarshipBehavior {
|
||||
new ConstructionExecution(this.player, UnitType.Warship, tile),
|
||||
);
|
||||
this.emojiBehavior.maybeSendEmoji(enemy, EMOJI_WARSHIP_RETALIATION);
|
||||
this.player.updateRelation(enemy, reason === "trade" ? -7.5 : -15);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ export enum GameMapType {
|
||||
Manicouagan = "Manicouagan",
|
||||
Lemnos = "Lemnos",
|
||||
Sierpinski = "Sierpinski",
|
||||
TheBox = "The Box",
|
||||
TwoLakes = "Two Lakes",
|
||||
StraitOfHormuz = "Strait of Hormuz",
|
||||
Surrounded = "Surrounded",
|
||||
@@ -177,6 +178,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.Surrounded,
|
||||
],
|
||||
arcade: [
|
||||
GameMapType.TheBox,
|
||||
GameMapType.Didier,
|
||||
GameMapType.DidierFrance,
|
||||
GameMapType.Sierpinski,
|
||||
|
||||
@@ -423,6 +423,14 @@ export class PlayerImpl implements Player {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasIncoming = this.incomingAllianceRequests().some(
|
||||
(ar) => ar.requestor() === other,
|
||||
);
|
||||
|
||||
if (hasIncoming) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const recent = this.pastOutgoingAllianceRequests
|
||||
.filter((ar) => ar.recipient() === other)
|
||||
.sort((a, b) => b.createdAt() - a.createdAt());
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PlayerRecord,
|
||||
ServerDesyncSchema,
|
||||
ServerErrorMessage,
|
||||
ServerLobbyInfoMessage,
|
||||
ServerPrestartMessageSchema,
|
||||
ServerStartGameMessage,
|
||||
ServerTurnMessage,
|
||||
@@ -78,6 +79,8 @@ export class GameServer {
|
||||
|
||||
public desyncCount = 0;
|
||||
|
||||
private lobbyInfoIntervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
readonly log_: Logger,
|
||||
@@ -232,6 +235,7 @@ export class GameServer {
|
||||
this.markClientDisconnected(client.clientID, false);
|
||||
this.allClients.set(client.clientID, client);
|
||||
this.addListeners(client);
|
||||
this.startLobbyInfoBroadcast();
|
||||
|
||||
// In case a client joined the game late and missed the start message.
|
||||
if (this._hasStarted) {
|
||||
@@ -280,6 +284,7 @@ export class GameServer {
|
||||
|
||||
client.ws = ws;
|
||||
this.addListeners(client);
|
||||
this.startLobbyInfoBroadcast();
|
||||
|
||||
if (this._hasStarted) {
|
||||
this.sendStartGameMsg(client.ws, msg.lastTurn);
|
||||
@@ -293,17 +298,16 @@ export class GameServer {
|
||||
const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
|
||||
if (!parsed.success) {
|
||||
const error = z.prettifyError(parsed.error);
|
||||
this.log.error("Failed to parse client message", error, {
|
||||
this.log.warn(`Failed to parse client message ${error}`, {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
client.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error,
|
||||
message,
|
||||
message: `Server could not parse message from client: ${message}`,
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
client.ws.close(1002, "ClientMessageSchema");
|
||||
return;
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
@@ -543,6 +547,47 @@ export class GameServer {
|
||||
});
|
||||
}
|
||||
|
||||
private startLobbyInfoBroadcast() {
|
||||
if (this._hasStarted || this._hasEnded) {
|
||||
return;
|
||||
}
|
||||
if (this.lobbyInfoIntervalId !== null) {
|
||||
return;
|
||||
}
|
||||
this.broadcastLobbyInfo();
|
||||
this.lobbyInfoIntervalId = setInterval(() => {
|
||||
if (
|
||||
this._hasStarted ||
|
||||
this._hasEnded ||
|
||||
this.activeClients.length === 0
|
||||
) {
|
||||
this.stopLobbyInfoBroadcast();
|
||||
return;
|
||||
}
|
||||
this.broadcastLobbyInfo();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopLobbyInfoBroadcast() {
|
||||
if (this.lobbyInfoIntervalId === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(this.lobbyInfoIntervalId);
|
||||
this.lobbyInfoIntervalId = null;
|
||||
}
|
||||
|
||||
private broadcastLobbyInfo() {
|
||||
const msg = JSON.stringify({
|
||||
type: "lobby_info",
|
||||
lobby: this.gameInfo(),
|
||||
} satisfies ServerLobbyInfoMessage);
|
||||
this.activeClients.forEach((c) => {
|
||||
if (c.ws.readyState === WebSocket.OPEN) {
|
||||
c.ws.send(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public start() {
|
||||
if (this._hasStarted || this._hasEnded) {
|
||||
return;
|
||||
@@ -772,12 +817,15 @@ export class GameServer {
|
||||
clientID: c.clientID,
|
||||
})),
|
||||
gameConfig: this.gameConfig,
|
||||
msUntilStart: this.isPublic()
|
||||
? this.createdAt + this.config.gameCreationRate()
|
||||
: undefined,
|
||||
msUntilStart: this.isPublic() ? this.getMsUntilStart() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private getMsUntilStart(): number {
|
||||
const startTime = this.createdAt + this.config.gameCreationRate();
|
||||
return Math.max(0, startTime - Date.now());
|
||||
}
|
||||
|
||||
public isPublic(): boolean {
|
||||
return this.gameConfig.gameType === GameType.Public;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Didier: 1,
|
||||
AmazonRiver: 3,
|
||||
Sierpinski: 10,
|
||||
TheBox: 3,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
@@ -267,7 +267,7 @@ async function fetchLobbies(): Promise<number> {
|
||||
gameID: gi.gameID,
|
||||
numClients: gi?.clients?.length ?? 0,
|
||||
gameConfig: gi.gameConfig,
|
||||
msUntilStart: (gi.msUntilStart ?? Date.now()) - Date.now(),
|
||||
msUntilStart: gi.msUntilStart,
|
||||
} as GameInfo;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@lit-labs/virtualizer/virtualize.js", async () => {
|
||||
const { html } = await import("lit");
|
||||
return {
|
||||
virtualize: vi.fn(() => html``),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../src/client/Utils", () => ({
|
||||
translateText: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"leaderboard_modal.win_score_tooltip":
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
"leaderboard_modal.loss_score_tooltip":
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
"leaderboard_modal.title": "Leaderboard",
|
||||
"leaderboard_modal.ranked_tab": "Ranked",
|
||||
"leaderboard_modal.clans_tab": "Clans",
|
||||
"leaderboard_modal.error": "Something went wrong",
|
||||
"leaderboard_modal.rank": "Rank",
|
||||
"leaderboard_modal.clan": "Clan",
|
||||
"leaderboard_modal.games": "Games",
|
||||
"leaderboard_modal.win_score": "Win Score",
|
||||
"leaderboard_modal.loss_score": "Loss Score",
|
||||
"leaderboard_modal.win_loss_ratio": "W/L",
|
||||
"leaderboard_modal.ratio": "Ratio",
|
||||
"leaderboard_modal.elo": "Elo",
|
||||
"leaderboard_modal.player": "Player",
|
||||
"leaderboard_modal.loading": "Loading",
|
||||
"leaderboard_modal.try_again": "Try Again",
|
||||
"leaderboard_modal.no_data_yet": "No data yet",
|
||||
"leaderboard_modal.no_stats": "No stats",
|
||||
"leaderboard_modal.your_ranking": "Your ranking",
|
||||
"common.close": "Close",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../src/client/Api", () => {
|
||||
const getApiBase = () => "http://localhost:3000";
|
||||
return {
|
||||
getApiBase: vi.fn(getApiBase),
|
||||
getUserMe: vi.fn(async () => false),
|
||||
fetchClanLeaderboard: vi.fn(async () => {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
return res.json();
|
||||
}),
|
||||
fetchPlayerLeaderboard: vi.fn(async (page: number) => {
|
||||
const url = new URL(`${getApiBase()}/leaderboard/ranked`);
|
||||
url.searchParams.set("page", String(page));
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 400) {
|
||||
const errorJson = await res.json().catch(() => null);
|
||||
if (errorJson?.message?.includes("Page must be between")) {
|
||||
return "reached_limit";
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const jsonRes = (data: any, ok = true, status = 200) => ({
|
||||
ok,
|
||||
status,
|
||||
json: async () => data,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: any) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : (input?.url ?? String(input));
|
||||
|
||||
if (url.includes("/public/clans/leaderboard")) {
|
||||
return jsonRes({ start: "...", end: "...", clans: [] });
|
||||
}
|
||||
if (url.includes("/leaderboard/ranked")) {
|
||||
return jsonRes({ "1v1": [] });
|
||||
}
|
||||
return jsonRes({}, false, 404);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
|
||||
|
||||
describe("LeaderboardModal", () => {
|
||||
let modal: LeaderboardModal;
|
||||
const awaitChildUpdate = async (selector: string) => {
|
||||
const el = modal.querySelector(selector) as {
|
||||
updateComplete?: Promise<unknown>;
|
||||
} | null;
|
||||
if (el?.updateComplete) {
|
||||
await el.updateComplete;
|
||||
}
|
||||
};
|
||||
const getClanTable = () =>
|
||||
modal.querySelector("leaderboard-clan-table") as {
|
||||
loadClanLeaderboard: () => Promise<void>;
|
||||
updateComplete: Promise<unknown>;
|
||||
} | null;
|
||||
const getPlayerList = () =>
|
||||
modal.querySelector("leaderboard-player-list") as {
|
||||
loadPlayerLeaderboard: (reset?: boolean) => Promise<void>;
|
||||
updateComplete: Promise<unknown>;
|
||||
playerData: Array<Record<string, unknown>>;
|
||||
currentUserEntry?: { playerId: string } | null;
|
||||
} | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
if (!customElements.get("leaderboard-modal")) {
|
||||
customElements.define("leaderboard-modal", LeaderboardModal);
|
||||
}
|
||||
modal = document.createElement("leaderboard-modal") as LeaderboardModal;
|
||||
document.body.appendChild(modal);
|
||||
await modal.updateComplete;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(modal);
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Tooltip Implementation - Issue #2508", () => {
|
||||
it("should render Win Score and Loss Score columns with title attributes", async () => {
|
||||
// Mock fetch to return sample clan leaderboard data
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [
|
||||
{
|
||||
clanTag: "[TEST]",
|
||||
games: 10,
|
||||
wins: 8,
|
||||
losses: 2,
|
||||
playerSessions: 25,
|
||||
weightedWins: 8.5,
|
||||
weightedLosses: 1.5,
|
||||
weightedWLRatio: 5.67,
|
||||
},
|
||||
{
|
||||
clanTag: "[DEMO]",
|
||||
games: 8,
|
||||
wins: 6,
|
||||
losses: 2,
|
||||
playerSessions: 20,
|
||||
weightedWins: 6.0,
|
||||
weightedLosses: 2.0,
|
||||
weightedWLRatio: 3.0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
(modal as unknown as { activeTab: string }).activeTab = "clans";
|
||||
const clanTable = getClanTable();
|
||||
expect(clanTable).toBeTruthy();
|
||||
await clanTable!.loadClanLeaderboard();
|
||||
await clanTable!.updateComplete;
|
||||
|
||||
const allHeaders = modal.querySelectorAll("th");
|
||||
let winScoreHeader: Element | null = null;
|
||||
let lossScoreHeader: Element | null = null;
|
||||
|
||||
// Find the headers by their text content and title attribute
|
||||
allHeaders.forEach((th) => {
|
||||
const title = th.getAttribute("title");
|
||||
if (title?.includes("Weighted wins")) {
|
||||
winScoreHeader = th;
|
||||
} else if (title?.includes("Weighted losses")) {
|
||||
lossScoreHeader = th;
|
||||
}
|
||||
});
|
||||
|
||||
// Assert that headers exist with correct tooltip text
|
||||
expect(winScoreHeader).toBeTruthy();
|
||||
expect(lossScoreHeader).toBeTruthy();
|
||||
|
||||
expect(winScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(lossScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use translateText for tooltip internationalization", async () => {
|
||||
// Verify translation keys are correct
|
||||
const { translateText } = await import("../../src/client/Utils");
|
||||
|
||||
expect(translateText("leaderboard_modal.win_score_tooltip")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(translateText("leaderboard_modal.loss_score_tooltip")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Player Data Mapping", () => {
|
||||
it("should map ranked leaderboard data and set current user entry", async () => {
|
||||
const { getUserMe } = await import("../../src/client/Api");
|
||||
(getUserMe as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
player: { publicId: "player-2" },
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
"1v1": [
|
||||
{
|
||||
rank: 1,
|
||||
elo: 1200,
|
||||
peakElo: 1300,
|
||||
wins: 6,
|
||||
losses: 4,
|
||||
total: 10,
|
||||
public_id: "player-1",
|
||||
username: "Alpha",
|
||||
clanTag: "[AAA]",
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
elo: 1100,
|
||||
peakElo: 1250,
|
||||
wins: 4,
|
||||
losses: 6,
|
||||
total: 10,
|
||||
public_id: "player-2",
|
||||
username: "Bravo",
|
||||
clanTag: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const playerList = getPlayerList();
|
||||
expect(playerList).toBeTruthy();
|
||||
await playerList!.loadPlayerLeaderboard(true);
|
||||
await playerList!.updateComplete;
|
||||
|
||||
const playerData = playerList!.playerData;
|
||||
|
||||
expect(playerData).toHaveLength(2);
|
||||
expect(playerData[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
playerId: "player-1",
|
||||
username: "Alpha",
|
||||
clanTag: "[AAA]",
|
||||
elo: 1200,
|
||||
games: 10,
|
||||
wins: 6,
|
||||
losses: 4,
|
||||
winRate: 0.6,
|
||||
}),
|
||||
);
|
||||
expect(playerData[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
playerId: "player-2",
|
||||
username: "Bravo",
|
||||
clanTag: undefined,
|
||||
winRate: 0.4,
|
||||
}),
|
||||
);
|
||||
expect(playerList!.currentUserEntry?.playerId).toBe("player-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Functionality", () => {
|
||||
it("should initialize with default state", () => {
|
||||
expect(modal).toBeTruthy();
|
||||
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
|
||||
"players",
|
||||
);
|
||||
});
|
||||
|
||||
it("should be a custom element", () => {
|
||||
expect(modal).toBeInstanceOf(LeaderboardModal);
|
||||
expect(modal.tagName.toLowerCase()).toBe("leaderboard-modal");
|
||||
});
|
||||
|
||||
it("should close on Escape when open", () => {
|
||||
const mockModalEl = { open: vi.fn(), close: vi.fn() };
|
||||
Object.defineProperty(modal, "modalEl", {
|
||||
get: () => mockModalEl,
|
||||
configurable: true,
|
||||
});
|
||||
(modal as unknown as { onOpen: () => void }).onOpen = vi.fn();
|
||||
|
||||
modal.open();
|
||||
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
expect((modal as unknown as { isModalOpen: boolean }).isModalOpen).toBe(
|
||||
false,
|
||||
);
|
||||
expect(mockModalEl.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Interaction", () => {
|
||||
it("should switch to clans tab and request clan leaderboard data", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const tab = modal.querySelector("#clan-leaderboard-tab");
|
||||
expect(tab).toBeTruthy();
|
||||
|
||||
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect((modal as unknown as { activeTab: string }).activeTab).toBe(
|
||||
"clans",
|
||||
);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/public/clans/leaderboard",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
await Promise.resolve();
|
||||
await modal.updateComplete;
|
||||
await awaitChildUpdate("leaderboard-clan-table");
|
||||
});
|
||||
|
||||
it("should render a no data state for empty clan leaderboard", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [],
|
||||
}),
|
||||
});
|
||||
|
||||
(modal as unknown as { activeTab: string }).activeTab = "clans";
|
||||
const clanTable = getClanTable();
|
||||
expect(clanTable).toBeTruthy();
|
||||
await clanTable!.loadClanLeaderboard();
|
||||
await clanTable!.updateComplete;
|
||||
|
||||
expect(modal.textContent).toContain("No data yet");
|
||||
expect(modal.textContent).toContain("No stats");
|
||||
});
|
||||
|
||||
it("should render an error state when clan leaderboard fails", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
(modal as unknown as { activeTab: string }).activeTab = "clans";
|
||||
const clanTable = getClanTable();
|
||||
expect(clanTable).toBeTruthy();
|
||||
await clanTable!.loadClanLeaderboard();
|
||||
await clanTable!.updateComplete;
|
||||
|
||||
expect(modal.textContent).toContain("Something went wrong");
|
||||
expect(modal.textContent).toContain("Try Again");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
import { StatsModal } from "../../src/client/StatsModal";
|
||||
|
||||
// Mock the translateText function
|
||||
vi.mock("../../src/client/Utils", () => ({
|
||||
translateText: vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"stats_modal.win_score_tooltip":
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
"stats_modal.loss_score_tooltip":
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../src/client/Api", () => ({
|
||||
getApiBase: vi.fn(() => "http://localhost:3000"),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
describe("StatsModal", () => {
|
||||
let modal: StatsModal;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Define the custom element if not already defined
|
||||
if (!customElements.get("stats-modal")) {
|
||||
customElements.define("stats-modal", StatsModal);
|
||||
}
|
||||
modal = document.createElement("stats-modal") as StatsModal;
|
||||
document.body.appendChild(modal);
|
||||
await modal.updateComplete;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(modal);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Tooltip Implementation - Issue #2508", () => {
|
||||
it("should render Win Score and Loss Score columns with title attributes", async () => {
|
||||
// Mock fetch to return sample clan leaderboard data
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
start: "2025-01-01T00:00:00Z",
|
||||
end: "2025-01-07T23:59:59Z",
|
||||
clans: [
|
||||
{
|
||||
clanTag: "[TEST]",
|
||||
games: 10,
|
||||
wins: 8,
|
||||
losses: 2,
|
||||
playerSessions: 25,
|
||||
weightedWins: 8.5,
|
||||
weightedLosses: 1.5,
|
||||
weightedWLRatio: 5.67,
|
||||
},
|
||||
{
|
||||
clanTag: "[DEMO]",
|
||||
games: 8,
|
||||
wins: 6,
|
||||
losses: 2,
|
||||
playerSessions: 20,
|
||||
weightedWins: 6.0,
|
||||
weightedLosses: 2.0,
|
||||
weightedWLRatio: 3.0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
// Mock the modal element's open method
|
||||
const mockModalEl = { open: vi.fn(), close: vi.fn() };
|
||||
Object.defineProperty(modal, "modalEl", {
|
||||
get: () => mockModalEl,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Trigger modal to load and render data
|
||||
modal.open();
|
||||
await modal.updateComplete;
|
||||
|
||||
// Wait for async loadLeaderboard to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await modal.updateComplete;
|
||||
|
||||
// Query the rendered DOM for table headers (StatsModal uses light DOM via createRenderRoot)
|
||||
const allHeaders = modal.querySelectorAll("th");
|
||||
let winScoreHeader: Element | null = null;
|
||||
let lossScoreHeader: Element | null = null;
|
||||
|
||||
// Find the headers by their text content and title attribute
|
||||
allHeaders.forEach((th) => {
|
||||
const title = th.getAttribute("title");
|
||||
if (title?.includes("Weighted wins")) {
|
||||
winScoreHeader = th;
|
||||
} else if (title?.includes("Weighted losses")) {
|
||||
lossScoreHeader = th;
|
||||
}
|
||||
});
|
||||
|
||||
// Assert that headers exist with correct tooltip text
|
||||
expect(winScoreHeader).toBeTruthy();
|
||||
expect(lossScoreHeader).toBeTruthy();
|
||||
|
||||
expect(winScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(lossScoreHeader!.getAttribute("title")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
|
||||
it("should use translateText for tooltip internationalization", async () => {
|
||||
// Verify translation keys are correct
|
||||
const { translateText } = await import("../../src/client/Utils");
|
||||
|
||||
expect(translateText("stats_modal.win_score_tooltip")).toBe(
|
||||
"Weighted wins based on clan participation and match difficulty",
|
||||
);
|
||||
expect(translateText("stats_modal.loss_score_tooltip")).toBe(
|
||||
"Weighted losses based on clan participation and match difficulty",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Modal Functionality", () => {
|
||||
it("should initialize with default state", () => {
|
||||
expect(modal).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should be a custom element", () => {
|
||||
expect(modal).toBeInstanceOf(StatsModal);
|
||||
expect(modal.tagName.toLowerCase()).toBe("stats-modal");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -341,6 +341,7 @@ describe("RadialMenuElements", () => {
|
||||
isPlayer: vi.fn(() => true),
|
||||
} as unknown as PlayerView;
|
||||
mockParams.selected = allyPlayer;
|
||||
mockGame.owner = vi.fn(() => allyPlayer);
|
||||
|
||||
const subMenu = rootMenuElement.subMenu!(mockParams);
|
||||
const allyMenu = subMenu.find((item) => item.id === "ally_break");
|
||||
|
||||
@@ -75,12 +75,6 @@ const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
|
||||
const findAllyBreak = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break");
|
||||
|
||||
const findAllyBreakConfirm = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break_confirm");
|
||||
|
||||
const findAllyBreakCancel = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break_cancel");
|
||||
|
||||
describe("RadialMenuElements ally break", () => {
|
||||
test("shows break option with correct color when allied", () => {
|
||||
const params = makeParams();
|
||||
@@ -91,29 +85,12 @@ describe("RadialMenuElements ally break", () => {
|
||||
expect(ally.color).toBe(COLORS.breakAlly);
|
||||
});
|
||||
|
||||
test("break option opens confirmation submenu", () => {
|
||||
test("break action calls handleBreakAlliance and closes menu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
|
||||
expect(ally.subMenu).toBeDefined();
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
expect(subMenuItems.length).toBe(2);
|
||||
|
||||
const confirmItem = findAllyBreakConfirm(subMenuItems);
|
||||
const cancelItem = findAllyBreakCancel(subMenuItems);
|
||||
expect(confirmItem).toBeTruthy();
|
||||
expect(cancelItem).toBeTruthy();
|
||||
});
|
||||
|
||||
test("confirm action calls handleBreakAlliance and closes menu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
const confirmItem = findAllyBreakConfirm(subMenuItems)!;
|
||||
|
||||
confirmItem.action!(params);
|
||||
ally.action!(params);
|
||||
|
||||
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
|
||||
params.myPlayer,
|
||||
@@ -121,19 +98,4 @@ describe("RadialMenuElements ally break", () => {
|
||||
);
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cancel action closes menu without breaking alliance", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
const cancelItem = findAllyBreakCancel(subMenuItems)!;
|
||||
|
||||
cancelItem.action!(params);
|
||||
|
||||
expect(
|
||||
params.playerActionHandler.handleBreakAlliance,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+20
-7
@@ -13,6 +13,24 @@ const __dirname = path.dirname(__filename);
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const isProduction = mode === "production";
|
||||
// In dev, redirect visits to /w*/game/* to "/" so Vite serves the index.html.
|
||||
const devGameHtmlBypass = (req?: {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers?: { accept?: string | string[] };
|
||||
}) => {
|
||||
if (req?.method !== "GET") return undefined;
|
||||
const accept = req.headers?.accept;
|
||||
const acceptValue = Array.isArray(accept)
|
||||
? accept.join(",")
|
||||
: (accept ?? "");
|
||||
if (!acceptValue.includes("text/html")) return undefined;
|
||||
if (!req.url) return undefined;
|
||||
if (/^\/w\d+\/game\/[^/]+/.test(req.url)) {
|
||||
return "/";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
test: {
|
||||
@@ -103,6 +121,7 @@ export default defineConfig(({ mode }) => {
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
bypass: (req) => devGameHtmlBypass(req),
|
||||
rewrite: (path) => path.replace(/^\/w0/, ""),
|
||||
},
|
||||
"/w1": {
|
||||
@@ -110,15 +129,9 @@ export default defineConfig(({ mode }) => {
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
bypass: (req) => devGameHtmlBypass(req),
|
||||
rewrite: (path) => path.replace(/^\/w1/, ""),
|
||||
},
|
||||
"/w2": {
|
||||
target: "ws://localhost:3003",
|
||||
ws: true,
|
||||
secure: false,
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/w2/, ""),
|
||||
},
|
||||
// API proxies
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
|
||||
Reference in New Issue
Block a user