force commit
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -16,6 +16,17 @@ This is a fork/rewrite of WarFront.io. Credit to https://github.com/WarFrontIO.
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](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.
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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 |
@@ -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": "Recipient’s 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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (x1–x100)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (x1–x100)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (x1–x100)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "タイプ"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Тип"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Тип"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
{
|
||||
"coordinates": [1094, 1093],
|
||||
"flag": "cd",
|
||||
"name": "Democratic Republic of the Congo",
|
||||
"name": "DR Congo",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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": [
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -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
|
||||
},
|
||||
{
|
||||
|
||||
@@ -253,7 +253,7 @@
|
||||
{
|
||||
"coordinates": [1074, 508],
|
||||
"flag": "cd",
|
||||
"name": "The Democratic Republic of the Congo",
|
||||
"name": "DR Congo",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
@@ -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``;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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``}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
};
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -158,7 +158,6 @@ export class TransformHandler {
|
||||
}
|
||||
|
||||
onGoToPlayer(event: GoToPlayerEvent) {
|
||||
this.game.setFocusedPlayer(event.player);
|
||||
this.clearTarget();
|
||||
const nameLocation = event.player.nameLocation();
|
||||
if (!nameLocation) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { UnitType } from "../../core/game/Game";
|
||||
|
||||
export interface UIState {
|
||||
attackRatio: number;
|
||||
ghostStructure: UnitType | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>[] {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||