force commit

This commit is contained in:
Aotumuri
2025-10-15 12:25:59 +09:00
119 changed files with 6057 additions and 2419 deletions
-4
View File
@@ -20,7 +20,6 @@ on:
type: choice
options:
- masters
- nbg1
- staging
- falk1
target_subdomain:
@@ -93,7 +92,6 @@ jobs:
- name: 🔑 Create SSH private key
env:
SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }}
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
@@ -102,7 +100,6 @@ jobs:
mkdir -p ~/.ssh
echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa
test -n "$SERVER_HOST_MASTERS" && ssh-keyscan -H "$SERVER_HOST_MASTERS" >> ~/.ssh/known_hosts
test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts
test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts
test -n "$SERVER_HOST_STAGING" && ssh-keyscan -H "$SERVER_HOST_STAGING" >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/id_rsa
@@ -125,7 +122,6 @@ jobs:
R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }}
API_KEY: ${{ secrets.API_KEY }}
SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }}
SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }}
SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }}
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
SSH_KEY: ~/.ssh/id_rsa
+8 -4
View File
@@ -13,10 +13,14 @@ jobs:
steps:
- uses: actions/stale@8f717f0dfca33b78d3c933452e42558e4456c8e7
with:
days-before-close: 14
days-before-stale: 14
days-before-pr-close: 14
days-before-pr-stale: 14
days-before-issue-close: -1
days-before-issue-stale: -1
exempt-draft-pr: true
exempt-pr-assignees: evanpelle
stale-pr-label: "stale"
exempt-pr-labels: "will not stale"
stale-pr-label: "Stale"
stale-pr-message: "This pull request is stale because it has been open for 14 days with no activity. If you want to keep this pull request open, add a comment or update the branch."
start-date: 2025-09-03T00:00:00+00:00
close-pr-message: "This pull request has been closed because twenty-eight days have passed without activity. If someone wants to keep working on it, feel free to take the code."
close-pr-label: "Orphaned"
+23 -1
View File
@@ -658,4 +658,26 @@ specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
<https://www.gnu.org/licenses/>.
=== ADDITIONAL TERMS (AGPL v3 Section 7) ===
Attribution Requirement:
All modified versions of this web game must prominently display one of the
following on the main menu and/or initial title screen:
- "Based on OpenFront"
- "Derived from OpenFront"
- "Powered by OpenFront"
- "Fork of OpenFront"
Display Requirements:
- Positioned directly beneath the game's title
- Minimum 12-point font size (or proportional equivalent)
- Sufficient contrast for clear readability
- Visible without scrolling or navigation
- Must remain visible until user chooses to proceed (no auto-skip)
- May include a link to https://openfront.io
Modified versions may add their own game title (e.g.,
"SuperGame - Based on OpenFront") but may not use "OpenFront"
as the primary title or imply official endorsement.
+17
View File
@@ -55,6 +55,23 @@ This document provides comprehensive licensing information for the OpenFront.io
- Proprietary assets override open assets during build process
- Contributors retain copyright while granting usage rights to OpenFront LLC
### Phase 5: AGPL with Additional Attribution Terms
- **Starting After:** 136cfa1316dd584bece48f7ad9f8f17e8603f1c7
- **Date:** October 12, 2025
- **Changes:**
- Added Section 7 attribution requirement to AGPL v3.0
- All forks must display "Based on OpenFront" (or similar) on main menu
- Make clear that "OpenFront" is a registered trademark
### Phase 6: Clarified AGPL Additional Attribution Terms
- **Starting After:** e7497bfb767c4bf3e1e00eac0a13de995419f8b5
- **Date:** October 14, 2025
- **Changes:**
- Added additional Section 7 attribution requirements to the LICENSE
- Removed trademark notice from LICENSE file as it's unnecessary
## Important Notes
### For Code:
+11 -4
View File
@@ -16,6 +16,17 @@ This is a fork/rewrite of WarFront.io. Credit to https://github.com/WarFrontIO.
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Assets: CC BY-SA 4.0](https://img.shields.io/badge/Assets-CC%20BY--SA%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by-sa/4.0/)
## License
OpenFront source code is licensed under the **GNU Affero General Public License v3.0** with additional attribution requirements:
- Any forks or derivative works must display attribution (e.g., "Based on OpenFront", "Derived from OpenFront", "Powered by OpenFront", or "Fork of OpenFront") prominently on the main menu and/or initial title screen.
See the `ADDITIONAL TERMS` section in [LICENSE](LICENSE) for complete requirements.
For asset licensing, see [LICENSE-ASSETS](LICENSE-ASSETS).
For license history, see [LICENSING.md](LICENSING.md).
## 🌟 Features
- **Real-time Strategy Gameplay**: Expand your territory and engage in strategic battles
@@ -124,10 +135,6 @@ npm run dev:prod
- `/src/server` - Backend game server
- `/resources` - Static assets (images, maps, etc.)
## 📝 License
This project is licensed under the terms found in the [LICENSE](LICENSE) file.
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+8 -2
View File
@@ -40,8 +40,7 @@ export default [
rules: {
// Disable rules that would fail. The failures should be fixed, and the entries here removed.
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-expressions": "off",
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
},
},
{
@@ -50,6 +49,13 @@ export default [
"@typescript-eslint/prefer-nullish-coalescing": "error",
eqeqeq: "error",
"no-case-declarations": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
args: "none",
caughtErrors: "none",
},
],
},
},
];
+2 -2
View File
@@ -17,8 +17,8 @@ export default {
coverageThreshold: {
global: {
statements: 21.5,
branches: 17.0,
lines: 22.0,
branches: 16,
lines: 21.0,
functions: 20.5,
},
},
@@ -1,14 +1,4 @@
{
"map": {
"height": 1500,
"num_land_tiles": 1961549,
"width": 1530
},
"mini_map": {
"height": 750,
"num_land_tiles": 485392,
"width": 765
},
"name": "Montreal",
"nations": [
{
+20 -18
View File
@@ -15,32 +15,34 @@ var maps = []struct {
}{
{Name: "africa"},
{Name: "asia"},
{Name: "world"},
{Name: "giantworldmap"},
{Name: "australia"},
{Name: "baikal"},
{Name: "betweentwoseas"},
{Name: "blacksea"},
{Name: "britannia"},
{Name: "deglaciatedantarctica"},
{Name: "eastasia"},
{Name: "europe"},
{Name: "europeclassic"},
{Name: "mars"},
{Name: "mena"},
{Name: "oceania"},
{Name: "northamerica"},
{Name: "southamerica"},
{Name: "britannia"},
{Name: "gatewaytotheatlantic"},
{Name: "australia"},
{Name: "pangaea"},
{Name: "iceland"},
{Name: "betweentwoseas"},
{Name: "eastasia"},
{Name: "faroeislands"},
{Name: "deglaciatedantarctica"},
{Name: "falklandislands"},
{Name: "baikal"},
{Name: "faroeislands"},
{Name: "gatewaytotheatlantic"},
{Name: "giantworldmap"},
{Name: "halkidiki"},
{Name: "iceland"},
{Name: "italia"},
{Name: "japan"},
{Name: "straitofgibraltar"},
{Name: "mars"},
{Name: "mena"},
{Name: "montreal"},
{Name: "northamerica"},
{Name: "oceania"},
{Name: "pangaea"},
{Name: "pluto"},
{Name: "southamerica"},
{Name: "straitofgibraltar"},
{Name: "world"},
{Name: "yenisei"},
{Name: "big_plains", IsTest: true},
{Name: "half_land_half_ocean", IsTest: true},
{Name: "ocean_and_land", IsTest: true},
+10 -10
View File
@@ -3604,13 +3604,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz",
"integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==",
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz",
"integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.15.0",
"@eslint/core": "^0.15.2",
"levn": "^0.4.1"
},
"engines": {
@@ -3618,9 +3618,9 @@
}
},
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz",
"integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==",
"version": "0.15.2",
"resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz",
"integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -18916,9 +18916,9 @@
}
},
"node_modules/tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+1011
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+89
View File
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
x="0px"
y="0px"
viewBox="0 0 100 100"
id="svg10"
sodipodi:docname="TraitorIcon.svg"
width="100"
height="100"
inkscape:version="1.1.2 (0a00cf5339, 2022-02-04)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs14" />
<sodipodi:namedview
id="namedview12"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
height="100px"
inkscape:zoom="6.984"
inkscape:cx="49.971363"
inkscape:cy="62.142039"
inkscape:window-width="3072"
inkscape:window-height="1653"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="1"
inkscape:current-layer="svg10" />
<g
transform="translate(0,-952.36218)"
id="g4">
<path
style="color:#000000;text-indent:0;text-transform:none;direction:ltr;baseline-shift:baseline;enable-background:accumulate"
d="m 49.682164,957.35229 c -0.08371,0.007 -0.167129,0.0173 -0.249965,0.0312 l -35.995035,7.00077 C 12.072501,964.6449 10.989932,965.96376 11,967.35339 v 32.00351 c 0.0012,0.13594 0.01165,0.27179 0.03125,0.4063 1.29114,9.0104 3.853487,15.5969 8.498827,22.2837 0.990121,1.4292 3.318267,1.6525 4.561871,0.4375 4.250005,-4.1812 7.885056,-7.78 11.967099,-11.9697 0.44046,-0.4164 0.750052,-0.9694 0.87488,-1.5626 0.628358,-3.8527 1.729676,-9.31167 2.530901,-13.59583 h 15.529108 c 1.243283,0.002 2.441795,-0.85453 2.843357,-2.03147 l 9.998621,-29.00319 c 0.567687,-1.6172 -0.568544,-3.6061 -2.249689,-3.93794 l -14.997932,-3.00033 c -0.298144,-0.0562 -0.604826,-0.0667 -0.906125,-0.0312 z m 23.902953,5.00055 c -1.078435,0.15321 -2.042235,0.94196 -2.405918,1.96897 L 58.899643,998.35679 H 44.995311 c -1.406702,-0.005 -2.733797,1.11295 -2.968341,2.50031 l -1.81225,10.9699 -14.27928,13.3139 c -1.128859,1.0576 -1.217322,3.0338 -0.187474,4.188 6.269465,7.139 13.562725,12.4576 22.778108,17.627 0.892103,0.5009 2.044992,0.5009 2.937095,0 19.535202,-10.9581 34.280717,-24.7622 37.494828,-47.1927 0.06997,-10.76177 0.03124,-21.61653 0.03124,-32.40981 0.0049,-1.40704 -1.112648,-2.73446 -2.499655,-2.96908 l -11.99834,-2.00021 c -0.298144,-0.0562 -0.604826,-0.0667 -0.906125,-0.0312 z m -23.621742,1.06261 11.060975,2.18774 -8.186371,23.75262 H 36.996414 c -1.378861,0.004 -2.67845,1.08304 -2.937095,2.43777 l -2.812112,15.06422 c -2.889686,2.9098 -5.919847,5.9695 -8.686302,8.7197 -2.891489,-4.8594 -4.568269,-9.6725 -5.561733,-16.50187 v -29.25322 z m 25.996415,5.28184 7.03028,1.18763 v 29.19071 c -2.868962,19.61817 -15.058331,31.46397 -32.995449,41.78587 -7.128175,-4.1138 -12.790202,-8.2954 -17.716306,-13.4077 l 12.779487,-11.9076 c 0.479911,-0.4525 0.804009,-1.0673 0.906125,-1.719 l 1.59353,-9.4698 h 13.435647 c 1.225447,-0.01 2.403546,-0.8446 2.812112,-2.0002 z"
fill="#FFFFFF"
fill-opacity="1"
fill-rule="nonzero"
stroke="none"
marker="none"
visibility="visible"
display="inline"
overflow="visible"
id="path2" />
</g>
<path
style="fill:#ff2d2d;fill-opacity:0;stroke-width:0.143184"
d="M 21.229102,70.741061 C 20.081432,70.373981 19.32618,69.461642 17.270525,65.959129 14.47136,61.189795 12.54351,55.744636 11.459279,49.545428 L 11.096793,47.472871 V 30.96265 c 0,-13.527886 0.03488,-16.593449 0.193078,-16.970972 0.258937,-0.617909 0.895032,-1.354431 1.391463,-1.611145 0.222726,-0.115176 8.704097,-1.825312 18.847492,-3.8003039 l 18.442537,-3.5908935 7.910402,1.5791153 c 4.350721,0.8685133 8.143647,1.6897975 8.428725,1.8250757 0.961973,0.4564861 1.758687,1.9751714 1.568376,2.9896164 -0.047,0.250541 -2.368442,7.075757 -5.158757,15.167146 -3.44537,9.990924 -5.193245,14.868872 -5.447096,15.201688 -0.205587,0.269539 -0.643902,0.634451 -0.974034,0.810914 -0.596667,0.318933 -0.648799,0.32127 -8.759053,0.392435 l -8.158815,0.07159 -0.470445,2.434135 c -0.258746,1.338774 -0.824052,4.367125 -1.256239,6.729668 -0.432185,2.362543 -0.88633,4.5664 -1.009211,4.897461 -0.177968,0.479472 -1.505767,1.882854 -6.526761,6.898286 -3.466837,3.462998 -6.517904,6.398177 -6.78015,6.52262 -0.601484,0.285423 -1.599068,0.395138 -2.109203,0.231972 z m 5.734389,-11.793889 4.338592,-4.340341 1.438261,-7.722946 c 1.617094,-8.683222 1.602011,-8.634211 2.883301,-9.369051 l 0.745198,-0.427381 8.253581,-0.0013 c 6.783328,-0.0011 8.266754,-0.03567 8.327529,-0.194046 0.04067,-0.105988 1.891336,-5.461638 4.11259,-11.901446 2.549671,-7.391954 3.982955,-11.742017 3.8876,-11.798993 -0.08307,-0.04964 -2.590856,-0.568451 -5.572852,-1.15292 l -5.42181,-1.062727 -16.494063,3.206249 -16.494064,3.206248 v 14.900444 c 0,14.430688 0.0093,14.952195 0.295638,16.541989 0.677724,3.763097 1.813851,7.543992 3.102693,10.32539 0.832524,1.796635 2.062365,4.131162 2.176318,4.131162 0.04559,0 2.035263,-1.953154 4.421488,-4.340342 z"
id="path944" />
<path
style="fill:#ff2d2d;fill-opacity:0;stroke-width:0.143184"
d="M 21.229102,70.741061 C 20.081432,70.373981 19.32618,69.461642 17.270525,65.959129 14.47136,61.189795 12.54351,55.744636 11.459279,49.545428 L 11.096793,47.472871 V 30.96265 c 0,-13.527886 0.03488,-16.593449 0.193078,-16.970972 0.258937,-0.617909 0.895032,-1.354431 1.391463,-1.611145 0.222726,-0.115176 8.704097,-1.825312 18.847492,-3.8003039 l 18.442537,-3.5908935 7.910402,1.5791153 c 4.350721,0.8685133 8.143647,1.6897975 8.428725,1.8250757 0.961973,0.4564861 1.758687,1.9751714 1.568376,2.9896164 -0.047,0.250541 -2.368442,7.075757 -5.158757,15.167146 -3.44537,9.990924 -5.193245,14.868872 -5.447096,15.201688 -0.205587,0.269539 -0.643902,0.634451 -0.974034,0.810914 -0.596667,0.318933 -0.648799,0.32127 -8.759053,0.392435 l -8.158815,0.07159 -0.470445,2.434135 c -0.258746,1.338774 -0.824052,4.367125 -1.256239,6.729668 -0.432185,2.362543 -0.88633,4.5664 -1.009211,4.897461 -0.177968,0.479472 -1.505767,1.882854 -6.526761,6.898286 -3.466837,3.462998 -6.517904,6.398177 -6.78015,6.52262 -0.601484,0.285423 -1.599068,0.395138 -2.109203,0.231972 z m 5.734389,-11.793889 4.338592,-4.340341 1.438261,-7.722946 c 1.617094,-8.683222 1.602011,-8.634211 2.883301,-9.369051 l 0.745198,-0.427381 8.253581,-0.0013 c 6.783328,-0.0011 8.266754,-0.03567 8.327529,-0.194046 0.04067,-0.105988 1.891336,-5.461638 4.11259,-11.901446 2.549671,-7.391954 3.982955,-11.742017 3.8876,-11.798993 -0.08307,-0.04964 -2.590856,-0.568451 -5.572852,-1.15292 l -5.42181,-1.062727 -16.494063,3.206249 -16.494064,3.206248 v 14.900444 c 0,14.430688 0.0093,14.952195 0.295638,16.541989 0.677724,3.763097 1.813851,7.543992 3.102693,10.32539 0.832524,1.796635 2.062365,4.131162 2.176318,4.131162 0.04559,0 2.035263,-1.953154 4.421488,-4.340342 z"
id="path983" />
<path
style="fill:#ff2d2d;fill-opacity:0;stroke-width:0.143184"
d="M 21.229102,70.741061 C 20.081432,70.373981 19.32618,69.461642 17.270525,65.959129 14.47136,61.189795 12.54351,55.744636 11.459279,49.545428 L 11.096793,47.472871 V 30.96265 c 0,-13.527886 0.03488,-16.593449 0.193078,-16.970972 0.258937,-0.617909 0.895032,-1.354431 1.391463,-1.611145 0.222726,-0.115176 8.704097,-1.825312 18.847492,-3.8003039 l 18.442537,-3.5908935 7.910402,1.5791153 c 4.350721,0.8685133 8.143647,1.6897975 8.428725,1.8250757 0.961973,0.4564861 1.758687,1.9751714 1.568376,2.9896164 -0.047,0.250541 -2.368442,7.075757 -5.158757,15.167146 -3.44537,9.990924 -5.193245,14.868872 -5.447096,15.201688 -0.205587,0.269539 -0.643902,0.634451 -0.974034,0.810914 -0.596667,0.318933 -0.648799,0.32127 -8.759053,0.392435 l -8.158815,0.07159 -0.470445,2.434135 c -0.258746,1.338774 -0.824052,4.367125 -1.256239,6.729668 -0.432185,2.362543 -0.88633,4.5664 -1.009211,4.897461 -0.177968,0.479472 -1.505767,1.882854 -6.526761,6.898286 -3.466837,3.462998 -6.517904,6.398177 -6.78015,6.52262 -0.601484,0.285423 -1.599068,0.395138 -2.109203,0.231972 z m 5.734389,-11.793889 4.338592,-4.340341 1.438261,-7.722946 c 1.617094,-8.683222 1.602011,-8.634211 2.883301,-9.369051 l 0.745198,-0.427381 8.253581,-0.0013 c 6.783328,-0.0011 8.266754,-0.03567 8.327529,-0.194046 0.04067,-0.105988 1.891336,-5.461638 4.11259,-11.901446 2.549671,-7.391954 3.982955,-11.742017 3.8876,-11.798993 -0.08307,-0.04964 -2.590856,-0.568451 -5.572852,-1.15292 l -5.42181,-1.062727 -16.494063,3.206249 -16.494064,3.206248 v 14.900444 c 0,14.430688 0.0093,14.952195 0.295638,16.541989 0.677724,3.763097 1.813851,7.543992 3.102693,10.32539 0.832524,1.796635 2.062365,4.131162 2.176318,4.131162 0.04559,0 2.035263,-1.953154 4.421488,-4.340342 z"
id="path1022" />
<path
style="fill:#ff2d2d;fill-opacity:0;stroke-width:0.143184"
d="M 21.229102,70.741061 C 20.081432,70.373981 19.32618,69.461642 17.270525,65.959129 14.47136,61.189795 12.54351,55.744636 11.459279,49.545428 L 11.096793,47.472871 V 30.96265 c 0,-13.527886 0.03488,-16.593449 0.193078,-16.970972 0.258937,-0.617909 0.895032,-1.354431 1.391463,-1.611145 0.222726,-0.115176 8.704097,-1.825312 18.847492,-3.8003039 l 18.442537,-3.5908935 7.910402,1.5791153 c 4.350721,0.8685133 8.143647,1.6897975 8.428725,1.8250757 0.961973,0.4564861 1.758687,1.9751714 1.568376,2.9896164 -0.047,0.250541 -2.368442,7.075757 -5.158757,15.167146 -3.44537,9.990924 -5.193245,14.868872 -5.447096,15.201688 -0.205587,0.269539 -0.643902,0.634451 -0.974034,0.810914 -0.596667,0.318933 -0.648799,0.32127 -8.759053,0.392435 l -8.158815,0.07159 -0.470445,2.434135 c -0.258746,1.338774 -0.824052,4.367125 -1.256239,6.729668 -0.432185,2.362543 -0.88633,4.5664 -1.009211,4.897461 -0.177968,0.479472 -1.505767,1.882854 -6.526761,6.898286 -3.466837,3.462998 -6.517904,6.398177 -6.78015,6.52262 -0.601484,0.285423 -1.599068,0.395138 -2.109203,0.231972 z m 5.734389,-11.793889 4.338592,-4.340341 1.438261,-7.722946 c 1.617094,-8.683222 1.602011,-8.634211 2.883301,-9.369051 l 0.745198,-0.427381 8.253581,-0.0013 c 6.783328,-0.0011 8.266754,-0.03567 8.327529,-0.194046 0.04067,-0.105988 1.891336,-5.461638 4.11259,-11.901446 2.549671,-7.391954 3.982955,-11.742017 3.8876,-11.798993 -0.08307,-0.04964 -2.590856,-0.568451 -5.572852,-1.15292 l -5.42181,-1.062727 -16.494063,3.206249 -16.494064,3.206248 v 14.900444 c 0,14.430688 0.0093,14.952195 0.295638,16.541989 0.677724,3.763097 1.813851,7.543992 3.102693,10.32539 0.832524,1.796635 2.062365,4.131162 2.176318,4.131162 0.04559,0 2.035263,-1.953154 4.421488,-4.340342 z"
id="path1061" />
<path
style="fill:#ff2d2d;fill-opacity:0;stroke-width:0.143184"
d="M 21.229102,70.741061 C 20.081432,70.373981 19.32618,69.461642 17.270525,65.959129 14.47136,61.189795 12.54351,55.744636 11.459279,49.545428 L 11.096793,47.472871 V 30.96265 c 0,-13.527886 0.03488,-16.593449 0.193078,-16.970972 0.258937,-0.617909 0.895032,-1.354431 1.391463,-1.611145 0.222726,-0.115176 8.704097,-1.825312 18.847492,-3.8003039 l 18.442537,-3.5908935 7.910402,1.5791153 c 4.350721,0.8685133 8.143647,1.6897975 8.428725,1.8250757 0.961973,0.4564861 1.758687,1.9751714 1.568376,2.9896164 -0.047,0.250541 -2.368442,7.075757 -5.158757,15.167146 -3.44537,9.990924 -5.193245,14.868872 -5.447096,15.201688 -0.205587,0.269539 -0.643902,0.634451 -0.974034,0.810914 -0.596667,0.318933 -0.648799,0.32127 -8.759053,0.392435 l -8.158815,0.07159 -0.470445,2.434135 c -0.258746,1.338774 -0.824052,4.367125 -1.256239,6.729668 -0.432185,2.362543 -0.88633,4.5664 -1.009211,4.897461 -0.177968,0.479472 -1.505767,1.882854 -6.526761,6.898286 -3.466837,3.462998 -6.517904,6.398177 -6.78015,6.52262 -0.601484,0.285423 -1.599068,0.395138 -2.109203,0.231972 z m 5.734389,-11.793889 4.338592,-4.340341 1.438261,-7.722946 c 1.617094,-8.683222 1.602011,-8.634211 2.883301,-9.369051 l 0.745198,-0.427381 8.253581,-0.0013 c 6.783328,-0.0011 8.266754,-0.03567 8.327529,-0.194046 0.04067,-0.105988 1.891336,-5.461638 4.11259,-11.901446 2.549671,-7.391954 3.982955,-11.742017 3.8876,-11.798993 -0.08307,-0.04964 -2.590856,-0.568451 -5.572852,-1.15292 l -5.42181,-1.062727 -16.494063,3.206249 -16.494064,3.206248 v 14.900444 c 0,14.430688 0.0093,14.952195 0.295638,16.541989 0.677724,3.763097 1.813851,7.543992 3.102693,10.32539 0.832524,1.796635 2.062365,4.131162 2.176318,4.131162 0.04559,0 2.035263,-1.953154 4.421488,-4.340342 z"
id="path1100" />
<path
style="fill:#ff2d2d;fill-opacity:0;stroke-width:0.143184"
d="M 22.451679,62.831985 C 22.040716,62.318926 20.614744,59.414324 19.904415,57.643392 18.969118,55.311589 18.276544,53.003918 17.644871,50.114548 L 17.190978,48.038373 17.14702,32.753436 c -0.02418,-8.406715 0.006,-15.283838 0.06717,-15.282495 0.06112,0.0013 7.450905,-1.423883 16.421746,-3.16717 l 16.310618,-3.169613 5.310229,1.037928 c 2.920626,0.570861 5.383063,1.077918 5.472081,1.126793 0.108366,0.0595 -1.175715,3.968788 -3.885785,11.829987 l -4.047636,11.741123 -8.356483,0.07159 c -6.695958,0.05736 -8.427615,0.110521 -8.714445,0.267501 -0.60381,0.330461 -1.419391,1.193902 -1.576025,1.66851 -0.08176,0.247726 -0.783479,3.851808 -1.559383,8.00907 l -1.410734,7.55866 -4.287731,4.287731 c -3.320136,3.320136 -4.321858,4.245124 -4.438961,4.098932 z"
id="path1139" />
<path
style="fill:#FFFFFF;fill-opacity:1;stroke-width:0.143184"
d="m 40.914948,36.977471 c 2.185353,-0.02223 5.761384,-0.02223 7.946736,0 2.185352,0.02223 0.397337,0.04042 -3.973368,0.04042 -4.370704,0 -6.15872,-0.01819 -3.973368,-0.04042 z"
id="path1178" />
<path
style="fill:#ff5a5a;fill-opacity:1;stroke-width:0.143184"
d="M 21.229102,70.741061 C 20.081432,70.373981 19.32618,69.461642 17.270525,65.959129 14.47136,61.189795 12.54351,55.744636 11.459279,49.545428 L 11.096793,47.472871 V 30.96265 c 0,-13.527886 0.03488,-16.593449 0.193078,-16.970972 0.258937,-0.617909 0.895032,-1.354431 1.391463,-1.611145 0.222726,-0.115176 8.704097,-1.825312 18.847492,-3.8003039 l 18.442537,-3.5908935 7.910402,1.5791153 c 4.350721,0.8685133 8.143647,1.6897975 8.428725,1.8250757 0.961973,0.4564861 1.758687,1.9751714 1.568376,2.9896164 -0.047,0.250541 -2.368442,7.075757 -5.158757,15.167146 -3.44537,9.990924 -5.193245,14.868872 -5.447096,15.201688 -0.205587,0.269539 -0.643902,0.634451 -0.974034,0.810914 -0.596667,0.318933 -0.648799,0.32127 -8.759053,0.392435 l -8.158815,0.07159 -0.470445,2.434135 c -0.258746,1.338774 -0.824052,4.367125 -1.256239,6.729668 -0.432185,2.362543 -0.88633,4.5664 -1.009211,4.897461 -0.177968,0.479472 -1.505767,1.882854 -6.526761,6.898286 -3.466837,3.462998 -6.517904,6.398177 -6.78015,6.52262 -0.601484,0.285423 -1.599068,0.395138 -2.109203,0.231972 z m 5.734389,-11.793889 4.338592,-4.340341 1.438261,-7.722946 c 1.617094,-8.683222 1.602011,-8.634211 2.883301,-9.369051 l 0.745198,-0.427381 8.253581,-0.0013 c 6.783328,-0.0011 8.266754,-0.03567 8.327529,-0.194046 0.04067,-0.105988 1.891336,-5.461638 4.11259,-11.901446 2.549671,-7.391954 3.982955,-11.742017 3.8876,-11.798993 -0.08307,-0.04964 -2.590856,-0.568451 -5.572852,-1.15292 l -5.42181,-1.062727 -16.494063,3.206249 -16.494064,3.206248 v 14.900444 c 0,14.430688 0.0093,14.952195 0.295638,16.541989 0.677724,3.763097 1.813851,7.543992 3.102693,10.32539 0.832524,1.796635 2.062365,4.131162 2.176318,4.131162 0.04559,0 2.035263,-1.953154 4.421488,-4.340342 z"
id="path9953" />
<path
style="fill:#ff5a5a;fill-opacity:1;stroke-width:0.143184"
d="M 49.327033,94.804719 C 48.714427,94.60513 47.768416,94.086861 45.174685,92.529868 37.917796,88.173624 32.73106,84.14232 27.795635,79.022291 c -1.145161,-1.187994 -2.213316,-2.431436 -2.374239,-2.763859 -0.397355,-0.820826 -0.385516,-1.87334 0.02963,-2.634656 0.211883,-0.388557 2.674852,-2.777493 7.195017,-6.978744 3.780069,-3.513371 7.046744,-6.559837 7.25928,-6.769926 0.367228,-0.363001 0.433179,-0.666405 1.327443,-6.106826 1.040786,-6.331825 1.066013,-6.412508 2.250084,-7.196324 l 0.596675,-0.39498 7.446948,-0.07159 7.446947,-0.07159 6.097509,-16.895762 c 3.353629,-9.292669 6.184278,-17.104473 6.290327,-17.359565 0.271361,-0.652727 1.177566,-1.423686 1.905252,-1.620901 0.55181,-0.149551 1.267997,-0.05876 6.811404,0.863504 3.407116,0.566845 6.423919,1.093902 6.704006,1.171238 0.637026,0.175891 1.521753,0.963097 1.870312,1.664151 0.253992,0.510853 0.264529,1.240952 0.247407,17.14297 -0.01766,16.399934 -0.02182,16.631061 -0.329684,18.327606 -2.58954,14.26993 -9.981308,25.824745 -22.991493,35.940307 -3.466755,2.695441 -7.896512,5.630746 -12.608706,8.354951 -2.064486,1.193517 -2.838015,1.444605 -3.642726,1.182428 z M 52.47709,87.119888 C 59.271254,83.059324 64.365846,79.189404 68.951193,74.605944 76.565106,66.995162 80.844811,58.899716 82.755326,48.494191 l 0.363231,-1.978318 V 32.007709 17.499544 l -0.322165,-0.07398 c -0.957493,-0.219871 -6.829216,-1.157603 -6.884521,-1.099478 -0.03586,0.03769 -2.785127,7.60719 -6.109478,16.821108 -3.324352,9.213917 -6.177636,16.993316 -6.340633,17.287551 -0.353145,0.637482 -1.164147,1.232386 -1.918855,1.407559 -0.300488,0.06975 -3.5908,0.128346 -7.311805,0.130225 -3.721005,0.0019 -6.765464,0.04073 -6.765464,0.08633 0,0.474978 -1.637454,9.59135 -1.804511,10.046442 -0.187845,0.511725 -1.260378,1.573508 -6.797799,6.729668 -3.61612,3.367143 -6.574871,6.194451 -6.575002,6.282908 -3e-4,0.204934 2.512831,2.628316 4.451717,4.292733 1.895466,1.627144 5.431341,4.277542 7.645781,5.731075 2.084726,1.368391 5.558468,3.470987 5.657133,3.424166 0.03938,-0.01869 1.134737,-0.669367 2.434135,-1.44596 z"
id="path10066" />
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

+88 -11
View File
@@ -6,7 +6,18 @@
"lang_code": "en"
},
"common": {
"close": "Close"
"close": "Close",
"available": "Available",
"preset_max": "Max",
"summary_send": "Send",
"summary_keep": "Keep",
"cancel": "Cancel",
"send": "Send",
"cap_label": "Cap",
"cap_tooltip": "Recipients remaining capacity",
"target_dead": "Target eliminated",
"target_dead_note": "You can't send resources to an eliminated player.",
"none": "None"
},
"main": {
"title": "OpenFront (ALPHA)",
@@ -249,6 +260,7 @@
},
"game_starting_modal": {
"title": "Game is Starting...",
"code_license": "Code licensed under AGPL-3.0",
"desc": "Preparing for the lobby to start. Please wait."
},
"difficulty": {
@@ -326,6 +338,25 @@
"view_options": "View Options",
"toggle_view": "Toggle View",
"toggle_view_desc": "Alternate view (terrain/countries)",
"build_controls": "Build Controls",
"build_city": "Build City",
"build_city_desc": "Build a City under your cursor.",
"build_factory": "Build Factory",
"build_factory_desc": "Build a Factory under your cursor.",
"build_defense_post": "Build Defense Post",
"build_defense_post_desc": "Build a Defense Post under your cursor.",
"build_port": "Build Port",
"build_port_desc": "Build a Port under your cursor.",
"build_warship": "Build Warship",
"build_warship_desc": "Build a Warship under your cursor.",
"build_missile_silo": "Build Missile Silo",
"build_missile_silo_desc": "Build a Missile Silo under your cursor.",
"build_sam_launcher": "Build SAM Launcher",
"build_sam_launcher_desc": "Build a SAM Launcher under your cursor.",
"build_atom_bomb": "Build Atom Bomb",
"build_atom_bomb_desc": "Build an Atom Bomb under your cursor.",
"build_hydrogen_bomb": "Build Hydrogen Bomb",
"build_hydrogen_bomb_desc": "Build a Hydrogen Bomb under your cursor.",
"attack_ratio_controls": "Attack Ratio Controls",
"attack_ratio_up": "Increase Attack Ratio",
"attack_ratio_up_desc": "Increase attack ratio by 10%",
@@ -361,7 +392,8 @@
"terrain_disabled": "Terrain view disabled",
"exit_game_label": "Exit Game",
"exit_game_info": "Return to main menu",
"background_music_volume": "Background Music Volume"
"background_music_volume": "Background Music Volume",
"sound_effects_volume": "Sound Effects Volume"
},
"chat": {
"title": "Quick Chat",
@@ -476,6 +508,7 @@
"other_won": "{player} has won!",
"exit": "Exit Game",
"keep": "Keep Playing",
"spectate": "Spectate",
"wishlist": "Wishlist on Steam!"
},
"leaderboard": {
@@ -546,6 +579,11 @@
"upgrade": "Upgrade",
"level": "Level"
},
"player_type": {
"player": "Player",
"nation": "Nation",
"bot": "Bot"
},
"relation": {
"hostile": "Hostile",
"distrustful": "Distrustful",
@@ -561,23 +599,49 @@
"player_panel": {
"gold": "Gold",
"troops": "Troops",
"betrayals": "Number of betrayals",
"betrayals": "Betrayals",
"traitor": "Traitor",
"trading": "Trading",
"active": "Active",
"stopped": "Stopped",
"alliance_time_remaining": "Alliance Expires In",
"embargo": "Stopped trading with you",
"nuke": "Nukes sent by them to you",
"start_trade": "Start trading",
"stop_trade": "Stop trading",
"yes": "Yes",
"no": "No",
"none": "None",
"start_trade": "Start Trading",
"stop_trade": "Stop Trading",
"alliances": "Alliances",
"flag": "Flag"
"flag": "Flag",
"chat": "Chat",
"target": "Target",
"break": "Break",
"break_alliance": "Break Alliance",
"alliance": "Alliance",
"send_alliance": "Send Alliance",
"send_troops": "Send Troops",
"send_gold": "Send Gold",
"emotes": "Emojis"
},
"send_troops_modal": {
"title_with_name": "Send Troops to {name}",
"available_tooltip": "Your current available troops",
"min_keep": "Min keep",
"min_keep_pct": "(30%)",
"slider_tooltip": "{{percent}}% • {{amount}}",
"toggle_attack_bar_mode": "Use attack bar to send troops",
"warning_attackbar": "Once enabled, you can't open this modal directly. You'll only send troops via the attack bar.",
"aria_slider": "Troops slider",
"capacity_note": "Receiver can accept only {{amount}} right now."
},
"send_gold_modal": {
"title_with_name": "Send Gold to {name}",
"available_tooltip": "Your current available gold",
"aria_slider": "Amount slider",
"slider_tooltip": "{{percent}}% • {{amount}}"
},
"replay_panel": {
"replay_speed": "Replay speed",
"game_speed": "Game speed",
"fastest_game_speed": "max"
"fastest_game_speed": "Max"
},
"error_modal": {
"crashed": "Game crashed!",
@@ -592,7 +656,8 @@
"choose_spawn": "Choose a starting location"
},
"territory_patterns": {
"title": "Select Territory Skin",
"title": "Skins",
"colors": "Colors",
"purchase": "Purchase",
"blocked": {
"login": "You must be logged in to access this pattern.",
@@ -709,5 +774,17 @@
"map": "Map",
"difficulty": "Difficulty",
"type": "Type"
},
"player_stats_tree": {
"public": "Public",
"private": "Private",
"singleplayer": "Single Player",
"mode": "Mode",
"stats_wins": "Wins",
"stats_losses": "Losses",
"stats_wlr": "Win:Loss Ratio",
"stats_games_played": "Games Played",
"mode_ffa": "Free-for-All",
"mode_team": "Team"
}
}
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "Malŝalti naciojn",
"instant_build": "Tujkonstruaĵo",
"infinite_gold": "Senfina oro",
"donate_gold": "Donaci oron",
"infinite_troops": "Senfinaj trupoj",
"donate_troops": "Donaci trupojn",
"compact_map": "Karteto",
"disable_nukes": "Malŝalti nukleajn armilojn",
"enables_title": "Ebligi Agordojn",
"start": "Komenci la ludon"
@@ -177,8 +176,10 @@
"halkidiki": "Ĥalkidiko",
"straitofgibraltar": "Ĝibraltara Markolo",
"italia": "Italio",
"japan": "Japanio",
"yenisei": "Jenisejo",
"pluto": "Plutono"
"pluto": "Plutono",
"montreal": "Montrealo"
},
"map_categories": {
"continental": "Kontinenta",
@@ -196,8 +197,9 @@
"join_lobby": "Aliĝi al la ludejo",
"checking": "Kontrolado de la ludejo...",
"not_found": "Ludejo ne trovita. Bonvolu kontroli la ID kaj reprovi.",
"error": "Eraro okazis. Bonvolu provi denove.",
"joined_waiting": "Sukcese aliĝis! Atendante la komencon de la ludo..."
"error": "Eraro okazis. Bonvolu reprovi aŭ kontaktu helpon.",
"joined_waiting": "Sukcese aliĝis! Atendante la komencon de la ludo...",
"version_mismatch": "Ĉi tiu ludo estis kreita kun malsama versio. Ne eblas aliĝi."
},
"public_lobby": {
"join": "Kunigi la baldaŭan ludon",
@@ -227,6 +229,7 @@
"donate_gold": "Donacu oron",
"infinite_troops": "Senfinaj trupoj",
"donate_troops": "Donacu trupojn",
"compact_map": "Karteto",
"enables_title": "Ebligi Agordojn",
"player": "Ludanto",
"players": "Ludantoj",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Elektu ĉu montri teritoriajn ŝablonajn dezajnojn en la ludo",
"performance_overlay_label": "Efikeco Supermetaĵo",
"performance_overlay_desc": "Ŝalti la efikecon supermetaĵon. Kiam ebligita, la efikeco supermetaĵo estos montrata. Premu la Majuskligan klavon\n+ D dum la ludo por ŝalti.",
"performance_overlay_enabled": "Funkciada supermetaĵo ŝaltita",
"performance_overlay_disabled": "Funkciada supermetaĵo malŝaltita",
"easter_writing_speed_label": "Rapidskriba multiganto",
"easter_writing_speed_desc": "Alĝustigu kiom rapide vi ŝajnigas kodi (x1x100)",
"easter_bug_count_label": "Nombro da cimoj",
@@ -494,7 +499,8 @@
"nation": "Nacio",
"player": "Ludanto",
"team": "Teamo",
"d_troops": "Defendante trupoj",
"alliance_timeout": "Alianco finiĝas en",
"troops": "Trupoj",
"a_troops": "Atakante trupoj",
"gold": "Oro",
"ports": "Havenoj",
@@ -585,7 +591,7 @@
"choose_spawn": "Elektu komencan lokon"
},
"territory_patterns": {
"title": "Elekti Teritoriajn Ŝablonojn",
"title": "Elekti Teritoria Ŝablono",
"purchase": "Aĉeti",
"blocked": {
"login": "Vi devas esti ensalutinta por aliri ĉi tiun ŝablonon.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Forigi trupunon",
"delete_unit_description": "Alklaku por forigi la plej proksiman trupunon"
},
"discord_user_header": {
"avatar_alt": "Profilbildo"
},
"player_stats_table": {
"building_stats": "Konstruaĵaj statistikoj",
"ship_arrivals": "Ŝipoj alvenoj",
"nuke_stats": "Bomboj statistikoj",
"player_metrics": "Ludanta statistikoj",
"building": "Konstruaĵo",
"ship_type": "Ŝipa tipo",
"weapon": "Armilo",
"built": "Konstruita",
"destroyed": "Detruita",
"captured": "Ekkaptita",
"lost": "Perdita",
"hits": "Frapoj",
"launched": "Lanĉita",
"landed": "Surterigita",
"sent": "Sendita",
"arrived": "Alveninta",
"attack": "Atako",
"received": "Ricevita",
"cancelled": "Nuligita",
"count": "Nombro",
"gold": "Oro",
"workers": "Laboristoj",
"war": "Milito",
"trade": "Komerci",
"steal": "Ŝteli",
"unit": {
"city": "Urbo",
"port": "Haveno",
"defp": "Defenda Posteno",
"saml": "SAM-lanĉilo",
"silo": "Misila Silo",
"wshp": "Militŝipo",
"fact": "Fabriko",
"trade": "Komerca ŝipo",
"trans": "Transporta ŝipo",
"abomb": "Atombombo",
"hbomb": "Hidrogenbombo",
"mirv": "MIRV",
"mirvw": "MIRV-kapo"
}
},
"game_list": {
"recent_games": "Lastaj ludoj",
"game_id": "Ludo ID",
"mode": "Reĝimo",
"mode_ffa": "Ĉiu por si",
"mode_team": "Teamo",
"view": "Vido",
"details": "Detaloj",
"started": "Komencita",
"map": "Karto",
"difficulty": "Malfacileco",
"type": "Tipo"
}
}
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "Désactiver les nations",
"instant_build": "Construction instantanée",
"infinite_gold": "Or infini",
"donate_gold": "Donner de l'or",
"infinite_troops": "Troupes infinies",
"donate_troops": "Donner des troupes",
"compact_map": "Mini-carte",
"disable_nukes": "Désactiver les armes nucléaires",
"enables_title": "Activer les paramètres",
"start": "Commencer la partie"
@@ -177,8 +176,10 @@
"halkidiki": "Chalcidique",
"straitofgibraltar": "Détroit de Gibraltar",
"italia": "Italie",
"japan": "Japon",
"yenisei": "Ienisseï",
"pluto": "Pluton"
"pluto": "Pluton",
"montreal": "Montréal"
},
"map_categories": {
"continental": "Continental",
@@ -196,8 +197,9 @@
"join_lobby": "Rejoindre le salon",
"checking": "Vérification du salon...",
"not_found": "Salon introuvable. Veuillez vérifier l'ID et réessayer.",
"error": "Une erreur est survenue. Veuillez réessayer.",
"joined_waiting": "Rejoint avec succès ! En attente du début de la partie..."
"error": "Une erreur s'est produite. Veuillez réessayer ou contacter le support.",
"joined_waiting": "Rejoint avec succès ! En attente du début de la partie...",
"version_mismatch": "Cette partie a été créée avec une version différente. Impossible de rejoindre."
},
"public_lobby": {
"join": "Rejoindre la prochaine partie",
@@ -227,6 +229,7 @@
"donate_gold": "Donner de l'or",
"infinite_troops": "Troupes infinies",
"donate_troops": "Donner des troupes",
"compact_map": "Mini-carte",
"enables_title": "Activer les paramètres",
"player": "Joueur",
"players": "Joueurs",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Choisissez si vous voulez afficher les motifs de territoire en jeu",
"performance_overlay_label": "Surcouche de performances",
"performance_overlay_desc": "Activer/désactiver la surcouche de performances. Lorsqu'elle est activée, la surcouche de performances sera affichée. Appuyez sur Maj+D pendant le jeu pour l\"activer.",
"performance_overlay_enabled": "Affichage des performances activé",
"performance_overlay_disabled": "Affichage des performances désactivé",
"easter_writing_speed_label": "Multiplicateur de vitesse d'écriture",
"easter_writing_speed_desc": "Ajuster la vitesse à laquelle vous prétendez coder (x1x100)",
"easter_bug_count_label": "Nombre de bugs",
@@ -494,7 +499,8 @@
"nation": "Nation",
"player": "Joueur",
"team": "Équipe",
"d_troops": "Troupes en défense",
"alliance_timeout": "L'alliance se termine dans",
"troops": "Troupes",
"a_troops": "Troupes en attaque",
"gold": "Or",
"ports": "Ports",
@@ -585,7 +591,7 @@
"choose_spawn": "Choisissez un emplacement de départ"
},
"territory_patterns": {
"title": "Sélectionnez un motif de territoire",
"title": "Sélectionner un motif de territoire",
"purchase": "Acheter",
"blocked": {
"login": "Vous devez être connecté pour accéder à ce motif.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Supprimer une unité",
"delete_unit_description": "Cliquez pour supprimer l'unité la plus proche"
},
"discord_user_header": {
"avatar_alt": "Avatar"
},
"player_stats_table": {
"building_stats": "Statistiques de construction",
"ship_arrivals": "Arrivées de navire",
"nuke_stats": "Statistiques de bombes",
"player_metrics": "Statistiques du joueur",
"building": "Batîment",
"ship_type": "Type de navire",
"weapon": "Arme",
"built": "Construit",
"destroyed": "Détruit",
"captured": "Capturé",
"lost": "Perdu",
"hits": "Coups",
"launched": "Lancés",
"landed": "Atterris",
"sent": "Envoyés",
"arrived": "Arrivés",
"attack": "Attaque",
"received": "Reçu",
"cancelled": "Annulé",
"count": "Compte",
"gold": "Or",
"workers": "Ouvriers",
"war": "Guerre",
"trade": "Commercer",
"steal": "Voler",
"unit": {
"city": "Ville",
"port": "Port",
"defp": "Poste de défense",
"saml": "Lanceur de SAM",
"silo": "Silo à missiles",
"wshp": "Navire de guerre",
"fact": "Usine",
"trade": "Bateau de commerce",
"trans": "Bateau de transport",
"abomb": "Bombe atomique",
"hbomb": "Bombe à hydrogène",
"mirv": "MIRV",
"mirvw": "Ogive de MIRV"
}
},
"game_list": {
"recent_games": "Parties récentes",
"game_id": "ID de la partie",
"mode": "Mode",
"mode_ffa": "Chacun pour soi",
"mode_team": "Équipe",
"view": "Vue",
"details": "Détails",
"started": "Débuté",
"map": "Carte",
"difficulty": "Difficulté",
"type": "Type"
}
}
+72 -8
View File
@@ -130,9 +130,8 @@
"disable_nations": "Letiltott nemzetek",
"instant_build": "Instant építés",
"infinite_gold": "Végtelen arany",
"donate_gold": "Arany adományomzása",
"infinite_troops": "Végtelen katonák",
"donate_troops": "Katonák adományozása",
"compact_map": "Mini térkép",
"disable_nukes": "Nukleáris fegyverek letiltása",
"enables_title": "Beállítások engedélyezése",
"start": "Játék indítása"
@@ -177,8 +176,10 @@
"halkidiki": "Halkidiki",
"straitofgibraltar": "Gibraltári-szoros",
"italia": "Olaszország",
"japan": "Japán",
"yenisei": "Jenisej",
"pluto": "Plútó"
"pluto": "Plútó",
"montreal": "Montreal"
},
"map_categories": {
"continental": "Kontinentális",
@@ -196,8 +197,9 @@
"join_lobby": "Csatlakozás lobbyhoz",
"checking": "Lobby ellenőrzése...",
"not_found": "Lobby nem található. Kérlek, ellenőrizd az azonosítót, és próbáld újra.",
"error": "Hiba történt. Kérlek, próbáld újra.",
"joined_waiting": "Sikeresen csatlakoztál! Várakozás a játék kezdésére..."
"error": "Hiba történt. \nKérjük próbáld újra, vagy lépj kapcsolatba velünk.",
"joined_waiting": "Sikeresen csatlakoztál! Várakozás a játék kezdésére...",
"version_mismatch": "Ez a játék egy másik verzióval lett létrehozva. Nem lehet csatlakozni."
},
"public_lobby": {
"join": "Csatlakozás a következő játékhoz",
@@ -227,6 +229,7 @@
"donate_gold": "Arany adományozása",
"infinite_troops": "Végtelen katonák",
"donate_troops": "Katonák adományozása",
"compact_map": "Mini térkép",
"enables_title": "Beállítások engedélyezése",
"player": "Játékos",
"players": "Játékosok",
@@ -264,10 +267,10 @@
},
"unit_type": {
"city": "Város",
"defense_post": "Védelmi poszt",
"defense_post": "Védelmi állás",
"port": "Kikötő",
"warship": "Hadihajó",
"missile_silo": "Rakéta siló",
"missile_silo": "Rakétasiló",
"sam_launcher": "Rakétaelhárító rendszer",
"atom_bomb": "Atombomba",
"hydrogen_bomb": "Hidrogénbomba",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Válaszd ki, hogy megjelenjenek-e a területmintázatok a játékban",
"performance_overlay_label": "Teljesítmény kijelző",
"performance_overlay_desc": "Kapcsoló a teljesítmény kijelzőhöz. Ha engedélyezve van, a teljesítmény kijelző megjelenik. A játék közben nyomd meg a Shift-D-t a váltáshoz.",
"performance_overlay_enabled": "Teljesítmény kijelző engedélyezve",
"performance_overlay_disabled": "Teljesítmény kijelző letiltva",
"easter_writing_speed_label": "Írási sebesség szorzó",
"easter_writing_speed_desc": "Állítsd be, milyen gyorsan szimulálod a kódolást (x1x100)",
"easter_bug_count_label": "Hiba számláló",
@@ -494,7 +499,8 @@
"nation": "Nemzet",
"player": "Játékos",
"team": "Csapat",
"d_troops": "Védekező katonák",
"alliance_timeout": "Szövetség felbomlik",
"troops": "Katonák",
"a_troops": "Támadó katonák",
"gold": "Arany",
"ports": "Kikötők",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Egység törlése",
"delete_unit_description": "Kattints a legközelebbi egység törléséhez"
},
"discord_user_header": {
"avatar_alt": "Profilkép"
},
"player_stats_table": {
"building_stats": "Épületstatisztikák",
"ship_arrivals": "Hajóérkezések",
"nuke_stats": "Nukleáris statisztikák",
"player_metrics": "Játékos mutatók",
"building": "Épület",
"ship_type": "Hajó típus",
"weapon": "Fegyver",
"built": "Épített",
"destroyed": "Megsemmisült",
"captured": "Elfoglalva",
"lost": "Elveszett",
"hits": "Találatok",
"launched": "Elindított",
"landed": "Célba ért",
"sent": "Elküldött",
"arrived": "Megérkezett",
"attack": "Támadás",
"received": "Fogadott",
"cancelled": "Megszakítva",
"count": "Mennyiség",
"gold": "Arany",
"workers": "Munkások",
"war": "Háború",
"trade": "Kereskedelem",
"steal": "Lopás",
"unit": {
"city": "Város",
"port": "Kikötő",
"defp": "Védelmi állás",
"saml": "Rakétaelhárító rendszer",
"silo": "Rakétasiló",
"wshp": "Hadihajó",
"fact": "Gyár",
"trade": "Kereskedelmi hajó",
"trans": "Szállítóhajó",
"abomb": "Atombomba",
"hbomb": "Hidrogénbomba",
"mirv": "MIRV",
"mirvw": "MIRV robbanófej"
}
},
"game_list": {
"recent_games": "Legutóbbi Játékok",
"game_id": "Játék azonosító",
"mode": "Mód",
"mode_ffa": "Mindenki mindenki ellen",
"mode_team": "Csapat",
"view": "Nézet",
"details": "Részletek",
"started": "Elkezdődött",
"map": "Térkép",
"difficulty": "Nehézség",
"type": "Típus"
}
}
+71 -7
View File
@@ -130,9 +130,8 @@
"disable_nations": "国家を無効化",
"instant_build": "即時建設",
"infinite_gold": "資金無限",
"donate_gold": "資金援助",
"infinite_troops": "兵士無限",
"donate_troops": "軍事支援",
"compact_map": "ミニマップ",
"disable_nukes": "核兵器使用禁止",
"enables_title": "機能の有効化",
"start": "ゲーム開始"
@@ -177,8 +176,10 @@
"halkidiki": "ハルキディキ半島",
"straitofgibraltar": "ジブラルタル海峡",
"italia": "イタリア",
"japan": "日本",
"yenisei": "エニセイ川",
"pluto": "冥王星"
"pluto": "冥王星",
"montreal": "モントリオール"
},
"map_categories": {
"continental": "大陸",
@@ -196,8 +197,9 @@
"join_lobby": "ロビーに参加",
"checking": "ロビーを確認中...",
"not_found": "ロビーが見つかりません。IDを確認してもう一度お試しください。",
"error": "エラーが発生しました。もう一度お試しください。",
"joined_waiting": "参加に成功しました!ゲーム開始をお待ちください..."
"error": "エラーが発生しました。もう一度試すか、サポートにお問い合わせください。",
"joined_waiting": "参加に成功しました!ゲーム開始をお待ちください...",
"version_mismatch": "このゲームは別のバージョンで作成されました。参加できません。"
},
"public_lobby": {
"join": "次のゲームに参加",
@@ -227,6 +229,7 @@
"donate_gold": "資金援助",
"infinite_troops": "兵士無限",
"donate_troops": "軍事支援",
"compact_map": "ミニマップ",
"enables_title": "機能設定",
"player": "プレイヤー",
"players": "プレイヤー",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "ゲーム内で領土の模様を表示するかどうか",
"performance_overlay_label": "パフォーマンスオーバーレイ",
"performance_overlay_desc": "パフォーマンス表示(オーバーレイ)を切り替えます。有効にすると画面上にパフォーマンスオーバーレイが表示されます。ゲーム中は Shift+D を押して切り替えられます。",
"performance_overlay_enabled": "パフォーマンスオーバーレイ有効",
"performance_overlay_disabled": "パフォーマンスオーバーレイ無効",
"easter_writing_speed_label": "書き込み速度の倍率",
"easter_writing_speed_desc": "コードを書く速さを調節する(x1-x100)",
"easter_bug_count_label": "バグの個数",
@@ -494,7 +499,8 @@
"nation": "国家",
"player": "プレイヤー",
"team": "チーム",
"d_troops": "防衛兵士数",
"alliance_timeout": "同盟終了まで",
"troops": "軍隊",
"a_troops": "攻撃兵士数",
"gold": "資金",
"ports": "港",
@@ -585,7 +591,7 @@
"choose_spawn": "スタート地点を選んで下さい"
},
"territory_patterns": {
"title": "領土の模様を選択",
"title": "領土スキンを選択",
"purchase": "購入",
"blocked": {
"login": "このページにアクセスするにはログインしてください。",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "ユニットを削除する",
"delete_unit_description": "クリックで最も近いユニットを削除します"
},
"discord_user_header": {
"avatar_alt": "アバター"
},
"player_stats_table": {
"building_stats": "建築物統計情報",
"ship_arrivals": "船舶到着数",
"nuke_stats": "核兵器統計情報",
"player_metrics": "プレイヤー統計情報",
"building": "建築物",
"ship_type": "船舶の種類",
"weapon": "兵器",
"built": "建設",
"destroyed": "破壊",
"captured": "鹵獲",
"lost": "損失",
"hits": "着弾",
"launched": "発射",
"landed": "着陸",
"sent": "送信",
"arrived": "到着",
"attack": "攻撃",
"received": "受信",
"cancelled": "取消",
"count": "数",
"gold": "資金",
"workers": "労働者",
"war": "戦争",
"trade": "取引",
"steal": "略奪",
"unit": {
"city": "都市",
"port": "港",
"defp": "防衛ポスト",
"saml": "SAMランチャー",
"silo": "ミサイル格納庫",
"wshp": "戦艦",
"fact": "工場",
"trade": "交易船",
"trans": "輸送船",
"abomb": "原子爆弾",
"hbomb": "水素爆弾",
"mirv": "MIRV",
"mirvw": "MIRV 弾頭"
}
},
"game_list": {
"recent_games": "最近のゲーム",
"game_id": "ゲームID",
"mode": "モード",
"mode_ffa": "バトルロワイヤル",
"mode_team": "チーム",
"view": "見る",
"details": "詳細",
"started": "既に開始",
"map": "地図",
"difficulty": "難易度",
"type": "タイプ"
}
}
+70 -6
View File
@@ -130,9 +130,8 @@
"disable_nations": "Отключить нации",
"instant_build": "Мгновенная стройка",
"infinite_gold": "Неограниченное золото",
"donate_gold": "Пожертвовать золото",
"infinite_troops": "Неограниченные войска",
"donate_troops": "Пожертвовать войска",
"compact_map": "Мини-карта",
"disable_nukes": "Отключить бомбы",
"enables_title": "Разрешения",
"start": "Начать игру"
@@ -177,8 +176,10 @@
"halkidiki": "Халкидики",
"straitofgibraltar": "Гибралтарский пролив",
"italia": "Италия",
"japan": "Япония",
"yenisei": "Енисей",
"pluto": "Плутон"
"pluto": "Плутон",
"montreal": "Монреаль"
},
"map_categories": {
"continental": "Континентальные",
@@ -196,8 +197,9 @@
"join_lobby": "Присоединиться к лобби",
"checking": "Проверка лобби...",
"not_found": "Лобби не найдено. Пожалуйста, проверьте правильность ID и попробуйте ещё раз.",
"error": "Произошла ошибка. Пожалуйста, попробуйте ещё раз.",
"joined_waiting": "Вы успешно присоединились! Ожидание начала игры..."
"error": "Произошла ошибка. Пожалуйста, попробуйте ещё раз или обратитесь в службу поддержки.",
"joined_waiting": "Вы успешно присоединились! Ожидание начала игры...",
"version_mismatch": "Эта игра была создана в другой версии. Невозможно присоединиться."
},
"public_lobby": {
"join": "Присоединиться к следующей игре",
@@ -227,6 +229,7 @@
"donate_gold": "Пожертвование золота",
"infinite_troops": "Неограниченные войска",
"donate_troops": "Пожертвование войск",
"compact_map": "Мини-карта",
"enables_title": "Разрешения",
"player": "Игрок",
"players": "Игрока(-ов)",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Выберите, показывать ли узоры территорий в игре",
"performance_overlay_label": "Оверлей производительности",
"performance_overlay_desc": "Включить/выключить оверлей производительности. Если включено, будет отображаться оверлей производительности. Нажмите Shift+D во время игры для включения/выключения.",
"performance_overlay_enabled": "Оверлей производительности включён",
"performance_overlay_disabled": "Оверлей производительности выключен",
"easter_writing_speed_label": "Множитель скорости печати",
"easter_writing_speed_desc": "Настройте скорость, с которой вы делаете вид, что программируете (x1–x100)",
"easter_bug_count_label": "Количество багов",
@@ -494,7 +499,8 @@
"nation": "Нация",
"player": "Игрок",
"team": "Команда",
"d_troops": "Войска защиты",
"alliance_timeout": "Конец союза через",
"troops": "Войска",
"a_troops": "Войска атаки",
"gold": "Золото",
"ports": "Порты",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Удалить объект",
"delete_unit_description": "Нажмите, чтобы удалить ближайший объект"
},
"discord_user_header": {
"avatar_alt": "Аватар"
},
"player_stats_table": {
"building_stats": "Статистика строительства",
"ship_arrivals": "Прибытия кораблей",
"nuke_stats": "Статистика бомбардирования",
"player_metrics": "Статистика игрока",
"building": "Строительство",
"ship_type": "Тип корабля",
"weapon": "Оружие",
"built": "Построено",
"destroyed": "Уничтожено",
"captured": "Захвачено",
"lost": "Утеряно",
"hits": "Удары",
"launched": "Запущено",
"landed": "Приземлено",
"sent": "Отправлено",
"arrived": "Прибыло",
"attack": "Атака",
"received": "Получено",
"cancelled": "Отменено",
"count": "Количество",
"gold": "Золото",
"workers": "Рабочие",
"war": "Войны",
"trade": "Обмен",
"steal": "Украдено",
"unit": {
"city": "Город",
"port": "Порт",
"defp": "Укрепление",
"saml": "Пусковая установка ЗРК",
"silo": "Ракетная шахта",
"wshp": "Военный корабль",
"fact": "Фабрика",
"trade": "Торговый корабль",
"trans": "Транспортный корабль",
"abomb": "Атомная бомба",
"hbomb": "Водородная бомба",
"mirv": "РГЧ ИН",
"mirvw": "Боеголовка РГЧ ИН"
}
},
"game_list": {
"recent_games": "Недавние игры",
"game_id": "ID игры",
"mode": "Режим",
"mode_ffa": "Каждый против каждого",
"mode_team": "Команда",
"view": "Осмотреть",
"details": "Подробности",
"started": "Начато",
"map": "Карта",
"difficulty": "Сложность",
"type": "Тип"
}
}
+78 -14
View File
@@ -130,9 +130,8 @@
"disable_nations": "Вимкнути нації",
"instant_build": "Миттєве будівництво",
"infinite_gold": "Необмежене золото",
"donate_gold": "Пожертвувати золото",
"infinite_troops": "Необмежені війська",
"donate_troops": "Пожертвувати війська",
"compact_map": "Мінімапа",
"disable_nukes": "Вимкнути бомби",
"enables_title": "Дозволи",
"start": "Розпочати гру"
@@ -177,8 +176,10 @@
"halkidiki": "Халкідіки",
"straitofgibraltar": "Гібралтарська протока",
"italia": "Італія",
"japan": "Японія",
"yenisei": "Єнісей",
"pluto": "Плутон"
"pluto": "Плутон",
"montreal": "Монреаль"
},
"map_categories": {
"continental": "Континентальні",
@@ -196,8 +197,9 @@
"join_lobby": "Приєднатися до лобі",
"checking": "Перевірка лобі...",
"not_found": "Лобі не знайдено. Будь ласка, перевірте дійсність ID і спробуйте знову.",
"error": "Сталася помилка. Будь ласка, спробуйте знову.",
"joined_waiting": "Ви успішно приєдналися! Очікування початку гри..."
"error": "Сталася помилка. Спробуйте ще раз або зверніться до служби підтримки.",
"joined_waiting": "Ви успішно приєдналися! Очікування початку гри...",
"version_mismatch": "Цю гру створено в іншій версії. Неможливо приєднатися."
},
"public_lobby": {
"join": "Приєднатися до наступної гри",
@@ -211,7 +213,7 @@
"enter_username": "Введіть своє ім'я гравця",
"not_string": "Ім'я гравця має бути рядком.",
"too_short": "Ім'я гравця повинно містити щонайменше {min} символів.",
"too_long": "Ім'я гравця не повинно перевищувати {max} символів.",
"too_long": "Довжина ім'я гравця не повинна перевищувати {max} символів.",
"invalid_chars": "Ім'я гравця може містити лише латинські літери, цифри, пробіли, знаки підкреслення та [квадратні дужки]."
},
"host_modal": {
@@ -227,6 +229,7 @@
"donate_gold": "Пожертвування золота",
"infinite_troops": "Безмежні війська",
"donate_troops": "Пожертвування військ",
"compact_map": "Мінімапа",
"enables_title": "Дозволи",
"player": "Гравець",
"players": "Гравці(в)",
@@ -314,6 +317,8 @@
"territory_patterns_desc": "Виберіть, чи показувати візерунки територій у грі",
"performance_overlay_label": "Оверлей продуктивности",
"performance_overlay_desc": "Увімкнення/вимкнення оверлея продуктивности. Якщо ввімкнено, буде показано оверлей продуктивности. Натисніть Shift+D під час гри, щоб увімкнути/вимкнути його.",
"performance_overlay_enabled": "Оверлей продуктивності увімкнено",
"performance_overlay_disabled": "Оверлей продуктивності вимкнено",
"easter_writing_speed_label": "Множник швидкості друку",
"easter_writing_speed_desc": "Налаштуйте швидкість, з якою ви удаєте, що програмуєте (x1–x100)",
"easter_bug_count_label": "Кількість багів",
@@ -494,7 +499,8 @@
"nation": "Нація",
"player": "Гравець",
"team": "Команда",
"d_troops": "Оборонні війська",
"alliance_timeout": "Кінець союзу через",
"troops": "Війська",
"a_troops": "Наступальні війська",
"gold": "Золото",
"ports": "Порти",
@@ -511,14 +517,14 @@
"retreating": "відступає",
"boat": "Човен",
"alliance_request_status": "{name} {status} запрошення до союзу",
"alliance_accepted": "прийняв",
"alliance_rejected": "відхилив",
"alliance_accepted": "приймає",
"alliance_rejected": "відхиляє",
"duration_second": "1 сек",
"betrayal_description": "Ви розірвали союз із {name}, ставши ЗРАДНИКОМ (оборону знижено на {malusPercent}% протягом {durationText})",
"duration_seconds_plural": "{seconds} сек",
"betrayed_you": "{name} розірвав союз із вами",
"about_to_expire": "Ваш союз із {name} скоро закінчиться!",
"alliance_expired": "Ваш союз із {name} закінчився",
"betrayed_you": "{name} розриває союз із вами",
"about_to_expire": "Союз із {name} скоро закінчиться!",
"alliance_expired": "Союз із {name} закінчився",
"attack_request": "{name} просить вас атакувати {target}",
"sent_emoji": "Надіслано {name}: {emoji}",
"renew_alliance": "Запит на поновлення",
@@ -526,7 +532,7 @@
"focus": "Оглянути",
"accept_alliance": "Прийняти",
"reject_alliance": "Відхилити",
"alliance_renewed": "Ваш союз із {name} було поновлено",
"alliance_renewed": "Союз із {name} було поновлено",
"ignore": "Ігнорувати",
"unit_voluntarily_deleted": "Об'єкт добровільно видалено"
},
@@ -585,7 +591,7 @@
"choose_spawn": "Виберіть початкове розташування"
},
"territory_patterns": {
"title": "Виберіть візерунок території",
"title": "Вибір візерунка території",
"purchase": "Придбати",
"blocked": {
"login": "Ви повинні ввійти, щоб отримати доступ до цього візерунку.",
@@ -644,5 +650,63 @@
"radial_menu": {
"delete_unit_title": "Видалити об'єкт",
"delete_unit_description": "Клацніть, щоб видалити найближчий об'єкт"
},
"discord_user_header": {
"avatar_alt": "Аватар"
},
"player_stats_table": {
"building_stats": "Статистика будівництва",
"ship_arrivals": "Прибуття кораблів",
"nuke_stats": "Статистика бомбардувань",
"player_metrics": "Статистика гравця",
"building": "Будівництво",
"ship_type": "Тип корабля",
"weapon": "Зброя",
"built": "Побудовано",
"destroyed": "Знищено",
"captured": "Захоплено",
"lost": "Втрачено",
"hits": "Удари",
"launched": "Запущено",
"landed": "Приземлено",
"sent": "Відправлено",
"arrived": "Прибуло",
"attack": "Атаки",
"received": "Отримано",
"cancelled": "Скасовано",
"count": "Кількість",
"gold": "Золото",
"workers": "Робітники",
"war": "Війни",
"trade": "Обмін",
"steal": "Украдено",
"unit": {
"city": "Місто",
"port": "Порт",
"defp": "Пункт оборони",
"saml": "Пускова установка ЗРК",
"silo": "Ракетна шахта",
"wshp": "Військовий корабель",
"fact": "Фабрика",
"trade": "Торговий корабель",
"trans": "Транспортний корабель",
"abomb": "Атомна бомба",
"hbomb": "Воднева бомба",
"mirv": "РГЧ ІН",
"mirvw": "Боєголовка РГЧ ІН"
}
},
"game_list": {
"recent_games": "Нещодавні ігри",
"game_id": "ID гри",
"mode": "Режим",
"mode_ffa": "Всі проти всіх",
"mode_team": "Команда",
"view": "Оглянути",
"details": "Подробиці",
"started": "Почато",
"map": "Мапа",
"difficulty": "Складність",
"type": "Тип"
}
}
+1 -1
View File
@@ -103,7 +103,7 @@
{
"coordinates": [1094, 1093],
"flag": "cd",
"name": "Democratic Republic of the Congo",
"name": "DR Congo",
"strength": 2
},
{
+1 -1
View File
@@ -169,7 +169,7 @@
{
"coordinates": [517, 1483],
"flag": "dz",
"name": "People's Democratic Republic of Algeria",
"name": "Algeria",
"strength": 1
},
{
@@ -85,7 +85,7 @@
{
"coordinates": [1609, 1837],
"flag": "dz",
"name": "People's Republic of Algeria'",
"name": "Algeria",
"strength": 2
},
{
+10 -5
View File
@@ -1,13 +1,18 @@
{
"map": {
"height": 1500,
"num_land_tiles": 1957697,
"width": 1530
"num_land_tiles": 1954940,
"width": 1528
},
"mini_map": {
"map16x": {
"height": 375,
"num_land_tiles": 118558,
"width": 382
},
"map4x": {
"height": 750,
"num_land_tiles": 484387,
"width": 765
"num_land_tiles": 483700,
"width": 764
},
"name": "Montreal",
"nations": [
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.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+2 -2
View File
@@ -85,7 +85,7 @@
{
"coordinates": [143, 37],
"flag": "la",
"name": "Lao People's Democratic Republic",
"name": "Lao PDR",
"strength": 1
},
{
@@ -115,7 +115,7 @@
{
"coordinates": [834, 215],
"flag": "fm",
"name": "Federated States of Micronesia",
"name": "Micronesia",
"strength": 1
},
{
+1 -1
View File
@@ -253,7 +253,7 @@
{
"coordinates": [1074, 508],
"flag": "cd",
"name": "The Democratic Republic of the Congo",
"name": "DR Congo",
"strength": 1
},
{
+8 -3
View File
@@ -1,12 +1,17 @@
{
"map": {
"height": 2500,
"num_land_tiles": 3371126,
"num_land_tiles": 3371389,
"width": 2000
},
"mini_map": {
"map16x": {
"height": 625,
"num_land_tiles": 202240,
"width": 500
},
"map4x": {
"height": 1250,
"num_land_tiles": 830966,
"num_land_tiles": 831004,
"width": 1000
},
"name": "Yenisei",
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.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
+5 -1
View File
@@ -4,7 +4,7 @@ import { UserMeResponse } from "../core/ApiSchemas";
import "./components/Difficulties";
import "./components/PatternButton";
import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt";
import { translateText } from "./Utils";
import { isInIframe, translateText } from "./Utils";
@customElement("account-modal")
export class AccountModal extends LitElement {
@@ -268,6 +268,10 @@ export class AccountButton extends LitElement {
}
render() {
if (isInIframe()) {
return html``;
}
if (!this.isVisible) {
return html``;
}
+17 -83
View File
@@ -5,7 +5,7 @@ import {
GameID,
GameRecord,
GameStartInfo,
PlayerPattern,
PlayerCosmeticRefs,
PlayerRecord,
ServerMessage,
} from "../core/Schemas";
@@ -51,27 +51,7 @@ import SoundManager from "./sound/SoundManager";
export interface LobbyConfig {
serverConfig: ServerConfig;
pattern: PlayerPattern | undefined;
flag: string;
structurePort: string | undefined;
structureCity: string | undefined;
structureFactory: string | undefined;
structureMissilesilo: string | undefined;
structureDefensepost: string | undefined;
structureSamlauncher: string | undefined;
spriteTransportship: string | undefined;
spriteWarship: string | undefined;
spriteSammissile: string | undefined;
spriteAtombomb: string | undefined;
spriteHydrogenbomb: string | undefined;
spriteTradeship: string | undefined;
spriteMirv: string | undefined;
spriteEngine: string | undefined;
spriteCarriage: string | undefined;
spriteLoadedcarriage: string | undefined;
cosmetics: PlayerCosmeticRefs;
playerName: string;
clientID: ClientID;
gameID: GameID;
@@ -204,7 +184,7 @@ async function createClientGame(
lobbyConfig,
eventBus,
gameRenderer,
new InputHandler(canvas, eventBus),
new InputHandler(gameRenderer.uiState, canvas, eventBus),
transport,
worker,
gameView,
@@ -217,7 +197,6 @@ export class ClientGameRunner {
private turnsSeen = 0;
private hasJoined = false;
private lastMousePosition: { x: number; y: number } | null = null;
private lastMessageTime: number = 0;
@@ -276,6 +255,7 @@ export class ClientGameRunner {
1000,
);
}, 20000);
this.eventBus.on(MouseUpEvent, this.inputEvent.bind(this));
this.eventBus.on(MouseMoveEvent, this.onMouseMove.bind(this));
this.eventBus.on(AutoUpgradeEvent, this.autoUpgradeEvent.bind(this));
@@ -406,7 +386,7 @@ export class ClientGameRunner {
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive) {
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
return;
}
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
@@ -443,16 +423,9 @@ export class ClientGameRunner {
this.myPlayer.troops() * this.renderer.uiState.attackRatio,
),
);
} else if (this.canBoatAttack(actions, tile)) {
} else if (this.canAutoBoat(actions, tile)) {
this.sendBoatAttackIntent(tile);
}
const owner = this.gameView.owner(tile);
if (owner.isPlayer()) {
this.gameView.setFocusedPlayer(owner as PlayerView);
} else {
this.gameView.setFocusedPlayer(null);
}
});
}
@@ -539,7 +512,7 @@ export class ClientGameRunner {
}
this.myPlayer.actions(tile).then((actions) => {
if (!actions.canAttack && this.canBoatAttack(actions, tile)) {
if (this.canBoatAttack(actions) !== false) {
this.sendBoatAttackIntent(tile);
}
});
@@ -587,7 +560,7 @@ export class ClientGameRunner {
return this.gameView.ref(cell.x, cell.y);
}
private canBoatAttack(actions: PlayerActions, tile: TileRef): boolean {
private canBoatAttack(actions: PlayerActions): false | TileRef {
const bu = actions.buildableUnits.find(
(bu) => bu.type === UnitType.TransportShip,
);
@@ -595,11 +568,7 @@ export class ClientGameRunner {
console.warn(`no transport ship buildable units`);
return false;
}
return (
bu.canBuild !== false &&
this.shouldBoat(tile, bu.canBuild) &&
this.gameView.isLand(tile)
);
return bu.canBuild;
}
private sendBoatAttackIntent(tile: TileRef) {
@@ -618,59 +587,24 @@ export class ClientGameRunner {
});
}
private shouldBoat(tile: TileRef, src: TileRef) {
private canAutoBoat(actions: PlayerActions, tile: TileRef): boolean {
if (!this.gameView.isLand(tile)) return false;
const canBuild = this.canBoatAttack(actions);
if (canBuild === false) return false;
// TODO: Global enable flag
// TODO: Global limit autoboat to nearby shore flag
// if (!enableAutoBoat) return false;
// if (!limitAutoBoatNear) return true;
const distanceSquared = this.gameView.euclideanDistSquared(tile, src);
const distanceSquared = this.gameView.euclideanDistSquared(tile, canBuild);
const limit = 100;
const limitSquared = limit * limit;
if (distanceSquared > limitSquared) return false;
return true;
return distanceSquared < limitSquared;
}
private onMouseMove(event: MouseMoveEvent) {
this.lastMousePosition = { x: event.x, y: event.y };
this.checkTileUnderCursor();
}
private checkTileUnderCursor() {
if (!this.lastMousePosition || !this.renderer.transformHandler) return;
const cell = this.renderer.transformHandler.screenToWorldCoordinates(
this.lastMousePosition.x,
this.lastMousePosition.y,
);
if (!cell || !this.gameView.isValidCoord(cell.x, cell.y)) {
return;
}
const tile = this.gameView.ref(cell.x, cell.y);
if (this.gameView.isLand(tile)) {
const owner = this.gameView.owner(tile);
if (owner.isPlayer()) {
this.gameView.setFocusedPlayer(owner as PlayerView);
} else {
this.gameView.setFocusedPlayer(null);
}
} else {
const units = this.gameView
.nearbyUnits(tile, 50, [
UnitType.Warship,
UnitType.TradeShip,
UnitType.TransportShip,
])
.sort((a, b) => a.distSquared - b.distSquared);
if (units.length > 0) {
this.gameView.setFocusedPlayer(units[0].unit.owner() as PlayerView);
} else {
this.gameView.setFocusedPlayer(null);
}
}
}
private onConnectionCheck() {
+9 -2
View File
@@ -84,13 +84,20 @@ export class GameStartingModal extends LitElement {
.modal button:active {
transform: translateY(1px);
}
.copyright {
font-size: 32px;
margin-top: 20px;
opacity: 1;
}
`;
render() {
return html`
<div class="modal ${this.isVisible ? "visible" : ""}">
<h2>${translateText("game_starting_modal.title")}</h2>
<p>${translateText("game_starting_modal.desc")}</p>
<div class="copyright">© OpenFront</div>
<h5>${translateText("game_starting_modal.code_license")}</h5>
<p>${translateText("game_starting_modal.title")}</p>
</div>
`;
}
+114
View File
@@ -0,0 +1,114 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { isInIframe } from "./Utils";
const LEFT_FUSE = "gutter-ad-container-left";
const RIGHT_FUSE = "gutter-ad-container-right";
// Minimum screen width to show ads (larger than typical Chromebook)
const MIN_SCREEN_WIDTH = 1400;
@customElement("gutter-ads")
export class GutterAds extends LitElement {
@state()
private isVisible: boolean = false;
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
return this;
}
private isScreenLargeEnough(): boolean {
return window.innerWidth >= MIN_SCREEN_WIDTH;
}
// Called after the component's DOM is first rendered
firstUpdated() {
// DOM is guaranteed to be available here
console.log("GutterAd DOM is ready");
}
public show(): void {
if (!this.isScreenLargeEnough()) {
console.log("Screen too small for gutter ads, skipping");
return;
}
if (isInIframe()) {
console.log("In iframe, showing gutter ads");
return;
}
console.log("showing GutterAds");
this.isVisible = true;
this.requestUpdate();
// Wait for the update to complete, then load ads
this.updateComplete.then(() => {
this.loadAds();
});
}
public hide(): void {
console.log("hiding GutterAds");
this.destroyAds();
this.requestUpdate();
}
private loadAds(): void {
// Ensure the container elements exist before loading ads
const leftContainer = this.querySelector(`#${LEFT_FUSE}`);
const rightContainer = this.querySelector(`#${RIGHT_FUSE}`);
if (!leftContainer || !rightContainer) {
console.warn("Ad containers not found in DOM");
return;
}
if (!window.fusetag) {
console.warn("Fuse tag not available");
return;
}
try {
console.log("registering zones");
window.fusetag.que.push(() => {
window.fusetag.registerZone(LEFT_FUSE);
window.fusetag.registerZone(RIGHT_FUSE);
});
} catch (error) {
console.error("Failed to load fuse ads:", error);
this.hide();
}
}
private destroyAds(): void {
if (!window.fusetag) {
return;
}
window.fusetag.que.push(() => {
window.fusetag.destroyZone(LEFT_FUSE);
window.fusetag.destroyZone(RIGHT_FUSE);
});
this.requestUpdate();
}
disconnectedCallback() {
super.disconnectedCallback();
this.destroyAds();
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<div class="fixed left-0 top-1/2 -translate-y-1/2 z-10">
<div id="${LEFT_FUSE}" data-fuse="lhs_sticky_vrec"></div>
</div>
<div class="fixed right-0 top-1/2 -translate-y-1/2 z-10">
<div id="${RIGHT_FUSE}" data-fuse="rhs_sticky_vrec"></div>
</div>
`;
}
}
+94 -80
View File
@@ -2,6 +2,7 @@ import { EventBus, GameEvent } from "../core/EventBus";
import { UnitType } from "../core/game/Game";
import { UnitView } from "../core/game/GameView";
import { UserSettings } from "../core/game/UserSettings";
import { UIState } from "./graphics/UIState";
import { ReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
export class MouseUpEvent implements GameEvent {
@@ -75,7 +76,7 @@ export class RefreshGraphicsEvent implements GameEvent {}
export class TogglePerformanceOverlayEvent implements GameEvent {}
export class ToggleStructureEvent implements GameEvent {
constructor(public readonly structureType: UnitType | null) {}
constructor(public readonly structureTypes: UnitType[] | null) {}
}
export class ShowBuildMenuEvent implements GameEvent {
@@ -136,14 +137,36 @@ export class InputHandler {
private readonly PAN_SPEED = 5;
private readonly ZOOM_SPEED = 10;
private userSettings: UserSettings = new UserSettings();
private readonly userSettings: UserSettings = new UserSettings();
constructor(
public uiState: UIState,
private canvas: HTMLCanvasElement,
private eventBus: EventBus,
) {}
initialize() {
let saved: Record<string, string> = {};
try {
const parsed = JSON.parse(
localStorage.getItem("settings.keybinds") ?? "{}",
);
// flatten { key: {key, value} } → { key: value } and accept legacy string values
saved = Object.fromEntries(
Object.entries(parsed)
.map(([k, v]) => {
if (v && typeof v === "object" && "value" in (v as any)) {
return [k, (v as any).value as string];
}
if (typeof v === "string") return [k, v];
return [k, undefined];
})
.filter(([, v]) => typeof v === "string" && v !== "Null"),
) as Record<string, string>;
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
this.keybinds = {
toggleView: "Space",
centerCamera: "KeyC",
@@ -153,13 +176,23 @@ export class InputHandler {
moveRight: "KeyD",
zoomOut: "KeyQ",
zoomIn: "KeyE",
attackRatioDown: "Digit1",
attackRatioUp: "Digit2",
attackRatioDown: "KeyT",
attackRatioUp: "KeyY",
boatAttack: "KeyB",
groundAttack: "KeyG",
modifierKey: "ControlLeft",
altKey: "AltLeft",
...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"),
buildCity: "Digit1",
buildFactory: "Digit2",
buildPort: "Digit3",
buildDefensePost: "Digit4",
buildMissileSilo: "Digit5",
buildSamLauncher: "Digit6",
buildWarship: "Digit7",
buildAtomBomb: "Digit8",
buildHydrogenBomb: "Digit9",
buildMIRV: "Digit0",
...saved,
};
// Mac users might have different keybinds
@@ -173,9 +206,7 @@ export class InputHandler {
this.canvas.addEventListener(
"wheel",
(e) => {
if (!this.onTrackpadPan(e)) {
this.onScroll(e);
}
this.onScroll(e);
this.onShiftScroll(e);
e.preventDefault();
},
@@ -188,16 +219,6 @@ export class InputHandler {
this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY));
}
});
this.canvas.addEventListener("touchstart", (e) => this.onTouchStart(e), {
passive: false,
});
this.canvas.addEventListener("touchmove", (e) => this.onTouchMove(e), {
passive: false,
});
this.canvas.addEventListener("touchend", (e) => this.onTouchEnd(e), {
passive: false,
});
this.pointers.clear();
this.moveInterval = setInterval(() => {
@@ -266,6 +287,7 @@ export class InputHandler {
if (e.code === "Escape") {
e.preventDefault();
this.eventBus.emit(new CloseViewEvent());
this.uiState.ghostStructure = null;
}
if (
@@ -331,6 +353,56 @@ export class InputHandler {
this.eventBus.emit(new CenterCameraEvent());
}
if (e.code === this.keybinds.buildCity) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.City;
}
if (e.code === this.keybinds.buildFactory) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.Factory;
}
if (e.code === this.keybinds.buildPort) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.Port;
}
if (e.code === this.keybinds.buildDefensePost) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.DefensePost;
}
if (e.code === this.keybinds.buildMissileSilo) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.MissileSilo;
}
if (e.code === this.keybinds.buildSamLauncher) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.SAMLauncher;
}
if (e.code === this.keybinds.buildAtomBomb) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.AtomBomb;
}
if (e.code === this.keybinds.buildHydrogenBomb) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.HydrogenBomb;
}
if (e.code === this.keybinds.buildWarship) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.Warship;
}
if (e.code === this.keybinds.buildMIRV) {
e.preventDefault();
this.uiState.ghostStructure = UnitType.MIRV;
}
// Shift-D to toggle performance overlay
console.log(e.code, e.shiftKey, e.ctrlKey, e.altKey, e.metaKey);
if (e.code === "KeyD" && e.shiftKey) {
@@ -427,27 +499,6 @@ export class InputHandler {
}
}
private onTrackpadPan(event: WheelEvent): boolean {
if (event.shiftKey || event.ctrlKey || event.metaKey) {
return false;
}
const isTrackpadPan = event.deltaMode === 0 && event.deltaX !== 0;
if (!isTrackpadPan) {
return false;
}
const panSensitivity = 1.0;
const deltaX = -event.deltaX * panSensitivity;
const deltaY = -event.deltaY * panSensitivity;
if (Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5) {
this.eventBus.emit(new DragEvent(deltaX, deltaY));
}
return true;
}
private onPointerMove(event: PointerEvent) {
if (event.button === 1) {
event.preventDefault();
@@ -489,50 +540,13 @@ export class InputHandler {
private onContextMenu(event: MouseEvent) {
event.preventDefault();
if (this.uiState.ghostStructure !== null) {
this.uiState.ghostStructure = null;
return;
}
this.eventBus.emit(new ContextMenuEvent(event.clientX, event.clientY));
}
private onTouchStart(event: TouchEvent) {
if (event.touches.length === 2) {
event.preventDefault();
// Solve screen jittering problem
const touch1 = event.touches[0];
const touch2 = event.touches[1];
this.lastPointerX = (touch1.clientX + touch2.clientX) / 2;
this.lastPointerY = (touch1.clientY + touch2.clientY) / 2;
}
}
private onTouchMove(event: TouchEvent) {
if (event.touches.length === 2) {
event.preventDefault();
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const centerX = (touch1.clientX + touch2.clientX) / 2;
const centerY = (touch1.clientY + touch2.clientY) / 2;
if (this.lastPointerX !== 0 && this.lastPointerY !== 0) {
const deltaX = centerX - this.lastPointerX;
const deltaY = centerY - this.lastPointerY;
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
this.eventBus.emit(new DragEvent(deltaX, deltaY));
}
}
this.lastPointerX = centerX;
this.lastPointerY = centerY;
}
}
private onTouchEnd(event: TouchEvent) {
if (event.touches.length < 2) {
this.lastPointerX = 0;
this.lastPointerY = 0;
}
}
private getPinchDistance(): number {
const pointerEvents = Array.from(this.pointers.values());
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
+131 -55
View File
@@ -15,6 +15,7 @@ import { FlagInput } from "./FlagInput";
import { FlagInputModal } from "./FlagInputModal";
import { GameStartingModal } from "./GameStartingModal";
import "./GoogleAdElement";
import { GutterAds } from "./GutterAds";
import { HelpModal } from "./HelpModal";
import { HostLobbyModal as HostPrivateLobbyModal } from "./HostLobbyModal";
import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
@@ -34,6 +35,7 @@ import { UsernameInput } from "./UsernameInput";
import {
generateCryptoRandomUUID,
incrementGamesPlayed,
isInIframe,
translateText,
} from "./Utils";
import "./components/NewsButton";
@@ -50,6 +52,12 @@ declare global {
newPageView: () => void;
};
};
fusetag: {
registerZone: (id: string) => void;
destroyZone: (id: string) => void;
pageInit: (options?: any) => void;
que: Array<() => void>;
};
ramp: {
que: Array<() => void>;
passiveMode: boolean;
@@ -93,6 +101,8 @@ class Client {
private patternsModal: TerritoryPatternsModal;
private tokenLoginModal: TokenLoginModal;
private gutterAds: GutterAds;
constructor() {}
initialize(): void {
@@ -105,10 +115,9 @@ class Client {
gameVersion.innerText = version;
const newsModal = document.querySelector("news-modal") as NewsModal;
if (!newsModal) {
if (!newsModal || !(newsModal instanceof NewsModal)) {
console.warn("News modal element not found");
}
newsModal instanceof NewsModal;
const newsButton = document.querySelector("news-button") as NewsButton;
if (!newsButton) {
console.warn("News button element not found");
@@ -160,6 +169,11 @@ class Client {
}
});
const gutterAds = document.querySelector("gutter-ads");
if (!(gutterAds instanceof GutterAds))
throw new Error("Missing gutter-ads");
this.gutterAds = gutterAds;
document.addEventListener("join-lobby", this.handleJoinLobby.bind(this));
document.addEventListener("leave-lobby", this.handleLeaveLobby.bind(this));
document.addEventListener("kick-player", this.handleKickPlayer.bind(this));
@@ -167,7 +181,9 @@ class Client {
const spModal = document.querySelector(
"single-player-modal",
) as SinglePlayerModal;
spModal instanceof SinglePlayerModal;
if (!spModal || !(spModal instanceof SinglePlayerModal)) {
console.warn("Singleplayer modal element not found");
}
const singlePlayer = document.getElementById("single-player");
if (singlePlayer === null) throw new Error("Missing single-player");
@@ -177,14 +193,10 @@ class Client {
}
});
// const ctModal = document.querySelector("chat-modal") as ChatModal;
// ctModal instanceof ChatModal;
// document.getElementById("chat-button").addEventListener("click", () => {
// ctModal.open();
// });
const hlpModal = document.querySelector("help-modal") as HelpModal;
hlpModal instanceof HelpModal;
if (!hlpModal || !(hlpModal instanceof HelpModal)) {
console.warn("Help modal element not found");
}
const helpButton = document.getElementById("help-button");
if (helpButton === null) throw new Error("Missing help-button");
helpButton.addEventListener("click", () => {
@@ -194,7 +206,10 @@ class Client {
const flagInputModal = document.querySelector(
"flag-input-modal",
) as FlagInputModal;
flagInputModal instanceof FlagInputModal;
if (!flagInputModal || !(flagInputModal instanceof FlagInputModal)) {
console.warn("Flag input modal element not found");
}
const flgInput = document.getElementById("flag-input_");
if (flgInput === null) throw new Error("Missing flag-input_");
flgInput.addEventListener("click", () => {
@@ -204,10 +219,25 @@ class Client {
this.patternsModal = document.querySelector(
"territory-patterns-modal",
) as TerritoryPatternsModal;
if (
!this.patternsModal ||
!(this.patternsModal instanceof TerritoryPatternsModal)
) {
console.warn("Territory patterns modal element not found");
}
const patternButton = document.getElementById(
"territory-patterns-input-preview-button",
);
this.patternsModal instanceof TerritoryPatternsModal;
if (isInIframe() && patternButton) {
patternButton.style.display = "none";
}
if (
!this.patternsModal ||
!(this.patternsModal instanceof TerritoryPatternsModal)
) {
console.warn("Territory patterns modal element not found");
}
if (patternButton === null)
throw new Error("territory-patterns-input-preview-button");
this.patternsModal.previewButton = patternButton;
@@ -219,7 +249,12 @@ class Client {
this.tokenLoginModal = document.querySelector(
"token-login",
) as TokenLoginModal;
this.tokenLoginModal instanceof TokenLoginModal;
if (
!this.tokenLoginModal ||
!(this.tokenLoginModal instanceof TokenLoginModal)
) {
console.warn("Token login modal element not found");
}
const onUserMe = async (userMeResponse: UserMeResponse | false) => {
document.dispatchEvent(
@@ -330,7 +365,9 @@ class Client {
const settingsModal = document.querySelector(
"user-setting",
) as UserSettingModal;
settingsModal instanceof UserSettingModal;
if (!settingsModal || !(settingsModal instanceof UserSettingModal)) {
console.warn("User settings modal element not found");
}
document
.getElementById("settings-button")
?.addEventListener("click", () => {
@@ -340,7 +377,9 @@ class Client {
const hostModal = document.querySelector(
"host-lobby-modal",
) as HostPrivateLobbyModal;
hostModal instanceof HostPrivateLobbyModal;
if (!hostModal || !(hostModal instanceof HostPrivateLobbyModal)) {
console.warn("Host private lobby modal element not found");
}
const hostLobbyButton = document.getElementById("host-lobby-button");
if (hostLobbyButton === null) throw new Error("Missing host-lobby-button");
hostLobbyButton.addEventListener("click", () => {
@@ -353,7 +392,9 @@ class Client {
this.joinModal = document.querySelector(
"join-private-lobby-modal",
) as JoinPrivateLobbyModal;
this.joinModal instanceof JoinPrivateLobbyModal;
if (!this.joinModal || !(this.joinModal instanceof JoinPrivateLobbyModal)) {
console.warn("Join private lobby modal element not found");
}
const joinPrivateLobbyButton = document.getElementById(
"join-private-lobby-button",
);
@@ -405,6 +446,8 @@ class Client {
updateSliderProgress(slider);
slider.addEventListener("input", () => updateSliderProgress(slider));
});
this.initializeFuseTag();
}
private handleHash() {
@@ -504,49 +547,56 @@ class Client {
}
const config = await getServerConfigFromClient();
const pattern = this.userSettings.getSelectedPatternName(
await fetchCosmetics(),
);
this.gameStop = joinLobby(
this.eventBus,
{
gameID: lobby.gameID,
serverConfig: config,
pattern:
this.userSettings.getSelectedPatternName(await fetchCosmetics()) ??
undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
structurePort:
this.userSettings.getSelectedStructurePort() ?? undefined,
structureCity:
this.userSettings.getSelectedStructureCity() ?? undefined,
structureFactory:
this.userSettings.getSelectedStructureFactory() ?? undefined,
structureMissilesilo:
this.userSettings.getSelectedStructureMissilesilo() ?? undefined,
structureDefensepost:
this.userSettings.getSelectedStructureDefensepost() ?? undefined,
structureSamlauncher:
this.userSettings.getSelectedStructureSamlauncher() ?? undefined,
cosmetics: {
color: this.userSettings.getSelectedColor() ?? undefined,
patternName: pattern?.name ?? undefined,
patternColorPaletteName: pattern?.colorPalette?.name ?? undefined,
flag:
this.flagInput === null || this.flagInput.getCurrentFlag() === "xx"
? ""
: this.flagInput.getCurrentFlag(),
structurePort:
this.userSettings.getSelectedStructurePort() ?? undefined,
structureCity:
this.userSettings.getSelectedStructureCity() ?? undefined,
structureFactory:
this.userSettings.getSelectedStructureFactory() ?? undefined,
structureMissilesilo:
this.userSettings.getSelectedStructureMissilesilo() ?? undefined,
structureDefensepost:
this.userSettings.getSelectedStructureDefensepost() ?? undefined,
structureSamlauncher:
this.userSettings.getSelectedStructureSamlauncher() ?? undefined,
spriteTransportship:
this.userSettings.getSelectedSpriteTransportship() ?? undefined,
spriteWarship:
this.userSettings.getSelectedSpriteWarship() ?? undefined,
spriteSammissile:
this.userSettings.getSelectedSpriteSammissile() ?? undefined,
spriteAtombomb:
this.userSettings.getSelectedSpriteAtombomb() ?? undefined,
spriteHydrogenbomb:
this.userSettings.getSelectedSpriteHydrogenbomb() ?? undefined,
spriteTradeship:
this.userSettings.getSelectedSpriteTradeship() ?? undefined,
spriteMirv: this.userSettings.getSelectedSpriteMirv() ?? undefined,
spriteEngine: this.userSettings.getSelectedSpriteEngine() ?? undefined,
spriteCarriage:
this.userSettings.getSelectedSpriteCarriage() ?? undefined,
spriteLoadedcarriage:
this.userSettings.getSelectedSpriteLoadedcarriage() ?? undefined,
spriteTransportship:
this.userSettings.getSelectedSpriteTransportship() ?? undefined,
spriteWarship:
this.userSettings.getSelectedSpriteWarship() ?? undefined,
spriteSammissile:
this.userSettings.getSelectedSpriteSammissile() ?? undefined,
spriteAtombomb:
this.userSettings.getSelectedSpriteAtombomb() ?? undefined,
spriteHydrogenbomb:
this.userSettings.getSelectedSpriteHydrogenbomb() ?? undefined,
spriteTradeship:
this.userSettings.getSelectedSpriteTradeship() ?? undefined,
spriteMirv: this.userSettings.getSelectedSpriteMirv() ?? undefined,
spriteEngine:
this.userSettings.getSelectedSpriteEngine() ?? undefined,
spriteCarriage:
this.userSettings.getSelectedSpriteCarriage() ?? undefined,
spriteLoadedcarriage:
this.userSettings.getSelectedSpriteLoadedcarriage() ?? undefined,
},
playerName: this.usernameInput?.getCurrentUsername() ?? "",
token: getPlayToken(),
clientID: lobby.clientID,
@@ -593,8 +643,10 @@ class Client {
const startingModal = document.querySelector(
"game-starting-modal",
) as GameStartingModal;
startingModal instanceof GameStartingModal;
startingModal.show();
if (startingModal && startingModal instanceof GameStartingModal) {
startingModal.show();
}
this.gutterAds.hide();
},
() => {
this.joinModal.close();
@@ -627,6 +679,7 @@ class Client {
console.log("leaving lobby, cancelling game");
this.gameStop();
this.gameStop = null;
this.gutterAds.hide();
this.publicLobby.leaveLobby();
}
@@ -638,6 +691,29 @@ class Client {
this.eventBus.emit(new SendKickPlayerIntentEvent(target));
}
}
private initializeFuseTag() {
const tryInitFuseTag = (): boolean => {
if (window.fusetag && typeof window.fusetag.pageInit === "function") {
console.log("initializing fuse tag");
window.fusetag.que.push(() => {
window.fusetag.pageInit({
blockingFuseIds: ["lhs_sticky_vrec", "rhs_sticky_vrec"],
});
this.gutterAds.show();
});
return true;
} else {
return false;
}
};
const interval = setInterval(() => {
if (tryInitFuseTag()) {
clearInterval(interval);
}
}, 100);
}
}
// Initialize the client when the DOM is loaded
+3
View File
@@ -450,6 +450,8 @@ export class SinglePlayerModal extends LitElement {
? (this.userSettings.getDevOnlyPattern() ?? null)
: null;
const selectedColor = this.userSettings.getSelectedColor();
this.dispatchEvent(
new CustomEvent("join-lobby", {
detail: {
@@ -467,6 +469,7 @@ export class SinglePlayerModal extends LitElement {
? ""
: flagInput.getCurrentFlag(),
pattern: selectedPattern ?? undefined,
color: selectedColor ? { color: selectedColor } : undefined,
pack: {
structurePort: fetchUrl(
this.userSettings.getSelectedStructurePort() ?? undefined,
+84 -3
View File
@@ -25,6 +25,9 @@ export class TerritoryPatternsModal extends LitElement {
public previewButton: HTMLElement | null = null;
@state() private selectedPattern: PlayerPattern | null;
@state() private selectedColor: string | null = null;
@state() private activeTab: "patterns" | "colors" = "patterns";
private cosmetics: Cosmetics | null = null;
@@ -44,6 +47,7 @@ export class TerritoryPatternsModal extends LitElement {
if (userMeResponse === null) {
this.userSettings.setSelectedPatternName(undefined);
this.selectedPattern = null;
this.selectedColor = null;
}
this.userMeResponse = userMeResponse;
this.cosmetics = await fetchCosmetics();
@@ -51,6 +55,7 @@ export class TerritoryPatternsModal extends LitElement {
this.cosmetics !== null
? this.userSettings.getSelectedPatternName(this.cosmetics)
: null;
this.selectedColor = this.userSettings.getSelectedColor() ?? null;
this.refresh();
}
@@ -58,6 +63,31 @@ export class TerritoryPatternsModal extends LitElement {
return this;
}
private renderTabNavigation(): TemplateResult {
return html`
<div class="flex border-b border-gray-600 mb-4 justify-center">
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "patterns"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "patterns")}
>
${translateText("territory_patterns.title")}
</button>
<button
class="px-4 py-2 text-sm font-medium transition-colors duration-200 ${this
.activeTab === "colors"
? "text-blue-400 border-b-2 border-blue-400 bg-blue-400/10"
: "text-gray-400 hover:text-white"}"
@click=${() => (this.activeTab = "colors")}
>
${translateText("territory_patterns.colors")}
</button>
</div>
`;
}
private renderPatternGrid(): TemplateResult {
const buttons: TemplateResult[] = [];
for (const pattern of Object.values(this.cosmetics?.patterns ?? {})) {
@@ -105,14 +135,39 @@ export class TerritoryPatternsModal extends LitElement {
`;
}
private renderColorSwatchGrid(): TemplateResult {
const hexCodes = (this.userMeResponse?.player.flares ?? [])
.filter((flare) => flare.startsWith("color:"))
.map((flare) => "#" + flare.split(":")[1]);
return html`
<div class="flex flex-wrap gap-3 p-2 justify-center items-center">
${hexCodes.map(
(hexCode) => html`
<div
class="w-12 h-12 rounded-lg border-2 border-white/30 cursor-pointer transition-all duration-200 hover:scale-110 hover:shadow-lg"
style="background-color: ${hexCode};"
title="${hexCode}"
@click=${() => this.selectColor(hexCode)}
></div>
`,
)}
</div>
`;
}
render() {
if (!this.isActive) return html``;
return html`
<o-modal
id="territoryPatternsModal"
title="${translateText("territory_patterns.title")}"
title="${this.activeTab === "patterns"
? translateText("territory_patterns.title")
: translateText("territory_patterns.colors")}"
>
${this.renderPatternGrid()}
${this.renderTabNavigation()}
${this.activeTab === "patterns"
? this.renderPatternGrid()
: this.renderColorSwatchGrid()}
</o-modal>
`;
}
@@ -130,6 +185,8 @@ export class TerritoryPatternsModal extends LitElement {
}
private selectPattern(pattern: PlayerPattern | null) {
this.selectedColor = null;
this.userSettings.setSelectedColor(undefined);
if (pattern === null) {
this.userSettings.setSelectedPatternName(undefined);
} else {
@@ -145,8 +202,32 @@ export class TerritoryPatternsModal extends LitElement {
this.close();
}
private selectColor(hexCode: string) {
this.selectedPattern = null;
this.userSettings.setSelectedPatternName(undefined);
this.selectedColor = hexCode;
this.userSettings.setSelectedColor(hexCode);
this.refresh();
this.close();
}
private renderColorPreview(
hexCode: string,
width: number,
height: number,
): TemplateResult {
return html`
<div
class="rounded"
style="width: ${width}px; height: ${height}px; background-color: ${hexCode};"
></div>
`;
}
public async refresh() {
const preview = renderPatternPreview(this.selectedPattern ?? null, 48, 48);
const preview = this.selectedColor
? this.renderColorPreview(this.selectedColor, 48, 48)
: renderPatternPreview(this.selectedPattern ?? null, 48, 48);
this.requestUpdate();
// Wait for the DOM to be updated and the o-modal element to be available
+4 -26
View File
@@ -353,7 +353,7 @@ export class Transport {
// TODO: make this a modal
alert(`connection refused: ${event.reason}`);
} else if (event.code !== 1000) {
console.log(`recieved error code ${event.code}, reconnecting`);
console.log(`received error code ${event.code}, reconnecting`);
this.reconnect();
}
};
@@ -377,29 +377,7 @@ export class Transport {
lastTurn: numTurns,
token: this.lobbyConfig.token,
username: this.lobbyConfig.playerName,
cosmetics: {
flag: this.lobbyConfig.flag,
patternName: this.lobbyConfig.pattern?.name,
patternColorPaletteName: this.lobbyConfig.pattern?.colorPalette?.name,
structurePort: this.lobbyConfig.structurePort,
structureCity: this.lobbyConfig.structureCity,
structureFactory: this.lobbyConfig.structureFactory,
structureMissilesilo: this.lobbyConfig.structureMissilesilo,
structureDefensepost: this.lobbyConfig.structureDefensepost,
structureSamlauncher: this.lobbyConfig.structureSamlauncher,
spriteTransportship: this.lobbyConfig.spriteTransportship,
spriteWarship: this.lobbyConfig.spriteWarship,
spriteSammissile: this.lobbyConfig.spriteSammissile,
spriteAtombomb: this.lobbyConfig.spriteAtombomb,
spriteHydrogenbomb: this.lobbyConfig.spriteHydrogenbomb,
spriteTradeship: this.lobbyConfig.spriteTradeship,
spriteMirv: this.lobbyConfig.spriteMirv,
spriteEngine: this.lobbyConfig.spriteEngine,
spriteCarriage: this.lobbyConfig.spriteCarriage,
spriteLoadedcarriage: this.lobbyConfig.spriteLoadedcarriage,
},
cosmetics: this.lobbyConfig.cosmetics,
} satisfies ClientJoinMessage);
}
@@ -412,15 +390,15 @@ export class Transport {
if (this.socket === null) return;
if (this.socket.readyState === WebSocket.OPEN) {
console.log("on stop: leaving game");
this.socket.close();
this.killExistingSocket();
} else {
console.log(
"WebSocket is not open. Current state:",
this.socket.readyState,
);
console.error("attempting reconnect");
this.killExistingSocket();
}
this.socket.onclose = (event: CloseEvent) => {};
}
private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) {
+116 -20
View File
@@ -13,7 +13,8 @@ export class UserSettingModal extends LitElement {
private userSettings: UserSettings = new UserSettings();
@state() private settingsMode: "basic" | "keybinds" = "basic";
@state() private keybinds: Record<string, string> = {};
@state() private keybinds: Record<string, { value: string; key: string }> =
{};
@state() private keySequence: string[] = [];
@state() private showEasterEggSettings = false;
@@ -206,14 +207,15 @@ export class UserSettingModal extends LitElement {
}
private handleKeybindChange(
e: CustomEvent<{ action: string; value: string }>,
e: CustomEvent<{ action: string; value: string; key: string }>,
) {
const { action, value } = e.detail;
const prevValue = this.keybinds[action] ?? "";
console.log("Keybind change event:", e);
const { action, value, key } = e.detail;
const prevValue = this.keybinds[action]?.value ?? "";
const values = Object.entries(this.keybinds)
.filter(([k]) => k !== action)
.map(([, v]) => v);
.map(([, v]) => v.value);
if (values.includes(value) && value !== "Null") {
const popup = document.createElement("div");
popup.className = "setting-popup";
@@ -228,7 +230,7 @@ export class UserSettingModal extends LitElement {
}
return;
}
this.keybinds = { ...this.keybinds, [action]: value };
this.keybinds = { ...this.keybinds, [action]: { value: value, key: key } };
localStorage.setItem("settings.keybinds", JSON.stringify(this.keybinds));
}
@@ -430,7 +432,101 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.toggle_view")}
description=${translateText("user_setting.toggle_view_desc")}
defaultKey="Space"
.value=${this.keybinds["toggleView"] ?? ""}
.value=${this.keybinds["toggleView"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<div class="text-center text-white text-base font-semibold mt-5 mb-2">
${translateText("user_setting.build_controls")}
</div>
<setting-keybind
action="buildCity"
label=${translateText("user_setting.build_city")}
description=${translateText("user_setting.build_city_desc")}
defaultKey="Digit1"
.value=${this.keybinds["buildCity"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildFactory"
label=${translateText("user_setting.build_factory")}
description=${translateText("user_setting.build_factory_desc")}
defaultKey="Digit2"
.value=${this.keybinds["buildFactory"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildPort"
label=${translateText("user_setting.build_port")}
description=${translateText("user_setting.build_port_desc")}
defaultKey="Digit3"
.value=${this.keybinds["buildPort"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildDefensePost"
label=${translateText("user_setting.build_defense_post")}
description=${translateText("user_setting.build_defense_post_desc")}
defaultKey="Digit4"
.value=${this.keybinds["buildDefensePost"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildMissileSilo"
label=${translateText("user_setting.build_missile_silo")}
description=${translateText("user_setting.build_missile_silo_desc")}
defaultKey="Digit5"
.value=${this.keybinds["buildMissileSilo"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildSamLauncher"
label=${translateText("user_setting.build_sam_launcher")}
description=${translateText("user_setting.build_sam_launcher_desc")}
defaultKey="Digit6"
.value=${this.keybinds["buildSamLauncher"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildWarship"
label=${translateText("user_setting.build_warship")}
description=${translateText("user_setting.build_warship_desc")}
defaultKey="Digit7"
.value=${this.keybinds["buildWarship"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildAtomBomb"
label=${translateText("user_setting.build_atom_bomb")}
description=${translateText("user_setting.build_atom_bomb_desc")}
defaultKey="Digit8"
.value=${this.keybinds["buildAtomBomb"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildHydrogenBomb"
label=${translateText("user_setting.build_hydrogen_bomb")}
description=${translateText("user_setting.build_hydrogen_bomb_desc")}
defaultKey="Digit9"
.value=${this.keybinds["buildHydrogenBomb"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
<setting-keybind
action="buildMIRV"
label=${translateText("user_setting.build_MIRV")}
description=${translateText("user_setting.build_MIRV_desc")}
defaultKey="Digit0"
.value=${this.keybinds["buildMIRV"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -442,8 +538,8 @@ export class UserSettingModal extends LitElement {
action="attackRatioDown"
label=${translateText("user_setting.attack_ratio_down")}
description=${translateText("user_setting.attack_ratio_down_desc")}
defaultKey="Digit1"
.value=${this.keybinds["attackRatioDown"] ?? ""}
defaultKey="KeyT"
.value=${this.keybinds["attackRatioDown"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -451,8 +547,8 @@ export class UserSettingModal extends LitElement {
action="attackRatioUp"
label=${translateText("user_setting.attack_ratio_up")}
description=${translateText("user_setting.attack_ratio_up_desc")}
defaultKey="Digit2"
.value=${this.keybinds["attackRatioUp"] ?? ""}
defaultKey="KeyY"
.value=${this.keybinds["attackRatioUp"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -465,7 +561,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.boat_attack")}
description=${translateText("user_setting.boat_attack_desc")}
defaultKey="KeyB"
.value=${this.keybinds["boatAttack"] ?? ""}
.value=${this.keybinds["boatAttack"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -474,7 +570,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.ground_attack")}
description=${translateText("user_setting.ground_attack_desc")}
defaultKey="KeyG"
.value=${this.keybinds["groundAttack"] ?? ""}
.value=${this.keybinds["groundAttack"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -487,7 +583,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.zoom_out")}
description=${translateText("user_setting.zoom_out_desc")}
defaultKey="KeyQ"
.value=${this.keybinds["zoomOut"] ?? ""}
.value=${this.keybinds["zoomOut"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -496,7 +592,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.zoom_in")}
description=${translateText("user_setting.zoom_in_desc")}
defaultKey="KeyE"
.value=${this.keybinds["zoomIn"] ?? ""}
.value=${this.keybinds["zoomIn"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -509,7 +605,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.center_camera")}
description=${translateText("user_setting.center_camera_desc")}
defaultKey="KeyC"
.value=${this.keybinds["centerCamera"] ?? ""}
.value=${this.keybinds["centerCamera"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -518,7 +614,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.move_up")}
description=${translateText("user_setting.move_up_desc")}
defaultKey="KeyW"
.value=${this.keybinds["moveUp"] ?? ""}
.value=${this.keybinds["moveUp"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -527,7 +623,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.move_left")}
description=${translateText("user_setting.move_left_desc")}
defaultKey="KeyA"
.value=${this.keybinds["moveLeft"] ?? ""}
.value=${this.keybinds["moveLeft"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -536,7 +632,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.move_down")}
description=${translateText("user_setting.move_down_desc")}
defaultKey="KeyS"
.value=${this.keybinds["moveDown"] ?? ""}
.value=${this.keybinds["moveDown"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
@@ -545,7 +641,7 @@ export class UserSettingModal extends LitElement {
label=${translateText("user_setting.move_right")}
description=${translateText("user_setting.move_right_desc")}
defaultKey="KeyD"
.value=${this.keybinds["moveRight"] ?? ""}
.value=${this.keybinds["moveRight"]?.key ?? ""}
@change=${this.handleKeybindChange}
></setting-keybind>
`;
+10
View File
@@ -235,3 +235,13 @@ export function incrementGamesPlayed(): void {
console.warn("Failed to increment games played in localStorage:", error);
}
}
export function isInIframe(): boolean {
try {
return window.self !== window.top;
} catch (e) {
// If we can't access window.top due to cross-origin restrictions,
// we're definitely in an iframe
return true;
}
}
+1
View File
@@ -74,6 +74,7 @@ export class DifficultyDisplay extends LitElement {
></path>
</svg>`;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const kingSkull = html`<svg
stroke="currentColor"
fill="currentColor"
@@ -80,7 +80,7 @@ export class SettingKeybind extends LitElement {
this.dispatchEvent(
new CustomEvent("change", {
detail: { action: this.action, value: code },
detail: { action: this.action, value: code, key: e.key },
bubbles: true,
composed: true,
}),
@@ -0,0 +1,54 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("player-stats-grid")
export class PlayerStatsGrid extends LitElement {
static styles = css`
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
.stat {
text-align: center;
color: white;
font-size: 1rem;
}
.stat-title {
color: #bbb;
font-size: 0.9rem;
}
.stat-value {
font-size: 1.25rem;
font-weight: bold;
}
`;
@property({ type: Array }) titles: string[] = [];
@property({ type: Array }) values: Array<string | number> = [];
// Currently fixed to display 4 stats (can be changed if needed)
private readonly VISIBLE_STATS_COUNT = 4;
render() {
return html`
<div class="grid mb-2">
${Array(this.VISIBLE_STATS_COUNT)
.fill(0)
.map(
(_, i) => html`
<div class="stat">
<div class="stat-value">${this.values[i] ?? ""}</div>
<div class="stat-title">${this.titles[i] ?? ""}</div>
</div>
`,
)}
</div>
`;
}
}
@@ -0,0 +1,202 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { PlayerStatsLeaf, PlayerStatsTree } from "../../../../core/ApiSchemas";
import {
Difficulty,
GameMode,
GameType,
isDifficulty,
isGameMode,
isGameType,
} from "../../../../core/game/Game";
import { PlayerStats } from "../../../../core/StatsSchemas";
import { renderNumber, translateText } from "../../../Utils";
import "./PlayerStatsGrid";
import "./PlayerStatsTable";
@customElement("player-stats-tree-view")
export class PlayerStatsTreeView extends LitElement {
@property({ type: Object }) statsTree?: PlayerStatsTree;
@state() selectedType: GameType = GameType.Public;
@state() selectedMode: GameMode = GameMode.FFA;
@state() selectedDifficulty: Difficulty = Difficulty.Medium;
private get availableTypes(): GameType[] {
if (!this.statsTree) return [];
return Object.keys(this.statsTree).filter(isGameType);
}
private get availableModes(): GameMode[] {
const typeNode = this.statsTree?.[this.selectedType];
if (!typeNode) return [];
return Object.keys(typeNode).filter(isGameMode);
}
private get availableDifficulties(): Difficulty[] {
const typeNode = this.statsTree?.[this.selectedType];
const modeNode = typeNode?.[this.selectedMode];
if (!modeNode) return [];
return Object.keys(modeNode).filter(isDifficulty);
}
private labelForMode(m: GameMode) {
return m === GameMode.FFA
? translateText("player_stats_tree.mode_ffa")
: translateText("player_stats_tree.mode_team");
}
createRenderRoot() {
return this;
}
private getSelectedLeaf(): PlayerStatsLeaf | null {
const typeNode = this.statsTree?.[this.selectedType];
if (!typeNode) return null;
const modeNode = typeNode[this.selectedMode];
if (!modeNode) return null;
const diffNode = modeNode[this.selectedDifficulty];
if (!diffNode) return null;
return diffNode;
}
private getDisplayedStats(): PlayerStats | null {
const leaf = this.getSelectedLeaf();
if (!leaf || !leaf.stats) return null;
return leaf.stats;
}
private setGameType(t: GameType) {
if (this.selectedType === t) return;
this.selectedType = t;
const modes = this.availableModes;
if (!modes.includes(this.selectedMode)) {
this.selectedMode = modes[0] ?? this.selectedMode;
}
const diffs = this.availableDifficulties;
if (!diffs.includes(this.selectedDifficulty)) {
this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty;
}
this.requestUpdate();
}
private setMode(m: GameMode) {
if (this.selectedMode === m) return;
this.selectedMode = m;
const diffs = this.availableDifficulties;
if (!diffs.includes(this.selectedDifficulty)) {
this.selectedDifficulty = diffs[0] ?? this.selectedDifficulty;
}
this.requestUpdate();
}
private setDifficulty(d: Difficulty) {
if (this.selectedDifficulty === d) return;
this.selectedDifficulty = d;
this.requestUpdate();
}
render() {
const types = this.availableTypes;
if (types.length && !types.includes(this.selectedType)) {
this.selectedType = types[0];
}
const modes = this.availableModes;
if (modes.length && !modes.includes(this.selectedMode)) {
this.selectedMode = modes[0];
}
const diffs = this.availableDifficulties;
if (diffs.length && !diffs.includes(this.selectedDifficulty)) {
this.selectedDifficulty = diffs[0];
}
const leaf = this.getSelectedLeaf();
const wlr = leaf
? leaf.losses === 0n
? Number(leaf.wins)
: Number(leaf.wins) / Number(leaf.losses)
: 0;
return html`
<!-- Type selector -->
<div class="flex gap-2 mt-2 justify-center">
${types.map(
(t) => html`
<button
class="text-xs px-2 py-0.5 rounded border ${this.selectedType ===
t
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setGameType(t)}
>
${t === GameType.Public
? translateText("player_stats_tree.public")
: t === GameType.Private
? translateText("player_stats_tree.private")
: translateText("player_stats_tree.singleplayer")}
</button>
`,
)}
</div>
<!-- Mode selector -->
${modes.length
? html`<div class="flex gap-2 mt-2 justify-center">
${modes.map(
(m) => html`
<button
class="text-xs px-2 py-0.5 rounded border ${this
.selectedMode === m
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setMode(m)}
title=${translateText("player_stats_tree.mode")}
>
${this.labelForMode(m)}
</button>
`,
)}
</div>`
: html``}
<!-- Difficulty selector -->
${diffs.length
? html`<div class="flex gap-2 mt-2 justify-center">
${diffs.map(
(d) =>
html` <button
class="text-xs px-2 py-0.5 rounded border ${this
.selectedDifficulty === d
? "border-white/60 text-white"
: "border-white/20 text-gray-300"}"
@click=${() => this.setDifficulty(d)}
title=${translateText("difficulty.difficulty")}
>
${translateText(`difficulty.${d}`)}
</button>`,
)}
</div>`
: html``}
${leaf
? html`
<hr class="w-2/3 border-gray-600 my-2" />
<player-stats-grid
.titles=${[
translateText("player_stats_tree.stats_wins"),
translateText("player_stats_tree.stats_losses"),
translateText("player_stats_tree.stats_wlr"),
translateText("player_stats_tree.stats_games_played"),
]}
.values=${[
renderNumber(leaf.wins),
renderNumber(leaf.losses),
wlr.toFixed(2),
renderNumber(leaf.total),
]}
></player-stats-grid>
<hr class="w-2/3 border-gray-600 my-2" />
<player-stats-table
.stats=${this.getDisplayedStats()}
></player-stats-table>
`
: html``}
`;
}
}
+69
View File
@@ -0,0 +1,69 @@
import { html, TemplateResult } from "lit";
export type ButtonVariant =
| "normal"
| "red"
| "green"
| "indigo"
| "yellow"
| "sky";
export interface ActionButtonProps {
onClick: (e: MouseEvent) => void;
type?: ButtonVariant;
icon: string;
iconAlt: string;
title: string;
label: string;
disabled?: boolean;
}
const ICON_SIZE =
"h-5 w-5 shrink-0 transition-transform group-hover:scale-110 text-zinc-400";
const TEXT_SIZE =
"text-base sm:text-[14px] leading-5 font-semibold tracking-tight";
const getButtonStyles = () => {
const btnBase =
"group w-full min-w-[50px] select-none flex flex-col items-center justify-center " +
"gap-1 rounded-lg py-1.5 border border-white/10 bg-white/[0.04] shadow-sm " +
"transition-all duration-150 " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/20 " +
"active:translate-y-[1px]";
return {
normal: `${btnBase} text-white/90 hover:bg-white/10 hover:text-white`,
red: `${btnBase} text-red-400 hover:bg-red-500/10 hover:text-red-300 focus-visible:ring-red-400/30`,
green: `${btnBase} text-emerald-400 hover:bg-emerald-500/10 hover:text-emerald-300 focus-visible:ring-emerald-400/30`,
yellow: `${btnBase} text-[#f59e0b] hover:bg-[#f59e0b]/10 hover:text-[#fbbf24] focus-visible:ring-[#f59e0b]/30`,
indigo: `${btnBase} text-indigo-400 hover:bg-indigo-500/10 hover:text-indigo-300 focus-visible:ring-indigo-400/30`,
sky: `${btnBase} text-[#38bdf8] hover:bg-[#38bdf8]/10 hover:text-[#0ea5e9] focus-visible:ring-[#38bdf8]/30`,
};
};
export const actionButton = (props: ActionButtonProps): TemplateResult => {
const {
onClick,
type = "normal",
icon,
iconAlt,
title,
label,
disabled = false,
} = props;
const buttonStyles = getButtonStyles();
const buttonClass = buttonStyles[type];
return html`
<button
@click=${onClick}
class="${buttonClass}"
title="${title}"
type="button"
aria-label="${title}"
?disabled=${disabled}
>
<img src=${icon} alt=${iconAlt} aria-hidden="true" class="${ICON_SIZE}" />
<span class="${TEXT_SIZE}">${label}</span>
</button>
`;
};
+33
View File
@@ -0,0 +1,33 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
export type DividerSpacing = "sm" | "md" | "lg";
@customElement("ui-divider")
export class Divider extends LitElement {
@property({ type: String })
spacing: DividerSpacing = "md";
@property({ type: String })
color: string = "bg-zinc-700/80";
createRenderRoot() {
return this;
}
render() {
const spacingClasses: Record<DividerSpacing, string> = {
sm: "my-0.5",
md: "my-1",
lg: "my-2",
} as const;
const spacing = spacingClasses[this.spacing] ?? spacingClasses.md;
const colorClass = this.color || "bg-zinc-700/80";
return html`<div
role="separator"
aria-hidden="true"
class="${spacing} h-px ${colorClass}"
></div>`;
}
}
+4 -13
View File
@@ -16,7 +16,6 @@ import { FPSDisplay } from "./layers/FPSDisplay";
import { FxLayer } from "./layers/FxLayer";
import { GameLeftSidebar } from "./layers/GameLeftSidebar";
import { GameRightSidebar } from "./layers/GameRightSidebar";
import { GutterAdModal } from "./layers/GutterAdModal";
import { HeadsUpMessage } from "./layers/HeadsUpMessage";
import { Layer } from "./layers/Layer";
import { Leaderboard } from "./layers/Leaderboard";
@@ -48,7 +47,7 @@ export function createRenderer(
const transformHandler = new TransformHandler(game, eventBus, canvas);
const userSettings = new UserSettings();
const uiState = { attackRatio: 20 };
const uiState = { attackRatio: 20, ghostStructure: null } as UIState;
//hide when the game renders
const startingModal = document.querySelector(
@@ -167,6 +166,7 @@ export function createRenderer(
}
unitDisplay.game = game;
unitDisplay.eventBus = eventBus;
unitDisplay.uiState = uiState;
const playerPanel = document.querySelector("player-panel") as PlayerPanel;
if (!(playerPanel instanceof PlayerPanel)) {
@@ -215,14 +215,6 @@ export function createRenderer(
}
spawnAd.g = game;
const gutterAdModal = document.querySelector(
"gutter-ad-modal",
) as GutterAdModal;
if (!(gutterAdModal instanceof GutterAdModal)) {
console.error("gutter ad modal not found");
}
gutterAdModal.eventBus = eventBus;
const alertFrame = document.querySelector("alert-frame") as AlertFrame;
if (!(alertFrame instanceof AlertFrame)) {
console.error("alert frame not found");
@@ -235,12 +227,12 @@ export function createRenderer(
const layers: Layer[] = [
new TerrainLayer(game, transformHandler),
new TerritoryLayer(game, eventBus, transformHandler, userSettings),
new RailroadLayer(game),
new RailroadLayer(game, transformHandler),
structureLayer,
new UnitLayer(game, eventBus, transformHandler),
new FxLayer(game),
new UILayer(game, eventBus, transformHandler),
new StructureIconsLayer(game, eventBus, transformHandler),
new StructureIconsLayer(game, eventBus, uiState, transformHandler),
new NameLayer(game, transformHandler, eventBus),
eventsDisplay,
chatDisplay,
@@ -269,7 +261,6 @@ export function createRenderer(
headsUpMessage,
multiTabModal,
spawnAd,
gutterAdModal,
alertFrame,
fpsDisplay,
];
-1
View File
@@ -158,7 +158,6 @@ export class TransformHandler {
}
onGoToPlayer(event: GoToPlayerEvent) {
this.game.setFocusedPlayer(event.player);
this.clearTarget();
const nameLocation = event.player.nameLocation();
if (!nameLocation) {
+3
View File
@@ -1,3 +1,6 @@
import { UnitType } from "../../core/game/Game";
export interface UIState {
attackRatio: number;
ghostStructure: UnitType | null;
}
-1
View File
@@ -77,7 +77,6 @@ export class SpriteFx implements Fx {
if (!this.animatedSprite.isActive() && !this.waitToTheEnd) return false;
const t = this.elapsedTime / this.duration;
this.animatedSprite.update(frameTime);
this.animatedSprite.draw(ctx, this.x, this.y);
return true;
+13 -11
View File
@@ -4,12 +4,14 @@ export class TargetFx implements Fx {
private lifeTime = 0;
private ended = false;
private endFade = 300;
private offset = 0;
private rotationSpeed = 14; // px per seconds
private radius = 4;
constructor(
private x: number,
private y: number,
private duration = 0,
private radius = 8,
private persistent = false,
) {}
@@ -36,26 +38,26 @@ export class TargetFx implements Fx {
const fadeAlpha =
this.persistent && this.ended ? 1 - this.lifeTime / this.endFade : 1;
const alpha = Math.max(0, Math.min(1, baseAlpha * fadeAlpha));
const pulse = 1 + 0.2 * Math.sin(t * Math.PI * 2);
ctx.save();
ctx.globalAlpha = alpha;
ctx.lineWidth = 1;
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
// size follows the pulsing radius so crosshair scales with it
const size = this.radius * pulse;
this.offset += this.rotationSpeed * (frameTime / 1000);
ctx.beginPath();
ctx.arc(this.x, this.y, size, 0, Math.PI * 2);
ctx.lineWidth = 1;
ctx.lineDashOffset = this.offset;
ctx.setLineDash([3, 3]);
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.stroke();
// crosshair (fixed size, does not scale with pulse)
ctx.beginPath();
ctx.moveTo(this.x - this.radius * 1.2, this.y);
ctx.lineTo(this.x + this.radius * 1.2, this.y);
ctx.moveTo(this.x, this.y - this.radius * 1.2);
ctx.lineTo(this.x, this.y + this.radius * 1.2);
ctx.strokeStyle = `rgba(255,0,0,${alpha})`;
ctx.lineWidth = 2;
ctx.lineDashOffset = -this.offset / 2;
ctx.setLineDash([19, 3]);
ctx.arc(this.x, this.y, 7, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
+2 -2
View File
@@ -4,7 +4,7 @@ import { EventBus } from "../../../core/EventBus";
import { AllPlayers } from "../../../core/game/Game";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { TerraNulliusImpl } from "../../../core/game/TerraNulliusImpl";
import { emojiTable, flattenedEmojiTable } from "../../../core/Util";
import { Emoji, emojiTable, flattenedEmojiTable } from "../../../core/Util";
import { CloseViewEvent, ShowEmojiMenuEvent } from "../../InputHandler";
import { SendEmojiIntentEvent } from "../../Transport";
import { TransformHandler } from "../TransformHandler";
@@ -42,7 +42,7 @@ export class EmojiTable extends LitElement {
eventBus.emit(
new SendEmojiIntentEvent(
recipient,
flattenedEmojiTable.indexOf(emoji),
flattenedEmojiTable.indexOf(emoji as Emoji),
),
);
this.hideTable();
+2 -2
View File
@@ -1051,7 +1051,7 @@ export class EventsDisplay extends LitElement implements Layer {
? this.renderButton({
content: this.getEventDescription(event),
onClick: () => {
event.focusID &&
if (event.focusID)
this.emitGoToPlayerEvent(event.focusID);
},
className: "text-left",
@@ -1060,7 +1060,7 @@ export class EventsDisplay extends LitElement implements Layer {
? this.renderButton({
content: this.getEventDescription(event),
onClick: () => {
event.unitView &&
if (event.unitView)
this.emitGoToUnitEvent(
event.unitView,
);
+5 -7
View File
@@ -7,6 +7,7 @@ import {
RailroadUpdate,
} from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import SoundManager, { SoundEffect } from "../../sound/SoundManager";
import { renderNumber } from "../../Utils";
import { AnimatedSpriteLoader } from "../AnimatedSpriteLoader";
import { conquestFxFactory } from "../fx/ConquestFx";
@@ -115,25 +116,20 @@ export class FxLayer implements Layer {
this.allFx.push(textFx);
}
addTargetFx(x: number, y: number) {
const fx = new TargetFx(x, y, 1200, 12);
this.allFx.push(fx);
}
onUnitEvent(unit: UnitView) {
switch (unit.type()) {
case UnitType.TransportShip: {
const my = this.game.myPlayer();
if (!my) return;
if (unit.owner() !== my) return;
if (!unit.isActive()) return;
if (!unit.isActive() || unit.retreating()) return;
if (this.boatTargetFxByUnitId.has(unit.id())) return;
const t = unit.targetTile();
if (t !== undefined) {
const x = this.game.x(t);
const y = this.game.y(t);
// persistent until boat finishes or retreats
const fx = new TargetFx(x, y, 0, 12, true);
const fx = new TargetFx(x, y, 0, true);
this.allFx.push(fx);
this.boatTargetFxByUnitId.set(unit.id(), fx);
}
@@ -216,6 +212,8 @@ export class FxLayer implements Layer {
return;
}
SoundManager.playSoundEffect(SoundEffect.KaChing);
const conquestFx = conquestFxFactory(
this.animatedSpriteLoader,
conquest,
-160
View File
@@ -1,160 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { getGamesPlayed } from "../../Utils";
import { Layer } from "./Layer";
export class GutterAdModalEvent implements GameEvent {
constructor(public readonly isVisible: boolean) {}
}
@customElement("gutter-ad-modal")
export class GutterAdModal extends LitElement implements Layer {
public eventBus: EventBus;
@state()
private isVisible: boolean = false;
@state()
private adLoaded: boolean = false;
private leftAdType: string = "left_rail";
private rightAdType: string = "right_rail";
private leftContainerId: string = "gutter-ad-container-left";
private rightContainerId: string = "gutter-ad-container-right";
private margin: string = "10px";
// Override createRenderRoot to disable shadow DOM
createRenderRoot() {
return this;
}
init() {
if (getGamesPlayed() > 1) {
this.eventBus.on(GutterAdModalEvent, (event) => {
if (event.isVisible) {
this.show();
} else {
this.hide();
}
});
}
}
tick() {}
static styles = css``;
// Called after the component's DOM is first rendered
firstUpdated() {
// DOM is guaranteed to be available here
console.log("GutterAdModal DOM is ready");
}
public show(): void {
console.log("showing GutterAdModal");
this.isVisible = true;
this.requestUpdate();
// Wait for the update to complete, then load ads
this.updateComplete.then(() => {
this.loadAds();
});
}
public hide(): void {
console.log("hiding GutterAdModal");
this.isVisible = false;
this.destroyAds();
this.adLoaded = false;
this.requestUpdate();
}
private loadAds(): void {
// Ensure the container elements exist before loading ads
const leftContainer = this.querySelector(`#${this.leftContainerId}`);
const rightContainer = this.querySelector(`#${this.rightContainerId}`);
if (!leftContainer || !rightContainer) {
console.warn("Ad containers not found in DOM");
return;
}
if (!window.ramp) {
console.warn("Playwire RAMP not available");
this.hide();
return;
}
if (this.adLoaded) {
console.log("Ads already loaded, skipping");
return;
}
try {
window.ramp.que.push(() => {
window.ramp.spaAddAds([
{
type: this.leftAdType,
selectorId: this.leftContainerId,
},
{
type: this.rightAdType,
selectorId: this.rightContainerId,
},
]);
this.adLoaded = true;
console.log("Playwire ads loaded:", this.leftAdType, this.rightAdType);
});
} catch (error) {
console.error("Failed to load Playwire ads:", error);
this.hide();
}
}
private destroyAds(): void {
if (!window.ramp || !this.adLoaded) {
return;
}
try {
window.ramp.destroyUnits("all");
} catch (error) {
console.error("Failed to destroy Playwire ad:", error);
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.destroyAds();
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed left-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[10] pointer-events-auto items-center justify-center"
style="margin-left: ${this.margin};"
>
<div
id="${this.leftContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed right-0 top-1/2 transform -translate-y-1/2 w-[160px] min-h-[600px] z-[10] pointer-events-auto items-center justify-center"
style="margin-right: ${this.margin};"
>
<div
id="${this.rightContainerId}"
class="w-full h-full flex items-center justify-center p-2"
></div>
</div>
`;
}
}
-278
View File
@@ -1,278 +0,0 @@
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameType } from "../../../core/game/Game";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { UserSettings } from "../../../core/game/UserSettings";
import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler";
import { PauseGameEvent } from "../../Transport";
import { translateText } from "../../Utils";
import { Layer } from "./Layer";
const button = ({
classes = "",
onClick = () => {},
title = "",
children = "",
}) => html`
<button
class="flex items-center justify-center p-1
bg-opacity-70 bg-gray-700 text-opacity-90 text-white
border-none rounded cursor-pointer
hover:bg-opacity-60 hover:bg-gray-600
transition-colors duration-200
text-sm lg:text-xl ${classes}"
@click=${onClick}
aria-label=${title}
title=${title}
>
${children}
</button>
`;
const secondsToHms = (d: number): string => {
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = Math.floor((d % 3600) % 60);
let time = d === 0 ? "-" : `${s}s`;
if (m > 0) time = `${m}m` + time;
if (h > 0) time = `${h}h` + time;
return time;
};
@customElement("options-menu")
export class OptionsMenu extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private userSettings: UserSettings = new UserSettings();
@state()
private showPauseButton: boolean = true;
@state()
private isPaused: boolean = false;
@state()
private timer: number = 0;
@state()
private showSettings: boolean = false;
private isVisible = false;
private hasWinner = false;
@state()
private alternateView: boolean = false;
private onTerrainButtonClick() {
this.alternateView = !this.alternateView;
this.eventBus.emit(new AlternateViewEvent(this.alternateView));
this.requestUpdate();
}
private onExitButtonClick() {
const isAlive = this.game.myPlayer()?.isAlive();
if (isAlive) {
const isConfirmed = confirm(
translateText("help_modal.exit_confirmation"),
);
if (!isConfirmed) return;
}
// redirect to the home page
window.location.href = "/";
}
createRenderRoot() {
return this;
}
private onSettingsButtonClick() {
this.showSettings = !this.showSettings;
this.requestUpdate();
}
private onPauseButtonClick() {
this.isPaused = !this.isPaused;
this.eventBus.emit(new PauseGameEvent(this.isPaused));
}
private onToggleEmojisButtonClick() {
this.userSettings.toggleEmojis();
this.requestUpdate();
}
private onToggleAlertFrameButtonClick() {
this.userSettings.toggleAlertFrame();
this.requestUpdate();
}
private onToggleSpecialEffectsButtonClick() {
this.userSettings.toggleFxLayer();
this.requestUpdate();
}
private onToggleDarkModeButtonClick() {
this.userSettings.toggleDarkMode();
this.requestUpdate();
this.eventBus.emit(new RefreshGraphicsEvent());
}
private onToggleRandomNameModeButtonClick() {
this.userSettings.toggleRandomName();
}
private onToggleFocusLockedButtonClick() {
this.userSettings.toggleFocusLocked();
this.requestUpdate();
}
private onToggleLeftClickOpensMenu() {
this.userSettings.toggleLeftClickOpenMenu();
}
private onToggleTerritoryPatterns() {
this.userSettings.toggleTerritoryPatterns();
this.requestUpdate();
}
private onTogglePerformanceOverlayButtonClick() {
this.userSettings.togglePerformanceOverlay();
this.requestUpdate();
}
init() {
console.log("init called from OptionsMenu");
this.showPauseButton =
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
this.game.config().isReplay();
this.isVisible = true;
this.requestUpdate();
}
tick() {
const updates = this.game.updatesSinceLastTick();
if (updates) {
this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0;
}
if (this.game.inSpawnPhase()) {
this.timer = 0;
} else if (!this.hasWinner && this.game.ticks() % 10 === 0) {
this.timer++;
}
this.isVisible = true;
this.requestUpdate();
}
render() {
if (!this.isVisible) {
return html``;
}
return html`
<div
class="top-0 lg:top-4 right-0 lg:right-4 z-50 pointer-events-auto"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-es-sm lg:rounded-lg backdrop-blur-md"
>
<div class="flex items-stretch gap-1 lg:gap-2">
${button({
classes: !this.showPauseButton ? "hidden" : "",
onClick: this.onPauseButtonClick,
title: this.isPaused ? "Resume game" : "Pause game",
children: this.isPaused ? "▶️" : "⏸",
})}
<div
class="w-[55px] h-8 lg:w-24 lg:h-10 flex items-center justify-center
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
rounded text-sm lg:text-xl"
>
${secondsToHms(this.timer)}
</div>
${button({
onClick: this.onExitButtonClick,
title: "Exit game",
children: "❌",
})}
${button({
onClick: this.onSettingsButtonClick,
title: "Settings",
children: "⚙️",
})}
</div>
</div>
<div
class="options-menu flex flex-col justify-around gap-y-3 mt-2 bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-lg backdrop-blur-md ${!this
.showSettings
? "hidden"
: ""}"
>
${button({
onClick: this.onTerrainButtonClick,
title: "Toggle Terrain",
children: "🌲: " + (this.alternateView ? "On" : "Off"),
})}
${button({
onClick: this.onToggleEmojisButtonClick,
title: "Toggle Emojis",
children: "🙂: " + (this.userSettings.emojis() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleAlertFrameButtonClick,
title: "Toggle Alert frame",
children: "🚨: " + (this.userSettings.alertFrame() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleSpecialEffectsButtonClick,
title: "Toggle Special effects",
children: "💥: " + (this.userSettings.fxLayer() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleTerritoryPatterns,
title: "Territory Patterns",
children:
"🏳️: " + (this.userSettings.territoryPatterns() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleDarkModeButtonClick,
title: "Dark Mode",
children: "🌙: " + (this.userSettings.darkMode() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleRandomNameModeButtonClick,
title: "Random name mode",
children:
"🥷: " + (this.userSettings.anonymousNames() ? "On" : "Off"),
})}
${button({
onClick: this.onToggleLeftClickOpensMenu,
title: "Left click",
children:
"🖱️: " +
(this.userSettings.leftClickOpensMenu()
? "Opens menu"
: "Attack"),
})}
${button({
onClick: this.onTogglePerformanceOverlayButtonClick,
title: "Performance Overlay",
children:
"🚀: " + (this.userSettings.performanceOverlay() ? "On" : "Off"),
})}
<!-- ${button({
onClick: this.onToggleFocusLockedButtonClick,
title: "Lock Focus",
children:
"🗺: " +
(this.userSettings.focusLocked()
? "Focus locked"
: "Hover focus"),
})} -->
</div>
</div>
`;
}
}
@@ -12,7 +12,6 @@ import {
SendDonateTroopsIntentEvent,
SendEmbargoIntentEvent,
SendEmojiIntentEvent,
SendQuickChatEvent,
SendSpawnIntentEvent,
SendTargetPlayerIntentEvent,
} from "../../Transport";
@@ -97,10 +96,6 @@ export class PlayerActionHandler {
this.eventBus.emit(new SendEmojiIntentEvent(targetPlayer, emojiIndex));
}
handleQuickChat(recipient: PlayerView, chatKey: string, params: any = {}) {
this.eventBus.emit(new SendQuickChatEvent(recipient, chatKey, params));
}
handleDeleteUnit(unitId: number) {
this.eventBus.emit(new SendDeleteUnitIntentEvent(unitId));
}
+11 -11
View File
@@ -2,12 +2,12 @@ import { LitElement, TemplateResult, html } from "lit";
import { ref } from "lit-html/directives/ref.js";
import { customElement, property, state } from "lit/decorators.js";
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
import portIcon from "../../../../resources/images/AnchorIcon.png";
import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg";
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import goldCoinIcon from "../../../../resources/images/GoldCoinIcon.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samLauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg";
import { renderPlayerFlag } from "../../../core/CustomFlag";
import { EventBus } from "../../../core/EventBus";
@@ -268,13 +268,13 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
let playerType = "";
switch (player.type()) {
case PlayerType.Bot:
playerType = translateText("player_info_overlay.bot");
playerType = translateText("player_type.bot");
break;
case PlayerType.FakeHuman:
playerType = translateText("player_info_overlay.nation");
playerType = translateText("player_type.nation");
break;
case PlayerType.Human:
playerType = translateText("player_info_overlay.player");
playerType = translateText("player_type.player");
break;
}
@@ -364,18 +364,18 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
cityIcon,
"player_info_overlay.cities",
)}
${this.displayUnitCount(
player,
UnitType.Port,
portIcon,
"player_info_overlay.ports",
)}
${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,
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@ import { Config } from "../../../core/configuration/Config";
import { AllPlayers, PlayerActions, UnitType } from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { flattenedEmojiTable } from "../../../core/Util";
import { Emoji, flattenedEmojiTable } from "../../../core/Util";
import { renderNumber, translateText } from "../../Utils";
import { BuildItemDisplay, BuildMenu, flattenedBuildTable } from "./BuildMenu";
import { ChatIntegration } from "./ChatIntegration";
@@ -106,6 +106,7 @@ export enum Slot {
Delete = "delete",
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const infoChatElement: MenuElement = {
id: "info_chat",
name: "chat",
@@ -123,6 +124,7 @@ const infoChatElement: MenuElement = {
})),
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyTargetElement: MenuElement = {
id: "ally_target",
name: "target",
@@ -138,6 +140,7 @@ const allyTargetElement: MenuElement = {
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyTradeElement: MenuElement = {
id: "ally_trade",
name: "trade",
@@ -153,6 +156,7 @@ const allyTradeElement: MenuElement = {
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyEmbargoElement: MenuElement = {
id: "ally_embargo",
name: "embargo",
@@ -204,6 +208,7 @@ const allyBreakElement: MenuElement = {
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyDonateGoldElement: MenuElement = {
id: "ally_donate_gold",
name: "donate gold",
@@ -217,6 +222,7 @@ const allyDonateGoldElement: MenuElement = {
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const allyDonateTroopsElement: MenuElement = {
id: "ally_donate_troops",
name: "donate troops",
@@ -230,6 +236,7 @@ const allyDonateTroopsElement: MenuElement = {
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const infoPlayerElement: MenuElement = {
id: "info_player",
name: "player",
@@ -241,6 +248,7 @@ const infoPlayerElement: MenuElement = {
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const infoEmojiElement: MenuElement = {
id: "info_emoji",
name: "emoji",
@@ -263,7 +271,7 @@ const infoEmojiElement: MenuElement = {
: params.selected;
params.playerActionHandler.handleEmoji(
targetPlayer!,
flattenedEmojiTable.indexOf(emoji),
flattenedEmojiTable.indexOf(emoji as Emoji),
);
params.emojiTable.hideTable();
});
+16 -3
View File
@@ -9,6 +9,7 @@ import {
RailType,
} from "../../../core/game/GameUpdates";
import { GameView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import { Layer } from "./Layer";
import { getBridgeRects, getRailroadRects } from "./RailroadSprites";
@@ -27,7 +28,10 @@ export class RailroadLayer implements Layer {
private nextRailIndexToCheck = 0;
private railTileList: TileRef[] = [];
constructor(private game: GameView) {
constructor(
private game: GameView,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
}
@@ -84,6 +88,7 @@ export class RailroadLayer implements Layer {
this.canvas.width = this.game.width() * 2;
this.canvas.height = this.game.height() * 2;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
for (const [_, rail] of this.existingRailroads) {
this.paintRail(rail.tile);
}
@@ -91,6 +96,15 @@ export class RailroadLayer implements Layer {
renderLayer(context: CanvasRenderingContext2D) {
this.updateRailColors();
const scale = this.transformHandler.scale;
if (scale <= 1) {
return;
}
const rawAlpha = (scale - 1) / (2 - 1); // maps 1->0, 2->1
const alpha = Math.max(0, Math.min(1, rawAlpha));
context.save();
context.globalAlpha = alpha;
context.drawImage(
this.canvas,
-this.game.width() / 2,
@@ -98,12 +112,11 @@ export class RailroadLayer implements Layer {
this.game.width(),
this.game.height(),
);
context.restore();
}
private handleRailroadRendering(railUpdate: RailroadUpdate) {
for (const railRoad of railUpdate.railTiles) {
const x = this.game.x(railRoad.tile);
const y = this.game.y(railRoad.tile);
if (railUpdate.isActive) {
this.paintRailroad(railRoad);
} else {
@@ -0,0 +1,588 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { EventBus } from "../../../core/EventBus";
import { GameView, PlayerView } from "../../../core/game/GameView";
import { within } from "../../../core/Util";
import {
SendDonateGoldIntentEvent,
SendDonateTroopsIntentEvent,
} from "../../Transport";
import { renderTroops, translateText } from "../../Utils";
import { UIState } from "../UIState";
@customElement("send-resource-modal")
export class SendResourceModal extends LitElement {
@property({ attribute: false }) eventBus: EventBus | null = null;
@property({ type: Boolean }) open: boolean = false;
@property({ type: String }) mode: "troops" | "gold" = "troops";
@property({ type: Object }) total: number | bigint = 0;
@property({ type: Object }) uiState: UIState | null = null; // to seed initial %
@property({ attribute: false }) format: (n: number) => string = renderTroops;
@property({ attribute: false }) myPlayer: PlayerView | null = null;
@property({ attribute: false }) target: PlayerView | null = null;
@property({ attribute: false }) gameView: GameView | null = null;
@property({ type: String }) heading: string | null = null;
@state() private sendAmount: number = 0;
@state() private selectedPercent: number | null = null;
private PRESETS = [10, 25, 50, 75, 100] as const;
createRenderRoot() {
return this;
}
connectedCallback() {
super.connectedCallback();
const initPct =
this.uiState && typeof this.uiState.attackRatio === "number"
? Math.round(this.uiState.attackRatio * 100)
: 100;
this.selectedPercent = this.sanitizePercent(initPct);
const basis = this.getPercentBasis();
this.sendAmount = this.clampSend(
Math.floor((basis * this.selectedPercent) / 100),
);
}
updated(changed: Map<string, unknown>) {
if (changed.has("open") && this.open) {
// If either side is dead, just close and do nothing
if (!this.isSenderAlive() || !this.isTargetAlive()) {
this.closeModal();
return;
}
queueMicrotask(() =>
(this.querySelector('[role="dialog"]') as HTMLElement | null)?.focus(),
);
}
if (
changed.has("total") ||
changed.has("mode") ||
changed.has("target") ||
changed.has("gameView")
) {
const basis = this.getPercentBasis();
if (this.selectedPercent !== null) {
const pct = this.sanitizePercent(this.selectedPercent);
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
} else {
this.sendAmount = this.clampSend(this.sendAmount);
}
}
}
private closeModal() {
this.dispatchEvent(new CustomEvent("close"));
}
private confirm() {
if (!this.isSenderAlive() || !this.isTargetAlive() || !this.eventBus) {
return;
}
const myPlayer = this.myPlayer;
const target = this.target;
const amount = this.limitAmount(this.sendAmount);
if (!myPlayer || !target || amount <= 0) return;
if (this.mode === "troops") {
const myTroops = Number(myPlayer.troops());
if (amount > myTroops) return;
this.eventBus.emit(new SendDonateTroopsIntentEvent(target, amount));
} else {
const myGold = Number(myPlayer.gold());
if (amount > myGold) return;
this.eventBus.emit(new SendDonateGoldIntentEvent(target, BigInt(amount)));
}
this.dispatchEvent(
new CustomEvent("confirm", {
detail: { amount, closePanel: true, success: true },
}),
);
this.closeModal();
}
private handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
this.closeModal();
}
if (e.key === "Enter") {
e.preventDefault();
this.confirm();
}
};
private toNum(x: unknown): number {
if (typeof x === "bigint") return Number(x);
return Number(x ?? 0);
}
private getTotalNumber(): number {
const base = this.toNum(this.total);
return this.isSenderAlive() ? base : 0;
}
private sanitizePercent(p: number) {
return within(p, 0, 100);
}
/** Internal capacity only for troops; gold is unlimited. */
private getCapacityLeft(): number | null {
if (!this.isTargetAlive()) return 0;
if (this.mode !== "troops") return null;
if (!this.gameView || !this.target) return null;
const current = this.toNum(this.target.troops());
const max = this.toNum(this.gameView.config().maxTroops(this.target));
return Math.max(0, max - current);
}
private getPercentBasis(): number {
return this.getTotalNumber();
}
private limitAmount(proposed: number): number {
const cap = this.getCapacityLeft();
const total = this.getTotalNumber();
const hardMax = cap === null ? total : Math.min(total, cap);
return within(proposed, 0, hardMax);
}
private clampSend(n: number) {
const total = this.getTotalNumber();
const byTotal = within(n, 0, total);
return this.limitAmount(byTotal);
}
private percentOfBasis(n: number): number {
const basis = this.getPercentBasis();
return basis ? Math.round((n / basis) * 100) : 0;
}
private keepAfter(allowed: number): number {
const total = this.getTotalNumber();
return Math.max(0, total - allowed);
}
private getFillColor(): string {
return this.mode === "troops"
? "rgb(168 85 247)" /* purple */
: "rgb(234 179 8)" /* amber */;
}
private getMinKeepRatio(): number {
return this.mode === "troops" ? 0.3 : 0;
}
private isTargetAlive(): boolean {
return this.target?.isAlive() ?? false;
}
private isSenderAlive(): boolean {
return this.myPlayer?.isAlive() ?? false;
}
private i18n = {
title: (name: string) =>
this.mode === "troops"
? translateText("send_troops_modal.title_with_name", { name })
: translateText("send_gold_modal.title_with_name", { name }),
availableChip: () => translateText("common.available"),
availableTooltip: () =>
this.mode === "troops"
? translateText("send_troops_modal.available_tooltip")
: translateText("send_gold_modal.available_tooltip"),
max: () => translateText("common.preset_max"),
ariaSlider: () =>
this.mode === "troops"
? translateText("send_troops_modal.aria_slider")
: translateText("send_gold_modal.aria_slider"),
summarySend: () => translateText("common.summary_send"),
summaryKeep: () => translateText("common.summary_keep"),
closeLabel: () => translateText("common.close"),
cancel: () => translateText("common.cancel"),
send: () => translateText("common.send"),
cap: () => translateText("common.cap_label"),
capTooltip: () => translateText("common.cap_tooltip"),
sliderTooltip: (percent: number, amountStr: string) =>
this.mode === "troops"
? translateText("send_troops_modal.slider_tooltip", {
percent,
amount: amountStr,
})
: translateText("send_gold_modal.slider_tooltip", {
percent,
amount: amountStr,
}),
capacityNote: (amountStr: string) =>
translateText("send_troops_modal.capacity_note", { amount: amountStr }),
targetDeadTitle: () => translateText("common.target_dead"),
targetDeadNote: () => translateText("common.target_dead_note"),
};
private renderHeader() {
const name = this.target?.name?.() ?? "";
return html`
<div class="mb-3 flex items-center justify-between">
<h2
id="send-title"
class="text-lg font-semibold tracking-tight text-zinc-100"
>
${this.heading ?? this.i18n.title(name)}
</h2>
<button
class="rounded-md px-2 text-2xl leading-none text-zinc-300 hover:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-white/30"
@click=${() => this.closeModal()}
aria-label=${this.i18n.closeLabel()}
>
×
</button>
</div>
`;
}
private renderAvailable() {
const total = this.getTotalNumber();
const cap = this.getCapacityLeft();
return html`
<div class="mb-4 pb-3 border-b border-zinc-800">
<div class="flex items-center gap-2 text-[13px]">
<!-- Available -->
<span
class="inline-flex items-center gap-1 rounded-full bg-indigo-600/15 px-2 py-0.5 ring-1 ring-indigo-400/40 text-indigo-100"
title=${this.i18n.availableTooltip()}
>
<span class="opacity-90">${this.i18n.availableChip()}</span>
<span class="font-mono tabular-nums">${this.format(total)}</span>
</span>
${cap !== null
? html`
<!-- Cap -->
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 ring-1 ring-amber-400/40 text-amber-200"
title=${this.i18n.capTooltip()}
>
<span class="opacity-90">${this.i18n.cap()}</span>
<span class="font-mono tabular-nums"
>${this.format(cap)}</span
>
</span>
`
: html``}
</div>
</div>
`;
}
private renderPresets(percentNow: number) {
const basis = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
return html`
<div class="mb-8 grid grid-cols-5 gap-2">
${this.PRESETS.map((p) => {
const pct = this.sanitizePercent(p);
const active = (this.selectedPercent ?? percentNow) === pct;
const label = pct === 100 ? this.i18n.max() : `${pct}%`;
return html`
<button
?disabled=${dead}
class="rounded-lg px-3 py-2 text-sm ring-1 transition
${dead
? "bg-zinc-800/70 text-zinc-400 ring-zinc-700 cursor-not-allowed"
: active
? "bg-indigo-600 text-white ring-indigo-300/60"
: "bg-zinc-800 text-zinc-200 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-50"}"
@click=${() => {
if (dead) return;
this.selectedPercent = pct;
const raw = Math.floor((basis * pct) / 100);
this.sendAmount = this.clampSend(raw);
}}
?aria-pressed=${active}
title="${pct}%"
>
${label}
</button>
`;
})}
</div>
`;
}
private renderSlider(percentNow: number) {
const basis = this.getTotalNumber();
const cap = this.getCapacityLeft();
const hardMax = cap === null ? basis : Math.min(basis, cap);
const dead = !this.isSenderAlive() || !this.isTargetAlive();
// Where to draw the cap marker (as % of Available)
const capPercent =
cap === null
? null
: Math.max(
0,
Math.min(
100,
Math.round((Math.min(cap, basis) / (basis || 1)) * 100),
),
);
const fill = this.getFillColor();
const disabled = basis <= 0 || dead;
const sliderOuterMb = capPercent !== null ? "mb-8" : "mb-2";
return html`
<div class="${sliderOuterMb}">
<div
class="relative px-1 rounded-lg overflow-visible focus-within:ring-2 focus-within:ring-indigo-500/30"
>
<input
type="range"
min="0"
.max=${basis}
.value=${this.sendAmount}
?disabled=${disabled}
@input=${(e: Event) => {
if (dead) return;
const raw = Number((e.target as HTMLInputElement).value);
const pctRaw = basis ? Math.round((raw / basis) * 100) : 0;
this.selectedPercent = this.sanitizePercent(pctRaw);
const clamped = Math.min(raw, hardMax);
this.sendAmount = this.clampSend(clamped);
}}
class="w-full appearance-none bg-transparent range-x focus:outline-none"
aria-label=${this.i18n.ariaSlider()}
aria-valuemin="0"
aria-valuemax=${hardMax}
aria-valuetext=${this.i18n.sliderTooltip(
percentNow,
this.format(this.sendAmount),
)}
style="--percent:${percentNow}%; --fill:${fill}; --track: rgba(255,255,255,.28); --thumb-ring: rgb(24 24 27);"
/>
<!-- Tooltip -->
<div
class="pointer-events-none absolute -top-6 -translate-x-1/2 select-none"
style="left:${percentNow}%"
>
<div
class="rounded bg-[#0f1116] ring-1 ring-zinc-700 text-zinc-100 px-1.5 py-0.5 text-[12px] shadow whitespace-nowrap w-max z-50"
>
${percentNow}% ${this.format(this.sendAmount)}
</div>
</div>
<!-- Cap marker -->
${capPercent !== null
? html`
<div
class="pointer-events-none absolute top-1/2 -translate-y-1/2 h-3 w-[2px] bg-amber-400/80 shadow"
style="left:${capPercent}%;"
title=${this.i18n.capTooltip()}
></div>
<div
class="pointer-events-none absolute top-full mt-1.5 -translate-x-1/2 select-none"
style="left:${capPercent}%"
>
<div
class="rounded bg-[#0f1116] ring-1 ring-amber-400/40 text-amber-200 px-1 py-0.5 text-[11px] shadow whitespace-nowrap"
>
${this.i18n.cap()}
</div>
</div>
`
: html``}
</div>
</div>
`;
}
private renderCapacityNote(allowed: number) {
const capped = allowed !== this.sendAmount;
if (!capped) return html``;
return html`<p class="mt-1 text-xs text-amber-300">
${this.i18n.capacityNote(this.format(allowed))}
</p>`;
}
private renderSummary(allowed: number) {
const total = this.getTotalNumber();
const keep = this.keepAfter(allowed);
const belowMinKeep =
this.getMinKeepRatio() > 0 &&
keep < Math.floor(total * this.getMinKeepRatio());
return html`
<div class="mt-3 text-center text-sm text-zinc-200">
${this.i18n.summarySend()}
<span class="font-semibold text-indigo-400 font-mono"
>${this.format(allowed)}</span
>
· ${this.i18n.summaryKeep()}
<span
class="font-semibold font-mono ${belowMinKeep
? "text-amber-400"
: "text-emerald-400"}"
>
${this.format(keep)}
</span>
</div>
`;
}
private renderActions() {
const total = this.getTotalNumber();
const dead = !this.isSenderAlive() || !this.isTargetAlive();
const disabled = total <= 0 || this.clampSend(this.sendAmount) <= 0 || dead;
return html`
<div class="mt-5 flex justify-end gap-2">
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold
text-zinc-100 bg-zinc-800 ring-1 ring-zinc-700
hover:bg-zinc-700 focus:outline-none
focus-visible:ring-2 focus-visible:ring-white/20"
@click=${() => this.closeModal()}
>
${this.i18n.cancel()}
</button>
<button
class="h-10 min-w-24 rounded-lg px-3 text-sm font-semibold text-white
bg-indigo-600 enabled:hover:bg-indigo-500
focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/50
disabled:cursor-not-allowed disabled:opacity-50"
?disabled=${disabled}
@click=${() => this.confirm()}
>
${this.i18n.send()}
</button>
</div>
`;
}
private renderDeadNote() {
return html`
<div
class="mb-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-amber-200 text-sm"
>
<div class="font-semibold">${this.i18n.targetDeadTitle()}</div>
<div>${this.i18n.targetDeadNote()}</div>
</div>
`;
}
private renderSliderStyles() {
return html`
<style>
.range-x {
-webkit-appearance: none;
appearance: none;
height: 8px;
outline: none;
background: transparent;
}
.range-x::-webkit-slider-runnable-track {
height: 8px;
border-radius: 9999px;
background: linear-gradient(
90deg,
var(--fill) 0,
var(--fill) var(--percent),
/* allowed (clamped) fill */ rgba(255, 255, 255, 0.22)
var(--percent),
rgba(255, 255, 255, 0.22) 100%
);
}
.range-x::-webkit-slider-thumb {
-webkit-appearance: none;
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
margin-top: -5px;
}
.range-x::-moz-range-track {
height: 8px;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.22);
}
.range-x::-moz-range-progress {
height: 8px;
border-radius: 9999px;
background: var(--fill);
}
.range-x::-moz-range-thumb {
height: 18px;
width: 18px;
border-radius: 9999px;
background: var(--fill);
border: 3px solid var(--thumb-ring);
}
</style>
`;
}
render() {
if (!this.open) return html``;
const percent = this.percentOfBasis(this.sendAmount);
const allowed = this.limitAmount(this.sendAmount);
return html`
<div class="fixed inset-0 z-[1100] flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/60 backdrop-blur-sm rounded-2xl"
@click=${() => this.closeModal()}
></div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="send-title"
class="relative z-10 w-full max-w-[540px] focus:outline-none"
tabindex="0"
@keydown=${this.handleKeydown}
>
<div
class="rounded-2xl bg-zinc-900 p-5 shadow-2xl ring-1 ring-zinc-800 max-h-[90vh] text-zinc-200"
@click=${(e: MouseEvent) => e.stopPropagation()}
>
${this.renderHeader()} ${this.renderAvailable()}
${!this.isTargetAlive() ? this.renderDeadNote() : html``}
${this.renderPresets(percent)} ${this.renderSlider(percent)}
${this.mode === "troops"
? this.renderCapacityNote(allowed)
: html``}
${this.renderSummary(allowed)} ${this.renderActions()}
${this.renderSliderStyles()}
</div>
</div>
</div>
`;
}
}
@@ -50,6 +50,7 @@ export class SettingsModal extends LitElement implements Layer {
SoundManager.setBackgroundMusicVolume(
this.userSettings.backgroundMusicVolume(),
);
SoundManager.setSoundEffectsVolume(this.userSettings.soundEffectsVolume());
this.eventBus.on(ShowSettingsModalEvent, (event) => {
this.isVisible = event.isVisible;
this.shouldPause = event.shouldPause;
@@ -162,6 +163,13 @@ export class SettingsModal extends LitElement implements Layer {
this.requestUpdate();
}
private onSoundEffectsVolumeChange(event: Event) {
const volume = parseFloat((event.target as HTMLInputElement).value) / 100;
this.userSettings.setSoundEffectsVolume(volume);
SoundManager.setSoundEffectsVolume(volume);
this.requestUpdate();
}
render() {
if (!this.isVisible) {
return null;
@@ -221,6 +229,33 @@ export class SettingsModal extends LitElement implements Layer {
</div>
</div>
<div
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
>
<img
src=${musicIcon}
alt="soundEffectsIcon"
width="20"
height="20"
/>
<div class="flex-1">
<div class="font-medium">
${translateText("user_setting.sound_effects_volume")}
</div>
<input
type="range"
min="0"
max="100"
.value=${this.userSettings.soundEffectsVolume() * 100}
@input=${this.onSoundEffectsVolumeChange}
class="w-full border border-slate-500 rounded-lg"
/>
</div>
<div class="text-sm text-slate-400">
${Math.round(this.userSettings.soundEffectsVolume() * 100)}%
</div>
</div>
<button
class="flex gap-3 items-center w-full text-left p-3 hover:bg-slate-700 rounded text-white transition-colors"
@click="${this.onTerrainButtonClick}"
@@ -0,0 +1,448 @@
import * as PIXI from "pixi.js";
import { Theme } from "../../../core/configuration/Config";
import { Cell, UnitType } from "../../../core/game/Game";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { TransformHandler } from "../TransformHandler";
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
import cityIcon from "../../../../resources/images/CityIcon.png";
import factoryIcon from "../../../../resources/images/FactoryUnit.png";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import SAMMissileIcon from "../../../../resources/images/SamLauncherUnit.png";
import shieldIcon from "../../../../resources/images/ShieldIcon.png";
export const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
[UnitType.City]: "circle",
[UnitType.Port]: "pentagon",
[UnitType.Factory]: "circle",
[UnitType.DefensePost]: "octagon",
[UnitType.SAMLauncher]: "square",
[UnitType.MissileSilo]: "triangle",
[UnitType.Warship]: "cross",
[UnitType.AtomBomb]: "cross",
[UnitType.HydrogenBomb]: "cross",
[UnitType.MIRV]: "cross",
};
export const LEVEL_SCALE_FACTOR = 3;
export const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5;
export const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4;
export const DOTS_ZOOM_THRESHOLD = 0.5;
export const ZOOM_THRESHOLD = 4.3;
export const ICON_SIZE = {
circle: 28,
octagon: 28,
pentagon: 30,
square: 28,
triangle: 28,
cross: 20,
};
export const OFFSET_ZOOM_Y = 4;
export type ShapeType =
| "triangle"
| "square"
| "pentagon"
| "octagon"
| "circle"
| "cross";
export class SpriteFactory {
private theme: Theme;
private game: GameView;
private transformHandler: TransformHandler;
private renderSprites: boolean;
private readonly textureCache: Map<string, PIXI.Texture> = new Map();
private readonly structuresInfos: Map<
UnitType,
{ iconPath: string; image: HTMLImageElement | null }
> = new Map([
[UnitType.City, { iconPath: cityIcon, image: null }],
[UnitType.Factory, { iconPath: factoryIcon, image: null }],
[UnitType.DefensePost, { iconPath: shieldIcon, image: null }],
[UnitType.Port, { iconPath: anchorIcon, image: null }],
[UnitType.MissileSilo, { iconPath: missileSiloIcon, image: null }],
[UnitType.SAMLauncher, { iconPath: SAMMissileIcon, image: null }],
]);
constructor(
theme: Theme,
game: GameView,
transformHandler: TransformHandler,
renderSprites: boolean,
) {
this.theme = theme;
this.game = game;
this.transformHandler = transformHandler;
this.renderSprites = renderSprites;
this.structuresInfos.forEach((u, unitType) => this.loadIcon(u, unitType));
}
private loadIcon(
unitInfo: {
iconPath: string;
image: HTMLImageElement | null;
},
unitType: UnitType,
) {
const image = new Image();
image.src = unitInfo.iconPath;
image.onload = () => {
unitInfo.image = image;
this.invalidateTextureCache(unitType);
};
image.onerror = () => {
console.error(
`Failed to load icon for ${unitType}: ${unitInfo.iconPath}`,
);
};
}
private invalidateTextureCache(unitType: UnitType) {
for (const key of Array.from(this.textureCache.keys())) {
if (
key.endsWith(`-${unitType}-icon`) ||
key === `construction-${unitType}-icon`
) {
this.textureCache.delete(key);
}
}
}
createGhostContainer(
player: PlayerView,
ghostStage: PIXI.Container,
pos: { x: number; y: number },
structureType: UnitType,
): PIXI.Container {
const parentContainer = new PIXI.Container();
const texture = this.createTexture(structureType, player, false, true);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
sprite.alpha = 0.5;
parentContainer.addChild(sprite);
parentContainer.position.set(pos.x, pos.y);
parentContainer.scale.set(
Math.min(1, this.transformHandler.scale / ICON_SCALE_FACTOR_ZOOMED_OUT),
);
ghostStage.addChild(parentContainer);
return parentContainer;
}
// --- internal helpers ---
public createUnitContainer(
unit: UnitView,
options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container },
): PIXI.Container {
const parentContainer = new PIXI.Container();
const tile = unit.tile();
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
const isConstruction = unit.type() === UnitType.Construction;
const constructionType = unit.constructionType();
const structureType = isConstruction ? constructionType! : unit.type();
const { type, stage } = options;
const { scale } = this.transformHandler;
if (type === "icon" || type === "dot") {
if (isConstruction && constructionType === undefined) {
console.warn(
`Unit ${unit.id()} is a construction but has no construction type.`,
);
return parentContainer;
}
const texture = this.createTexture(
structureType,
unit.owner(),
isConstruction,
type === "icon",
);
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
parentContainer.addChild(sprite);
}
if ((type === "icon" || type === "level") && unit.level() > 1) {
const text = new PIXI.BitmapText({
text: unit.level().toString(),
style: { fontFamily: "round_6x6_modified", fontSize: 14 },
});
text.anchor.set(0.5);
const shape = STRUCTURE_SHAPES[structureType];
if (shape !== undefined) {
text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2);
}
parentContainer.addChild(text);
}
const posX = Math.round(screenPos.x);
let posY = Math.round(screenPos.y);
if (type === "level" && scale >= ZOOM_THRESHOLD && this.renderSprites) {
posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y);
}
parentContainer.position.set(posX, posY);
if (type === "icon") {
const s =
scale >= ZOOM_THRESHOLD && !this.renderSprites
? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN)
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
parentContainer.scale.set(s);
} else if (type === "level") {
parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR));
}
stage.addChild(parentContainer);
return parentContainer;
}
private createTexture(
type: UnitType,
owner: PlayerView,
isConstruction: boolean,
renderIcon: boolean,
): PIXI.Texture {
const cacheKey = isConstruction
? `construction-${type}` + (renderIcon ? "-icon" : "")
: `${this.theme.territoryColor(owner).toRgbString()}-${type}` +
(renderIcon ? "-icon" : "");
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const shape = STRUCTURE_SHAPES[type];
const texture = shape
? this.createIcon(owner, type, isConstruction, shape, renderIcon)
: PIXI.Texture.EMPTY;
this.textureCache.set(cacheKey, texture);
return texture;
}
private createIcon(
owner: PlayerView,
structureType: UnitType,
isConstruction: boolean,
shape: string,
renderIcon: boolean,
): PIXI.Texture {
const structureCanvas = document.createElement("canvas");
let iconSize = ICON_SIZE[shape];
if (!renderIcon) {
iconSize /= 2.5;
}
structureCanvas.width = Math.ceil(iconSize);
structureCanvas.height = Math.ceil(iconSize);
const context = structureCanvas.getContext("2d")!;
const tc = owner.territoryColor();
const bc = owner.borderColor();
const darker = bc.luminance() < tc.luminance() ? bc : tc;
const lighter = bc.luminance() < tc.luminance() ? tc : bc;
let borderColor: string;
if (isConstruction) {
context.fillStyle = "rgb(198, 198, 198)";
borderColor = "rgb(128, 127, 127)";
} else {
context.fillStyle = lighter
.lighten(0.13)
.alpha(renderIcon ? 0.65 : 1)
.toRgbString();
const darken = darker.isLight() ? 0.17 : 0.15;
borderColor = darker.darken(darken).toRgbString();
}
context.strokeStyle = borderColor;
context.lineWidth = 1;
const halfIconSize = iconSize / 2;
switch (shape) {
case "triangle":
context.beginPath();
context.moveTo(halfIconSize, 1); // Top
context.lineTo(iconSize - 1, iconSize - 1); // Bottom right
context.lineTo(0, iconSize - 1); // Bottom left
context.closePath();
context.fill();
context.stroke();
break;
case "square":
context.fillRect(1, 1, iconSize - 2, iconSize - 2);
context.strokeRect(1, 1, iconSize - 3, iconSize - 3);
break;
case "octagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 8;
context.beginPath();
for (let i = 0; i < 8; i++) {
const angle = step * i - Math.PI / 8; // slight rotation for flat top
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "pentagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 5;
context.beginPath();
for (let i = 0; i < 5; i++) {
const angle = step * i - Math.PI / 2; // rotate to have flat base or point up
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "cross": {
context.strokeStyle = "rgba(0, 0, 0, 1)";
context.fillStyle = "rgba(0, 0, 0, 1)";
const gap = iconSize * 0.18; // gap at center
const lineLen = iconSize / 2;
context.save();
context.translate(halfIconSize, halfIconSize);
// Up
context.beginPath();
context.moveTo(0, -gap);
context.lineTo(0, -lineLen);
context.stroke();
// Down
context.beginPath();
context.moveTo(0, gap);
context.lineTo(0, lineLen);
context.stroke();
// Left
context.beginPath();
context.moveTo(-gap, 0);
context.lineTo(-lineLen, 0);
context.stroke();
// Right
context.beginPath();
context.moveTo(gap, 0);
context.lineTo(lineLen, 0);
context.stroke();
context.restore();
break;
}
case "circle":
context.beginPath();
context.arc(
halfIconSize,
halfIconSize,
halfIconSize - 1,
0,
Math.PI * 2,
);
context.fill();
context.stroke();
break;
default:
throw new Error(`Unknown shape: ${shape}`);
}
const structureInfo = this.structuresInfos.get(structureType);
if (!structureInfo?.image) {
return PIXI.Texture.from(structureCanvas);
}
if (renderIcon) {
const SHAPE_OFFSETS = {
triangle: [6, 11],
square: [5, 5],
octagon: [6, 6],
pentagon: [7, 7],
circle: [6, 6],
cross: [0, 0],
};
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
context.drawImage(
this.getImageColored(structureInfo.image, borderColor),
offsetX,
offsetY,
);
}
return PIXI.Texture.from(structureCanvas);
}
public createRange(
type: UnitType,
stage: PIXI.Container,
pos: { x: number; y: number },
): PIXI.Container | null {
if (stage === undefined) throw new Error("Not initialized");
const parentContainer = new PIXI.Container();
const circle = new PIXI.Graphics();
let radius = 0;
switch (type) {
case UnitType.SAMLauncher:
radius = this.game.config().defaultSamRange();
break;
case UnitType.Factory:
radius = this.game.config().trainStationMaxRange();
break;
case UnitType.DefensePost:
radius = this.game.config().defensePostRange();
break;
case UnitType.AtomBomb:
radius = this.game.config().nukeMagnitudes(UnitType.AtomBomb).outer;
break;
case UnitType.HydrogenBomb:
radius = this.game.config().nukeMagnitudes(UnitType.HydrogenBomb).outer;
break;
default:
return null;
}
circle
.circle(0, 0, radius)
.stroke({ width: 1, color: 0xffffff, alpha: 0.2 });
parentContainer.addChild(circle);
parentContainer.position.set(pos.x, pos.y);
parentContainer.scale.set(this.transformHandler.scale);
stage.addChild(parentContainer);
return parentContainer;
}
private getImageColored(
image: HTMLImageElement,
color: string,
): HTMLCanvasElement {
const imageCanvas = document.createElement("canvas");
imageCanvas.width = image.width;
imageCanvas.height = image.height;
const ctx = imageCanvas.getContext("2d")!;
ctx.fillStyle = color;
ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(image, 0, 0);
return imageCanvas;
}
}
+308 -396
View File
@@ -3,25 +3,44 @@ import a11yPlugin from "colord/plugins/a11y";
import { OutlineFilter } from "pixi-filters";
import * as PIXI from "pixi.js";
import bitmapFont from "../../../../resources/fonts/round_6x6_modified.xml";
import anchorIcon from "../../../../resources/images/AnchorIcon.png";
import cityIcon from "../../../../resources/images/CityIcon.png";
import factoryIcon from "../../../../resources/images/FactoryUnit.png";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import SAMMissileIcon from "../../../../resources/images/SamLauncherUnit.png";
import shieldIcon from "../../../../resources/images/ShieldIcon.png";
import { Theme } from "../../../core/configuration/Config";
import { EventBus } from "../../../core/EventBus";
import { Cell, PlayerID, UnitType } from "../../../core/game/Game";
import {
BuildableUnit,
Cell,
PlayerActions,
PlayerID,
UnitType,
} from "../../../core/game/Game";
import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, PlayerView, UnitView } from "../../../core/game/GameView";
import { ToggleStructureEvent } from "../../InputHandler";
import { GameView, UnitView } from "../../../core/game/GameView";
import {
MouseMoveEvent,
MouseUpEvent,
ToggleStructureEvent as ToggleStructuresEvent,
} from "../../InputHandler";
import {
BuildUnitIntentEvent,
SendUpgradeStructureIntentEvent,
} from "../../Transport";
import { TransformHandler } from "../TransformHandler";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
import {
DOTS_ZOOM_THRESHOLD,
ICON_SCALE_FACTOR_ZOOMED_IN,
ICON_SCALE_FACTOR_ZOOMED_OUT,
ICON_SIZE,
LEVEL_SCALE_FACTOR,
OFFSET_ZOOM_Y,
SpriteFactory,
STRUCTURE_SHAPES,
ZOOM_THRESHOLD,
} from "./StructureDrawingUtils";
extend([a11yPlugin]);
type ShapeType = "triangle" | "square" | "pentagon" | "octagon" | "circle";
class StructureRenderInfo {
public isOnScreen: boolean = false;
constructor(
@@ -35,68 +54,50 @@ class StructureRenderInfo {
) {}
}
const STRUCTURE_SHAPES: Partial<Record<UnitType, ShapeType>> = {
[UnitType.City]: "circle",
[UnitType.Port]: "pentagon",
[UnitType.Factory]: "circle",
[UnitType.DefensePost]: "octagon",
[UnitType.SAMLauncher]: "square",
[UnitType.MissileSilo]: "triangle",
};
const LEVEL_SCALE_FACTOR = 3;
const ICON_SCALE_FACTOR_ZOOMED_IN = 3.5;
const ICON_SCALE_FACTOR_ZOOMED_OUT = 1.4;
const DOTS_ZOOM_THRESHOLD = 0.5;
const ZOOM_THRESHOLD = 4.3;
const ICON_SIZE = {
circle: 28,
octagon: 28,
pentagon: 30,
square: 28,
triangle: 28,
};
const OFFSET_ZOOM_Y = 4; // offset for the y position of the level over the sprite
export class StructureIconsLayer implements Layer {
private ghostUnit: {
container: PIXI.Container;
range: PIXI.Container | null;
buildableUnit: BuildableUnit;
} | null = null;
private pixicanvas: HTMLCanvasElement;
private iconsStage: PIXI.Container;
private ghostStage: PIXI.Container;
private levelsStage: PIXI.Container;
private rootStage: PIXI.Container = new PIXI.Container();
public playerActions: PlayerActions | null = null;
private dotsStage: PIXI.Container;
private shouldRedraw: boolean = true;
private textureCache: Map<string, PIXI.Texture> = new Map();
private theme: Theme;
private readonly theme: Theme;
private renderer: PIXI.Renderer;
private renders: StructureRenderInfo[] = [];
private seenUnits: Set<UnitView> = new Set();
private structures: Map<
UnitType,
{ visible: boolean; iconPath: string; image: HTMLImageElement | null }
> = new Map([
[UnitType.City, { visible: true, iconPath: cityIcon, image: null }],
[UnitType.Factory, { visible: true, iconPath: factoryIcon, image: null }],
[
UnitType.DefensePost,
{ visible: true, iconPath: shieldIcon, image: null },
],
[UnitType.Port, { visible: true, iconPath: anchorIcon, image: null }],
[
UnitType.MissileSilo,
{ visible: true, iconPath: missileSiloIcon, image: null },
],
[
UnitType.SAMLauncher,
{ visible: true, iconPath: SAMMissileIcon, image: null },
],
]);
private readonly seenUnits: Set<UnitView> = new Set();
private readonly mousePos = { x: 0, y: 0 };
private renderSprites = true;
private factory: SpriteFactory;
private readonly structures: Map<UnitType, { visible: boolean }> = new Map([
[UnitType.City, { visible: true }],
[UnitType.Factory, { visible: true }],
[UnitType.DefensePost, { visible: true }],
[UnitType.Port, { visible: true }],
[UnitType.MissileSilo, { visible: true }],
[UnitType.SAMLauncher, { visible: true }],
]);
private lastGhostQueryAt: number;
potentialUpgrade: StructureRenderInfo | undefined;
constructor(
private game: GameView,
private eventBus: EventBus,
public uiState: UIState,
private transformHandler: TransformHandler,
) {
this.theme = game.config().theme();
this.structures.forEach((u, unitType) => this.loadIcon(u, unitType));
this.factory = new SpriteFactory(
this.theme,
game,
transformHandler,
this.renderSprites,
);
}
async setupRenderer() {
@@ -114,6 +115,10 @@ export class StructureIconsLayer implements Layer {
this.iconsStage.position.set(0, 0);
this.iconsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
this.ghostStage = new PIXI.Container();
this.ghostStage.position.set(0, 0);
this.ghostStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
this.levelsStage = new PIXI.Container();
this.levelsStage.position.set(0, 0);
this.levelsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
@@ -122,6 +127,15 @@ export class StructureIconsLayer implements Layer {
this.dotsStage.position.set(0, 0);
this.dotsStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
this.rootStage.addChild(
this.dotsStage,
this.iconsStage,
this.levelsStage,
this.ghostStage,
);
this.rootStage.position.set(0, 0);
this.rootStage.setSize(this.pixicanvas.width, this.pixicanvas.height);
await this.renderer.init({
canvas: this.pixicanvas,
resolution: 1,
@@ -134,33 +148,18 @@ export class StructureIconsLayer implements Layer {
});
}
private loadIcon(
unitInfo: {
iconPath: string;
image: HTMLImageElement | null;
},
unitType: UnitType,
) {
const image = new Image();
image.src = unitInfo.iconPath;
image.onload = () => {
unitInfo.image = image;
};
image.onerror = () => {
console.error(
`Failed to load icon for ${unitType}: ${unitInfo.iconPath}`,
);
};
}
shouldTransform(): boolean {
return false;
}
async init() {
this.eventBus.on(ToggleStructureEvent, (e) =>
this.toggleStructure(e.structureType),
this.eventBus.on(ToggleStructuresEvent, (e) =>
this.toggleStructures(e.structureTypes),
);
this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e));
this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e));
window.addEventListener("resize", () => this.resizeCanvas());
await this.setupRenderer();
this.redraw();
@@ -171,11 +170,10 @@ export class StructureIconsLayer implements Layer {
this.pixicanvas.width = window.innerWidth;
this.pixicanvas.height = window.innerHeight;
this.renderer.resize(innerWidth, innerHeight, 1);
this.shouldRedraw = true;
}
}
public tick() {
tick() {
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
@@ -192,15 +190,220 @@ export class StructureIconsLayer implements Layer {
this.game.config().userSettings()?.structureSprites() ?? true;
}
private toggleStructure(toggleStructureType: UnitType | null): void {
redraw() {
this.resizeCanvas();
}
renderLayer(mainContext: CanvasRenderingContext2D) {
if (!this.renderer) {
return;
}
if (this.ghostUnit) {
if (this.uiState.ghostStructure === null) {
this.removeGhostStructure();
} else if (
this.uiState.ghostStructure !== this.ghostUnit.buildableUnit.type
) {
this.clearGhostStructure();
}
} else if (this.uiState.ghostStructure !== null) {
this.createGhostStructure(this.uiState.ghostStructure);
}
this.renderGhost();
if (this.transformHandler.hasChanged()) {
for (const render of this.renders) {
this.computeNewLocation(render);
}
}
const scale = this.transformHandler.scale;
this.dotsStage!.visible = scale <= DOTS_ZOOM_THRESHOLD;
this.iconsStage!.visible =
scale > DOTS_ZOOM_THRESHOLD &&
(scale <= ZOOM_THRESHOLD || !this.renderSprites);
this.levelsStage!.visible = scale > ZOOM_THRESHOLD && this.renderSprites;
this.renderer.render(this.rootStage);
mainContext.drawImage(this.renderer.canvas, 0, 0);
}
renderGhost() {
if (!this.ghostUnit) return;
const now = performance.now();
if (now - this.lastGhostQueryAt < 50) {
return;
}
const rect = this.transformHandler.boundingRect();
if (!rect) return;
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
this.lastGhostQueryAt = now;
let tileRef: TileRef | undefined;
const tile = this.transformHandler.screenToWorldCoordinates(localX, localY);
if (this.game.isValidCoord(tile.x, tile.y)) {
tileRef = this.game.ref(tile.x, tile.y);
}
this.game
?.myPlayer()
?.actions(tileRef)
.then((actions) => {
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [];
this.potentialUpgrade.dotContainer.filters = [];
}
if (this.ghostUnit?.container) {
this.ghostUnit.container.filters = [];
}
if (!this.ghostUnit) return;
const unit = actions.buildableUnits.find(
(u) => u.type === this.ghostUnit!.buildableUnit.type,
);
if (!unit) {
Object.assign(this.ghostUnit.buildableUnit, {
canBuild: false,
canUpgrade: false,
});
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
return;
}
this.ghostUnit.buildableUnit = unit;
if (unit.canUpgrade) {
this.potentialUpgrade = this.renders.find(
(r) =>
r.unit.id() === unit.canUpgrade &&
r.unit.owner().id() === this.game.myPlayer()?.id(),
);
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
this.potentialUpgrade.dotContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
];
}
} else if (unit.canBuild === false) {
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
}
const scale = this.transformHandler.scale;
const s =
scale >= ZOOM_THRESHOLD
? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN)
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
this.ghostUnit.container.scale.set(s);
this.ghostUnit.range?.scale.set(this.transformHandler.scale);
});
}
private createStructure(e: MouseUpEvent) {
if (!this.ghostUnit) return;
if (
this.ghostUnit.buildableUnit.canBuild === false &&
this.ghostUnit.buildableUnit.canUpgrade === false
) {
this.removeGhostStructure();
return;
}
const rect = this.transformHandler.boundingRect();
if (!rect) return;
const x = e.x - rect.left;
const y = e.y - rect.top;
const tile = this.transformHandler.screenToWorldCoordinates(x, y);
if (this.ghostUnit.buildableUnit.canUpgrade !== false) {
this.eventBus.emit(
new SendUpgradeStructureIntentEvent(
this.ghostUnit.buildableUnit.canUpgrade,
this.ghostUnit.buildableUnit.type,
),
);
} else if (this.ghostUnit.buildableUnit.canBuild) {
this.eventBus.emit(
new BuildUnitIntentEvent(
this.ghostUnit.buildableUnit.type,
this.game.ref(tile.x, tile.y),
),
);
}
this.removeGhostStructure();
}
private moveGhost(e: MouseMoveEvent) {
this.mousePos.x = e.x;
this.mousePos.y = e.y;
if (!this.ghostUnit) return;
const rect = this.transformHandler.boundingRect();
if (!rect) return;
const localX = e.x - rect.left;
const localY = e.y - rect.top;
this.ghostUnit.container.position.set(localX, localY);
this.ghostUnit.range?.position.set(localX, localY);
}
private createGhostStructure(type: UnitType | null) {
const player = this.game.myPlayer();
if (!player) return;
if (type === null) {
return;
}
const rect = this.transformHandler.boundingRect();
const localX = this.mousePos.x - rect.left;
const localY = this.mousePos.y - rect.top;
this.ghostUnit = {
container: this.factory.createGhostContainer(
player,
this.ghostStage,
{ x: localX, y: localY },
type,
),
range: this.factory.createRange(type, this.ghostStage, {
x: localX,
y: localY,
}),
buildableUnit: { type, canBuild: false, canUpgrade: false, cost: 0n },
};
}
private clearGhostStructure() {
if (this.ghostUnit) {
this.ghostUnit.container.destroy();
this.ghostUnit.range?.destroy();
this.ghostUnit = null;
}
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [];
this.potentialUpgrade.dotContainer.filters = [];
this.potentialUpgrade = undefined;
}
}
private removeGhostStructure() {
this.clearGhostStructure();
this.uiState.ghostStructure = null;
}
private toggleStructures(toggleStructureType: UnitType[] | null): void {
for (const [structureType, infos] of this.structures) {
infos.visible =
structureType === toggleStructureType || toggleStructureType === null;
toggleStructureType?.indexOf(structureType) !== -1 ||
toggleStructureType === null;
}
for (const render of this.renders) {
this.modifyVisibility(render);
}
this.shouldRedraw = true;
}
private findRenderByUnit(
@@ -229,7 +432,6 @@ export class StructureIconsLayer implements Layer {
const render = this.findRenderByUnit(unitView);
if (render) {
this.deleteStructure(render);
this.shouldRedraw = true;
}
}
@@ -278,7 +480,6 @@ export class StructureIconsLayer implements Layer {
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
this.shouldRedraw = true;
}
}
@@ -290,7 +491,6 @@ export class StructureIconsLayer implements Layer {
render.iconContainer = this.createIconSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
this.shouldRedraw = true;
}
}
@@ -304,317 +504,9 @@ export class StructureIconsLayer implements Layer {
render.levelContainer = this.createLevelSprite(unit);
render.dotContainer = this.createDotSprite(unit);
this.modifyVisibility(render);
this.shouldRedraw = true;
}
}
redraw() {
this.resizeCanvas();
}
renderLayer(mainContext: CanvasRenderingContext2D) {
if (!this.renderer) {
return;
}
if (this.transformHandler.hasChanged()) {
for (const render of this.renders) {
this.computeNewLocation(render);
}
}
if (this.transformHandler.hasChanged() || this.shouldRedraw) {
if (this.transformHandler.scale > ZOOM_THRESHOLD && this.renderSprites) {
this.renderer.render(this.levelsStage);
} else if (this.transformHandler.scale > DOTS_ZOOM_THRESHOLD) {
this.renderer.render(this.iconsStage);
} else {
this.renderer.render(this.dotsStage);
}
this.shouldRedraw = false;
}
mainContext.drawImage(this.renderer.canvas, 0, 0);
}
private createTexture(unit: UnitView, renderIcon: boolean): PIXI.Texture {
const isConstruction = unit.type() === UnitType.Construction;
const constructionType = unit.constructionType();
if (isConstruction && constructionType === undefined) {
console.warn(
`Unit ${unit.id()} is a construction but has no construction type.`,
);
return PIXI.Texture.EMPTY;
}
const structureType = isConstruction ? constructionType! : unit.type();
const cacheKey = isConstruction
? `construction-${structureType}` + (renderIcon ? "-icon" : "")
: `${unit.owner().territoryColor().toRgbString()}-${structureType}` +
(renderIcon ? "-icon" : "");
if (this.textureCache.has(cacheKey)) {
return this.textureCache.get(cacheKey)!;
}
const shape = STRUCTURE_SHAPES[structureType];
const texture = shape
? this.createIcon(
unit.owner(),
structureType,
isConstruction,
shape,
renderIcon,
)
: PIXI.Texture.EMPTY;
this.textureCache.set(cacheKey, texture);
return texture;
}
private createIcon(
owner: PlayerView,
structureType: UnitType,
isConstruction: boolean,
shape: ShapeType,
renderIcon: boolean,
): PIXI.Texture {
const structureCanvas = document.createElement("canvas");
let iconSize = ICON_SIZE[shape];
if (!renderIcon) {
iconSize /= 2.5;
}
structureCanvas.width = Math.ceil(iconSize);
structureCanvas.height = Math.ceil(iconSize);
const context = structureCanvas.getContext("2d")!;
const tc = owner.territoryColor();
const bc = owner.borderColor();
const darker = bc.luminance() < tc.luminance() ? bc : tc;
const lighter = bc.luminance() < tc.luminance() ? tc : bc;
let borderColor: string;
if (isConstruction) {
context.fillStyle = "rgb(198, 198, 198)";
borderColor = "rgb(128, 127, 127)";
} else {
context.fillStyle = lighter
.lighten(0.13)
.alpha(renderIcon ? 0.65 : 1)
.toRgbString();
const darken = darker.isLight() ? 0.17 : 0.15;
borderColor = darker.darken(darken).toRgbString();
}
context.strokeStyle = borderColor;
context.lineWidth = 1;
const halfIconSize = iconSize / 2;
switch (shape) {
case "triangle":
context.beginPath();
context.moveTo(halfIconSize, 1); // Top
context.lineTo(iconSize - 1, iconSize - 1); // Bottom right
context.lineTo(0, iconSize - 1); // Bottom left
context.closePath();
context.fill();
context.stroke();
break;
case "square":
context.fillRect(1, 1, iconSize - 2, iconSize - 2);
context.strokeRect(1, 1, iconSize - 3, iconSize - 3);
break;
case "octagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 8;
context.beginPath();
for (let i = 0; i < 8; i++) {
const angle = step * i - Math.PI / 8; // slight rotation for flat top
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "pentagon":
{
const cx = halfIconSize;
const cy = halfIconSize;
const r = halfIconSize - 1;
const step = (Math.PI * 2) / 5;
context.beginPath();
for (let i = 0; i < 5; i++) {
const angle = step * i - Math.PI / 2; // rotate to have flat base or point up
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
}
context.closePath();
context.fill();
context.stroke();
}
break;
case "circle":
context.beginPath();
context.arc(
halfIconSize,
halfIconSize,
halfIconSize - 1,
0,
Math.PI * 2,
);
context.fill();
context.stroke();
break;
default:
throw new Error(`Unknown shape: ${shape}`);
}
const structureInfo = this.structures.get(structureType);
if (!structureInfo?.image) {
console.warn(`Image not loaded for unit type: ${structureType}`);
return PIXI.Texture.from(structureCanvas);
}
if (renderIcon) {
const SHAPE_OFFSETS = {
triangle: [6, 11],
square: [5, 5],
octagon: [6, 6],
pentagon: [7, 7],
circle: [6, 6],
};
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
context.drawImage(
this.getImageColored(structureInfo.image, borderColor),
offsetX,
offsetY,
);
}
return PIXI.Texture.from(structureCanvas);
}
private createLevelSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
type: "level",
stage: this.levelsStage,
});
}
private createDotSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
type: "dot",
stage: this.dotsStage,
});
}
private createIconSprite(unit: UnitView): PIXI.Container {
return this.createUnitContainer(unit, {
type: "icon",
stage: this.iconsStage,
});
}
private createUnitContainer(
unit: UnitView,
options: { type?: "icon" | "dot" | "level"; stage: PIXI.Container },
): PIXI.Container {
const parentContainer = new PIXI.Container();
const tile = unit.tile();
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
const screenPos = this.transformHandler.worldToScreenCoordinates(worldPos);
const { type, stage } = options;
const scale = this.transformHandler.scale;
const spritesEnabled = this.game
.config()
.userSettings()
?.structureSprites?.();
// Add sprite if needed
if (type === "icon" || type === "dot") {
const texture = this.createTexture(unit, type === "icon");
const sprite = new PIXI.Sprite(texture);
sprite.anchor.set(0.5);
parentContainer.addChild(sprite);
}
// Add level text if needed
if ((type === "icon" || type === "level") && unit.level() > 1) {
const text = new PIXI.BitmapText({
text: unit.level().toString(),
style: {
fontFamily: "round_6x6_modified",
fontSize: 14,
},
});
text.anchor.set(0.5);
const unitType =
unit.type() === UnitType.Construction
? unit.constructionType()
: unit.type();
const shape = STRUCTURE_SHAPES[unitType!];
if (shape !== undefined) {
text.position.y = Math.round(-ICON_SIZE[shape] / 2 - 2);
}
parentContainer.addChild(text);
}
// Positioning
const posX = Math.round(screenPos.x);
let posY = Math.round(screenPos.y);
if (type === "level" && scale >= ZOOM_THRESHOLD && spritesEnabled) {
posY = Math.round(screenPos.y - scale * OFFSET_ZOOM_Y);
}
parentContainer.position.set(posX, posY);
// Scaling
if (type === "icon") {
const s =
scale >= ZOOM_THRESHOLD && !spritesEnabled
? Math.max(1, scale / ICON_SCALE_FACTOR_ZOOMED_IN)
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
parentContainer.scale.set(s);
} else if (type === "level") {
parentContainer.scale.set(Math.max(1, scale / LEVEL_SCALE_FACTOR));
}
stage.addChild(parentContainer);
return parentContainer;
}
private getImageColored(
image: HTMLImageElement,
color: string,
): HTMLCanvasElement {
const imageCanvas = document.createElement("canvas");
imageCanvas.width = image.width;
imageCanvas.height = image.height;
const ctx = imageCanvas.getContext("2d")!;
ctx.fillStyle = color;
ctx.fillRect(0, 0, imageCanvas.width, imageCanvas.height);
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage(image, 0, 0);
return imageCanvas;
}
private computeNewLocation(render: StructureRenderInfo) {
const tile = render.unit.tile();
const worldPos = new Cell(this.game.x(tile), this.game.y(tile));
@@ -691,7 +583,27 @@ export class StructureIconsLayer implements Layer {
this.renders.push(render);
this.computeNewLocation(render);
this.modifyVisibility(render);
this.shouldRedraw = true;
}
private createLevelSprite(unit: UnitView): PIXI.Container {
return this.factory.createUnitContainer(unit, {
type: "level",
stage: this.levelsStage,
});
}
private createDotSprite(unit: UnitView): PIXI.Container {
return this.factory.createUnitContainer(unit, {
type: "dot",
stage: this.dotsStage,
});
}
private createIconSprite(unit: UnitView): PIXI.Container {
return this.factory.createUnitContainer(unit, {
type: "icon",
stage: this.iconsStage,
});
}
private deleteStructure(render: StructureRenderInfo) {
+4 -1
View File
@@ -157,7 +157,9 @@ export class StructureLayer implements Layer {
Promise.all(
Array.from(this.unitIcons.values()).map((img) =>
img.decode?.().catch(() => {}),
img.decode?.().catch((err) => {
console.warn("Failed to decode unit icon image:", err);
}),
),
).finally(() => {
this.game.units().forEach((u) => this.handleUnitRendering(u));
@@ -265,6 +267,7 @@ export class StructureLayer implements Layer {
) {
let color = unit.owner().borderColor();
if (unit.type() === UnitType.Construction) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
color = underConstructionColor;
}
+3 -1
View File
@@ -83,7 +83,9 @@ export class TeamStats extends LitElement implements Layer {
}
}
const totalScorePercent = totalScoreSort / this.game.numLandTiles();
const numTilesWithoutFallout =
this.game.numLandTiles() - this.game.numTilesWithFallout();
const totalScorePercent = totalScoreSort / numTilesWithoutFallout;
return {
teamName: teamStr,
+1 -1
View File
@@ -49,7 +49,7 @@ export class TerrainLayer implements Layer {
this.theme = this.game.config().theme();
this.game.forEachTile((tile) => {
const terrainColor = this.theme.terrainColor(this.game, tile);
// TODO: isn'te tileref and index the same?
// TODO: isn't tileref and index the same?
const index = this.game.y(tile) * this.game.width() + this.game.x(tile);
const offset = index * 4;
this.imageData.data[offset] = terrainColor.rgba.r;
+36 -25
View File
@@ -149,6 +149,11 @@ export class TerritoryLayer implements Layer {
if (!this.game.inSpawnPhase()) {
return;
}
this.spawnHighlight();
}
private spawnHighlight() {
if (this.game.ticks() % 5 === 0) {
return;
}
@@ -159,11 +164,18 @@ export class TerritoryLayer implements Layer {
this.game.width(),
this.game.height(),
);
this.drawFocusedPlayerHighlight();
const humans = this.game
.playerViews()
.filter((p) => p.type() === PlayerType.Human);
const focusedPlayer = this.game.focusedPlayer();
for (const human of humans) {
if (human === focusedPlayer) {
continue;
}
const center = human.nameLocation();
if (!center) {
continue;
@@ -190,37 +202,34 @@ export class TerritoryLayer implements Layer {
}
}
}
}
private drawFocusedPlayerHighlight() {
const focusedPlayer = this.game.focusedPlayer();
if (!focusedPlayer) {
return;
}
const center = focusedPlayer.nameLocation();
if (!center) {
return;
}
// Breathing border animation
this.borderAnimTime += 1;
const minPadding = 3;
const maxPadding = 8;
this.borderAnimTime += 3;
const minPadding = 6;
const maxPadding = 12;
// Range: [minPadding..maxPadding]
const breathingPadding =
minPadding +
(maxPadding - minPadding) *
(0.5 + 0.5 * Math.sin(this.borderAnimTime * 0.3));
if (focusedPlayer) {
// Clear previous animated border
if (this.highlightContext) {
this.highlightContext.clearRect(
0,
0,
this.game.width(),
this.game.height(),
);
}
const center = focusedPlayer.nameLocation();
if (center) {
this.drawBreathingRing(
center.x,
center.y,
breathingPadding,
this.theme.spawnHighlightColor(),
);
}
}
this.drawBreathingRing(
center.x,
center.y,
breathingPadding,
this.theme.spawnHighlightColor(),
);
}
init() {
@@ -447,12 +456,14 @@ export class TerritoryLayer implements Layer {
return;
}
const owner = this.game.owner(tile) as PlayerView;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const isHighlighted =
this.highlightedTerritory &&
this.highlightedTerritory.id() === owner.id();
const myPlayer = this.game.myPlayer();
if (this.game.isBorder(tile)) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const playerIsFocused = owner && this.game.focusedPlayer() === owner;
if (myPlayer) {
const alternativeColor = this.alternateViewColor(owner);
@@ -558,7 +569,7 @@ export class TerritoryLayer implements Layer {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = color.toRgbString();
ctx.lineWidth = 2;
ctx.lineWidth = 4;
ctx.stroke();
}
}
+240 -69
View File
@@ -1,30 +1,39 @@
import { html, LitElement } from "lit";
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import portIcon from "../../../../resources/images/AnchorIcon.png";
import warshipIcon from "../../../../resources/images/BattleshipIconWhite.svg";
import cityIcon from "../../../../resources/images/CityIconWhite.svg";
import factoryIcon from "../../../../resources/images/FactoryIconWhite.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloUnit.png";
import mirvIcon from "../../../../resources/images/MIRVIcon.svg";
import missileSiloIcon from "../../../../resources/images/MissileSiloIconWhite.svg";
import hydrogenBombIcon from "../../../../resources/images/MushroomCloudIconWhite.svg";
import atomBombIcon from "../../../../resources/images/NukeIconWhite.svg";
import portIcon from "../../../../resources/images/PortIcon.svg";
import samLauncherIcon from "../../../../resources/images/SamLauncherIconWhite.svg";
import defensePostIcon from "../../../../resources/images/ShieldIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
import { UnitType } from "../../../core/game/Game";
import { Gold, PlayerActions, UnitType } from "../../../core/game/Game";
import { GameView } from "../../../core/game/GameView";
import { ToggleStructureEvent } from "../../InputHandler";
import { renderNumber } from "../../Utils";
import { renderNumber, translateText } from "../../Utils";
import { UIState } from "../UIState";
import { Layer } from "./Layer";
@customElement("unit-display")
export class UnitDisplay extends LitElement implements Layer {
public game: GameView;
public eventBus: EventBus;
private _selectedStructure: UnitType | null = null;
public uiState: UIState;
private playerActions: PlayerActions | null = null;
private keybinds: Record<string, { value: string; key: string }> = {};
private _cities = 0;
private _warships = 0;
private _factories = 0;
private _missileSilo = 0;
private _port = 0;
private _defensePost = 0;
private _samLauncher = 0;
private allDisabled = false;
private _hoveredUnit: UnitType | null = null;
createRenderRoot() {
return this;
@@ -32,18 +41,65 @@ export class UnitDisplay extends LitElement implements Layer {
init() {
const config = this.game.config();
const savedKeybinds = localStorage.getItem("settings.keybinds");
if (savedKeybinds) {
try {
this.keybinds = JSON.parse(savedKeybinds);
} catch (e) {
console.warn("Invalid keybinds JSON:", e);
}
}
this.allDisabled =
config.isUnitDisabled(UnitType.City) &&
config.isUnitDisabled(UnitType.Factory) &&
config.isUnitDisabled(UnitType.Port) &&
config.isUnitDisabled(UnitType.DefensePost) &&
config.isUnitDisabled(UnitType.MissileSilo) &&
config.isUnitDisabled(UnitType.SAMLauncher);
config.isUnitDisabled(UnitType.SAMLauncher) &&
config.isUnitDisabled(UnitType.Warship) &&
config.isUnitDisabled(UnitType.AtomBomb) &&
config.isUnitDisabled(UnitType.HydrogenBomb) &&
config.isUnitDisabled(UnitType.MIRV);
this.requestUpdate();
}
private cost(item: UnitType): Gold {
for (const bu of this.playerActions?.buildableUnits ?? []) {
if (bu.type === item) {
return bu.cost;
}
}
return 0n;
}
private canBuild(item: UnitType): boolean {
if (this.game?.config().isUnitDisabled(item)) return false;
const player = this.game?.myPlayer();
switch (item) {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
case UnitType.MIRV:
return (
this.cost(item) <= (player?.gold() ?? 0n) &&
(player?.units(UnitType.MissileSilo).length ?? 0) > 0
);
case UnitType.Warship:
return (
this.cost(item) <= (player?.gold() ?? 0n) &&
(player?.units(UnitType.Port).length ?? 0) > 0
);
default:
return this.cost(item) <= (player?.gold() ?? 0n);
}
}
tick() {
const player = this.game?.myPlayer();
player?.actions().then((actions) => {
this.playerActions = actions;
});
if (!player) return;
this._cities = player.totalUnitLevels(UnitType.City);
this._missileSilo = player.totalUnitLevels(UnitType.MissileSilo);
@@ -51,42 +107,10 @@ export class UnitDisplay extends LitElement implements Layer {
this._defensePost = player.totalUnitLevels(UnitType.DefensePost);
this._samLauncher = player.totalUnitLevels(UnitType.SAMLauncher);
this._factories = player.totalUnitLevels(UnitType.Factory);
this._warships = player.totalUnitLevels(UnitType.Warship);
this.requestUpdate();
}
private renderUnitItem(
icon: string,
number: number,
unitType: UnitType,
altText: string,
) {
if (this.game.config().isUnitDisabled(unitType)) {
return html``;
}
return html`
<div
class="px-2 flex items-center gap-2 cursor-pointer hover:bg-slate-700/50 rounded text-white"
style="background: ${this._selectedStructure === unitType
? "#ffffff2e"
: "none"}"
@mouseenter="${() =>
this.eventBus.emit(new ToggleStructureEvent(unitType))}"
@mouseleave="${() =>
this.eventBus.emit(new ToggleStructureEvent(null))}"
>
<img
src=${icon}
alt=${altText}
width="20"
height="20"
style="vertical-align: middle;"
/>
${renderNumber(number)}
</div>
`;
}
render() {
const myPlayer = this.game?.myPlayer();
if (
@@ -97,42 +121,189 @@ export class UnitDisplay extends LitElement implements Layer {
) {
return null;
}
if (this.allDisabled) {
return null;
}
return html`
<div
class="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] bg-gray-800/70 backdrop-blur-sm border border-slate-400 rounded-lg p-2 hidden lg:block"
class="hidden 2xl:flex lg:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] 2xl:flex-row xl:flex-col lg:flex-col 2xl:gap-5 xl:gap-2 lg:gap-2 justify-center items-center"
>
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1">
${this.renderUnitItem(cityIcon, this._cities, UnitType.City, "city")}
${this.renderUnitItem(
factoryIcon,
this._factories,
UnitType.Factory,
"factory",
)}
${this.renderUnitItem(portIcon, this._port, UnitType.Port, "port")}
${this.renderUnitItem(
defensePostIcon,
this._defensePost,
UnitType.DefensePost,
"defense post",
)}
${this.renderUnitItem(
missileSiloIcon,
this._missileSilo,
UnitType.MissileSilo,
"missile silo",
)}
${this.renderUnitItem(
samLauncherIcon,
this._samLauncher,
UnitType.SAMLauncher,
"SAM launcher",
)}
<div class="bg-gray-800/70 backdrop-blur-sm rounded-lg p-0.5">
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 w-fit">
${this.renderUnitItem(
cityIcon,
this._cities,
UnitType.City,
"city",
this.keybinds["buildCity"]?.key ?? "1",
)}
${this.renderUnitItem(
factoryIcon,
this._factories,
UnitType.Factory,
"factory",
this.keybinds["buildFactory"]?.key ?? "2",
)}
${this.renderUnitItem(
portIcon,
this._port,
UnitType.Port,
"port",
this.keybinds["buildPort"]?.key ?? "3",
)}
${this.renderUnitItem(
defensePostIcon,
this._defensePost,
UnitType.DefensePost,
"defense_post",
this.keybinds["buildDefensePost"]?.key ?? "4",
)}
${this.renderUnitItem(
missileSiloIcon,
this._missileSilo,
UnitType.MissileSilo,
"missile_silo",
this.keybinds["buildMissileSilo"]?.key ?? "5",
)}
${this.renderUnitItem(
samLauncherIcon,
this._samLauncher,
UnitType.SAMLauncher,
"sam_launcher",
this.keybinds["buildSamLauncher"]?.key ?? "6",
)}
</div>
</div>
<div class="bg-gray-800/70 backdrop-blur-sm rounded-lg p-0.5 w-fit">
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1">
${this.renderUnitItem(
warshipIcon,
this._warships,
UnitType.Warship,
"warship",
this.keybinds["buildWarship"]?.key ?? "7",
)}
${this.renderUnitItem(
atomBombIcon,
null,
UnitType.AtomBomb,
"atom_bomb",
this.keybinds["buildAtomBomb"]?.key ?? "8",
)}
${this.renderUnitItem(
hydrogenBombIcon,
null,
UnitType.HydrogenBomb,
"hydrogen_bomb",
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
)}
${this.renderUnitItem(
mirvIcon,
null,
UnitType.MIRV,
"mirv",
this.keybinds["buildMIRV"]?.key ?? "0",
)}
</div>
</div>
</div>
`;
}
private renderUnitItem(
icon: string,
number: number | null,
unitType: UnitType,
structureKey: string,
hotkey: string,
) {
if (this.game.config().isUnitDisabled(unitType)) {
return html``;
}
const selected = this.uiState.ghostStructure === unitType;
const hovered = this._hoveredUnit === unitType;
return html`
<div
class="flex flex-col items-center relative"
@mouseenter=${() => {
this._hoveredUnit = unitType;
this.requestUpdate();
}}
@mouseleave=${() => {
this._hoveredUnit = null;
this.requestUpdate();
}}
>
${hovered
? html`
<div
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-sm rounded p-1 z-20 shadow-lg pointer-events-none"
>
<div class="font-bold text-sm mb-1">
${translateText(
"unit_type." + structureKey,
)}${` [${hotkey.toUpperCase()}]`}
</div>
<div class="p-2">
${translateText("build_menu.desc." + structureKey)}
</div>
<div>
<span class="text-yellow-300"
>${renderNumber(this.cost(unitType))}</span
>
${translateText("player_info_overlay.gold")}
</div>
</div>
`
: null}
<div
class="${this.canBuild(unitType)
? ""
: "opacity-40"} border border-slate-500 rounded pr-2 pb-1 flex items-center gap-2 cursor-pointer
${selected ? "hover:bg-gray-400/10" : "hover:bg-gray-800"}
rounded text-white ${selected ? "bg-slate-400/20" : ""}"
@click=${() => {
if (selected) {
this.uiState.ghostStructure = null;
} else if (this.canBuild(unitType)) {
this.uiState.ghostStructure = unitType;
}
this.requestUpdate();
}}
@mouseenter=${() => {
switch (unitType) {
case UnitType.AtomBomb:
case UnitType.HydrogenBomb:
this.eventBus?.emit(
new ToggleStructureEvent([
UnitType.MissileSilo,
UnitType.SAMLauncher,
]),
);
break;
case UnitType.Warship:
this.eventBus?.emit(new ToggleStructureEvent([UnitType.Port]));
break;
default:
this.eventBus?.emit(new ToggleStructureEvent([unitType]));
}
}}
@mouseleave=${() =>
this.eventBus?.emit(new ToggleStructureEvent(null))}
>
${html`<div class="ml-1 text-xs relative -top-1.5 text-gray-400">
${hotkey.toUpperCase()}
</div>`}
<div class="flex items-center gap-1 pt-1">
<img
src=${icon}
alt=${structureKey}
style="vertical-align: middle; width: 24px; height: 24px;"
/>
${number !== null ? renderNumber(number) : null}
</div>
</div>
</div>
`;
+16 -2
View File
@@ -1,6 +1,6 @@
import { LitElement, TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { translateText } from "../../../client/Utils";
import { isInIframe, translateText } from "../../../client/Utils";
import { ColorPalette, Pattern } from "../../../core/CosmeticSchemas";
import { EventBus } from "../../../core/EventBus";
import { GameUpdateType } from "../../../core/game/GameUpdates";
@@ -28,11 +28,16 @@ export class WinModal extends LitElement implements Layer {
@state()
showButtons = false;
@state()
private isWin = false;
@state()
private patternContent: TemplateResult | null = null;
private _title: string;
private rand = Math.random();
// Override to prevent shadow DOM creation
createRenderRoot() {
return this;
@@ -68,7 +73,9 @@ export class WinModal extends LitElement implements Layer {
@click=${this.hide}
class="flex-1 px-3 py-3 text-base cursor-pointer bg-blue-500/60 text-white border-0 rounded transition-all duration-200 hover:bg-blue-500/80 hover:-translate-y-px active:translate-y-px"
>
${translateText("win_modal.keep")}
${this.isWin
? translateText("win_modal.keep")
: translateText("win_modal.spectate")}
</button>
</div>
</div>
@@ -93,6 +100,9 @@ export class WinModal extends LitElement implements Layer {
}
innerHtml() {
if (isInIframe() || this.rand < 0.25) {
return this.steamWishlist();
}
return this.renderPatternButton();
}
@@ -230,10 +240,12 @@ export class WinModal extends LitElement implements Layer {
this.eventBus.emit(new SendWinnerEvent(wu.winner, wu.allPlayersStats));
if (wu.winner[1] === this.game.myPlayer()?.team()) {
this._title = translateText("win_modal.your_team");
this.isWin = true;
} else {
this._title = translateText("win_modal.other_team", {
team: wu.winner[1],
});
this.isWin = false;
}
this.show();
} else {
@@ -250,10 +262,12 @@ export class WinModal extends LitElement implements Layer {
winnerClient === this.game.myPlayer()?.clientID()
) {
this._title = translateText("win_modal.you_won");
this.isWin = true;
} else {
this._title = translateText("win_modal.other_won", {
player: winner.name(),
});
this.isWin = false;
}
this.show();
}
+6 -21
View File
@@ -91,12 +91,11 @@
document.documentElement.className = "preload";
</script>
<!-- Playwire ads -->
<script>
window.ramp = window.ramp || {};
window.ramp.que = window.ramp.que || [];
window.ramp.passiveMode = true;
</script>
<!-- Publift/Fuse ads -->
<script
async
src="https://cdn.fuseplatform.net/publift/tags/2/4121/fuse.js"
></script>
<!-- Analytics -->
<script
@@ -197,7 +196,7 @@
</header>
<div class="bg-image"></div>
<gutter-ad-modal></gutter-ad-modal>
<gutter-ads></gutter-ads>
<!-- Main container with responsive padding -->
<main class="flex justify-center flex-grow">
@@ -286,7 +285,6 @@
<div id="app"></div>
<div id="radialMenu" class="radial-menu"></div>
<div class="flex gap-2 fixed right-[10px] top-[10px] z-50 flex-col">
<options-menu></options-menu>
<player-info-overlay></player-info-overlay>
</div>
<div
@@ -370,16 +368,6 @@
>
Terms of Service
</a>
<p style="text-align: center">
<a
href="https://www.playwire.com/contact-direct-sales"
data-i18n="main.advertise"
class="t-link"
target="_blank"
rel="noopener"
>Advertise</a
>
</p>
</div>
</div>
</footer>
@@ -439,9 +427,6 @@
});
</script>
<!-- Playwire ads -->
<script async src="//cdn.intergient.com/1025558/75940/ramp.js"></script>
<!-- Analytics -->
<script
defer
+67 -6
View File
@@ -1,22 +1,42 @@
import { Howl, Howler } from "howler";
import { Howl } from "howler";
import of4 from "../../../proprietary/sounds/music/of4.mp3";
import openfront from "../../../proprietary/sounds/music/openfront.mp3";
import war from "../../../proprietary/sounds/music/war.mp3";
import kaChingSound from "../../../resources/sounds/effects/ka-ching.mp3";
export enum SoundEffect {
KaChing = "ka-ching",
}
class SoundManager {
private backgroundMusic: Howl[] = [];
private currentTrack: number = 0;
private soundEffects: Map<SoundEffect, Howl> = new Map();
private soundEffectsVolume: number = 1;
private backgroundMusicVolume: number = 0;
constructor() {
this.backgroundMusic = [
new Howl({ src: [of4], loop: false, onend: this.playNext.bind(this) }),
new Howl({
src: [of4],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [openfront],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({
src: [war],
loop: false,
onend: this.playNext.bind(this),
volume: 0,
}),
new Howl({ src: [war], loop: false, onend: this.playNext.bind(this) }),
];
this.setBackgroundMusicVolume(0);
this.loadSoundEffect(SoundEffect.KaChing, kaChingSound);
}
public playBackgroundMusic(): void {
@@ -35,14 +55,55 @@ class SoundManager {
}
public setBackgroundMusicVolume(volume: number): void {
const newVolume = Math.max(0, Math.min(1, volume));
Howler.volume(newVolume);
this.backgroundMusicVolume = Math.max(0, Math.min(1, volume));
this.backgroundMusic.forEach((track) => {
track.volume(this.backgroundMusicVolume);
});
}
private playNext(): void {
this.currentTrack = (this.currentTrack + 1) % this.backgroundMusic.length;
this.playBackgroundMusic();
}
public loadSoundEffect(name: SoundEffect, src: string): void {
if (!this.soundEffects.has(name)) {
const sound = new Howl({
src: [src],
volume: this.soundEffectsVolume,
});
this.soundEffects.set(name, sound);
}
}
public playSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.play();
}
}
public setSoundEffectsVolume(volume: number): void {
this.soundEffectsVolume = Math.max(0, Math.min(1, volume));
this.soundEffects.forEach((sound) => {
sound.volume(this.soundEffectsVolume);
});
}
public stopSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.stop();
}
}
public unloadSoundEffect(name: SoundEffect): void {
const sound = this.soundEffects.get(name);
if (sound) {
sound.unload();
this.soundEffects.delete(name);
}
}
}
export default new SoundManager();
+6 -5
View File
@@ -179,18 +179,19 @@ export class GameRunner {
public playerActions(
playerID: PlayerID,
x: number,
y: number,
x?: number,
y?: number,
): PlayerActions {
const player = this.game.player(playerID);
const tile = this.game.ref(x, y);
const tile =
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
const actions = {
canAttack: player.canAttack(tile),
canAttack: tile !== null && player.canAttack(tile),
buildableUnits: player.buildableUnits(tile),
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
} as PlayerActions;
if (this.game.hasOwner(tile)) {
if (tile !== null && this.game.hasOwner(tile)) {
const other = this.game.owner(tile) as Player;
actions.interaction = {
sharedBorder: player.sharesBorderWith(other),
+9 -2
View File
@@ -14,7 +14,6 @@ import {
GameMapType,
GameMode,
GameType,
PlayerType,
Quads,
Trios,
UnitType,
@@ -116,9 +115,9 @@ export type Player = z.infer<typeof PlayerSchema>;
export type PlayerCosmetics = z.infer<typeof PlayerCosmeticsSchema>;
export type PlayerCosmeticRefs = z.infer<typeof PlayerCosmeticRefsSchema>;
export type PlayerPattern = z.infer<typeof PlayerPatternSchema>;
export type PlayerColor = z.infer<typeof PlayerColorSchema>;
export type Flag = z.infer<typeof FlagSchema>;
export type GameStartInfo = z.infer<typeof GameStartInfoSchema>;
const PlayerTypeSchema = z.enum(PlayerType);
export interface GameInfo {
gameID: GameID;
@@ -388,6 +387,7 @@ export const FlagSchema = z
export const PlayerCosmeticRefsSchema = z.object({
flag: FlagSchema.optional(),
color: z.string().optional(),
patternName: PatternNameSchema.optional(),
patternColorPaletteName: z.string().optional(),
@@ -416,6 +416,11 @@ export const PlayerPatternSchema = z.object({
colorPalette: ColorPaletteSchema.optional(),
});
export const PlayerColorSchema = z.object({
color: z.string(),
});
export const PlayerPackSchema = z.object({
structurePort: z.string().optional(),
structureCity: z.string().optional(),
@@ -440,8 +445,10 @@ export type PlayerPack = z.infer<typeof PlayerPackSchema>;
export const PlayerCosmeticsSchema = z.object({
flag: FlagSchema.optional(),
pattern: PlayerPatternSchema.optional(),
color: PlayerColorSchema.optional(),
pack: PlayerPackSchema.optional(),
});
export const PlayerSchema = z.object({
clientID: ID,
username: UsernameSchema,
+58 -25
View File
@@ -91,6 +91,58 @@ export function calculateBoundingBox(
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
}
export function boundingBoxTiles(
gm: GameMap,
center: TileRef,
radius: number,
): TileRef[] {
const tiles: TileRef[] = [];
const centerX = gm.x(center);
const centerY = gm.y(center);
const minX = centerX - radius;
const maxX = centerX + radius;
const minY = centerY - radius;
const maxY = centerY + radius;
// Top and bottom edges (full width)
for (let x = minX; x <= maxX; x++) {
if (gm.isValidCoord(x, minY)) {
tiles.push(gm.ref(x, minY));
}
if (gm.isValidCoord(x, maxY) && minY !== maxY) {
tiles.push(gm.ref(x, maxY));
}
}
// Left and right edges (exclude corners already added)
for (let y = minY + 1; y < maxY; y++) {
if (gm.isValidCoord(minX, y)) {
tiles.push(gm.ref(minX, y));
}
if (gm.isValidCoord(maxX, y) && minX !== maxX) {
tiles.push(gm.ref(maxX, y));
}
}
return tiles;
}
export function getMode<T>(counts: Map<T, number>): T | null {
let mode: T | null = null;
let maxCount = 0;
for (const [item, count] of counts) {
if (count > maxCount) {
maxCount = count;
mode = item;
}
}
return mode;
}
export function calculateBoundingBoxCenter(
gm: GameMap,
borderTiles: ReadonlySet<TileRef>,
@@ -114,27 +166,6 @@ export function inscribed(
);
}
export function getMode(list: Set<number>): number {
// Count occurrences
const counts = new Map<number, number>();
for (const item of list) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
// Find the item with the highest count
let mode = 0;
let maxCount = 0;
for (const [item, count] of counts) {
if (count > maxCount) {
maxCount = count;
mode = item;
}
}
return mode;
}
export function sanitize(name: string): string {
return Array.from(name)
.join("")
@@ -213,7 +244,7 @@ export function assertNever(x: never): never {
export function generateID(): GameID {
const nanoid = customAlphabet(
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
"123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ",
8,
);
return nanoid();
@@ -257,7 +288,7 @@ export function createRandomName(
return randomName;
}
export const emojiTable: string[][] = [
export const emojiTable = [
["😀", "😊", "🥰", "😇", "😎"],
["😞", "🥺", "😭", "😱", "😡"],
["😈", "🤡", "🖕", "🥱", "🤦‍♂️"],
@@ -269,9 +300,11 @@ export const emojiTable: string[][] = [
["⬅️", "🎯", "➡️", "🥈", "🥉"],
["↙️", "⬇️", "↘️", "❤️", "💔"],
["💰", "⚓", "⛵", "🏡", "🛡️"],
];
] as const;
// 2d to 1d array
export const flattenedEmojiTable: string[] = emojiTable.flat();
export const flattenedEmojiTable = emojiTable.flat();
export type Emoji = (typeof flattenedEmojiTable)[number];
/**
* JSON.stringify replacer function that converts bigint values to strings.
+1 -1
View File
@@ -183,7 +183,7 @@ export interface Theme {
// Don't call directly, use PlayerView
territoryColor(playerInfo: PlayerView): Colord;
// Don't call directly, use PlayerView
borderColor(playerInfo: PlayerView): Colord;
borderColor(territoryColor: Colord): Colord;
// Don't call directly, use PlayerView
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
focusedBorderColor(): Colord;
+4
View File
@@ -669,6 +669,10 @@ export class DefaultConfig implements Config {
}
if (attacker.isPlayer() && defender.isPlayer()) {
if (defender.isDisconnected() && attacker.isOnSameTeam(defender)) {
// No troop loss if defender is disconnected.
mag = 0;
}
if (
attacker.type() === PlayerType.Human &&
defender.type() === PlayerType.Bot
+1 -5
View File
@@ -58,13 +58,9 @@ export class DevConfig extends DefaultConfig {
super(sc, gc, us, isReplay);
}
// numSpawnPhaseTurns(): number {
// return this.gameConfig().gameType == GameType.Singleplayer ? 70 : 100;
// // return 100
// }
unitInfo(type: UnitType): UnitInfo {
const info = super.unitInfo(type);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const oldCost = info.cost;
// info.cost = (p: Player) => oldCost(p) / 1000000000;
return info;
+2 -13
View File
@@ -55,19 +55,8 @@ export class PastelTheme implements Theme {
}
// Don't call directly, use PlayerView
borderColor(player: PlayerView): Colord {
if (this.borderColorCache.has(player.id())) {
return this.borderColorCache.get(player.id())!;
}
const tc = this.territoryColor(player).rgba;
const color = colord({
r: Math.max(tc.r - 40, 0),
g: Math.max(tc.g - 40, 0),
b: Math.max(tc.b - 40, 0),
});
this.borderColorCache.set(player.id(), color);
return color;
borderColor(territoryColor: Colord): Colord {
return territoryColor.darken(0.125);
}
defendedBorderColors(territoryColor: Colord): {
-7
View File
@@ -100,13 +100,6 @@ export class AttackExecution implements Execution {
this.active = false;
return;
}
if (this._owner.isOnSameTeam(this.target)) {
console.warn(
`${this._owner.displayName()} cannot attack ${this.target.displayName()} because they are on the same team`,
);
this.active = false;
return;
}
}
this.startTroops ??= this.mg
+2 -2
View File
@@ -20,8 +20,8 @@ export class BotExecution implements Execution {
this.random = new PseudoRandom(simpleHash(bot.id()));
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(20, 30) / 100;
this.triggerRatio = this.random.nextInt(50, 60) / 100;
this.reserveRatio = this.random.nextInt(30, 40) / 100;
this.expandRatio = this.random.nextInt(10, 20) / 100;
}
+79 -165
View File
@@ -1,6 +1,5 @@
import {
Cell,
Difficulty,
Execution,
Game,
Gold,
@@ -14,17 +13,18 @@ import {
Unit,
UnitType,
} from "../game/Game";
import { euclDistFN, manhattanDistFN, TileRef } from "../game/GameMap";
import { TileRef, euclDistFN } from "../game/GameMap";
import { PseudoRandom } from "../PseudoRandom";
import { GameID } from "../Schemas";
import { calculateBoundingBox, flattenedEmojiTable, simpleHash } from "../Util";
import { boundingBoxTiles, calculateBoundingBox, simpleHash } from "../Util";
import { ConstructionExecution } from "./ConstructionExecution";
import { EmojiExecution } from "./EmojiExecution";
import { structureSpawnTileValue } from "./nation/structureSpawnTileValue";
import { NukeExecution } from "./NukeExecution";
import { SpawnExecution } from "./SpawnExecution";
import { TransportShipExecution } from "./TransportShipExecution";
import { closestTwoTiles } from "./Util";
import { BotBehavior } from "./utils/BotBehavior";
import { BotBehavior, EMOJI_HECKLE } from "./utils/BotBehavior";
export class FakeHumanExecution implements Execution {
private active = true;
@@ -39,10 +39,9 @@ export class FakeHumanExecution implements Execution {
private reserveRatio: number;
private expandRatio: number;
private lastEmojiSent = new Map<Player, Tick>();
private lastNukeSent: [Tick, TileRef][] = [];
private embargoMalusApplied = new Set<PlayerID>();
private heckleEmoji: number[];
private readonly lastEmojiSent = new Map<Player, Tick>();
private readonly lastNukeSent: [Tick, TileRef][] = [];
private readonly embargoMalusApplied = new Set<PlayerID>();
constructor(
gameID: GameID,
@@ -53,10 +52,9 @@ export class FakeHumanExecution implements Execution {
);
this.attackRate = this.random.nextInt(40, 80);
this.attackTick = this.random.nextInt(0, this.attackRate);
this.triggerRatio = this.random.nextInt(60, 90) / 100;
this.reserveRatio = this.random.nextInt(30, 60) / 100;
this.expandRatio = this.random.nextInt(15, 25) / 100;
this.heckleEmoji = ["🤡", "😡"].map((e) => flattenedEmojiTable.indexOf(e));
this.triggerRatio = this.random.nextInt(50, 60) / 100;
this.reserveRatio = this.random.nextInt(30, 40) / 100;
this.expandRatio = this.random.nextInt(10, 20) / 100;
}
init(mg: Game) {
@@ -115,7 +113,7 @@ export class FakeHumanExecution implements Execution {
if (ticks % this.attackRate !== this.attackTick) return;
if (this.mg.inSpawnPhase()) {
const rl = this.randomLand();
const rl = this.randomSpawnLand();
if (rl === null) {
console.warn(`cannot spawn ${this.nation.playerInfo.name}`);
return;
@@ -150,7 +148,7 @@ export class FakeHumanExecution implements Execution {
);
// Send an attack on the first tick
this.behavior.sendAttack(this.mg.terraNullius());
this.behavior.forceSendAttack(this.mg.terraNullius());
return;
}
@@ -182,7 +180,6 @@ export class FakeHumanExecution implements Execution {
this.player.breakAlliance(alliance);
// Successfully broken an alliance
return true;
}
@@ -225,23 +222,12 @@ export class FakeHumanExecution implements Execution {
const toAlly = this.random.randElement(enemies);
if (this.player.canSendAllianceRequest(toAlly)) {
this.player.createAllianceRequest(toAlly);
return;
}
}
// 50-50 attack weakest player vs random player
const toAttack = this.random.chance(2)
? enemies[0]
: this.random.randElement(enemies);
if (this.shouldAttack(toAttack)) {
this.behavior.sendAttack(toAttack);
return;
}
this.behavior.forgetOldEnemies();
this.behavior.assistAllies();
const enemy = this.behavior.selectEnemy();
const enemy = this.behavior.selectEnemy(enemies);
if (!enemy) return;
this.maybeSendEmoji(enemy);
this.maybeSendNuke(enemy);
@@ -252,50 +238,6 @@ export class FakeHumanExecution implements Execution {
}
}
private shouldAttack(other: Player): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.player.isOnSameTeam(other)) {
return false;
}
// Consider betrayal for allies
if (this.player.isAlliedWith(other)) {
const canProceed = this.maybeConsiderBetrayal(other);
return canProceed;
}
if (this.player.isFriendly(other)) {
if (this.shouldDiscourageAttack(other)) {
return this.random.chance(200);
}
return this.random.chance(50);
} else {
if (this.shouldDiscourageAttack(other)) {
return this.random.chance(4);
}
return true;
}
}
private shouldDiscourageAttack(other: Player) {
if (other.isTraitor()) {
return false;
}
const difficulty = this.mg.config().gameConfig().difficulty;
if (
difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible
) {
return false;
}
if (other.type() !== PlayerType.Human) {
return false;
}
// Only discourage attacks on Humans who are not traitors on easy or medium difficulty.
return true;
}
private maybeSendEmoji(enemy: Player) {
if (this.player === null) throw new Error("not initialized");
if (enemy.type() !== PlayerType.Human) return;
@@ -306,7 +248,7 @@ export class FakeHumanExecution implements Execution {
new EmojiExecution(
this.player,
enemy.id(),
this.random.randElement(this.heckleEmoji),
this.random.randElement(EMOJI_HECKLE),
),
);
}
@@ -323,6 +265,12 @@ export class FakeHumanExecution implements Execution {
return;
}
const nukeType =
this.player.gold() > this.cost(UnitType.HydrogenBomb)
? UnitType.HydrogenBomb
: UnitType.AtomBomb;
const range = nukeType === UnitType.HydrogenBomb ? 60 : 15;
const structures = other.units(
UnitType.City,
UnitType.DefensePost,
@@ -331,10 +279,7 @@ export class FakeHumanExecution implements Execution {
UnitType.SAMLauncher,
);
const structureTiles = structures.map((u) => u.tile());
const randomTiles: (TileRef | null)[] = new Array(10);
for (let i = 0; i < randomTiles.length; i++) {
randomTiles[i] = this.randTerritoryTile(other);
}
const randomTiles = this.randTerritoryTileArray(10);
const allTiles = randomTiles.concat(structureTiles);
let bestTile: TileRef | null = null;
@@ -342,13 +287,16 @@ export class FakeHumanExecution implements Execution {
this.removeOldNukeEvents();
outer: for (const tile of new Set(allTiles)) {
if (tile === null) continue;
for (const t of this.mg.bfs(tile, manhattanDistFN(tile, 15))) {
// Make sure we nuke at least 15 tiles in border
const boundingBox = boundingBoxTiles(this.mg, tile, range)
// Add radius / 2 in case there is a piece of unwanted territory inside the outer radius that we miss.
.concat(boundingBoxTiles(this.mg, tile, Math.floor(range / 2)));
for (const t of boundingBox) {
// Make sure we nuke away from the border
if (this.mg.owner(t) !== other) {
continue outer;
}
}
if (!this.player.canBuild(UnitType.AtomBomb, tile)) continue;
if (!this.player.canBuild(nukeType, tile)) continue;
const value = this.nukeTileScore(tile, silos, structures);
if (value > bestValue) {
bestTile = tile;
@@ -356,7 +304,7 @@ export class FakeHumanExecution implements Execution {
}
}
if (bestTile !== null) {
this.sendNuke(bestTile);
this.sendNuke(bestTile, nukeType);
}
}
@@ -371,13 +319,14 @@ export class FakeHumanExecution implements Execution {
}
}
private sendNuke(tile: TileRef) {
private sendNuke(
tile: TileRef,
nukeType: UnitType.AtomBomb | UnitType.HydrogenBomb,
) {
if (this.player === null) throw new Error("not initialized");
const tick = this.mg.ticks();
this.lastNukeSent.push([tick, tile]);
this.mg.addExecution(
new NukeExecution(UnitType.AtomBomb, this.player, tile),
);
this.mg.addExecution(new NukeExecution(nukeType, this.player, tile));
}
private nukeTileScore(tile: TileRef, silos: Unit[], targets: Unit[]): number {
@@ -454,18 +403,23 @@ export class FakeHumanExecution implements Execution {
private handleUnits() {
return (
this.maybeSpawnStructure(UnitType.City) ||
this.maybeSpawnStructure(UnitType.Port) ||
this.maybeSpawnStructure(UnitType.City, (num) => num) ||
this.maybeSpawnStructure(UnitType.Port, (num) => num) ||
this.maybeSpawnWarship() ||
this.maybeSpawnStructure(UnitType.Factory) ||
this.maybeSpawnStructure(UnitType.MissileSilo)
this.maybeSpawnStructure(UnitType.Factory, (num) => num) ||
this.maybeSpawnStructure(UnitType.DefensePost, (num) => (num + 2) ** 2) ||
this.maybeSpawnStructure(UnitType.SAMLauncher, (num) => num ** 2) ||
this.maybeSpawnStructure(UnitType.MissileSilo, (num) => num ** 2)
);
}
private maybeSpawnStructure(type: UnitType): boolean {
private maybeSpawnStructure(
type: UnitType,
multiplier: (num: number) => number,
) {
if (this.player === null) throw new Error("not initialized");
const owned = this.player.unitsOwned(type);
const perceivedCostMultiplier = Math.min(owned + 1, 5);
const perceivedCostMultiplier = multiplier(owned + 1);
const realCost = this.cost(type);
const perceivedCost = realCost * BigInt(perceivedCostMultiplier);
if (this.player.gold() < perceivedCost) {
@@ -484,19 +438,17 @@ export class FakeHumanExecution implements Execution {
}
private structureSpawnTile(type: UnitType): TileRef | null {
if (this.player === null) throw new Error("not initialized");
if (this.mg === undefined) throw new Error("Not initialized");
if (this.player === null) throw new Error("Not initialized");
const tiles =
type === UnitType.Port
? Array.from(this.player.borderTiles()).filter((t) =>
this.mg.isOceanShore(t),
)
: Array.from(this.player.tiles());
? this.randCoastalTileArray(25)
: this.randTerritoryTileArray(25);
if (tiles.length === 0) return null;
const valueFunction = this.structureSpawnTileValue(type);
const valueFunction = structureSpawnTileValue(this.mg, this.player, type);
let bestTile: TileRef | null = null;
let bestValue = 0;
const sampledTiles = this.arraySampler(tiles);
for (const t of sampledTiles) {
for (const t of tiles) {
const v = valueFunction(t);
if (v <= bestValue && bestTile !== null) continue;
if (!this.player.canBuild(type, t)) continue;
@@ -507,7 +459,14 @@ export class FakeHumanExecution implements Execution {
return bestTile;
}
private *arraySampler<T>(a: T[], sampleSize = 50): Generator<T> {
private randCoastalTileArray(numTiles: number): TileRef[] {
const tiles = Array.from(this.player!.borderTiles()).filter((t) =>
this.mg.isOceanShore(t),
);
return Array.from(this.arraySampler(tiles, numTiles));
}
private *arraySampler<T>(a: T[], sampleSize: number): Generator<T> {
if (a.length <= sampleSize) {
// Return all elements
yield* a;
@@ -522,69 +481,6 @@ export class FakeHumanExecution implements Execution {
}
}
private structureSpawnTileValue(type: UnitType): (tile: TileRef) => number {
if (this.player === null) throw new Error("not initialized");
const borderTiles = this.player.borderTiles();
const mg = this.mg;
const otherUnits = this.player.units(type);
// Prefer spacing structures out of atom bomb range
const borderSpacing = this.mg
.config()
.nukeMagnitudes(UnitType.AtomBomb).outer;
const structureSpacing = borderSpacing * 2;
switch (type) {
case UnitType.Port:
return (tile) => {
let w = 0;
// Prefer to be far away from other structures of the same type
const otherTiles: Set<TileRef> = new Set(
otherUnits.map((u) => u.tile()),
);
otherTiles.delete(tile);
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
if (closestOther !== null) {
const d = mg.manhattanDist(closestOther.x, tile);
w += Math.min(d, structureSpacing);
}
return w;
};
case UnitType.City:
case UnitType.Factory:
case UnitType.MissileSilo:
return (tile) => {
let w = 0;
// Prefer higher elevations
w += mg.magnitude(tile);
// Prefer to be away from the border
const closestBorder = closestTwoTiles(mg, borderTiles, [tile]);
if (closestBorder !== null) {
const d = mg.manhattanDist(closestBorder.x, tile);
w += Math.min(d, borderSpacing);
}
// Prefer to be away from other structures of the same type
const otherTiles: Set<TileRef> = new Set(
otherUnits.map((u) => u.tile()),
);
otherTiles.delete(tile);
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
if (closestOther !== null) {
const d = mg.manhattanDist(closestOther.x, tile);
w += Math.min(d, structureSpacing);
}
// TODO: Cities and factories should consider train range limits
return w;
};
default:
throw new Error(`Value function not implemented for ${type}`);
}
}
private maybeSpawnWarship(): boolean {
if (this.player === null) throw new Error("not initialized");
if (!this.random.chance(50)) {
@@ -615,8 +511,26 @@ export class FakeHumanExecution implements Execution {
return false;
}
private randTerritoryTile(p: Player): TileRef | null {
const boundingBox = calculateBoundingBox(this.mg, p.borderTiles());
private randTerritoryTileArray(numTiles: number): TileRef[] {
const boundingBox = calculateBoundingBox(
this.mg,
this.player!.borderTiles(),
);
const tiles: TileRef[] = [];
for (let i = 0; i < numTiles; i++) {
const tile = this.randTerritoryTile(this.player!, boundingBox);
if (tile !== null) {
tiles.push(tile);
}
}
return tiles;
}
private randTerritoryTile(
p: Player,
boundingBox: { min: Cell; max: Cell } | null = null,
): TileRef | null {
boundingBox ??= calculateBoundingBox(this.mg, p.borderTiles());
for (let i = 0; i < 100; i++) {
const randX = this.random.nextInt(boundingBox.min.x, boundingBox.max.x);
const randY = this.random.nextInt(boundingBox.min.y, boundingBox.max.y);
@@ -689,7 +603,7 @@ export class FakeHumanExecution implements Execution {
return;
}
randomLand(): TileRef | null {
randomSpawnLand(): TileRef | null {
const delta = 25;
let tries = 0;
while (tries < 50) {
+42 -30
View File
@@ -27,18 +27,30 @@ export class PlayerExecution implements Execution {
tick(ticks: number) {
this.player.decayRelations();
this.player.units().forEach((u) => {
const tileOwner = this.mg!.owner(u.tile());
if (u.info().territoryBound) {
if (tileOwner.isPlayer()) {
if (tileOwner !== this.player) {
this.mg!.player(tileOwner.id()).captureUnit(u);
}
} else {
u.delete();
}
for (const u of this.player.units()) {
if (!u.info().territoryBound) {
continue;
}
});
const owner = this.mg!.owner(u.tile());
if (!owner?.isPlayer()) {
u.delete();
continue;
}
if (owner === this.player) {
continue;
}
const captor = this.mg!.player(owner.id());
if (u.type() === UnitType.DefensePost) {
u.decreaseLevel(captor);
if (u.isActive()) {
captor.captureUnit(u);
}
} else {
captor.captureUnit(u);
}
}
if (!this.player.isAlive()) {
// Player has no tiles, delete any remaining units and gold
@@ -209,22 +221,29 @@ export class PlayerExecution implements Execution {
}
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
const neighborsIDs = new Set<number>();
const neighbors = new Map<Player, number>();
for (const t of cluster) {
for (const neighbor of this.mg.neighbors(t)) {
if (this.mg.ownerID(neighbor) !== this.player.smallID()) {
neighborsIDs.add(this.mg.ownerID(neighbor));
const owner = this.mg.owner(neighbor);
if (
owner.isPlayer() &&
owner !== this.player &&
!owner.isFriendly(this.player)
) {
neighbors.set(owner, (neighbors.get(owner) ?? 0) + 1);
}
}
}
// If there are no enemies, return null
if (neighbors.size === 0) {
return null;
}
// Get the largest attack from the neighbors
let largestNeighborAttack: Player | null = null;
let largestTroopCount: number = 0;
for (const id of neighborsIDs) {
const neighbor = this.mg.playerBySmallID(id);
if (!neighbor.isPlayer() || this.player.isFriendly(neighbor)) {
continue;
}
let largestTroopCount = 0;
for (const [neighbor] of neighbors) {
for (const attack of neighbor.outgoingAttacks()) {
if (attack.target() === this.player) {
if (attack.troops() > largestTroopCount) {
@@ -234,20 +253,13 @@ export class PlayerExecution implements Execution {
}
}
}
if (largestNeighborAttack !== null) {
return largestNeighborAttack;
}
// fall back to getting mode if no attacks
const mode = getMode(neighborsIDs);
if (!this.mg.playerBySmallID(mode).isPlayer()) {
return null;
}
const capturing = this.mg.playerBySmallID(mode);
if (!capturing.isPlayer()) {
return null;
}
return capturing;
// There are no ongoing attacks, so find the enemy with the largest border.
return getMode(neighbors);
}
private calculateClusters(): Set<TileRef>[] {
+2 -2
View File
@@ -164,7 +164,7 @@ export class RailroadExecution implements Execution {
}
private redrawBuildings() {
this.railRoad.from.unit.isActive() && this.railRoad.from.unit.touch();
this.railRoad.to.unit.isActive() && this.railRoad.to.unit.touch();
if (this.railRoad.from.unit.isActive()) this.railRoad.from.unit.touch();
if (this.railRoad.to.unit.isActive()) this.railRoad.to.unit.touch();
}
}
+2 -1
View File
@@ -50,7 +50,8 @@ class SAMTargetingSystem {
private isInRange(tile: TileRef) {
const samTile = this.sam.tile();
const rangeSquared = this.mg.config().defaultSamRange() ** 2;
const range = this.mg.config().defaultSamRange();
const rangeSquared = range * range;
return this.mg.euclideanDistSquared(samTile, tile) <= rangeSquared;
}
+17
View File
@@ -6,6 +6,23 @@ export function getSpawnTiles(gm: GameMap, tile: TileRef): TileRef[] {
);
}
export function closestTile(
gm: GameMap,
refs: Iterable<TileRef>,
tile: TileRef,
): [TileRef | null, number] {
let minDistance = Infinity;
let minRef: TileRef | null = null;
for (const ref of refs) {
const distance = gm.manhattanDist(ref, tile);
if (distance < minDistance) {
minDistance = distance;
minRef = ref;
}
}
return [minRef, minDistance];
}
export function closestTwoTiles(
gm: GameMap,
x: Iterable<TileRef>,
@@ -0,0 +1,154 @@
import { Game, Player, Relation, UnitType } from "../../game/Game";
import { TileRef } from "../../game/GameMap";
import { closestTile, closestTwoTiles } from "../Util";
export function structureSpawnTileValue(
mg: Game,
player: Player,
type: UnitType,
): (tile: TileRef) => number {
const borderTiles = player.borderTiles();
const otherUnits = player.units(type);
// Prefer spacing structures out of atom bomb range
const borderSpacing = mg.config().nukeMagnitudes(UnitType.AtomBomb).outer;
const structureSpacing = borderSpacing * 2;
switch (type) {
case UnitType.City:
case UnitType.Factory:
case UnitType.MissileSilo: {
return (tile) => {
let w = 0;
// Prefer higher elevations
w += mg.magnitude(tile);
// Prefer to be away from the border
const [, closestBorderDist] = closestTile(mg, borderTiles, tile);
w += Math.min(closestBorderDist, borderSpacing);
// Prefer to be away from other structures of the same type
const otherTiles: Set<TileRef> = new Set(
otherUnits.map((u) => u.tile()),
);
otherTiles.delete(tile);
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
if (closestOther !== null) {
const d = mg.manhattanDist(closestOther.x, tile);
w += Math.min(d, structureSpacing);
}
// TODO: Cities and factories should consider train range limits
return w;
};
}
case UnitType.Port: {
return (tile) => {
let w = 0;
// Prefer to be away from other structures of the same type
const otherTiles: Set<TileRef> = new Set(
otherUnits.map((u) => u.tile()),
);
otherTiles.delete(tile);
const [, closestOtherDist] = closestTile(mg, otherTiles, tile);
w += Math.min(closestOtherDist, structureSpacing);
return w;
};
}
case UnitType.DefensePost: {
return (tile) => {
let w = 0;
// Prefer higher elevations
w += mg.magnitude(tile);
const [closest, closestBorderDist] = closestTile(mg, borderTiles, tile);
if (closest !== null) {
// Prefer to be borderSpacing tiles from the border
w += Math.max(
0,
borderSpacing - Math.abs(borderSpacing - closestBorderDist),
);
// Prefer adjacent players who are hostile
const neighbors: Set<Player> = new Set();
for (const tile of mg.neighbors(closest)) {
if (!mg.isLand(tile)) continue;
const id = mg.ownerID(tile);
if (id === player.smallID()) continue;
const neighbor = mg.playerBySmallID(id);
if (!neighbor.isPlayer()) continue;
neighbors.add(neighbor);
}
for (const neighbor of neighbors) {
w +=
borderSpacing * (Relation.Friendly - player.relation(neighbor));
}
}
// Prefer to be away from other structures of the same type
const otherTiles: Set<TileRef> = new Set(
otherUnits.map((u) => u.tile()),
);
otherTiles.delete(tile);
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
if (closestOther !== null) {
const d = mg.manhattanDist(closestOther.x, tile);
w += Math.min(d, structureSpacing);
}
return w;
};
}
case UnitType.SAMLauncher: {
const protectTiles: Set<TileRef> = new Set();
for (const unit of player.units()) {
switch (unit.type()) {
case UnitType.City:
case UnitType.Factory:
case UnitType.MissileSilo:
case UnitType.Port:
protectTiles.add(unit.tile());
}
}
const range = mg.config().defaultSamRange();
const rangeSquared = range * range;
return (tile) => {
let w = 0;
// Prefer higher elevations
w += mg.magnitude(tile);
// Prefer to be away from the border
const closestBorder = closestTwoTiles(mg, borderTiles, [tile]);
if (closestBorder !== null) {
const d = mg.manhattanDist(closestBorder.x, tile);
w += Math.min(d, borderSpacing);
}
// Prefer to be away from other structures of the same type
const otherTiles: Set<TileRef> = new Set(
otherUnits.map((u) => u.tile()),
);
otherTiles.delete(tile);
const closestOther = closestTwoTiles(mg, otherTiles, [tile]);
if (closestOther !== null) {
const d = mg.manhattanDist(closestOther.x, tile);
w += Math.min(d, structureSpacing);
}
// Prefer to be in range of other structures
for (const maybeProtected of protectTiles) {
const distanceSquared = mg.euclideanDistSquared(tile, maybeProtected);
if (distanceSquared > rangeSquared) continue;
w += structureSpacing;
}
return w;
};
}
default:
throw new Error(`Value function not implemented for ${type}`);
}
}
+107 -18
View File
@@ -1,5 +1,6 @@
import {
AllianceRequest,
Difficulty,
Game,
Player,
PlayerType,
@@ -13,11 +14,17 @@ import { AllianceExtensionExecution } from "../alliance/AllianceExtensionExecuti
import { AttackExecution } from "../AttackExecution";
import { EmojiExecution } from "../EmojiExecution";
const emojiId = (e: (typeof flattenedEmojiTable)[number]) =>
flattenedEmojiTable.indexOf(e);
const EMOJI_ASSIST_ACCEPT = (["👍", "⛵", "🤝", "🎯"] as const).map(emojiId);
const EMOJI_RELATION_TOO_LOW = (["🥱", "🤦‍♂️"] as const).map(emojiId);
const EMOJI_TARGET_ME = (["🥺", "💀"] as const).map(emojiId);
const EMOJI_TARGET_ALLY = (["🕊️", "👎"] as const).map(emojiId);
export const EMOJI_HECKLE = (["🤡", "😡"] as const).map(emojiId);
export class BotBehavior {
private enemy: Player | null = null;
private enemyUpdated: Tick;
private assistAcceptEmoji = flattenedEmojiTable.indexOf("👍");
private enemyUpdated: Tick | undefined;
constructor(
private random: PseudoRandom,
@@ -65,23 +72,80 @@ export class BotBehavior {
this.game.addExecution(new EmojiExecution(this.player, player.id(), emoji));
}
private setNewEnemy(newEnemy: Player | null) {
private setNewEnemy(newEnemy: Player | null, force = false) {
if (newEnemy !== null && !force && !this.shouldAttack(newEnemy)) return;
this.enemy = newEnemy;
this.enemyUpdated = this.game.ticks();
}
private shouldAttack(other: Player): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.player.isOnSameTeam(other)) {
return false;
}
const shouldAttack = this.attackChance(other);
if (shouldAttack && this.player.isAlliedWith(other)) {
this.betray(other);
return true;
}
return shouldAttack;
}
private betray(target: Player): void {
if (this.player === null) throw new Error("not initialized");
const alliance = this.player.allianceWith(target);
if (!alliance) return;
this.player.breakAlliance(alliance);
}
private attackChance(other: Player): boolean {
if (this.player === null) throw new Error("not initialized");
if (this.player.isAlliedWith(other)) {
return this.shouldDiscourageAttack(other)
? this.random.chance(200)
: this.random.chance(50);
} else {
return this.shouldDiscourageAttack(other) ? this.random.chance(4) : true;
}
}
private shouldDiscourageAttack(other: Player) {
if (other.isTraitor()) {
return false;
}
const { difficulty } = this.game.config().gameConfig();
if (
difficulty === Difficulty.Hard ||
difficulty === Difficulty.Impossible
) {
return false;
}
if (other.type() !== PlayerType.Human) {
return false;
}
// Only discourage attacks on Humans who are not traitors on easy or medium difficulty.
return true;
}
private clearEnemy() {
this.enemy = null;
}
forgetOldEnemies() {
// Forget old enemies
if (this.game.ticks() - this.enemyUpdated > 100) {
if (this.game.ticks() - (this.enemyUpdated ?? 0) > 100) {
this.clearEnemy();
}
}
private hasSufficientTroops(): boolean {
private hasReserveRatioTroops(): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const ratio = this.player.troops() / maxTroops;
return ratio >= this.reserveRatio;
}
private hasTriggerRatioTroops(): boolean {
const maxTroops = this.game.config().maxTroops(this.player);
const ratio = this.player.troops() / maxTroops;
return ratio >= this.triggerRatio;
@@ -98,7 +162,7 @@ export class BotBehavior {
largestAttacker = attack.attacker();
}
if (largestAttacker !== undefined) {
this.setNewEnemy(largestAttacker);
this.setNewEnemy(largestAttacker, true);
}
}
@@ -110,34 +174,37 @@ export class BotBehavior {
}
assistAllies() {
outer: for (const ally of this.player.allies()) {
for (const ally of this.player.allies()) {
if (ally.targets().length === 0) continue;
if (this.player.relation(ally) < Relation.Friendly) {
// this.emoji(ally, "🤦");
this.emoji(ally, this.random.randElement(EMOJI_RELATION_TOO_LOW));
continue;
}
for (const target of ally.targets()) {
if (target === this.player) {
// this.emoji(ally, "💀");
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ME));
continue;
}
if (this.player.isAlliedWith(target)) {
// this.emoji(ally, "👎");
this.emoji(ally, this.random.randElement(EMOJI_TARGET_ALLY));
continue;
}
// All checks passed, assist them
this.player.updateRelation(ally, -20);
this.setNewEnemy(target);
this.emoji(ally, this.assistAcceptEmoji);
break outer;
this.emoji(ally, this.random.randElement(EMOJI_ASSIST_ACCEPT));
return;
}
}
}
selectEnemy(): Player | null {
selectEnemy(enemies: Player[]): Player | null {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
if (!this.hasSufficientTroops()) return null;
// Save up troops until we reach the reserve ratio
if (!this.hasReserveRatioTroops()) return null;
// Maybe save up troops until we reach the trigger ratio
if (!this.hasTriggerRatioTroops() && !this.random.chance(10)) return null;
// Prefer neighboring bots
const bots = this.player
@@ -165,11 +232,13 @@ export class BotBehavior {
// Retaliate against incoming attacks
if (this.enemy === null) {
// Only after clearing bots
this.checkIncomingAttacks();
}
// Select the most hated player
if (this.enemy === null) {
if (this.enemy === null && this.random.chance(2)) {
// 50% chance
const mostHated = this.player.allRelationsSorted()[0];
if (
mostHated !== undefined &&
@@ -178,6 +247,16 @@ export class BotBehavior {
this.setNewEnemy(mostHated.player);
}
}
// Select the weakest player
if (this.enemy === null && enemies.length > 0) {
this.setNewEnemy(enemies[0]);
}
// Select a random player
if (this.enemy === null && enemies.length > 0) {
this.setNewEnemy(this.random.randElement(enemies));
}
}
// Sanity check, don't attack our allies or teammates
@@ -187,7 +266,7 @@ export class BotBehavior {
selectRandomEnemy(): Player | TerraNullius | null {
if (this.enemy === null) {
// Save up troops until we reach the trigger ratio
if (!this.hasSufficientTroops()) return null;
if (!this.hasTriggerRatioTroops()) return null;
// Choose a new enemy randomly
const neighbors = this.player.neighbors();
@@ -229,6 +308,16 @@ export class BotBehavior {
return this.enemy;
}
forceSendAttack(target: Player | TerraNullius) {
this.game.addExecution(
new AttackExecution(
this.player.troops() / 2,
this.player,
target.isPlayer() ? target.id() : this.game.terraNullius().id(),
),
);
}
sendAttack(target: Player | TerraNullius) {
// Skip attacking friendly targets (allies or teammates) - decision to break alliances should be made by caller
if (target.isPlayer() && this.player.isFriendly(target)) return;
+15 -1
View File
@@ -11,6 +11,13 @@ import { RailNetwork } from "./RailNetwork";
import { Stats } from "./Stats";
import { UnitPredicate } from "./UnitGrid";
function isEnumValue<T extends Record<string, string | number>>(
enumObj: T,
value: unknown,
): value is T[keyof T] {
return Object.values(enumObj).includes(value as T[keyof T]);
}
export type PlayerID = string;
export type Tick = number;
export type Gold = bigint;
@@ -37,6 +44,8 @@ export enum Difficulty {
Hard = "Hard",
Impossible = "Impossible",
}
export const isDifficulty = (value: unknown): value is Difficulty =>
isEnumValue(Difficulty, value);
export type Team = string;
@@ -134,11 +143,15 @@ export enum GameType {
Public = "Public",
Private = "Private",
}
export const isGameType = (value: unknown): value is GameType =>
isEnumValue(GameType, value);
export enum GameMode {
FFA = "Free For All",
Team = "Team",
}
export const isGameMode = (value: unknown): value is GameMode =>
isEnumValue(GameMode, value);
export enum GameMapSize {
Compact = "Compact",
@@ -481,6 +494,7 @@ export interface Unit {
// Upgradable Structures
level(): number;
increaseLevel(): void;
decreaseLevel(destroyer?: Player): void;
// Warships
setPatrolTile(tile: TileRef): void;
@@ -546,7 +560,7 @@ export interface Player {
unitCount(type: UnitType): number;
unitsConstructed(type: UnitType): number;
unitsOwned(type: UnitType): number;
buildableUnits(tile: TileRef): BuildableUnit[];
buildableUnits(tile: TileRef | null): BuildableUnit[];
canBuild(type: UnitType, targetTile: TileRef): TileRef | false;
buildUnit<T extends UnitType>(
type: T,
+2
View File
@@ -160,6 +160,7 @@ export interface PlayerUpdate {
allies: number[];
embargoes: Set<PlayerID>;
isTraitor: boolean;
traitorRemainingTicks?: number;
targets: number[];
outgoingEmojis: EmojiMessage[];
outgoingAttacks: AttackUpdate[];
@@ -168,6 +169,7 @@ export interface PlayerUpdate {
alliances: AllianceView[];
hasSpawned: boolean;
betrayals?: bigint;
lastDeleteUnitTick: Tick;
}
export interface AllianceView {
+40 -27
View File
@@ -197,36 +197,44 @@ export class PlayerView {
);
}
const defaultTerritoryColor = this.game
.config()
.theme()
.territoryColor(this);
const defaultBorderColor = this.game
.config()
.theme()
.borderColor(defaultTerritoryColor);
const pattern = this.cosmetics.pattern;
if (pattern) {
const territoryColor = this.game.config().theme().territoryColor(this);
pattern.colorPalette ??= {
name: "",
primaryColor: territoryColor.toHex(),
secondaryColor: territoryColor.darken(0.125).toHex(),
primaryColor: defaultTerritoryColor.toHex(),
secondaryColor: defaultBorderColor.toHex(),
} satisfies ColorPalette;
}
if (
this.team() === null &&
this.cosmetics.pattern?.colorPalette?.primaryColor !== undefined
) {
if (this.team() === null) {
this._territoryColor = colord(
this.cosmetics.pattern.colorPalette.primaryColor,
this.cosmetics.color?.color ??
this.cosmetics.pattern?.colorPalette?.primaryColor ??
defaultTerritoryColor.toHex(),
);
} else {
this._territoryColor = this.game.config().theme().territoryColor(this);
this._territoryColor = defaultTerritoryColor;
}
if (this.cosmetics.pattern?.colorPalette?.secondaryColor !== undefined) {
this._borderColor = colord(
this.cosmetics.pattern.colorPalette.secondaryColor,
);
} else if (this.game.myClientID() === this.data.clientID) {
this._borderColor = this.game.config().theme().focusedBorderColor();
} else {
this._borderColor = this.game.config().theme().borderColor(this);
}
const maybeFocusedBorderColor =
this.game.myClientID() === this.data.clientID
? this.game.config().theme().focusedBorderColor()
: defaultBorderColor;
this._borderColor = new Colord(
pattern?.colorPalette?.secondaryColor ??
this.cosmetics.color?.color ??
maybeFocusedBorderColor.toHex(),
);
this._defendedBorderColors = this.game
.config()
@@ -264,11 +272,11 @@ export class PlayerView {
: this._defendedBorderColors.dark;
}
async actions(tile: TileRef): Promise<PlayerActions> {
async actions(tile?: TileRef): Promise<PlayerActions> {
return this.game.worker.playerInteraction(
this.id(),
this.game.x(tile),
this.game.y(tile),
tile && this.game.x(tile),
tile && this.game.y(tile),
);
}
@@ -404,6 +412,9 @@ export class PlayerView {
isTraitor(): boolean {
return this.data.isTraitor;
}
getTraitorRemainingTicks(): number {
return Math.max(0, this.data.traitorRemainingTicks ?? 0);
}
outgoingEmojis(): EmojiMessage[] {
return this.data.outgoingEmojis;
}
@@ -415,8 +426,15 @@ export class PlayerView {
return this.data.isDisconnected;
}
lastDeleteUnitTick(): Tick {
return this.data.lastDeleteUnitTick;
}
canDeleteUnit(): boolean {
return true;
return (
this.game.ticks() + 1 - this.lastDeleteUnitTick() >=
this.game.config().deleteUnitCooldown()
);
}
}
@@ -428,7 +446,6 @@ export class GameView implements GameMap {
private updatedTiles: TileRef[] = [];
private _myPlayer: PlayerView | null = null;
private _focusedPlayer: PlayerView | null = null;
private unitGrid: UnitGrid;
@@ -745,10 +762,6 @@ export class GameView implements GameMap {
}
focusedPlayer(): PlayerView | null {
// TODO: renable when performance issues are fixed.
return this.myPlayer();
}
setFocusedPlayer(player: PlayerView | null): void {
this._focusedPlayer = player;
}
}

Some files were not shown because too many files have changed in this diff Show More