mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-22 10:08:11 +00:00
Merge branch 'main' into 2393-show-boat-troops-as-attacking-troops
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
## API Usage
|
||||
# API Usage
|
||||
|
||||
## Games
|
||||
|
||||
### List Game Metadata
|
||||
|
||||
@@ -40,8 +42,7 @@ curl "https://api.openfront.io/public/games?start=2025-10-25T00:00:00Z&end=2025-
|
||||
"type": "Singleplayer",
|
||||
"mode": "Free For All",
|
||||
"difficulty": "Medium"
|
||||
},
|
||||
...
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -75,7 +76,7 @@ curl "https://api.openfront.io/public/game/ABSgwin6?turns=false"
|
||||
|
||||
**Note:** Public player IDs are stripped from game records for privacy.
|
||||
|
||||
---
|
||||
## Players
|
||||
|
||||
### Get Player Info
|
||||
|
||||
@@ -92,3 +93,116 @@ GET https://api.openfront.io/public/player/:playerId
|
||||
```bash
|
||||
curl "https://api.openfront.io/public/player/HabCsQYR"
|
||||
```
|
||||
|
||||
### Get Player Sessions
|
||||
|
||||
Retrieve a list of games & client ids (session ids) for a specific player.
|
||||
|
||||
**Endpoint:**
|
||||
|
||||
```
|
||||
GET https://api.openfront.io/public/player/:playerId/sessions
|
||||
```
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
curl "https://api.openfront.io/public/player/HabCsQYR/sessions"
|
||||
```
|
||||
|
||||
## Clans
|
||||
|
||||
### Clan Leaderboard
|
||||
|
||||
Shows the top 100 clans by `weighted wins`.
|
||||
|
||||
**Endpoint:**
|
||||
|
||||
```
|
||||
GET https://api.openfront.io/public/clans/leaderboard
|
||||
```
|
||||
|
||||
Weighted wins have a half-life of 30 days to favor recent wins.
|
||||
|
||||
Weighted wins are calculated using the following formula:
|
||||
|
||||
```
|
||||
FUNCTION calculateScore(session: ClanSession, decay: NUMBER = 1) → NUMBER
|
||||
// 1. Calculate average team size
|
||||
avgTeamSize ← session.totalPlayerCount ÷ session.numTeams
|
||||
|
||||
// 2. Determine how much the clan contributed to their team
|
||||
// (clan players divided by average players per team)
|
||||
clanMemberRatio ← session.clanPlayerCount ÷ avgTeamSize
|
||||
|
||||
// 3. Apply decay factor (e.g., for older sessions)
|
||||
weightedValue ← clanMemberRatio × decay
|
||||
|
||||
// 4. Calculate match difficulty based on number of teams
|
||||
// More teams → harder to win → higher reward for victory
|
||||
// Uses square root to avoid extreme scaling
|
||||
difficulty ← MAX(1, √(session.numTeams - 1))
|
||||
|
||||
// 5. Return final score:
|
||||
// - Win: reward is multiplied by difficulty
|
||||
// - Loss: penalty is divided by difficulty (less punishment in harder matches)
|
||||
IF session.hasWon THEN
|
||||
RETURN weightedValue × difficulty
|
||||
ELSE
|
||||
RETURN weightedValue ÷ difficulty
|
||||
END IF
|
||||
END FUNCTION
|
||||
```
|
||||
|
||||
### Clan stats
|
||||
|
||||
Displays comprehensive clan performance statistics for a specified clan over a chosen time range. If no time range is provided, it shows lifetime stats (starting from early November 2025).
|
||||
|
||||
Key metrics include:
|
||||
|
||||
- Total games, wins, losses, and win rate
|
||||
- Win/loss ratio and weighted win/loss ratio\* broken down by:
|
||||
- Team type (e.g., 2 teams, 3 teams, duos, trios, etc)
|
||||
- Number of teams in the game (2 teams, 5 teams, 20 teams, etc)
|
||||
|
||||
**Note:** No decay is used, so weighted wins will be different from in the leaderboard.
|
||||
|
||||
**Endpoint**
|
||||
|
||||
```
|
||||
GET https://openfront.io/public/clan/:clanTag
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `start` (optional): ISO 8601 timestamp
|
||||
- `end` (optonal): ISO 8601 timestamp
|
||||
|
||||
**Example**
|
||||
|
||||
```bash
|
||||
curl https://api.openfront.io/public/clan/UN?start=2025-11-15T00:00:00Z &
|
||||
end=2025-11-18T23:59:59Z
|
||||
```
|
||||
|
||||
### Clan Sessions
|
||||
|
||||
A clan session is created any time a player with that clan tag is in a public team game. If no start or end query parameter is provided, lifetime sessions (starting early November 2025) are shown.
|
||||
|
||||
**Endpoint**
|
||||
|
||||
```
|
||||
GET https://api.openfront.io/public/clan/:clanTag/sessions
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `start` (optional): ISO 8601 timestamp
|
||||
- `end` (optonal): ISO 8601 timestamp
|
||||
|
||||
**Example**
|
||||
|
||||
```bash
|
||||
curl https://api.openfront.io/public/clan/UN/sessions?start=2025-11-15T00:00:00Z &
|
||||
end=2025-11-18T23:59:59Z
|
||||
```
|
||||
|
||||
+3
-1
@@ -25,4 +25,6 @@ Licensed under [CC-BY 4.0](https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
## Icons
|
||||
|
||||
Icons from [The Noun Project](https://thenounproject.com/)
|
||||
### [The Noun Project](https://thenounproject.com/)
|
||||
|
||||
Stats icon by [Meko](https://thenounproject.com/mekoda/) – https://thenounproject.com/icon/stats-4942475/
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "Four Islands",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [403, 1296],
|
||||
"flag": "",
|
||||
"name": "Korinthal",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1152, 1251],
|
||||
"flag": "",
|
||||
"name": "Lunareth",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1328, 322],
|
||||
"flag": "",
|
||||
"name": "Sylvoria",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [114, 121],
|
||||
"flag": "",
|
||||
"name": "Myrkwind",
|
||||
"strength": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [1409, 372],
|
||||
"name": "Palestinian Territory",
|
||||
"name": "Palestine",
|
||||
"strength": 1,
|
||||
"flag": "ps"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ var maps = []struct {
|
||||
{Name: "europeclassic"},
|
||||
{Name: "falklandislands"},
|
||||
{Name: "faroeislands"},
|
||||
{Name: "fourislands"},
|
||||
{Name: "gatewaytotheatlantic"},
|
||||
{Name: "giantworldmap"},
|
||||
{Name: "halkidiki"},
|
||||
@@ -195,4 +196,4 @@ func main() {
|
||||
}
|
||||
|
||||
fmt.Println("Terrain maps generated successfully")
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7
-7
@@ -29,7 +29,7 @@
|
||||
"intl-messageformat": "^10.7.16",
|
||||
"ip-anonymize": "^0.1.0",
|
||||
"jose": "^6.0.10",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"nanoid": "^3.3.6",
|
||||
"obscenity": "^0.4.3",
|
||||
"seedrandom": "^3.0.5",
|
||||
@@ -12144,9 +12144,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.4.5",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
|
||||
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -14958,9 +14958,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@
|
||||
"intl-messageformat": "^10.7.16",
|
||||
"ip-anonymize": "^0.1.0",
|
||||
"jose": "^6.0.10",
|
||||
"js-yaml": "^4.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"nanoid": "^3.3.6",
|
||||
"obscenity": "^0.4.3",
|
||||
"seedrandom": "^3.0.5",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1200pt" height="1200pt" version="1.1" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="m479.78 149.95c-4-0.015625-7.8359 1.5664-10.664 4.3906-2.8281 2.8281-4.4102 6.668-4.3945 10.668v870.1c0.042969 3.9609 1.6484 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7695-1.5391 10.59-4.3164 2.8203-2.7812 4.4258-6.5625 4.4688-10.523v-870.1c0.015625-4-1.5625-7.8398-4.3906-10.664-2.8281-2.8281-6.668-4.4102-10.668-4.3945zm14.836 30.117h210.61v839.98h-210.61z"/>
|
||||
<path fill="white" d="m165.09 440.05c-3.9609-0.015626-7.7656 1.5352-10.586 4.3164-2.8203 2.7812-4.4297 6.5625-4.4727 10.523v580.21c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.625 4.332 10.586 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-580.21c-0.042969-3.9609-1.6523-7.7422-4.4727-10.523-2.8203-2.7812-6.625-4.332-10.586-4.3164zm14.84 29.898h210.61v550.1h-210.61z"/>
|
||||
<path fill="white" d="m794.47 729.94c-4-0.015625-7.8398 1.5664-10.668 4.3945-2.8281 2.8281-4.4102 6.668-4.3945 10.664v290.11c0.042969 3.9609 1.6523 7.7422 4.4727 10.523 2.8203 2.7773 6.6289 4.332 10.59 4.3164h240.5c3.9609 0.015625 7.7656-1.5391 10.586-4.3164 2.8203-2.7812 4.4297-6.5625 4.4727-10.523v-290.11c0.015625-3.9961-1.5664-7.8359-4.3945-10.664-2.8281-2.8281-6.6641-4.4102-10.664-4.3945zm14.836 30.121h210.61v259.99h-210.61z"/>
|
||||
<path fill="white" d="m479.69 164.99h240.63v870.02h-240.63z"/>
|
||||
<path fill="white" d="m165 455h240.63v580.01h-240.63z"/>
|
||||
<path fill="white" d="m794.38 745h240.63v290h-240.63z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="834"
|
||||
height="834"
|
||||
shape-rendering="geometricPrecision"
|
||||
image-rendering="optimizeQuality"
|
||||
fill-rule="evenodd"
|
||||
version="1.1"
|
||||
id="svg6"
|
||||
sodipodi:docname="AllianceIconFaded.svg"
|
||||
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
|
||||
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="defs6" />
|
||||
<sodipodi:namedview
|
||||
id="namedview6"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.5"
|
||||
inkscape:cx="417"
|
||||
inkscape:cy="257"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1009"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg6" />
|
||||
<path
|
||||
d="M-.5 397.5v-5l136-239.5c1.667-.667 3.333-.667 5 0 20.752 12.458 41.252 25.291 61.5 38.5.638 1.109 1.138 2.275 1.5 3.5l-135 240.5c-3.0617 2.497-6.0617 2.33-9-.5l-60-37.5zm834-5v5c-21.238 14.207-43.071 27.541-65.5 40-1.285-.45-2.452-1.117-3.5-2L629.5 195c.583-2.499 1.916-4.499 4-6l59-36c1.667-.667 3.333-.667 5 0l136 239.5z"
|
||||
fill="#278f06"
|
||||
id="path1"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
d="M432.5 217.5c22.065-.582 44.065.251 66 2.5l85 17c5.667.667 11.333.667 17 0 9.847-1.549 19.514-3.549 29-6L734 417.5c-10.863 11.197-22.029 22.03-33.5 32.5-7.415 5.46-15.248 10.293-23.5 14.5-79.554-66.724-165.721-123.891-258.5-171.5-4.198-2.514-8.698-4.347-13.5-5.5-14.009 3.961-27.842 8.461-41.5 13.5-13.712 20.712-31.045 37.712-52 51-13.542 7.51-28.042 11.677-43.5 12.5-3.583-.558-7.083-1.391-10.5-2.5-2.082-2.409-3.082-5.243-3-8.5.561-6.244 2.061-12.244 4.5-18 21.319-34.477 44.486-67.644 69.5-99.5 11.022-5.673 22.688-9.34 35-11 23.037-3.569 46.037-6.069 69-7.5zm-231 15c10.445 5.724 21.112 11.224 32 16.5 18.501 4.528 37.168 8.195 56 11-17.45 23.055-33.283 47.222-47.5 72.5-11.16 34.914 1.34 50.747 37.5 47.5 17.364-2.453 33.364-8.453 48-18 18.377-12.71 33.71-28.376 46-47 10.749-4.255 21.749-7.255 33-9 66.843 32.897 129.843 71.564 189 116 24.877 18.276 49.21 37.276 73 57 17.316 16.374 17.316 32.708 0 49-10.221 5.794-20.888 6.794-32 3L502 451.5c-9.201 3.025-10.701 8.191-4.5 15.5L622 542.5c3.242 24.405-7.258 37.905-31.5 40.5-4.333.667-8.667.667-13 0l-109-63c-8.528-.156-11.695 4.011-9.5 12.5l107 63c1.107 12.35-2.726 22.85-11.5 31.5-3.448 1.927-7.115 3.261-11 4-8.72.839-17.387.505-26-1l-85-41c-9.548-.747-12.715 3.42-9.5 12.5l2.5 2.5 82 40c-16.901 24.862-40.068 33.362-69.5 25.5-18.921-3.688-37.421-8.855-55.5-15.5 16.937-19.691 18.437-40.524 4.5-62.5-10.469-13.324-23.969-19.657-40.5-19 1.841-25.828-8.825-44.328-32-55.5-5.764-2.214-11.597-3.047-17.5-2.5 2.293-30.025-10.873-49.525-39.5-58.5-15.974-2.677-29.141 2.157-39.5 14.5-2.973 4.474-6.14 8.807-9.5 13-13.651-30.999-36.318-40.832-68-29.5l-11 9c-10.856-14.036-22.19-27.702-34-41l106-189.5z"
|
||||
fill="#278f04"
|
||||
id="path2"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#28900b"
|
||||
d="M158.5 464.5c17.008.509 28.175 8.842 33.5 25 1.31 7.371.31 14.371-3 21-7.473 10.107-15.306 19.94-23.5 29.5-18.101 6.072-31.601.572-40.5-16.5-3.601-8.846-3.267-17.512 1-26 7.473-10.107 15.306-19.94 23.5-29.5 3.071-1.296 6.071-2.462 9-3.5z"
|
||||
id="path3"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#289009"
|
||||
d="M246.5 471.5c24.731 2.84 35.564 16.507 32.5 41l-58 81c-10.712 9.718-22.212 10.552-34.5 2.5-12.299-9.422-16.465-21.588-12.5-36.5 19.728-28.396 40.228-56.229 61.5-83.5 3.553-2.121 7.22-3.621 11-4.5z"
|
||||
id="path4"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#28900b"
|
||||
d="M293.5 530.5c18.901-.264 30.734 8.736 35.5 27 1.405 7.217.071 13.884-4 20L279.5 639c-19.61 8.459-34.11 2.959-43.5-16.5-3.226-7.013-3.226-14.013 0-21l47-65c3.101-2.852 6.601-4.852 10.5-6z"
|
||||
id="path5"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
<path
|
||||
fill="#28900c"
|
||||
d="M344.5 588.5c23.569 1.069 35.402 13.402 35.5 37-.856 4.044-2.522 7.711-5 11-8.91 11.488-17.577 23.155-26 35-9.872 10.173-21.039 11.673-33.5 4.5-13.736-9.632-18.236-22.465-13.5-38.5 11.116-15.451 22.616-30.617 34.5-45.5 2.809-1.093 5.476-2.26 8-3.5z"
|
||||
id="path6"
|
||||
style="fill:#88da6f;fill-opacity:1;stroke:#000000;stroke-opacity:1;stroke-width:5;stroke-dasharray:none" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+33
-2
@@ -164,6 +164,18 @@
|
||||
"logged_in_with_discord": "Logged in with Discord",
|
||||
"recovery_email_sent": "Recovery email sent to {email}"
|
||||
},
|
||||
"stats_modal": {
|
||||
"title": "Stats",
|
||||
"clan_stats": "Clan Stats",
|
||||
"loading": "Loading...",
|
||||
"error": "Error loading clan stats",
|
||||
"no_stats": "No clan stats available",
|
||||
"clan": "Clan",
|
||||
"games": "Games",
|
||||
"win_score": "Win Score",
|
||||
"loss_score": "Loss Score",
|
||||
"win_loss_ratio": "Win/Loss"
|
||||
},
|
||||
"map": {
|
||||
"map": "Map",
|
||||
"world": "World",
|
||||
@@ -198,7 +210,8 @@
|
||||
"pluto": "Pluto",
|
||||
"montreal": "Montreal",
|
||||
"achiran": "Achiran",
|
||||
"baikalnukewars": "Baikal (Nuke Wars)"
|
||||
"baikalnukewars": "Baikal (Nuke Wars)",
|
||||
"fourislands": "Four Islands"
|
||||
},
|
||||
"map_categories": {
|
||||
"continental": "Continental",
|
||||
@@ -265,7 +278,11 @@
|
||||
"waiting": "Waiting for players...",
|
||||
"random_spawn": "Random spawn",
|
||||
"start": "Start Game",
|
||||
"host_badge": "Host"
|
||||
"host_badge": "Host",
|
||||
"assigned_teams": "Assigned Teams",
|
||||
"empty_teams": "Empty Teams",
|
||||
"empty_team": "Empty",
|
||||
"remove_player": "Remove {{username}}"
|
||||
},
|
||||
"team_colors": {
|
||||
"red": "Red",
|
||||
@@ -672,6 +689,19 @@
|
||||
},
|
||||
"desync_notice": "You are desynced from other players. What you see might differ from other players."
|
||||
},
|
||||
"performance_overlay": {
|
||||
"reset": "Reset",
|
||||
"copy_json_title": "Copy current performance metrics as JSON",
|
||||
"copy_clipboard": "Copy JSON",
|
||||
"copied": "Copied!",
|
||||
"failed_copy": "Failed to copy",
|
||||
"fps": "FPS:",
|
||||
"avg_60s": "Avg (60s):",
|
||||
"frame": "Frame:",
|
||||
"tick_exec": "Tick Exec:",
|
||||
"tick_delay": "Tick Delay:",
|
||||
"layers_header": "Layers (avg / max, sorted by total time):"
|
||||
},
|
||||
"heads_up_message": {
|
||||
"choose_spawn": "Choose a starting location",
|
||||
"random_spawn": "Random spawn is enabled. Selecting starting location for you..."
|
||||
@@ -680,6 +710,7 @@
|
||||
"title": "Skins",
|
||||
"colors": "Colors",
|
||||
"purchase": "Purchase",
|
||||
"show_only_owned": "My Skins",
|
||||
"blocked": {
|
||||
"login": "You must be logged in to access this skin.",
|
||||
"purchase": "Purchase this skin to unlock it."
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"map": {
|
||||
"height": 1500,
|
||||
"num_land_tiles": 517044,
|
||||
"width": 1500
|
||||
},
|
||||
"map16x": {
|
||||
"height": 375,
|
||||
"num_land_tiles": 31134,
|
||||
"width": 375
|
||||
},
|
||||
"map4x": {
|
||||
"height": 750,
|
||||
"num_land_tiles": 127644,
|
||||
"width": 750
|
||||
},
|
||||
"name": "Four Islands",
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [403, 1296],
|
||||
"flag": "",
|
||||
"name": "Korinthal",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1152, 1251],
|
||||
"flag": "",
|
||||
"name": "Lunareth",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [1328, 322],
|
||||
"flag": "",
|
||||
"name": "Sylvoria",
|
||||
"strength": 2
|
||||
},
|
||||
{
|
||||
"coordinates": [114, 121],
|
||||
"flag": "",
|
||||
"name": "Myrkwind",
|
||||
"strength": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -67,7 +67,7 @@
|
||||
{
|
||||
"coordinates": [1409, 372],
|
||||
"flag": "ps",
|
||||
"name": "Palestinian Territory",
|
||||
"name": "Palestine",
|
||||
"strength": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { generateID } from "../core/Util";
|
||||
import "./components/baseComponents/Modal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/LobbyTeamView";
|
||||
import "./components/Maps";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
|
||||
@@ -554,27 +555,13 @@ export class HostLobbyModal extends LitElement {
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="players-list">
|
||||
${this.clients.map(
|
||||
(client) => html`
|
||||
<span class="player-tag">
|
||||
${client.username}
|
||||
${client.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="host-badge"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`
|
||||
<button
|
||||
class="remove-player-btn"
|
||||
@click=${() => this.kickPlayer(client.clientID)}
|
||||
title="Remove ${client.username}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
`}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
<lobby-team-view
|
||||
.gameMode=${this.gameMode}
|
||||
.clients=${this.clients}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.teamCount=${this.teamCount}
|
||||
.onKickPlayer=${(clientID: string) => this.kickPlayer(clientID)}
|
||||
></lobby-team-view>
|
||||
</div>
|
||||
|
||||
<div class="start-game-button-container">
|
||||
|
||||
@@ -27,6 +27,7 @@ import { NewsModal } from "./NewsModal";
|
||||
import "./PublicLobby";
|
||||
import { PublicLobby } from "./PublicLobby";
|
||||
import { SinglePlayerModal } from "./SinglePlayerModal";
|
||||
import "./StatsModal";
|
||||
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
|
||||
import { TokenLoginModal } from "./TokenLoginModal";
|
||||
import { SendKickPlayerIntentEvent } from "./Transport";
|
||||
@@ -524,6 +525,7 @@ class Client {
|
||||
"news-modal",
|
||||
"flag-input-modal",
|
||||
"account-button",
|
||||
"stats-button",
|
||||
"token-login",
|
||||
"matchmaking-modal",
|
||||
].forEach((tag) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { renderDuration, translateText } from "../client/Utils";
|
||||
import { GameMapType, GameMode } from "../core/game/Game";
|
||||
import { GameMapType, GameMode, HumansVsNations } from "../core/game/Game";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
@@ -161,7 +161,9 @@ export class PublicLobby extends LitElement {
|
||||
>
|
||||
${lobby.gameConfig.gameMode === GameMode.Team
|
||||
? typeof teamCount === "string"
|
||||
? translateText(`public_lobby.teams_${teamCount}`)
|
||||
? teamCount === HumansVsNations
|
||||
? translateText("public_lobby.teams_hvn")
|
||||
: translateText(`public_lobby.teams_${teamCount}`)
|
||||
: translateText("public_lobby.teams", {
|
||||
num: teamCount ?? 0,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators.js";
|
||||
import {
|
||||
ClanLeaderboardResponse,
|
||||
ClanLeaderboardResponseSchema,
|
||||
} from "../core/ApiSchemas";
|
||||
import { getApiBase } from "./jwt";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@customElement("stats-modal")
|
||||
export class StatsModal extends LitElement {
|
||||
@query("o-modal")
|
||||
private modalEl!: HTMLElement & {
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
@state() private isLoading: boolean = false;
|
||||
@state() private error: string | null = null;
|
||||
@state() private data: ClanLeaderboardResponse | null = null;
|
||||
|
||||
private hasLoaded = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.modalEl?.open();
|
||||
if (!this.hasLoaded && !this.isLoading) {
|
||||
void this.loadLeaderboard();
|
||||
}
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
|
||||
private async loadLeaderboard() {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${getApiBase()}/public/clans/leaderboard`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Unexpected status ${res.status}`);
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const parsed = ClanLeaderboardResponseSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
console.warn(
|
||||
"ClanLeaderboardModal: invalid response schema",
|
||||
parsed.error,
|
||||
);
|
||||
throw new Error("Invalid response format");
|
||||
}
|
||||
|
||||
this.data = parsed.data;
|
||||
this.hasLoaded = true;
|
||||
} catch (err) {
|
||||
console.warn("ClanLeaderboardModal: failed to load leaderboard", err);
|
||||
this.error = translateText("stats_modal.error");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private renderBody() {
|
||||
if (this.isLoading) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-2 text-lg font-semibold">
|
||||
${translateText("stats_modal.loading")}
|
||||
</p>
|
||||
<div
|
||||
class="w-6 h-6 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this.error) {
|
||||
return html`
|
||||
<div class="flex flex-col items-center justify-center p-6 text-white">
|
||||
<p class="mb-4 text-center">${this.error}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium"
|
||||
@click=${() => this.loadLeaderboard()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this.data || this.data.clans.length === 0) {
|
||||
return html`
|
||||
<div class="p-6 text-center text-gray-200">
|
||||
<p class="text-lg font-semibold mb-2">
|
||||
${translateText("stats_modal.no_stats")}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const { start, end, clans } = this.data;
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
return html`
|
||||
<div class="p-4 md:p-6 text-gray-200">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-center md:justify-between mb-4 gap-2"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">
|
||||
${translateText("stats_modal.clan_stats")}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
${startDate.toLocaleDateString()} ·
|
||||
${endDate.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-700 text-gray-300">
|
||||
<th class="py-2 pr-3 text-left">
|
||||
${translateText("stats_modal.clan")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.games")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.win_score")}
|
||||
</th>
|
||||
<th class="py-2 px-2 text-right">
|
||||
${translateText("stats_modal.loss_score")}
|
||||
</th>
|
||||
<th class="py-2 pl-2 text-right">
|
||||
${translateText("stats_modal.win_loss_ratio")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${clans.map(
|
||||
(clan) => html`
|
||||
<tr class="border-b border-gray-800 last:border-b-0">
|
||||
<td class="py-2 pr-3 font-semibold text-left">
|
||||
${clan.clanTag}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
${clan.games.toLocaleString()}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedWins}</td>
|
||||
<td class="py-2 px-2 text-right">${clan.weightedLosses}</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
${clan.weightedWLRatio}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<o-modal id="stats-modal" title="${translateText("stats_modal.title")}">
|
||||
${this.renderBody()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("stats-button")
|
||||
export class StatsButton extends LitElement {
|
||||
@query("stats-modal") private statsModal: StatsModal;
|
||||
@state() private isVisible: boolean = true;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed top-20 right-4 z-[9999]">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${translateText("stats_modal.title")}"
|
||||
>
|
||||
<img src="/icons/stats.svg" alt="Stats" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<stats-modal></stats-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.isVisible = true;
|
||||
this.requestUpdate();
|
||||
this.statsModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.statsModal?.close();
|
||||
this.isVisible = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
@state() private selectedColor: string | null = null;
|
||||
|
||||
@state() private activeTab: "patterns" | "colors" = "patterns";
|
||||
@state() private showOnlyOwned: boolean = false;
|
||||
|
||||
private cosmetics: Cosmetics | null = null;
|
||||
|
||||
@@ -112,6 +113,9 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
if (rel === "blocked") {
|
||||
continue;
|
||||
}
|
||||
if (this.showOnlyOwned && rel !== "owned") {
|
||||
continue;
|
||||
}
|
||||
buttons.push(html`
|
||||
<pattern-button
|
||||
.pattern=${pattern}
|
||||
@@ -128,19 +132,34 @@ export class TerritoryPatternsModal extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2"
|
||||
style="justify-content: center; align-items: flex-start;"
|
||||
>
|
||||
${this.affiliateCode === null
|
||||
? html`
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
`
|
||||
: html``}
|
||||
${buttons}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium transition-colors duration-200 rounded-lg ${this
|
||||
.showOnlyOwned
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600"}"
|
||||
@click=${() => {
|
||||
this.showOnlyOwned = !this.showOnlyOwned;
|
||||
}}
|
||||
>
|
||||
${translateText("territory_patterns.show_only_owned")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-4 p-2"
|
||||
style="justify-content: center; align-items: flex-start;"
|
||||
>
|
||||
${this.affiliateCode === null
|
||||
? html`
|
||||
<pattern-button
|
||||
.pattern=${null}
|
||||
.onSelect=${(p: Pattern | null) => this.selectPattern(null)}
|
||||
></pattern-button>
|
||||
`
|
||||
: html``}
|
||||
${buttons}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { PastelTheme } from "../../core/configuration/PastelTheme";
|
||||
import {
|
||||
ColoredTeams,
|
||||
Duos,
|
||||
GameMode,
|
||||
HumansVsNations,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
Quads,
|
||||
Team,
|
||||
Trios,
|
||||
} from "../../core/game/Game";
|
||||
import { assignTeams } from "../../core/game/TeamAssignment";
|
||||
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export interface TeamPreviewData {
|
||||
team: Team;
|
||||
players: ClientInfo[];
|
||||
}
|
||||
|
||||
@customElement("lobby-team-view")
|
||||
export class LobbyTeamView extends LitElement {
|
||||
@property({ type: String }) gameMode: GameMode = GameMode.FFA;
|
||||
@property({ type: Array }) clients: ClientInfo[] = [];
|
||||
@state() private teamPreview: TeamPreviewData[] = [];
|
||||
@state() private teamMaxSize: number = 0;
|
||||
@property({ type: String }) lobbyCreatorClientID: string = "";
|
||||
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
|
||||
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
|
||||
|
||||
private theme: PastelTheme = new PastelTheme();
|
||||
@state() private showTeamColors: boolean = false;
|
||||
|
||||
willUpdate(changedProperties: Map<string, any>) {
|
||||
// Recompute team preview when relevant properties change
|
||||
if (
|
||||
changedProperties.has("gameMode") ||
|
||||
changedProperties.has("clients") ||
|
||||
changedProperties.has("teamCount")
|
||||
) {
|
||||
this.computeTeamPreview();
|
||||
this.showTeamColors = this.getTeamList().length <= 7;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="players-list">
|
||||
${this.gameMode === GameMode.Team
|
||||
? this.renderTeamMode()
|
||||
: this.renderFreeForAll()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private renderTeamMode() {
|
||||
const active = this.teamPreview.filter((t) => t.players.length > 0);
|
||||
const empty = this.teamPreview.filter((t) => t.players.length === 0);
|
||||
return html` <div
|
||||
class="flex flex-col md:flex-row gap-3 md:gap-4 items-stretch max-h-[65vh]"
|
||||
>
|
||||
<div
|
||||
class="w-full md:w-60 bg-gray-800 p-2 border border-gray-700 rounded-lg max-h-40 md:max-h-[65vh] overflow-auto"
|
||||
>
|
||||
<div class="font-bold mb-1.5 text-gray-300 text-sm">
|
||||
${translateText("host_modal.players")}
|
||||
</div>
|
||||
${repeat(
|
||||
this.clients,
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<div class="px-2 py-1 rounded bg-gray-700/70 mb-1 text-xs">
|
||||
${client.username}
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 flex flex-col gap-3 md:gap-4 overflow-auto max-h-[65vh] md:pr-1"
|
||||
>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-200 mb-1 text-sm">
|
||||
${translateText("host_modal.assigned_teams")}
|
||||
</div>
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
|
||||
${repeat(
|
||||
active,
|
||||
(p) => p.team,
|
||||
(preview) => this.renderTeamCard(preview, false),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-200 mb-1 text-sm">
|
||||
${translateText("host_modal.empty_teams")}
|
||||
</div>
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-2 md:gap-3">
|
||||
${repeat(
|
||||
empty,
|
||||
(p) => p.team,
|
||||
(preview) => this.renderTeamCard(preview, true),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderFreeForAll() {
|
||||
return html`${repeat(
|
||||
this.clients,
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<span class="player-tag">
|
||||
${client.username}
|
||||
${client.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="host-badge"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`<button
|
||||
class="remove-player-btn"
|
||||
@click=${() => this.onKickPlayer?.(client.clientID)}
|
||||
aria-label=${translateText("host_modal.remove_player", {
|
||||
username: client.username,
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>`}
|
||||
</span>`,
|
||||
)} `;
|
||||
}
|
||||
|
||||
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
|
||||
return html`
|
||||
<div class="bg-gray-800 border border-gray-700 rounded-xl flex flex-col">
|
||||
<div
|
||||
class="px-2 py-1 font-bold flex items-center justify-between text-white rounded-t-xl text-[13px] gap-2 bg-gray-700/70"
|
||||
>
|
||||
${this.showTeamColors
|
||||
? html`<span
|
||||
class="inline-block w-2.5 h-2.5 rounded-full border-2 border-white/90 shadow-inner"
|
||||
style="background:${this.teamHeaderColor(preview.team)};"
|
||||
></span>`
|
||||
: null}
|
||||
<span class="truncate">${preview.team}</span>
|
||||
<span class="text-white/90"
|
||||
>${preview.players.length}/${this.teamMaxSize}</span
|
||||
>
|
||||
</div>
|
||||
<div class="p-2 ${isEmpty ? "" : "flex flex-col gap-1.5"}">
|
||||
${isEmpty
|
||||
? html`<div class="text-[11px] italic text-gray-400">
|
||||
${translateText("host_modal.empty_team")}
|
||||
</div>`
|
||||
: repeat(
|
||||
preview.players,
|
||||
(p) => p.clientID ?? p.username,
|
||||
(p) =>
|
||||
html` <div
|
||||
class="bg-gray-700/70 px-2 py-1 rounded text-xs flex items-center justify-between"
|
||||
>
|
||||
<span class="truncate">${p.username}</span>
|
||||
${p.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="ml-2 text-[11px] text-green-300"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
>`
|
||||
: html`<button
|
||||
class="remove-player-btn ml-2"
|
||||
@click=${() => this.onKickPlayer?.(p.clientID)}
|
||||
aria-label=${translateText(
|
||||
"host_modal.remove_player",
|
||||
{
|
||||
username: p.username,
|
||||
},
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>`}
|
||||
</div>`,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getTeamList(): Team[] {
|
||||
if (this.gameMode !== GameMode.Team) return [];
|
||||
const playerCount = this.clients.length;
|
||||
const config = this.teamCount;
|
||||
|
||||
if (config === HumansVsNations) {
|
||||
return [ColoredTeams.Humans, ColoredTeams.Nations];
|
||||
}
|
||||
|
||||
let numTeams: number;
|
||||
if (typeof config === "number") {
|
||||
numTeams = Math.max(2, config);
|
||||
} else {
|
||||
const divisor =
|
||||
config === Duos ? 2 : config === Trios ? 3 : config === Quads ? 4 : 2;
|
||||
numTeams = Math.max(2, Math.ceil(playerCount / divisor));
|
||||
}
|
||||
|
||||
if (numTeams < 8) {
|
||||
const ordered: Team[] = [
|
||||
ColoredTeams.Red,
|
||||
ColoredTeams.Blue,
|
||||
ColoredTeams.Yellow,
|
||||
ColoredTeams.Green,
|
||||
ColoredTeams.Purple,
|
||||
ColoredTeams.Orange,
|
||||
ColoredTeams.Teal,
|
||||
];
|
||||
return ordered.slice(0, numTeams);
|
||||
}
|
||||
|
||||
return Array.from({ length: numTeams }, (_, i) => `Team ${i + 1}`);
|
||||
}
|
||||
|
||||
private teamHeaderColor(team: Team): string {
|
||||
try {
|
||||
return this.theme.teamColor(team).toHex();
|
||||
} catch {
|
||||
return "#3b3f46"; // Default gray for unknown teams
|
||||
}
|
||||
}
|
||||
|
||||
private computeTeamPreview() {
|
||||
if (this.gameMode !== GameMode.Team) {
|
||||
this.teamPreview = [];
|
||||
this.teamMaxSize = 0;
|
||||
return;
|
||||
}
|
||||
const teams = this.getTeamList();
|
||||
|
||||
// HumansVsNations: show all clients under Humans initially
|
||||
if (this.teamCount === HumansVsNations) {
|
||||
this.teamMaxSize = this.clients.length;
|
||||
this.teamPreview = [
|
||||
{ team: ColoredTeams.Humans, players: [...this.clients] },
|
||||
{ team: ColoredTeams.Nations, players: [] },
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
const players = this.clients.map(
|
||||
(c) =>
|
||||
new PlayerInfo(c.username, PlayerType.Human, c.clientID, c.clientID),
|
||||
);
|
||||
const assignment = assignTeams(players, teams);
|
||||
const buckets = new Map<Team, ClientInfo[]>();
|
||||
for (const t of teams) buckets.set(t, []);
|
||||
|
||||
for (const [p, team] of assignment.entries()) {
|
||||
if (team === "kicked") continue;
|
||||
const bucket = buckets.get(team);
|
||||
if (!bucket) continue;
|
||||
const client =
|
||||
this.clients.find((c) => c.clientID === p.clientID) ??
|
||||
this.clients.find((c) => c.username === p.name);
|
||||
if (client) bucket.push(client);
|
||||
}
|
||||
|
||||
// Compute per-team capacity safely and align with common team sizes
|
||||
if (this.teamCount === Duos) {
|
||||
this.teamMaxSize = 2;
|
||||
} else if (this.teamCount === Trios) {
|
||||
this.teamMaxSize = 3;
|
||||
} else if (this.teamCount === Quads) {
|
||||
this.teamMaxSize = 4;
|
||||
} else {
|
||||
// Fallback: divide players across teams; guard against 0 and empty lobbies
|
||||
this.teamMaxSize = Math.max(
|
||||
1,
|
||||
Math.ceil(this.clients.length / teams.length),
|
||||
);
|
||||
}
|
||||
this.teamPreview = teams.map((t) => ({
|
||||
team: t,
|
||||
players: buckets.get(t) ?? [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
Montreal: "Montreal",
|
||||
Achiran: "Achiran",
|
||||
BaikalNukeWars: "Baikal (Nuke Wars)",
|
||||
FourIslands: "Four Islands",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -979,6 +979,11 @@
|
||||
"continent": "Asia",
|
||||
"name": "Iran"
|
||||
},
|
||||
{
|
||||
"code": "Pahlavi Iran",
|
||||
"continent": "Asia",
|
||||
"name": "Pahlavi Iran"
|
||||
},
|
||||
{
|
||||
"code": "ie",
|
||||
"continent": "Europe",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export class FrameProfiler {
|
||||
private static timings: Record<string, number> = {};
|
||||
private static enabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Enable or disable profiling.
|
||||
*/
|
||||
static setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if profiling is enabled.
|
||||
*/
|
||||
static isEnabled(): boolean {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all accumulated timings for the current frame.
|
||||
*/
|
||||
static clear(): void {
|
||||
if (!this.enabled) return;
|
||||
this.timings = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a duration (in ms) for a named span.
|
||||
*/
|
||||
static record(name: string, duration: number): void {
|
||||
if (!this.enabled || !Number.isFinite(duration)) return;
|
||||
this.timings[name] = (this.timings[name] ?? 0) + duration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to start a span.
|
||||
* Returns a high-resolution timestamp to be passed into end().
|
||||
*/
|
||||
static start(): number {
|
||||
if (!this.enabled) return 0;
|
||||
return performance.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to end a span started with start().
|
||||
*/
|
||||
static end(name: string, startTime: number): void {
|
||||
if (!this.enabled || startTime === 0) return;
|
||||
const duration = performance.now() - startTime;
|
||||
this.record(name, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume and reset all timings collected so far.
|
||||
*/
|
||||
static consume(): Record<string, number> {
|
||||
if (!this.enabled) return {};
|
||||
const copy = { ...this.timings };
|
||||
this.timings = {};
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { GameView } from "../../core/game/GameView";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { GameStartingModal } from "../GameStartingModal";
|
||||
import { RefreshGraphicsEvent as RedrawGraphicsEvent } from "../InputHandler";
|
||||
import { FrameProfiler } from "./FrameProfiler";
|
||||
import { TransformHandler } from "./TransformHandler";
|
||||
import { UIState } from "./UIState";
|
||||
import { AdTimer } from "./layers/AdTimer";
|
||||
@@ -343,6 +344,7 @@ export class GameRenderer {
|
||||
}
|
||||
|
||||
renderGame() {
|
||||
FrameProfiler.clear();
|
||||
const start = performance.now();
|
||||
// Set background
|
||||
this.context.fillStyle = this.game
|
||||
@@ -375,7 +377,10 @@ export class GameRenderer {
|
||||
needsTransform,
|
||||
isTransformActive,
|
||||
);
|
||||
|
||||
const layerStart = FrameProfiler.start();
|
||||
layer.renderLayer?.(this.context);
|
||||
FrameProfiler.end(layer.constructor?.name ?? "UnknownLayer", layerStart);
|
||||
}
|
||||
handleTransformState(false, isTransformActive); // Ensure context is clean after rendering
|
||||
this.transformHandler.resetChanged();
|
||||
@@ -383,7 +388,8 @@ export class GameRenderer {
|
||||
requestAnimationFrame(() => this.renderGame());
|
||||
const duration = performance.now() - start;
|
||||
|
||||
this.performanceOverlay.updateFrameMetrics(duration);
|
||||
const layerDurations = FrameProfiler.consume();
|
||||
this.performanceOverlay.updateFrameMetrics(duration, layerDurations);
|
||||
|
||||
if (duration > 50) {
|
||||
console.warn(
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import allianceIcon from "../../../resources/images/AllianceIcon.svg";
|
||||
import allianceIconFaded from "../../../resources/images/AllianceIconFaded.svg";
|
||||
import allianceRequestBlackIcon from "../../../resources/images/AllianceRequestBlackIcon.svg";
|
||||
import allianceRequestWhiteIcon from "../../../resources/images/AllianceRequestWhiteIcon.svg";
|
||||
import crownIcon from "../../../resources/images/CrownIcon.svg";
|
||||
import disconnectedIcon from "../../../resources/images/DisconnectedIcon.svg";
|
||||
import embargoBlackIcon from "../../../resources/images/EmbargoBlackIcon.svg";
|
||||
import embargoWhiteIcon from "../../../resources/images/EmbargoWhiteIcon.svg";
|
||||
import nukeRedIcon from "../../../resources/images/NukeIconRed.svg";
|
||||
import nukeWhiteIcon from "../../../resources/images/NukeIconWhite.svg";
|
||||
import questionMarkIcon from "../../../resources/images/QuestionMarkIcon.svg";
|
||||
import targetIcon from "../../../resources/images/TargetIcon.svg";
|
||||
import traitorIcon from "../../../resources/images/TraitorIcon.svg";
|
||||
import { AllPlayers, nukeTypes } from "../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../core/game/GameView";
|
||||
|
||||
export type PlayerIconId =
|
||||
| "crown"
|
||||
| "traitor"
|
||||
| "disconnected"
|
||||
| "alliance"
|
||||
| "alliance-request"
|
||||
| "target"
|
||||
| "emoji"
|
||||
| "embargo"
|
||||
| "nuke";
|
||||
|
||||
export type PlayerIconKind = "image" | "emoji";
|
||||
|
||||
export interface PlayerIconDescriptor {
|
||||
id: PlayerIconId;
|
||||
kind: PlayerIconKind;
|
||||
/** Image URL for image icons */
|
||||
src?: string;
|
||||
/** Text content for emoji icons */
|
||||
text?: string;
|
||||
/** Whether the icon should be visually centered over the name */
|
||||
center?: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerIconParams {
|
||||
game: GameView;
|
||||
player: PlayerView;
|
||||
/** Whether the alliance icon (handshake) should be included */
|
||||
includeAllianceIcon: boolean;
|
||||
/** Player currently in first place, used for the crown icon */
|
||||
firstPlace: PlayerView | null;
|
||||
}
|
||||
|
||||
export function getFirstPlacePlayer(game: GameView): PlayerView | null {
|
||||
const sorted = game
|
||||
.playerViews()
|
||||
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
|
||||
return sorted.length > 0 ? sorted[0] : null;
|
||||
}
|
||||
|
||||
export function getPlayerIcons(
|
||||
params: PlayerIconParams,
|
||||
): PlayerIconDescriptor[] {
|
||||
const { game, player, includeAllianceIcon, firstPlace } = params;
|
||||
|
||||
const myPlayer = game.myPlayer();
|
||||
const userSettings = game.config().userSettings();
|
||||
const isDarkMode = userSettings?.darkMode() ?? false;
|
||||
const emojisEnabled = userSettings?.emojis() ?? false;
|
||||
|
||||
const icons: PlayerIconDescriptor[] = [];
|
||||
|
||||
// Crown icon for first place
|
||||
if (player === firstPlace) {
|
||||
icons.push({ id: "crown", kind: "image", src: crownIcon });
|
||||
}
|
||||
|
||||
// Traitor icon
|
||||
if (player.isTraitor()) {
|
||||
icons.push({ id: "traitor", kind: "image", src: traitorIcon });
|
||||
}
|
||||
|
||||
// Disconnected icon
|
||||
if (player.isDisconnected()) {
|
||||
icons.push({ id: "disconnected", kind: "image", src: disconnectedIcon });
|
||||
}
|
||||
|
||||
// Alliance icon
|
||||
if (
|
||||
includeAllianceIcon &&
|
||||
myPlayer !== null &&
|
||||
myPlayer.isAlliedWith(player)
|
||||
) {
|
||||
icons.push({ id: "alliance", kind: "image", src: allianceIcon });
|
||||
}
|
||||
|
||||
// Alliance request icon (theme dependent)
|
||||
if (myPlayer !== null && player.isRequestingAllianceWith(myPlayer)) {
|
||||
const allianceRequestIcon = isDarkMode
|
||||
? allianceRequestWhiteIcon
|
||||
: allianceRequestBlackIcon;
|
||||
icons.push({
|
||||
id: "alliance-request",
|
||||
kind: "image",
|
||||
src: allianceRequestIcon,
|
||||
});
|
||||
}
|
||||
|
||||
// Target icon (centered on the map, but regular in overlays)
|
||||
if (myPlayer !== null && new Set(myPlayer.transitiveTargets()).has(player)) {
|
||||
icons.push({ id: "target", kind: "image", src: targetIcon, center: true });
|
||||
}
|
||||
|
||||
// Emoji handling
|
||||
if (emojisEnabled) {
|
||||
const emojis = player
|
||||
.outgoingEmojis()
|
||||
.filter(
|
||||
(emoji) =>
|
||||
emoji.recipientID === AllPlayers ||
|
||||
emoji.recipientID === myPlayer?.smallID(),
|
||||
);
|
||||
|
||||
if (emojis.length > 0) {
|
||||
icons.push({
|
||||
id: "emoji",
|
||||
kind: "emoji",
|
||||
text: emojis[0].message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Embargo icon (theme dependent)
|
||||
if (myPlayer?.hasEmbargo(player)) {
|
||||
const embargoIcon = isDarkMode ? embargoWhiteIcon : embargoBlackIcon;
|
||||
icons.push({ id: "embargo", kind: "image", src: embargoIcon });
|
||||
}
|
||||
|
||||
// Nuke icon (different color depending on whether the local player is the target)
|
||||
const nukesSentByOtherPlayer = game.units(...nukeTypes).filter((unit) => {
|
||||
const isSendingNuke = player.id() === unit.owner().id();
|
||||
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
|
||||
return isSendingNuke && notMyPlayer && unit.isActive();
|
||||
});
|
||||
|
||||
const isMyPlayerTarget = nukesSentByOtherPlayer.some((unit) => {
|
||||
const detonationDst = unit.targetTile();
|
||||
if (!detonationDst || !myPlayer) return false;
|
||||
const targetId = game.owner(detonationDst).id();
|
||||
return targetId === myPlayer.id();
|
||||
});
|
||||
|
||||
if (nukesSentByOtherPlayer.length > 0) {
|
||||
const icon = isMyPlayerTarget ? nukeRedIcon : nukeWhiteIcon;
|
||||
icons.push({ id: "nuke", kind: "image", src: icon });
|
||||
}
|
||||
|
||||
return icons;
|
||||
}
|
||||
|
||||
export function createAllianceProgressIcon(
|
||||
size: number,
|
||||
fraction: number,
|
||||
hasExtensionRequest: boolean,
|
||||
darkMode: boolean,
|
||||
): HTMLDivElement {
|
||||
// Wrapper
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.setAttribute("data-icon", "alliance");
|
||||
wrapper.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.style.width = `${size}px`;
|
||||
wrapper.style.height = `${size}px`;
|
||||
wrapper.style.display = "inline-block";
|
||||
|
||||
// Base faded icon (full)
|
||||
const base = document.createElement("img");
|
||||
base.src = allianceIconFaded;
|
||||
base.style.width = `${size}px`;
|
||||
base.style.height = `${size}px`;
|
||||
base.style.display = "block";
|
||||
base.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(base);
|
||||
|
||||
// Overlay container for green portion, clipped from the top via clip-path
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "alliance-progress-overlay";
|
||||
overlay.style.position = "absolute";
|
||||
overlay.style.left = "0";
|
||||
overlay.style.top = "0";
|
||||
overlay.style.width = "100%";
|
||||
overlay.style.height = "100%";
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
|
||||
const colored = document.createElement("img");
|
||||
colored.src = allianceIcon; // green icon
|
||||
colored.style.width = `${size}px`;
|
||||
colored.style.height = `${size}px`;
|
||||
colored.style.display = "block";
|
||||
colored.setAttribute("dark-mode", darkMode.toString());
|
||||
overlay.appendChild(colored);
|
||||
|
||||
wrapper.appendChild(overlay);
|
||||
|
||||
// Question mark overlay (shown when there's a pending extension request)
|
||||
const questionMark = document.createElement("img");
|
||||
questionMark.className = "alliance-question-mark";
|
||||
questionMark.src = questionMarkIcon;
|
||||
questionMark.style.position = "absolute";
|
||||
questionMark.style.left = "0";
|
||||
questionMark.style.top = "0";
|
||||
questionMark.style.width = `${size}px`;
|
||||
questionMark.style.height = `${size}px`;
|
||||
questionMark.style.display = hasExtensionRequest ? "block" : "none";
|
||||
questionMark.style.pointerEvents = "none";
|
||||
questionMark.setAttribute("dark-mode", darkMode.toString());
|
||||
wrapper.appendChild(questionMark);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
export function computeAllianceClipPath(fraction: number): string {
|
||||
const topCut = 20 + (1 - fraction) * 80 * 0.78; // min 20%, max 82.40%
|
||||
return `inset(${topCut.toFixed(2)}% -2px 0 -2px)`;
|
||||
}
|
||||
@@ -1,29 +1,25 @@
|
||||
import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
|
||||
import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg";
|
||||
import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg";
|
||||
import crownIcon from "../../../../resources/images/CrownIcon.svg";
|
||||
import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg";
|
||||
import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg";
|
||||
import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg";
|
||||
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
|
||||
import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg";
|
||||
import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg";
|
||||
import targetIcon from "../../../../resources/images/TargetIcon.svg";
|
||||
import traitorIcon from "../../../../resources/images/TraitorIcon.svg";
|
||||
import { renderPlayerFlag } from "../../../core/CustomFlag";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { PseudoRandom } from "../../../core/PseudoRandom";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
|
||||
import {
|
||||
computeAllianceClipPath,
|
||||
createAllianceProgressIcon,
|
||||
getFirstPlacePlayer,
|
||||
getPlayerIcons,
|
||||
PlayerIconId,
|
||||
} from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
class RenderInfo {
|
||||
public icons: Map<string, HTMLImageElement> = new Map(); // Track icon elements
|
||||
public icons: Map<PlayerIconId, HTMLElement> = new Map(); // Track icon elements
|
||||
|
||||
constructor(
|
||||
public player: PlayerView,
|
||||
@@ -43,51 +39,20 @@ export class NameLayer implements Layer {
|
||||
private rand = new PseudoRandom(10);
|
||||
private renders: RenderInfo[] = [];
|
||||
private seenPlayers: Set<PlayerView> = new Set();
|
||||
private traitorIconImage: HTMLImageElement;
|
||||
private disconnectedIconImage: HTMLImageElement;
|
||||
private allianceRequestBlackIconImage: HTMLImageElement;
|
||||
private allianceRequestWhiteIconImage: HTMLImageElement;
|
||||
private allianceIconImage: HTMLImageElement;
|
||||
private targetIconImage: HTMLImageElement;
|
||||
private crownIconImage: HTMLImageElement;
|
||||
private embargoBlackIconImage: HTMLImageElement;
|
||||
private embargoWhiteIconImage: HTMLImageElement;
|
||||
private nukeWhiteIconImage: HTMLImageElement;
|
||||
private nukeRedIconImage: HTMLImageElement;
|
||||
private shieldIconImage: HTMLImageElement;
|
||||
private container: HTMLDivElement;
|
||||
private firstPlace: PlayerView | null = null;
|
||||
private theme: Theme = this.game.config().theme();
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
private isVisible: boolean = true;
|
||||
private firstPlace: PlayerView | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
private transformHandler: TransformHandler,
|
||||
private eventBus: EventBus,
|
||||
) {
|
||||
this.traitorIconImage = new Image();
|
||||
this.traitorIconImage.src = traitorIcon;
|
||||
this.disconnectedIconImage = new Image();
|
||||
this.disconnectedIconImage.src = disconnectedIcon;
|
||||
this.allianceIconImage = new Image();
|
||||
this.allianceIconImage.src = allianceIcon;
|
||||
this.allianceRequestBlackIconImage = new Image();
|
||||
this.allianceRequestBlackIconImage.src = allianceRequestBlackIcon;
|
||||
this.allianceRequestWhiteIconImage = new Image();
|
||||
this.allianceRequestWhiteIconImage.src = allianceRequestWhiteIcon;
|
||||
this.crownIconImage = new Image();
|
||||
this.crownIconImage.src = crownIcon;
|
||||
this.targetIconImage = new Image();
|
||||
this.targetIconImage.src = targetIcon;
|
||||
this.embargoBlackIconImage = new Image();
|
||||
this.embargoBlackIconImage.src = embargoBlackIcon;
|
||||
this.embargoWhiteIconImage = new Image();
|
||||
this.embargoWhiteIconImage.src = embargoWhiteIcon;
|
||||
this.nukeWhiteIconImage = new Image();
|
||||
this.nukeWhiteIconImage.src = nukeWhiteIcon;
|
||||
this.nukeRedIconImage = new Image();
|
||||
this.nukeRedIconImage.src = nukeRedIcon;
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
this.shieldIconImage = new Image();
|
||||
this.shieldIconImage.src = shieldIcon;
|
||||
}
|
||||
@@ -172,12 +137,9 @@ export class NameLayer implements Layer {
|
||||
if (this.game.ticks() % 10 !== 0) {
|
||||
return;
|
||||
}
|
||||
const sorted = this.game
|
||||
.playerViews()
|
||||
.sort((a, b) => b.numTilesOwned() - a.numTilesOwned());
|
||||
if (sorted.length > 0) {
|
||||
this.firstPlace = sorted[0];
|
||||
}
|
||||
|
||||
// Precompute the first-place player for performance
|
||||
this.firstPlace = getFirstPlacePlayer(this.game);
|
||||
|
||||
for (const player of this.game.playerViews()) {
|
||||
if (player.isAlive()) {
|
||||
@@ -404,251 +366,152 @@ export class NameLayer implements Layer {
|
||||
".player-icons",
|
||||
) as HTMLDivElement;
|
||||
const iconSize = Math.min(render.fontSize * 1.5, 48);
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const isDarkMode = this.userSettings.darkMode();
|
||||
|
||||
// Crown icon
|
||||
const existingCrown = iconsDiv.querySelector('[data-icon="crown"]');
|
||||
if (render.player === this.firstPlace) {
|
||||
if (!existingCrown) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.crownIconImage.src,
|
||||
iconSize,
|
||||
"crown",
|
||||
false,
|
||||
),
|
||||
);
|
||||
// Compute which icons should be shown for this player using shared logic
|
||||
const icons = getPlayerIcons({
|
||||
game: this.game,
|
||||
player: render.player,
|
||||
includeAllianceIcon: true,
|
||||
firstPlace: this.firstPlace,
|
||||
});
|
||||
|
||||
// Build a set of desired icon IDs
|
||||
const desiredIconIds = new Set(icons.map((icon) => icon.id));
|
||||
|
||||
// Remove any icons that are no longer needed
|
||||
for (const [id, element] of render.icons) {
|
||||
if (!desiredIconIds.has(id)) {
|
||||
element.remove();
|
||||
render.icons.delete(id);
|
||||
}
|
||||
} else if (existingCrown) {
|
||||
existingCrown.remove();
|
||||
}
|
||||
|
||||
// Traitor icon
|
||||
let existingTraitor = iconsDiv.querySelector('[data-icon="traitor"]');
|
||||
if (render.player.isTraitor()) {
|
||||
const remainingTicks = render.player.getTraitorRemainingTicks();
|
||||
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
|
||||
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
|
||||
// Add or update icons that should be shown
|
||||
for (const icon of icons) {
|
||||
if (icon.kind === "emoji" && icon.text) {
|
||||
let emojiDiv = render.icons.get(icon.id) as HTMLDivElement | undefined;
|
||||
|
||||
if (!existingTraitor) {
|
||||
existingTraitor = this.createIconElement(
|
||||
this.traitorIconImage.src,
|
||||
iconSize,
|
||||
"traitor",
|
||||
);
|
||||
iconsDiv.appendChild(existingTraitor);
|
||||
}
|
||||
if (!emojiDiv) {
|
||||
emojiDiv = document.createElement("div");
|
||||
emojiDiv.style.position = "absolute";
|
||||
emojiDiv.style.top = "50%";
|
||||
emojiDiv.style.transform = "translateY(-50%)";
|
||||
iconsDiv.appendChild(emojiDiv);
|
||||
render.icons.set(icon.id, emojiDiv);
|
||||
}
|
||||
|
||||
// Apply flashing animation - smooth speed increase starting at 15s
|
||||
if (existingTraitor instanceof HTMLImageElement) {
|
||||
if (remainingSeconds <= 15) {
|
||||
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
|
||||
// Using cubic ease-out for slower, more gradual acceleration
|
||||
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
|
||||
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
|
||||
emojiDiv.textContent = icon.text;
|
||||
emojiDiv.style.fontSize = `${iconSize}px`;
|
||||
} else if (icon.kind === "image" && icon.src) {
|
||||
// Special handling for alliance icon with progress indicator
|
||||
if (icon.id === "alliance") {
|
||||
let allianceWrapper = render.icons.get(icon.id) as
|
||||
| HTMLDivElement
|
||||
| undefined;
|
||||
|
||||
// Cubic ease-out: slower acceleration, smoother transition
|
||||
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const allianceView = myPlayer
|
||||
?.alliances()
|
||||
.find((a) => a.other === render.player.id());
|
||||
|
||||
const maxDuration = 1.0; // Slow flash at 15 seconds
|
||||
const minDuration = 0.2; // Fast flash at 0 seconds
|
||||
const duration =
|
||||
minDuration + (maxDuration - minDuration) * easedProgress;
|
||||
const animationDuration = `${duration.toFixed(2)}s`;
|
||||
let fraction = 0;
|
||||
let hasExtensionRequest = false;
|
||||
if (allianceView) {
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
allianceView.expiresAt - this.game.ticks(),
|
||||
);
|
||||
const duration = Math.max(1, this.game.config().allianceDuration());
|
||||
fraction = Math.max(0, Math.min(1, remaining / duration));
|
||||
hasExtensionRequest = allianceView.hasExtensionRequest;
|
||||
}
|
||||
|
||||
existingTraitor.style.animation = `traitorFlash ${animationDuration} infinite`;
|
||||
existingTraitor.style.animationTimingFunction = "ease-in-out";
|
||||
} else {
|
||||
// Don't flash if more than 15 seconds remaining
|
||||
existingTraitor.style.animation = "none";
|
||||
if (!allianceWrapper) {
|
||||
allianceWrapper = createAllianceProgressIcon(
|
||||
iconSize,
|
||||
fraction,
|
||||
hasExtensionRequest,
|
||||
this.userSettings.darkMode(),
|
||||
);
|
||||
iconsDiv.appendChild(allianceWrapper);
|
||||
render.icons.set(icon.id, allianceWrapper);
|
||||
} else {
|
||||
// Update existing alliance icon
|
||||
allianceWrapper.style.width = `${iconSize}px`;
|
||||
allianceWrapper.style.height = `${iconSize}px`;
|
||||
|
||||
const overlay = allianceWrapper.querySelector(
|
||||
".alliance-progress-overlay",
|
||||
) as HTMLDivElement | null;
|
||||
if (overlay) {
|
||||
overlay.style.clipPath = computeAllianceClipPath(fraction);
|
||||
}
|
||||
|
||||
const questionMark = allianceWrapper.querySelector(
|
||||
".alliance-question-mark",
|
||||
) as HTMLImageElement | null;
|
||||
if (questionMark) {
|
||||
questionMark.style.display = hasExtensionRequest
|
||||
? "block"
|
||||
: "none";
|
||||
}
|
||||
|
||||
// Update inner image sizes
|
||||
const imgs = allianceWrapper.getElementsByTagName("img");
|
||||
for (const img of imgs) {
|
||||
img.style.width = `${iconSize}px`;
|
||||
img.style.height = `${iconSize}px`;
|
||||
}
|
||||
}
|
||||
continue; // Skip regular image handling
|
||||
}
|
||||
|
||||
let imgElement = render.icons.get(icon.id) as
|
||||
| HTMLImageElement
|
||||
| undefined;
|
||||
|
||||
if (!imgElement) {
|
||||
imgElement = this.createIconElement(icon.src, iconSize, icon.center);
|
||||
iconsDiv.appendChild(imgElement);
|
||||
render.icons.set(icon.id, imgElement);
|
||||
}
|
||||
|
||||
// Update src if it changed (e.g., nuke red/white or dark-mode icons)
|
||||
if (imgElement.src !== icon.src) {
|
||||
imgElement.src = icon.src;
|
||||
}
|
||||
|
||||
imgElement.style.width = `${iconSize}px`;
|
||||
imgElement.style.height = `${iconSize}px`;
|
||||
|
||||
// Traitor flashing - smooth speed increase starting at 15s
|
||||
if (icon.id === "traitor") {
|
||||
const remainingTicks = render.player.getTraitorRemainingTicks();
|
||||
// Use precise seconds (not rounded) for smoother transitions, rounded to 0.5s intervals
|
||||
const remainingSeconds = Math.round((remainingTicks / 10) * 2) / 2;
|
||||
|
||||
if (remainingSeconds <= 15) {
|
||||
// Smooth transition: starts at 1s at 15 seconds, decreases to 0.2s at 0 seconds
|
||||
// Using cubic ease-out for slower, more gradual acceleration
|
||||
const clampedSeconds = Math.max(0, Math.min(15, remainingSeconds));
|
||||
const normalizedTime = clampedSeconds / 15; // 0 to 1 (1 = 15s remaining, 0 = 0s remaining)
|
||||
|
||||
// Cubic ease-out: slower acceleration, smoother transition
|
||||
const easedProgress = 1 - Math.pow(1 - normalizedTime, 3);
|
||||
const maxDuration = 1.0; // Slow flash at 15 seconds
|
||||
const minDuration = 0.2; // Fast flash at 0 seconds
|
||||
const duration =
|
||||
minDuration + (maxDuration - minDuration) * easedProgress;
|
||||
const animationDuration = `${duration.toFixed(2)}s`;
|
||||
|
||||
imgElement.style.animation = `traitorFlash ${animationDuration} infinite`;
|
||||
imgElement.style.animationTimingFunction = "ease-in-out";
|
||||
} else {
|
||||
// Don't flash if more than 15 seconds remaining
|
||||
imgElement.style.animation = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (existingTraitor) {
|
||||
existingTraitor.remove();
|
||||
}
|
||||
|
||||
// Disconnected icon
|
||||
const existingDisconnected = iconsDiv.querySelector(
|
||||
'[data-icon="disconnected"]',
|
||||
);
|
||||
if (render.player.isDisconnected()) {
|
||||
if (!existingDisconnected) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.disconnectedIconImage.src,
|
||||
iconSize,
|
||||
"disconnected",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingDisconnected) {
|
||||
existingDisconnected.remove();
|
||||
}
|
||||
|
||||
// Alliance icon
|
||||
const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]');
|
||||
if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) {
|
||||
if (!existingAlliance) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.allianceIconImage.src,
|
||||
iconSize,
|
||||
"alliance",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingAlliance) {
|
||||
existingAlliance.remove();
|
||||
}
|
||||
|
||||
// Alliance request icon
|
||||
let existingRequestAlliance = iconsDiv.querySelector(
|
||||
'[data-icon="alliance-request"]',
|
||||
);
|
||||
const isThemeAllianceRequestIcon =
|
||||
existingRequestAlliance?.getAttribute("dark-mode") ===
|
||||
isDarkMode.toString();
|
||||
const AllianceRequestIconImageSrc = isDarkMode
|
||||
? this.allianceRequestWhiteIconImage.src
|
||||
: this.allianceRequestBlackIconImage.src;
|
||||
|
||||
if (myPlayer !== null && render.player.isRequestingAllianceWith(myPlayer)) {
|
||||
// Create new icon to match theme
|
||||
if (existingRequestAlliance && !isThemeAllianceRequestIcon) {
|
||||
existingRequestAlliance.remove();
|
||||
existingRequestAlliance = null;
|
||||
}
|
||||
|
||||
if (!existingRequestAlliance) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
AllianceRequestIconImageSrc,
|
||||
iconSize,
|
||||
"alliance-request",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingRequestAlliance) {
|
||||
existingRequestAlliance.remove();
|
||||
}
|
||||
|
||||
// Target icon
|
||||
const existingTarget = iconsDiv.querySelector('[data-icon="target"]');
|
||||
if (
|
||||
myPlayer !== null &&
|
||||
new Set(myPlayer.transitiveTargets()).has(render.player)
|
||||
) {
|
||||
if (!existingTarget) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(
|
||||
this.targetIconImage.src,
|
||||
iconSize,
|
||||
"target",
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (existingTarget) {
|
||||
existingTarget.remove();
|
||||
}
|
||||
|
||||
// Emoji handling
|
||||
const existingEmoji = iconsDiv.querySelector('[data-icon="emoji"]');
|
||||
const emojis = render.player
|
||||
.outgoingEmojis()
|
||||
.filter(
|
||||
(emoji) =>
|
||||
emoji.recipientID === AllPlayers ||
|
||||
emoji.recipientID === myPlayer?.smallID(),
|
||||
);
|
||||
|
||||
if (this.game.config().userSettings()?.emojis() && emojis.length > 0) {
|
||||
if (!existingEmoji) {
|
||||
const emojiDiv = document.createElement("div");
|
||||
emojiDiv.setAttribute("data-icon", "emoji");
|
||||
emojiDiv.style.fontSize = `${iconSize}px`;
|
||||
emojiDiv.textContent = emojis[0].message;
|
||||
emojiDiv.style.position = "absolute";
|
||||
emojiDiv.style.top = "50%";
|
||||
emojiDiv.style.transform = "translateY(-50%)";
|
||||
iconsDiv.appendChild(emojiDiv);
|
||||
}
|
||||
} else if (existingEmoji) {
|
||||
existingEmoji.remove();
|
||||
}
|
||||
|
||||
// Embargo icon
|
||||
let existingEmbargo = iconsDiv.querySelector('[data-icon="embargo"]');
|
||||
const isThemeEmbargoIcon =
|
||||
existingEmbargo?.getAttribute("dark-mode") === isDarkMode.toString();
|
||||
const embargoIconImageSrc = isDarkMode
|
||||
? this.embargoWhiteIconImage.src
|
||||
: this.embargoBlackIconImage.src;
|
||||
|
||||
if (myPlayer?.hasEmbargo(render.player)) {
|
||||
// Create new icon to match theme
|
||||
if (existingEmbargo && !isThemeEmbargoIcon) {
|
||||
existingEmbargo.remove();
|
||||
existingEmbargo = null;
|
||||
}
|
||||
|
||||
if (!existingEmbargo) {
|
||||
iconsDiv.appendChild(
|
||||
this.createIconElement(embargoIconImageSrc, iconSize, "embargo"),
|
||||
);
|
||||
}
|
||||
} else if (existingEmbargo) {
|
||||
existingEmbargo.remove();
|
||||
}
|
||||
|
||||
const nukesSentByOtherPlayer = this.game.units().filter((unit) => {
|
||||
const isSendingNuke = render.player.id() === unit.owner().id();
|
||||
const notMyPlayer = !myPlayer || unit.owner().id() !== myPlayer.id();
|
||||
return (
|
||||
nukeTypes.includes(unit.type()) &&
|
||||
isSendingNuke &&
|
||||
notMyPlayer &&
|
||||
unit.isActive()
|
||||
);
|
||||
});
|
||||
const isMyPlayerTarget = nukesSentByOtherPlayer.find((unit) => {
|
||||
const detonationDst = unit.targetTile();
|
||||
if (detonationDst === undefined) return false;
|
||||
const targetId = this.game.owner(detonationDst).id();
|
||||
return myPlayer && targetId === myPlayer.id();
|
||||
});
|
||||
const existingNuke = iconsDiv.querySelector(
|
||||
'[data-icon="nuke"]',
|
||||
) as HTMLImageElement;
|
||||
|
||||
if (existingNuke) {
|
||||
if (nukesSentByOtherPlayer.length === 0) {
|
||||
existingNuke.remove();
|
||||
} else if (
|
||||
isMyPlayerTarget &&
|
||||
existingNuke.src !== this.nukeRedIconImage.src
|
||||
) {
|
||||
existingNuke.src = this.nukeRedIconImage.src;
|
||||
} else if (
|
||||
!isMyPlayerTarget &&
|
||||
existingNuke.src !== this.nukeWhiteIconImage.src
|
||||
) {
|
||||
existingNuke.src = this.nukeWhiteIconImage.src;
|
||||
}
|
||||
} else if (nukesSentByOtherPlayer.length > 0) {
|
||||
if (!existingNuke) {
|
||||
const icon = isMyPlayerTarget
|
||||
? this.nukeRedIconImage.src
|
||||
: this.nukeWhiteIconImage.src;
|
||||
iconsDiv.appendChild(this.createIconElement(icon, iconSize, "nuke"));
|
||||
}
|
||||
}
|
||||
// Update all icon sizes
|
||||
const icons = iconsDiv.getElementsByTagName("img");
|
||||
for (const icon of icons) {
|
||||
icon.style.width = `${iconSize}px`;
|
||||
icon.style.height = `${iconSize}px`;
|
||||
}
|
||||
|
||||
// Position element with scale
|
||||
@@ -661,14 +524,12 @@ export class NameLayer implements Layer {
|
||||
private createIconElement(
|
||||
src: string,
|
||||
size: number,
|
||||
id: string,
|
||||
center: boolean = false,
|
||||
): HTMLImageElement {
|
||||
const icon = document.createElement("img");
|
||||
icon.src = src;
|
||||
icon.style.width = `${size}px`;
|
||||
icon.style.height = `${size}px`;
|
||||
icon.setAttribute("data-icon", id);
|
||||
icon.setAttribute("dark-mode", this.userSettings.darkMode().toString());
|
||||
if (center) {
|
||||
icon.style.position = "absolute";
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
TickMetricsEvent,
|
||||
TogglePerformanceOverlayEvent,
|
||||
} from "../../InputHandler";
|
||||
import { translateText } from "../../Utils";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@customElement("performance-overlay")
|
||||
@@ -46,6 +48,9 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
@state()
|
||||
private position: { x: number; y: number } = { x: 50, y: 20 }; // Percentage values
|
||||
|
||||
@state()
|
||||
private copyStatus: "idle" | "success" | "error" = "idle";
|
||||
|
||||
private frameCount: number = 0;
|
||||
private lastTime: number = 0;
|
||||
private frameTimes: number[] = [];
|
||||
@@ -56,6 +61,22 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
private tickExecutionTimes: number[] = [];
|
||||
private tickDelayTimes: number[] = [];
|
||||
|
||||
private copyStatusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Smoothed per-layer render timings (EMA over recent frames)
|
||||
private layerStats: Map<
|
||||
string,
|
||||
{ avg: number; max: number; last: number; total: number }
|
||||
> = new Map();
|
||||
|
||||
@state()
|
||||
private layerBreakdown: {
|
||||
name: string;
|
||||
avg: number;
|
||||
max: number;
|
||||
total: number;
|
||||
}[] = [];
|
||||
|
||||
static styles = css`
|
||||
.performance-overlay {
|
||||
position: fixed;
|
||||
@@ -64,7 +85,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
@@ -72,6 +93,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
transition: none;
|
||||
min-width: 420px;
|
||||
}
|
||||
|
||||
.performance-overlay.dragging {
|
||||
@@ -115,6 +137,86 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.copy-json-button {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 70px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.layers-section {
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.layer-name {
|
||||
flex: 0 0 280px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.layer-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-bar-fill {
|
||||
height: 100%;
|
||||
background: #38bdf8;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.layer-metrics {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
@@ -124,6 +226,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
init() {
|
||||
this.eventBus.on(TogglePerformanceOverlayEvent, () => {
|
||||
this.userSettings.togglePerformanceOverlay();
|
||||
this.setVisible(this.userSettings.performanceOverlay());
|
||||
});
|
||||
this.eventBus.on(TickMetricsEvent, (event: TickMetricsEvent) => {
|
||||
this.updateTickMetrics(event.tickExecutionDuration, event.tickDelay);
|
||||
@@ -132,6 +235,7 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
|
||||
setVisible(visible: boolean) {
|
||||
this.isVisible = visible;
|
||||
FrameProfiler.setEnabled(visible);
|
||||
}
|
||||
|
||||
private handleClose() {
|
||||
@@ -140,7 +244,12 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
|
||||
private handleMouseDown = (e: MouseEvent) => {
|
||||
// Don't start dragging if clicking on close button
|
||||
if ((e.target as HTMLElement).classList.contains("close-button")) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.classList.contains("close-button") ||
|
||||
target.classList.contains("reset-button") ||
|
||||
target.classList.contains("copy-json-button")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,9 +288,45 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
document.removeEventListener("mouseup", this.handleMouseUp);
|
||||
};
|
||||
|
||||
updateFrameMetrics(frameDuration: number) {
|
||||
private handleReset = () => {
|
||||
// reset FPS / frame stats
|
||||
this.frameCount = 0;
|
||||
this.lastTime = 0;
|
||||
this.frameTimes = [];
|
||||
this.fpsHistory = [];
|
||||
this.lastSecondTime = 0;
|
||||
this.framesThisSecond = 0;
|
||||
this.currentFPS = 0;
|
||||
this.averageFPS = 0;
|
||||
this.frameTime = 0;
|
||||
|
||||
// reset tick metrics
|
||||
this.tickExecutionTimes = [];
|
||||
this.tickDelayTimes = [];
|
||||
this.tickExecutionAvg = 0;
|
||||
this.tickExecutionMax = 0;
|
||||
this.tickDelayAvg = 0;
|
||||
this.tickDelayMax = 0;
|
||||
|
||||
// reset layer breakdown
|
||||
this.layerStats.clear();
|
||||
this.layerBreakdown = [];
|
||||
|
||||
this.requestUpdate();
|
||||
};
|
||||
|
||||
updateFrameMetrics(
|
||||
frameDuration: number,
|
||||
layerDurations?: Record<string, number>,
|
||||
) {
|
||||
const wasVisible = this.isVisible;
|
||||
this.isVisible = this.userSettings.performanceOverlay();
|
||||
|
||||
// Update FrameProfiler enabled state when visibility changes
|
||||
if (wasVisible !== this.isVisible) {
|
||||
FrameProfiler.setEnabled(this.isVisible);
|
||||
}
|
||||
|
||||
if (!this.isVisible) return;
|
||||
|
||||
const now = performance.now();
|
||||
@@ -233,9 +378,46 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
this.lastTime = now;
|
||||
this.frameCount++;
|
||||
|
||||
if (layerDurations) {
|
||||
this.updateLayerStats(layerDurations);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private updateLayerStats(layerDurations: Record<string, number>) {
|
||||
const alpha = 0.2; // smoothing factor for EMA
|
||||
|
||||
Object.entries(layerDurations).forEach(([name, duration]) => {
|
||||
const existing = this.layerStats.get(name);
|
||||
if (!existing) {
|
||||
this.layerStats.set(name, {
|
||||
avg: duration,
|
||||
max: duration,
|
||||
last: duration,
|
||||
total: duration,
|
||||
});
|
||||
} else {
|
||||
const avg = existing.avg + alpha * (duration - existing.avg);
|
||||
const max = Math.max(existing.max, duration);
|
||||
const total = existing.total + duration;
|
||||
this.layerStats.set(name, { avg, max, last: duration, total });
|
||||
}
|
||||
});
|
||||
|
||||
// Derive contributors sorted by total accumulated time spent
|
||||
const breakdown = Array.from(this.layerStats.entries())
|
||||
.map(([name, stats]) => ({
|
||||
name,
|
||||
avg: stats.avg,
|
||||
max: stats.max,
|
||||
total: stats.total,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
this.layerBreakdown = breakdown;
|
||||
}
|
||||
|
||||
updateTickMetrics(tickExecutionDuration?: number, tickDelay?: number) {
|
||||
if (!this.isVisible || !this.userSettings.performanceOverlay()) return;
|
||||
|
||||
@@ -286,6 +468,70 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
return "performance-bad";
|
||||
}
|
||||
|
||||
private buildPerformanceSnapshot() {
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
fps: {
|
||||
current: this.currentFPS,
|
||||
average60s: this.averageFPS,
|
||||
frameTimeMs: this.frameTime,
|
||||
history: [...this.fpsHistory],
|
||||
},
|
||||
ticks: {
|
||||
executionAvgMs: this.tickExecutionAvg,
|
||||
executionMaxMs: this.tickExecutionMax,
|
||||
delayAvgMs: this.tickDelayAvg,
|
||||
delayMaxMs: this.tickDelayMax,
|
||||
executionSamples: [...this.tickExecutionTimes],
|
||||
delaySamples: [...this.tickDelayTimes],
|
||||
},
|
||||
layers: this.layerBreakdown.map((layer) => ({ ...layer })),
|
||||
};
|
||||
}
|
||||
|
||||
private clearCopyStatusTimeout() {
|
||||
if (this.copyStatusTimeoutId !== null) {
|
||||
clearTimeout(this.copyStatusTimeoutId);
|
||||
this.copyStatusTimeoutId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleCopyStatusReset() {
|
||||
this.clearCopyStatusTimeout();
|
||||
this.copyStatusTimeoutId = setTimeout(() => {
|
||||
this.copyStatus = "idle";
|
||||
this.copyStatusTimeoutId = null;
|
||||
this.requestUpdate();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private async handleCopyJson() {
|
||||
const snapshot = this.buildPerformanceSnapshot();
|
||||
const json = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(json);
|
||||
} else {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = json;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
this.copyStatus = "success";
|
||||
} catch (err) {
|
||||
console.warn("Failed to copy performance snapshot", err);
|
||||
this.copyStatus = "error";
|
||||
}
|
||||
|
||||
this.scheduleCopyStatusReset();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.isVisible) {
|
||||
return html``;
|
||||
@@ -297,41 +543,87 @@ export class PerformanceOverlay extends LitElement implements Layer {
|
||||
transform: none;
|
||||
`;
|
||||
|
||||
const copyLabel =
|
||||
this.copyStatus === "success"
|
||||
? translateText("performance_overlay.copied")
|
||||
: this.copyStatus === "error"
|
||||
? translateText("performance_overlay.failed_copy")
|
||||
: translateText("performance_overlay.copy_clipboard");
|
||||
|
||||
const maxLayerAvg =
|
||||
this.layerBreakdown.length > 0
|
||||
? Math.max(...this.layerBreakdown.map((l) => l.avg))
|
||||
: 1;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="performance-overlay ${this.isDragging ? "dragging" : ""}"
|
||||
style="${style}"
|
||||
@mousedown="${this.handleMouseDown}"
|
||||
>
|
||||
<button class="reset-button" @click="${this.handleReset}">
|
||||
${translateText("performance_overlay.reset")}
|
||||
</button>
|
||||
<button
|
||||
class="copy-json-button"
|
||||
@click="${this.handleCopyJson}"
|
||||
title="${translateText("performance_overlay.copy_json_title")}"
|
||||
>
|
||||
${copyLabel}
|
||||
</button>
|
||||
<button class="close-button" @click="${this.handleClose}">×</button>
|
||||
<div class="performance-line">
|
||||
FPS:
|
||||
${translateText("performance_overlay.fps")}
|
||||
<span class="${this.getPerformanceColor(this.currentFPS)}"
|
||||
>${this.currentFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
Avg (60s):
|
||||
${translateText("performance_overlay.avg_60s")}
|
||||
<span class="${this.getPerformanceColor(this.averageFPS)}"
|
||||
>${this.averageFPS}</span
|
||||
>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
Frame:
|
||||
${translateText("performance_overlay.frame")}
|
||||
<span class="${this.getPerformanceColor(1000 / this.frameTime)}"
|
||||
>${this.frameTime}ms</span
|
||||
>
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
Tick Exec:
|
||||
${translateText("performance_overlay.tick_exec")}
|
||||
<span>${this.tickExecutionAvg.toFixed(2)}ms</span>
|
||||
(max: <span>${this.tickExecutionMax}ms</span>)
|
||||
</div>
|
||||
<div class="performance-line">
|
||||
Tick Delay:
|
||||
${translateText("performance_overlay.tick_delay")}
|
||||
<span>${this.tickDelayAvg.toFixed(2)}ms</span>
|
||||
(max: <span>${this.tickDelayMax}ms</span>)
|
||||
</div>
|
||||
${this.layerBreakdown.length
|
||||
? html`<div class="layers-section">
|
||||
<div class="performance-line">
|
||||
${translateText("performance_overlay.layers_header")}
|
||||
</div>
|
||||
${this.layerBreakdown.map((layer) => {
|
||||
const width = Math.min(
|
||||
100,
|
||||
(layer.avg / maxLayerAvg) * 100 || 0,
|
||||
);
|
||||
return html`<div class="layer-row">
|
||||
<span class="layer-name" title=${layer.name}
|
||||
>${layer.name}</span
|
||||
>
|
||||
<div class="layer-bar">
|
||||
<div class="layer-bar-fill" style="width: ${width}%;"></div>
|
||||
</div>
|
||||
<span class="layer-metrics">
|
||||
${layer.avg.toFixed(2)} / ${layer.max.toFixed(2)}ms
|
||||
</span>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
renderTroops,
|
||||
translateText,
|
||||
} from "../../Utils";
|
||||
import { getFirstPlacePlayer, getPlayerIcons } from "../PlayerIcons";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
import { CloseRadialMenuEvent } from "./RadialMenu";
|
||||
@@ -221,6 +222,33 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
return renderDuration(remainingSeconds);
|
||||
}
|
||||
|
||||
private renderPlayerNameIcons(player: PlayerView) {
|
||||
const firstPlace = getFirstPlacePlayer(this.game);
|
||||
const icons = getPlayerIcons({
|
||||
game: this.game,
|
||||
player,
|
||||
// Because we already show the alliance icon next to the alliance expiration timer, we don't need to show it a second time in this render
|
||||
includeAllianceIcon: false,
|
||||
firstPlace,
|
||||
});
|
||||
|
||||
if (icons.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`<span class="flex items-center gap-1 ml-1 shrink-0">
|
||||
${icons.map((icon) =>
|
||||
icon.kind === "emoji" && icon.text
|
||||
? html`<span class="text-sm shrink-0" translate="no"
|
||||
>${icon.text}</span
|
||||
>`
|
||||
: icon.kind === "image" && icon.src
|
||||
? html`<img src=${icon.src} alt="" class="w-4 h-4 shrink-0" />`
|
||||
: html``,
|
||||
)}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
private renderPlayerInfo(player: PlayerView) {
|
||||
const myPlayer = this.game.myPlayer();
|
||||
const isFriendly = myPlayer?.isFriendly(player);
|
||||
@@ -306,7 +334,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer {
|
||||
src=${"/flags/" + player.cosmetics.flag! + ".svg"}
|
||||
/>`
|
||||
: html``}
|
||||
${player.name()}
|
||||
<span>${player.name()}</span>
|
||||
${this.renderPlayerNameIcons(player)}
|
||||
</button>
|
||||
|
||||
<!-- Collapsible section -->
|
||||
|
||||
@@ -253,26 +253,13 @@ export class SpriteFactory {
|
||||
structureCanvas.height = Math.ceil(iconSize);
|
||||
const context = structureCanvas.getContext("2d")!;
|
||||
|
||||
const tc = owner.territoryColor();
|
||||
const bc = owner.borderColor();
|
||||
|
||||
// Potentially change logic here. Some TC/BC combinations do not provide good color contrast.
|
||||
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;
|
||||
// Use structureColors defined from the PlayerView.
|
||||
context.fillStyle = isConstruction
|
||||
? "rgb(198,198,198)"
|
||||
: owner.structureColors().light.toRgbString();
|
||||
context.strokeStyle = isConstruction
|
||||
? "rgb(127,127, 127)"
|
||||
: owner.structureColors().dark.toRgbString();
|
||||
context.lineWidth = 1;
|
||||
const halfIconSize = iconSize / 2;
|
||||
|
||||
@@ -400,7 +387,10 @@ export class SpriteFactory {
|
||||
};
|
||||
const [offsetX, offsetY] = SHAPE_OFFSETS[shape] || [0, 0];
|
||||
context.drawImage(
|
||||
this.getImageColored(structureInfo.image, borderColor),
|
||||
this.getImageColored(
|
||||
structureInfo.image,
|
||||
owner.structureColors().dark.toRgbString(),
|
||||
),
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DragEvent,
|
||||
MouseOverEvent,
|
||||
} from "../../InputHandler";
|
||||
import { FrameProfiler } from "../FrameProfiler";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -399,7 +400,9 @@ export class TerritoryLayer implements Layer {
|
||||
now > this.lastRefresh + this.refreshRate
|
||||
) {
|
||||
this.lastRefresh = now;
|
||||
const renderTerritoryStart = FrameProfiler.start();
|
||||
this.renderTerritory();
|
||||
FrameProfiler.end("TerritoryLayer:renderTerritory", renderTerritoryStart);
|
||||
|
||||
const [topLeft, bottomRight] = this.transformHandler.screenBoundingRect();
|
||||
const vx0 = Math.max(0, topLeft.x);
|
||||
@@ -411,6 +414,7 @@ export class TerritoryLayer implements Layer {
|
||||
const h = vy1 - vy0 + 1;
|
||||
|
||||
if (w > 0 && h > 0) {
|
||||
const putImageStart = FrameProfiler.start();
|
||||
this.context.putImageData(
|
||||
this.alternativeView ? this.alternativeImageData : this.imageData,
|
||||
0,
|
||||
@@ -420,9 +424,11 @@ export class TerritoryLayer implements Layer {
|
||||
w,
|
||||
h,
|
||||
);
|
||||
FrameProfiler.end("TerritoryLayer:putImageData", putImageStart);
|
||||
}
|
||||
}
|
||||
|
||||
const drawCanvasStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
@@ -430,7 +436,9 @@ export class TerritoryLayer implements Layer {
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end("TerritoryLayer:drawCanvas", drawCanvasStart);
|
||||
if (this.game.inSpawnPhase()) {
|
||||
const highlightDrawStart = FrameProfiler.start();
|
||||
context.drawImage(
|
||||
this.highlightCanvas,
|
||||
-this.game.width() / 2,
|
||||
@@ -438,6 +446,10 @@ export class TerritoryLayer implements Layer {
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
FrameProfiler.end(
|
||||
"TerritoryLayer:drawHighlightCanvas",
|
||||
highlightDrawStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
.bg-image {
|
||||
content: "";
|
||||
background-image: url("/images/EuropeBackground.webp");
|
||||
background-image: url("/images/EuropeBackgroundBlurred.webp");
|
||||
background-position: center;
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
@@ -73,7 +73,6 @@
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.dark .bg-image {
|
||||
@@ -406,6 +405,7 @@
|
||||
<spawn-timer></spawn-timer>
|
||||
<help-modal></help-modal>
|
||||
<dark-mode-button></dark-mode-button>
|
||||
<stats-button></stats-button>
|
||||
<alert-frame></alert-frame>
|
||||
<chat-modal></chat-modal>
|
||||
<user-setting></user-setting>
|
||||
|
||||
+4
-29
@@ -32,26 +32,6 @@ export function getApiBase() {
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
// Check window hash
|
||||
const { hash } = window.location;
|
||||
if (hash.startsWith("#")) {
|
||||
const params = new URLSearchParams(hash.slice(1));
|
||||
const token = params.get("token");
|
||||
if (token) {
|
||||
localStorage.setItem("token", token);
|
||||
params.delete("token");
|
||||
params.toString();
|
||||
}
|
||||
// Clean the URL
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname +
|
||||
window.location.search +
|
||||
(params.size > 0 ? "#" + params.toString() : ""),
|
||||
);
|
||||
}
|
||||
|
||||
// Check cookie
|
||||
const cookie = document.cookie
|
||||
.split(";")
|
||||
@@ -83,21 +63,16 @@ export function discordLogin() {
|
||||
export async function tokenLogin(token: string): Promise<string | null> {
|
||||
const response = await fetch(
|
||||
`${getApiBase()}/login/token?login-token=${token}`,
|
||||
{
|
||||
credentials: "include",
|
||||
},
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
console.error("Token login failed", response);
|
||||
return null;
|
||||
}
|
||||
const json = await response.json();
|
||||
const { jwt, email } = json;
|
||||
const payload = decodeJwt(jwt);
|
||||
const result = TokenPayloadSchema.safeParse(payload);
|
||||
if (!result.success) {
|
||||
console.error("Invalid token", result.error, result.error.message);
|
||||
return null;
|
||||
}
|
||||
clearToken();
|
||||
localStorage.setItem("token", jwt);
|
||||
const { email } = json;
|
||||
return email;
|
||||
}
|
||||
|
||||
|
||||
@@ -90,3 +90,24 @@ export const PlayerProfileSchema = z.object({
|
||||
stats: PlayerStatsTreeSchema,
|
||||
});
|
||||
export type PlayerProfile = z.infer<typeof PlayerProfileSchema>;
|
||||
|
||||
export const ClanLeaderboardEntrySchema = z.object({
|
||||
clanTag: z.string(),
|
||||
games: z.number(),
|
||||
wins: z.number(),
|
||||
losses: z.number(),
|
||||
playerSessions: z.number(),
|
||||
weightedWins: z.number(),
|
||||
weightedLosses: z.number(),
|
||||
weightedWLRatio: z.number(),
|
||||
});
|
||||
export type ClanLeaderboardEntry = z.infer<typeof ClanLeaderboardEntrySchema>;
|
||||
|
||||
export const ClanLeaderboardResponseSchema = z.object({
|
||||
start: z.iso.datetime(),
|
||||
end: z.iso.datetime(),
|
||||
clans: ClanLeaderboardEntrySchema.array(),
|
||||
});
|
||||
export type ClanLeaderboardResponse = z.infer<
|
||||
typeof ClanLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
+140
-188
@@ -42,222 +42,174 @@ function generateTeamColors(baseColor: Colord): Colord[] {
|
||||
}
|
||||
|
||||
export const nationColors: Colord[] = [
|
||||
colord("rgb(210,210,100)"), // Lime Yellow
|
||||
colord("rgb(180,210,120)"), // Light Green
|
||||
colord("rgb(170,190,100)"), // Yellow Green
|
||||
colord("rgb(80,200,120)"), // Emerald Green
|
||||
colord("rgb(130,200,130)"), // Light Sea Green
|
||||
colord("rgb(140,180,140)"), // Dark Sea Green
|
||||
colord("rgb(160,190,160)"), // Pale Green
|
||||
colord("rgb(160,180,140)"), // Dark Olive Green
|
||||
colord("rgb(100,160,80)"), // Olive Green
|
||||
colord("rgb(100,140,110)"), // Sea Green
|
||||
colord("rgb(100,180,160)"), // Aquamarine
|
||||
colord("rgb(130,180,170)"), // Medium Aquamarine
|
||||
colord("rgb(170,190,180)"), // Pale Blue Green
|
||||
colord("rgb(100,130,150)"), // Steel Blue
|
||||
colord("rgb(120,160,200)"), // Cornflower Blue
|
||||
colord("rgb(140,150,180)"), // Light Slate Gray
|
||||
colord("rgb(100,210,210)"), // Turquoise
|
||||
colord("rgb(140,180,220)"), // Light Blue
|
||||
colord("rgb(130,170,190)"), // Cadet Blue
|
||||
colord("rgb(100,180,230)"), // Sky Blue
|
||||
colord("rgb(80,130,190)"), // Navy Blue
|
||||
colord("rgb(120,120,190)"), // Periwinkle
|
||||
colord("rgb(150,110,190)"), // Lavender
|
||||
colord("rgb(160,120,160)"), // Purple Gray
|
||||
colord("rgb(170,140,190)"), // Medium Purple
|
||||
colord("rgb(180,130,180)"), // Plum
|
||||
colord("rgb(190,140,150)"), // Puce
|
||||
colord("rgb(180,100,230)"), // Purple
|
||||
colord("rgb(180,160,180)"), // Mauve
|
||||
colord("rgb(170,150,170)"), // Dusty Rose
|
||||
colord("rgb(150,130,150)"), // Thistle
|
||||
colord("rgb(230,180,180)"), // Light Pink
|
||||
colord("rgb(210,160,200)"), // Orchid
|
||||
colord("rgb(230,130,180)"), // Pink
|
||||
colord("rgb(210,100,160)"), // Hot Pink
|
||||
colord("rgb(190,100,130)"), // Maroon
|
||||
colord("rgb(220,120,120)"), // Coral
|
||||
colord("rgb(200,130,110)"), // Dark Salmon
|
||||
colord("rgb(230,140,140)"), // Salmon
|
||||
colord("rgb(230,100,100)"), // Bright Red
|
||||
colord("rgb(100,180,230)"), // Sky Blue
|
||||
colord("rgb(230,180,80)"), // Golden Yellow
|
||||
colord("rgb(180,100,230)"), // Purple
|
||||
colord("rgb(80,200,120)"), // Emerald Green
|
||||
colord("rgb(230,130,180)"), // Pink
|
||||
colord("rgb(100,160,80)"), // Olive Green
|
||||
colord("rgb(230,150,100)"), // Peach
|
||||
colord("rgb(80,130,190)"), // Navy Blue
|
||||
colord("rgb(210,210,100)"), // Lime Yellow
|
||||
colord("rgb(190,100,130)"), // Maroon
|
||||
colord("rgb(100,210,210)"), // Turquoise
|
||||
colord("rgb(210,140,80)"), // Light Orange
|
||||
colord("rgb(150,110,190)"), // Lavender
|
||||
colord("rgb(180,210,120)"), // Light Green
|
||||
colord("rgb(210,100,160)"), // Hot Pink
|
||||
colord("rgb(100,140,110)"), // Sea Green
|
||||
colord("rgb(230,180,180)"), // Light Pink
|
||||
colord("rgb(120,120,190)"), // Periwinkle
|
||||
colord("rgb(190,170,100)"), // Sand
|
||||
colord("rgb(100,180,160)"), // Aquamarine
|
||||
colord("rgb(210,160,200)"), // Orchid
|
||||
colord("rgb(170,190,100)"), // Yellow Green
|
||||
colord("rgb(100,130,150)"), // Steel Blue
|
||||
colord("rgb(230,140,140)"), // Salmon
|
||||
colord("rgb(140,180,220)"), // Light Blue
|
||||
colord("rgb(200,160,110)"), // Tan
|
||||
colord("rgb(180,130,180)"), // Plum
|
||||
colord("rgb(130,200,130)"), // Light Sea Green
|
||||
colord("rgb(220,120,120)"), // Coral
|
||||
colord("rgb(120,160,200)"), // Cornflower Blue
|
||||
colord("rgb(200,200,140)"), // Khaki
|
||||
colord("rgb(160,120,160)"), // Purple Gray
|
||||
colord("rgb(140,180,140)"), // Dark Sea Green
|
||||
colord("rgb(200,130,110)"), // Dark Salmon
|
||||
colord("rgb(130,170,190)"), // Cadet Blue
|
||||
colord("rgb(190,180,160)"), // Tan Gray
|
||||
colord("rgb(170,140,190)"), // Medium Purple
|
||||
colord("rgb(160,190,160)"), // Pale Green
|
||||
colord("rgb(190,150,130)"), // Rosy Brown
|
||||
colord("rgb(140,150,180)"), // Light Slate Gray
|
||||
colord("rgb(180,170,140)"), // Dark Khaki
|
||||
colord("rgb(150,130,150)"), // Thistle
|
||||
colord("rgb(170,190,180)"), // Pale Blue Green
|
||||
colord("rgb(190,140,150)"), // Puce
|
||||
colord("rgb(130,180,170)"), // Medium Aquamarine
|
||||
colord("rgb(180,160,180)"), // Mauve
|
||||
colord("rgb(160,180,140)"), // Dark Olive Green
|
||||
colord("rgb(170,150,170)"), // Dusty Rose
|
||||
colord("rgb(100,180,230)"), // Sky Blue
|
||||
colord("rgb(230,180,80)"), // Golden Yellow
|
||||
colord("rgb(180,100,230)"), // Purple
|
||||
colord("rgb(80,200,120)"), // Emerald Green
|
||||
colord("rgb(230,130,180)"), // Pink
|
||||
colord("rgb(100,160,80)"), // Olive Green
|
||||
colord("rgb(230,150,100)"), // Peach
|
||||
colord("rgb(80,130,190)"), // Navy Blue
|
||||
colord("rgb(210,210,100)"), // Lime Yellow
|
||||
colord("rgb(190,100,130)"), // Maroon
|
||||
colord("rgb(100,210,210)"), // Turquoise
|
||||
colord("rgb(210,140,80)"), // Light Orange
|
||||
colord("rgb(150,110,190)"), // Lavender
|
||||
colord("rgb(180,210,120)"), // Light Green
|
||||
colord("rgb(210,100,160)"), // Hot Pink
|
||||
colord("rgb(100,140,110)"), // Sea Green
|
||||
colord("rgb(230,180,180)"), // Light Pink
|
||||
colord("rgb(120,120,190)"), // Periwinkle
|
||||
colord("rgb(190,170,100)"), // Sand
|
||||
colord("rgb(100,180,160)"), // Aquamarine
|
||||
colord("rgb(210,160,200)"), // Orchid
|
||||
colord("rgb(170,190,100)"), // Yellow Green
|
||||
colord("rgb(100,130,150)"), // Steel Blue
|
||||
colord("rgb(230,140,140)"), // Salmon
|
||||
colord("rgb(140,180,220)"), // Light Blue
|
||||
colord("rgb(200,160,110)"), // Tan
|
||||
colord("rgb(180,130,180)"), // Plum
|
||||
colord("rgb(130,200,130)"), // Light Sea Green
|
||||
colord("rgb(220,120,120)"), // Coral
|
||||
colord("rgb(120,160,200)"), // Cornflower Blue
|
||||
colord("rgb(200,200,140)"), // Khaki
|
||||
colord("rgb(160,120,160)"), // Purple Gray
|
||||
colord("rgb(140,180,140)"), // Dark Sea Green
|
||||
colord("rgb(200,130,110)"), // Dark Salmon
|
||||
colord("rgb(130,170,190)"), // Cadet Blue
|
||||
colord("rgb(190,180,160)"), // Tan Gray
|
||||
colord("rgb(170,140,190)"), // Medium Purple
|
||||
colord("rgb(160,190,160)"), // Pale Green
|
||||
colord("rgb(190,150,130)"), // Rosy Brown
|
||||
colord("rgb(140,150,180)"), // Light Slate Gray
|
||||
colord("rgb(190,180,160)"), // Tan Gray
|
||||
colord("rgb(180,170,140)"), // Dark Khaki
|
||||
colord("rgb(150,130,150)"), // Thistle
|
||||
colord("rgb(170,190,180)"), // Pale Blue Green
|
||||
colord("rgb(190,140,150)"), // Puce
|
||||
colord("rgb(130,180,170)"), // Medium Aquamarine
|
||||
colord("rgb(180,160,180)"), // Mauve
|
||||
colord("rgb(160,180,140)"), // Dark Olive Green
|
||||
colord("rgb(170,150,170)"), // Dusty Rose
|
||||
colord("rgb(200,200,140)"), // Khaki
|
||||
colord("rgb(190,170,100)"), // Sand
|
||||
];
|
||||
|
||||
// Bright pastel theme with 64 colors
|
||||
export const humanColors: Colord[] = [
|
||||
colord("rgb(16,185,129)"), // Sea Green
|
||||
colord("rgb(34,197,94)"), // Emerald
|
||||
colord("rgb(45,212,191)"), // Turquoise
|
||||
colord("rgb(48,178,180)"), // Teal
|
||||
colord("rgb(52,211,153)"), // Spearmint
|
||||
colord("rgb(56,189,248)"), // Light Blue
|
||||
colord("rgb(59,130,246)"), // Royal Blue
|
||||
colord("rgb(67,190,84)"), // Fresh Green
|
||||
colord("rgb(74,222,128)"), // Mint
|
||||
colord("rgb(79,70,229)"), // Indigo
|
||||
colord("rgb(82,183,136)"), // Jade
|
||||
colord("rgb(96,165,250)"), // Sky Blue
|
||||
colord("rgb(99,202,253)"), // Azure
|
||||
colord("rgb(110,231,183)"), // Seafoam
|
||||
colord("rgb(124,58,237)"), // Royal Purple
|
||||
colord("rgb(125,211,252)"), // Crystal Blue
|
||||
colord("rgb(132,204,22)"), // Lime
|
||||
colord("rgb(133,77,14)"), // Chocolate
|
||||
colord("rgb(134,239,172)"), // Light Green
|
||||
colord("rgb(147,51,234)"), // Bright Purple
|
||||
colord("rgb(147,197,253)"), // Powder Blue
|
||||
colord("rgb(151,255,187)"), // Fresh Mint
|
||||
colord("rgb(163,230,53)"), // Yellow Green
|
||||
colord("rgb(167,139,250)"), // Periwinkle
|
||||
colord("rgb(168,85,247)"), // Vibrant Purple
|
||||
colord("rgb(179,136,255)"), // Light Purple
|
||||
colord("rgb(132,204,22)"), // Lime
|
||||
colord("rgb(16,185,129)"), // Sea Green
|
||||
colord("rgb(52,211,153)"), // Spearmint
|
||||
colord("rgb(45,212,191)"), // Turquoise
|
||||
colord("rgb(74,222,128)"), // Mint
|
||||
colord("rgb(110,231,183)"), // Seafoam
|
||||
colord("rgb(134,239,172)"), // Light Green
|
||||
colord("rgb(151,255,187)"), // Fresh Mint
|
||||
colord("rgb(186,255,201)"), // Pale Emerald
|
||||
colord("rgb(230,250,210)"), // Pastel Lime
|
||||
colord("rgb(34,197,94)"), // Emerald
|
||||
colord("rgb(67,190,84)"), // Fresh Green
|
||||
colord("rgb(82,183,136)"), // Jade
|
||||
colord("rgb(48,178,180)"), // Teal
|
||||
colord("rgb(230,255,250)"), // Mint Whisper
|
||||
colord("rgb(220,240,250)"), // Ice Blue
|
||||
colord("rgb(233,213,255)"), // Light Lilac
|
||||
colord("rgb(204,204,255)"), // Soft Lavender Blue
|
||||
colord("rgb(220,220,255)"), // Meringue Blue
|
||||
colord("rgb(202,225,255)"), // Baby Blue
|
||||
colord("rgb(147,197,253)"), // Powder Blue
|
||||
colord("rgb(125,211,252)"), // Crystal Blue
|
||||
colord("rgb(99,202,253)"), // Azure
|
||||
colord("rgb(56,189,248)"), // Light Blue
|
||||
colord("rgb(96,165,250)"), // Sky Blue
|
||||
colord("rgb(59,130,246)"), // Royal Blue
|
||||
colord("rgb(79,70,229)"), // Indigo
|
||||
colord("rgb(124,58,237)"), // Royal Purple
|
||||
colord("rgb(147,51,234)"), // Bright Purple
|
||||
colord("rgb(179,136,255)"), // Light Purple
|
||||
colord("rgb(167,139,250)"), // Periwinkle
|
||||
colord("rgb(217,70,239)"), // Fuchsia
|
||||
colord("rgb(168,85,247)"), // Vibrant Purple
|
||||
colord("rgb(190,92,251)"), // Amethyst
|
||||
colord("rgb(192,132,252)"), // Lavender
|
||||
colord("rgb(202,138,4)"), // Rich Gold
|
||||
colord("rgb(202,225,255)"), // Baby Blue
|
||||
colord("rgb(204,204,255)"), // Soft Lavender Blue
|
||||
colord("rgb(217,70,239)"), // Fuchsia
|
||||
colord("rgb(220,38,38)"), // Ruby
|
||||
colord("rgb(220,220,255)"), // Meringue Blue
|
||||
colord("rgb(220,240,250)"), // Ice Blue
|
||||
colord("rgb(230,250,210)"), // Pastel Lime
|
||||
colord("rgb(230,255,250)"), // Mint Whisper
|
||||
colord("rgb(233,213,255)"), // Light Lilac
|
||||
colord("rgb(234,88,12)"), // Burnt Orange
|
||||
colord("rgb(234,179,8)"), // Sunflower
|
||||
colord("rgb(235,75,75)"), // Bright Red
|
||||
colord("rgb(236,72,153)"), // Deep Pink
|
||||
colord("rgb(239,68,68)"), // Crimson
|
||||
colord("rgb(240,171,252)"), // Orchid
|
||||
colord("rgb(240,240,200)"), // Light Khaki
|
||||
colord("rgb(244,114,182)"), // Rose
|
||||
colord("rgb(236,72,153)"), // Deep Pink
|
||||
colord("rgb(220,38,38)"), // Ruby
|
||||
colord("rgb(239,68,68)"), // Crimson
|
||||
colord("rgb(235,75,75)"), // Bright Red
|
||||
colord("rgb(245,101,101)"), // Coral
|
||||
colord("rgb(245,158,11)"), // Amber
|
||||
colord("rgb(248,113,113)"), // Warm Red
|
||||
colord("rgb(249,115,22)"), // Tangerine
|
||||
colord("rgb(250,215,225)"), // Cotton Candy
|
||||
colord("rgb(250,250,210)"), // Pastel Lemon
|
||||
colord("rgb(251,113,133)"), // Watermelon
|
||||
colord("rgb(251,146,60)"), // Light Orange
|
||||
colord("rgb(251,191,36)"), // Marigold
|
||||
colord("rgb(251,235,245)"), // Rose Powder
|
||||
colord("rgb(252,165,165)"), // Peach
|
||||
colord("rgb(252,211,77)"), // Golden
|
||||
colord("rgb(253,164,175)"), // Salmon Pink
|
||||
colord("rgb(252,165,165)"), // Peach
|
||||
colord("rgb(255,204,229)"), // Blush Pink
|
||||
colord("rgb(255,223,186)"), // Apricot Cream
|
||||
colord("rgb(250,215,225)"), // Cotton Candy
|
||||
colord("rgb(251,235,245)"), // Rose Powder
|
||||
colord("rgb(240,240,200)"), // Light Khaki
|
||||
colord("rgb(250,250,210)"), // Pastel Lemon
|
||||
colord("rgb(255,240,200)"), // Vanilla
|
||||
colord("rgb(255,223,186)"), // Apricot Cream
|
||||
colord("rgb(252,211,77)"), // Golden
|
||||
colord("rgb(251,191,36)"), // Marigold
|
||||
colord("rgb(234,179,8)"), // Sunflower
|
||||
colord("rgb(202,138,4)"), // Rich Gold
|
||||
colord("rgb(245,158,11)"), // Amber
|
||||
colord("rgb(251,146,60)"), // Light Orange
|
||||
colord("rgb(249,115,22)"), // Tangerine
|
||||
colord("rgb(234,88,12)"), // Burnt Orange
|
||||
colord("rgb(133,77,14)"), // Chocolate
|
||||
];
|
||||
|
||||
export const botColors: Colord[] = [
|
||||
colord("rgb(190,120,120)"), // Muted Red
|
||||
colord("rgb(120,160,190)"), // Muted Sky Blue
|
||||
colord("rgb(190,160,100)"), // Muted Golden Yellow
|
||||
colord("rgb(160,120,190)"), // Muted Purple
|
||||
colord("rgb(100,170,130)"), // Muted Emerald Green
|
||||
colord("rgb(190,130,160)"), // Muted Pink
|
||||
colord("rgb(120,150,100)"), // Muted Olive Green
|
||||
colord("rgb(190,140,120)"), // Muted Peach
|
||||
colord("rgb(100,120,160)"), // Muted Navy Blue
|
||||
colord("rgb(170,170,120)"), // Muted Lime Yellow
|
||||
colord("rgb(160,120,130)"), // Muted Maroon
|
||||
colord("rgb(120,170,170)"), // Muted Turquoise
|
||||
colord("rgb(170,140,100)"), // Muted Light Orange
|
||||
colord("rgb(140,120,160)"), // Muted Lavender
|
||||
colord("rgb(150,170,130)"), // Muted Light Green
|
||||
colord("rgb(170,120,140)"), // Muted Hot Pink
|
||||
colord("rgb(120,140,120)"), // Muted Sea Green
|
||||
colord("rgb(180,160,160)"), // Muted Light Pink
|
||||
colord("rgb(130,130,160)"), // Muted Periwinkle
|
||||
colord("rgb(160,150,120)"), // Muted Sand
|
||||
colord("rgb(120,160,150)"), // Muted Aquamarine
|
||||
colord("rgb(170,150,170)"), // Muted Orchid
|
||||
colord("rgb(150,160,120)"), // Muted Yellow Green
|
||||
colord("rgb(120,130,140)"), // Muted Steel Blue
|
||||
colord("rgb(180,140,140)"), // Muted Salmon
|
||||
colord("rgb(140,160,170)"), // Muted Light Blue
|
||||
colord("rgb(170,150,130)"), // Muted Tan
|
||||
colord("rgb(160,130,160)"), // Muted Plum
|
||||
colord("rgb(130,170,130)"), // Muted Light Sea Green
|
||||
colord("rgb(170,130,130)"), // Muted Coral
|
||||
colord("rgb(130,150,170)"), // Muted Cornflower Blue
|
||||
colord("rgb(170,170,140)"), // Muted Khaki
|
||||
colord("rgb(150,130,150)"), // Muted Purple Gray
|
||||
colord("rgb(140,160,140)"), // Muted Dark Sea Green
|
||||
colord("rgb(170,130,120)"), // Muted Dark Salmon
|
||||
colord("rgb(130,150,160)"), // Muted Cadet Blue
|
||||
colord("rgb(160,160,150)"), // Muted Tan Gray
|
||||
colord("rgb(150,140,160)"), // Muted Medium Purple
|
||||
colord("rgb(150,170,150)"), // Muted Pale Green
|
||||
colord("rgb(160,140,130)"), // Muted Rosy Brown
|
||||
colord("rgb(140,150,160)"), // Muted Light Slate Gray
|
||||
colord("rgb(160,150,140)"), // Muted Dark Khaki
|
||||
colord("rgb(140,130,140)"), // Muted Thistle
|
||||
colord("rgb(150,160,160)"), // Muted Pale Blue Green
|
||||
colord("rgb(160,140,150)"), // Muted Puce
|
||||
colord("rgb(130,160,150)"), // Muted Medium Aquamarine
|
||||
colord("rgb(160,150,160)"), // Muted Mauve
|
||||
colord("rgb(150,160,140)"), // Muted Dark Olive Green
|
||||
colord("rgb(160,160,150)"), // Muted Tan Gray
|
||||
colord("rgb(170,170,140)"), // Muted Khaki
|
||||
colord("rgb(170,170,120)"), // Muted Lime Yellow
|
||||
colord("rgb(150,160,120)"), // Muted Yellow Green
|
||||
colord("rgb(150,170,130)"), // Muted Light Green
|
||||
colord("rgb(150,170,150)"), // Muted Pale Green
|
||||
colord("rgb(130,170,130)"), // Muted Light Sea Green
|
||||
colord("rgb(140,160,140)"), // Muted Dark Sea Green
|
||||
colord("rgb(120,150,100)"), // Muted Olive Green
|
||||
colord("rgb(120,140,120)"), // Muted Sea Green
|
||||
colord("rgb(100,170,130)"), // Muted Emerald Green
|
||||
colord("rgb(120,160,150)"), // Muted Aquamarine
|
||||
colord("rgb(130,160,150)"), // Muted Medium Aquamarine
|
||||
colord("rgb(120,170,170)"), // Muted Turquoise
|
||||
colord("rgb(120,160,190)"), // Muted Sky Blue
|
||||
colord("rgb(130,150,170)"), // Muted Cornflower Blue
|
||||
colord("rgb(130,150,160)"), // Muted Cadet Blue
|
||||
colord("rgb(140,150,160)"), // Muted Light Slate Gray
|
||||
colord("rgb(140,160,170)"), // Muted Light Blue
|
||||
colord("rgb(150,160,160)"), // Muted Pale Blue Green
|
||||
colord("rgb(100,120,160)"), // Muted Navy Blue
|
||||
colord("rgb(120,130,140)"), // Muted Steel Blue
|
||||
colord("rgb(130,130,160)"), // Muted Periwinkle
|
||||
colord("rgb(140,130,140)"), // Muted Thistle
|
||||
colord("rgb(140,120,160)"), // Muted Lavender
|
||||
colord("rgb(150,130,150)"), // Muted Purple Gray
|
||||
colord("rgb(150,140,160)"), // Muted Medium Purple
|
||||
colord("rgb(160,130,160)"), // Muted Plum
|
||||
colord("rgb(170,150,170)"), // Muted Orchid
|
||||
colord("rgb(160,120,190)"), // Muted Purple
|
||||
colord("rgb(160,120,130)"), // Muted Maroon
|
||||
colord("rgb(170,120,140)"), // Muted Hot Pink
|
||||
colord("rgb(170,130,120)"), // Muted Dark Salmon
|
||||
colord("rgb(170,130,130)"), // Muted Coral
|
||||
colord("rgb(180,140,140)"), // Muted Salmon
|
||||
colord("rgb(190,130,160)"), // Muted Pink
|
||||
colord("rgb(190,120,120)"), // Muted Red
|
||||
colord("rgb(190,140,120)"), // Muted Peach
|
||||
colord("rgb(190,160,100)"), // Muted Golden Yellow
|
||||
colord("rgb(170,140,100)"), // Muted Light Orange
|
||||
colord("rgb(160,140,130)"), // Muted Rosy Brown
|
||||
colord("rgb(170,150,130)"), // Muted Tan
|
||||
colord("rgb(160,150,120)"), // Muted Sand
|
||||
colord("rgb(160,150,140)"), // Muted Dark Khaki
|
||||
colord("rgb(160,140,150)"), // Muted Puce
|
||||
colord("rgb(160,150,160)"), // Muted Mauve
|
||||
colord("rgb(150,140,150)"), // Muted Dusty Rose
|
||||
colord("rgb(180,160,160)"), // Muted Light Pink
|
||||
];
|
||||
|
||||
// Fallback colors for when the color palette is exhausted.
|
||||
|
||||
@@ -190,6 +190,8 @@ export interface Theme {
|
||||
// Don't call directly, use PlayerView
|
||||
territoryColor(playerInfo: PlayerView): Colord;
|
||||
// Don't call directly, use PlayerView
|
||||
structureColors(territoryColor: Colord): { light: Colord; dark: Colord };
|
||||
// Don't call directly, use PlayerView
|
||||
borderColor(territoryColor: Colord): Colord;
|
||||
// Don't call directly, use PlayerView
|
||||
defendedBorderColors(territoryColor: Colord): { light: Colord; dark: Colord };
|
||||
|
||||
@@ -60,6 +60,7 @@ const numPlayersConfig = {
|
||||
[GameMapType.Europe]: [100, 70, 50],
|
||||
[GameMapType.EuropeClassic]: [50, 30, 30],
|
||||
[GameMapType.FalklandIslands]: [50, 30, 20],
|
||||
[GameMapType.FourIslands]: [20, 15, 10],
|
||||
[GameMapType.FaroeIslands]: [20, 15, 10],
|
||||
[GameMapType.GatewayToTheAtlantic]: [100, 70, 50],
|
||||
[GameMapType.GiantWorldMap]: [100, 70, 50],
|
||||
@@ -692,7 +693,7 @@ export class DefaultConfig implements Config {
|
||||
|
||||
if (attacker.isPlayer() && defender.isPlayer()) {
|
||||
if (defender.isDisconnected() && attacker.isOnSameTeam(defender)) {
|
||||
// No troop loss if defender is disconnected.
|
||||
// No troop loss if defender is disconnected and on same team
|
||||
mag = 0;
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Colord, colord } from "colord";
|
||||
import { Colord, colord, LabaColor } from "colord";
|
||||
import { PseudoRandom } from "../PseudoRandom";
|
||||
import { PlayerType, Team, TerrainType } from "../game/Game";
|
||||
import { GameMap, TileRef } from "../game/GameMap";
|
||||
@@ -7,10 +7,7 @@ import { ColorAllocator } from "./ColorAllocator";
|
||||
import { botColors, fallbackColors, humanColors, nationColors } from "./Colors";
|
||||
import { Theme } from "./Config";
|
||||
|
||||
type ColorCache = Map<string, Colord>;
|
||||
|
||||
export class PastelTheme implements Theme {
|
||||
private borderColorCache: ColorCache = new Map<string, Colord>();
|
||||
private rand = new PseudoRandom(123);
|
||||
private humanColorAllocator = new ColorAllocator(humanColors, fallbackColors);
|
||||
private botColorAllocator = new ColorAllocator(botColors, botColors);
|
||||
@@ -65,6 +62,59 @@ export class PastelTheme implements Theme {
|
||||
return this.nationColorAllocator.assignColor(player.id());
|
||||
}
|
||||
|
||||
structureColors(territoryColor: Colord): { light: Colord; dark: Colord } {
|
||||
// Convert territory color to LAB color space. Territory color is rendered in game with alpha = 150/255, use that here.
|
||||
const lightLAB = territoryColor.alpha(150 / 255).toLab();
|
||||
// Get "border color" from territory color & convert to LAB color space
|
||||
const darkLAB = this.borderColor(territoryColor).toLab();
|
||||
// Calculate the contrast of the two provided colors
|
||||
let contrast = this.contrast(lightLAB, darkLAB);
|
||||
|
||||
// Don't want excessive contrast, so incrementally increase contrast within a loop.
|
||||
// Define target values, looping limits, and loop counter
|
||||
const loopLimit = 10; // Switch from darkening border to lightening fill if loopLimit is reached
|
||||
const maxIterations = 50; // maximum number of loops allowed, throw error above this limit
|
||||
const contrastTarget = 0.5;
|
||||
let loopCount = 0;
|
||||
|
||||
// Adjust luminance by 5 in each iteration. This is a balance between speed and not overdoing contrast changes.
|
||||
const luminanceChange = 5;
|
||||
|
||||
while (contrast < contrastTarget) {
|
||||
if (loopCount > maxIterations) {
|
||||
// Prevent runaway loops
|
||||
console.warn(`Infinite loop detected during structure color calculation.
|
||||
Light color: ${colord(lightLAB).toRgbString()},
|
||||
Dark color: ${colord(darkLAB).toRgbString()},
|
||||
Contrast: ${contrast}`);
|
||||
break;
|
||||
|
||||
// Increase the light color if the "loop limit" has been reach
|
||||
// (probably due to the dark color already being as dark as it can be)
|
||||
} else if (loopCount > loopLimit) {
|
||||
lightLAB.l = this.clamp(lightLAB.l + luminanceChange);
|
||||
|
||||
// Decrease the dark color first to keep the light color as close
|
||||
// to the territory color as possible
|
||||
} else {
|
||||
darkLAB.l = this.clamp(darkLAB.l - luminanceChange);
|
||||
}
|
||||
|
||||
// re-calculate contrast and increment loop counter
|
||||
contrast = this.contrast(lightLAB, darkLAB);
|
||||
loopCount++;
|
||||
}
|
||||
return { light: colord(lightLAB), dark: colord(darkLAB) };
|
||||
}
|
||||
|
||||
private contrast(first: LabaColor, second: LabaColor): number {
|
||||
return colord(first).delta(colord(second));
|
||||
}
|
||||
|
||||
private clamp(num: number, low: number = 0, high: number = 100): number {
|
||||
return Math.min(Math.max(low, num), high);
|
||||
}
|
||||
|
||||
// Don't call directly, use PlayerView
|
||||
borderColor(territoryColor: Colord): Colord {
|
||||
return territoryColor.darken(0.125);
|
||||
|
||||
@@ -186,6 +186,13 @@ export class AttackExecution implements Execution {
|
||||
this._owner.id(),
|
||||
);
|
||||
}
|
||||
if (this.removeTroops === false && this.sourceTile === null) {
|
||||
// startTroops are always added to attack troops at init but not always removed from owner troops
|
||||
// subtract startTroops from attack troops so we don't give back startTroops to owner that were never removed
|
||||
// boat attacks (sourceTile !== null) are the exception: troops were removed at departure and must be returned after attack still
|
||||
this.attack.setTroops(this.attack.troops() - (this.startTroops ?? 0));
|
||||
}
|
||||
|
||||
const survivors = this.attack.troops() - deaths;
|
||||
this._owner.addTroops(survivors);
|
||||
this.attack.delete();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { renderTroops } from "../../client/Utils";
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
@@ -14,6 +15,7 @@ import { PathFindResultType } from "../pathfinding/AStar";
|
||||
import { PathFinder } from "../pathfinding/PathFinding";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
|
||||
const malusForRetreat = 25;
|
||||
export class TransportShipExecution implements Execution {
|
||||
private lastMove: number;
|
||||
|
||||
@@ -33,13 +35,17 @@ export class TransportShipExecution implements Execution {
|
||||
|
||||
private pathFinder: PathFinder;
|
||||
|
||||
private originalOwner: Player;
|
||||
|
||||
constructor(
|
||||
private attacker: Player,
|
||||
private targetID: PlayerID | null,
|
||||
private ref: TileRef,
|
||||
private startTroops: number,
|
||||
private src: TileRef | null,
|
||||
) {}
|
||||
) {
|
||||
this.originalOwner = this.attacker;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
@@ -173,11 +179,44 @@ export class TransportShipExecution implements Execution {
|
||||
}
|
||||
this.lastMove = ticks;
|
||||
|
||||
if (this.boat.retreating()) {
|
||||
this.dst = this.src!; // src is guaranteed to be set at this point
|
||||
// Team mate can conquer disconnected player and get their ships
|
||||
// captureUnit has changed the owner of the unit, now update attacker
|
||||
const boatOwner = this.boat.owner();
|
||||
if (
|
||||
this.originalOwner.isDisconnected() &&
|
||||
boatOwner !== this.originalOwner &&
|
||||
boatOwner.isOnSameTeam(this.originalOwner)
|
||||
) {
|
||||
this.attacker = boatOwner;
|
||||
this.originalOwner = boatOwner; // for when this owner disconnects too
|
||||
}
|
||||
|
||||
if (this.boat.targetTile() !== this.dst) {
|
||||
this.boat.setTargetTile(this.dst);
|
||||
if (this.boat.retreating()) {
|
||||
// Ensure retreat source is still valid for (new) owner
|
||||
if (this.mg.owner(this.src!) !== this.attacker) {
|
||||
// Use bestTransportShipSpawn, not canBuild because of its max boats check etc
|
||||
const newSrc = this.attacker.bestTransportShipSpawn(this.dst);
|
||||
if (newSrc === false) {
|
||||
this.src = null;
|
||||
} else {
|
||||
this.src = newSrc;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.src === null) {
|
||||
console.warn(
|
||||
`TransportShipExecution: retreating but no src found for new attacker`,
|
||||
);
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
return;
|
||||
} else {
|
||||
this.dst = this.src;
|
||||
|
||||
if (this.boat.targetTile() !== this.dst) {
|
||||
this.boat.setTargetTile(this.dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,14 +229,23 @@ export class TransportShipExecution implements Execution {
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
if (this.mg.owner(this.dst) === this.attacker) {
|
||||
this.attacker.addTroops(this.boat.troops());
|
||||
const deaths = this.boat.troops() * (malusForRetreat / 100);
|
||||
const survivors = this.boat.troops() - deaths;
|
||||
this.attacker.addTroops(survivors);
|
||||
this.boat.delete(false);
|
||||
this.active = false;
|
||||
|
||||
// Record stats
|
||||
this.mg
|
||||
.stats()
|
||||
.boatArriveTroops(this.attacker, this.target, this.boat.troops());
|
||||
.boatArriveTroops(this.attacker, this.target, survivors);
|
||||
if (deaths) {
|
||||
this.mg.displayMessage(
|
||||
`Attack cancelled, ${renderTroops(deaths)} soldiers killed during retreat.`,
|
||||
MessageType.ATTACK_CANCELLED,
|
||||
this.attacker.id(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.attacker.conquer(this.dst);
|
||||
|
||||
@@ -55,10 +55,6 @@ export class WarshipExecution implements Execution {
|
||||
this.warship.delete();
|
||||
return;
|
||||
}
|
||||
if (this.warship.owner().isDisconnected()) {
|
||||
this.warship.delete();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPort = this.warship.owner().unitCount(UnitType.Port) > 0;
|
||||
if (hasPort) {
|
||||
@@ -93,7 +89,7 @@ export class WarshipExecution implements Execution {
|
||||
if (
|
||||
unit.owner() === this.warship.owner() ||
|
||||
unit === this.warship ||
|
||||
unit.owner().isFriendly(this.warship.owner()) ||
|
||||
unit.owner().isFriendly(this.warship.owner(), true) ||
|
||||
this.alreadySentShell.has(unit)
|
||||
) {
|
||||
continue;
|
||||
|
||||
@@ -101,6 +101,7 @@ export enum GameMapType {
|
||||
Montreal = "Montreal",
|
||||
Achiran = "Achiran",
|
||||
BaikalNukeWars = "Baikal (Nuke Wars)",
|
||||
FourIslands = "Four Islands",
|
||||
}
|
||||
|
||||
export type GameMapName = keyof typeof GameMapType;
|
||||
@@ -143,6 +144,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.DeglaciatedAntarctica,
|
||||
GameMapType.Achiran,
|
||||
GameMapType.BaikalNukeWars,
|
||||
GameMapType.FourIslands,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -601,7 +603,7 @@ export interface Player {
|
||||
decayRelations(): void;
|
||||
isOnSameTeam(other: Player): boolean;
|
||||
// Either allied or on same team.
|
||||
isFriendly(other: Player): boolean;
|
||||
isFriendly(other: Player, treatAFKFriendly?: boolean): boolean;
|
||||
team(): Team | null;
|
||||
clan(): string | null;
|
||||
incomingAllianceRequests(): AllianceRequest[];
|
||||
|
||||
@@ -895,6 +895,20 @@ export class GameImpl implements Game {
|
||||
return this._railNetwork;
|
||||
}
|
||||
conquerPlayer(conqueror: Player, conquered: Player) {
|
||||
if (conquered.isDisconnected() && conqueror.isOnSameTeam(conquered)) {
|
||||
const ships = conquered
|
||||
.units()
|
||||
.filter(
|
||||
(u) =>
|
||||
u.type() === UnitType.Warship ||
|
||||
u.type() === UnitType.TransportShip,
|
||||
);
|
||||
|
||||
for (const ship of ships) {
|
||||
conqueror.captureUnit(ship);
|
||||
}
|
||||
}
|
||||
|
||||
const gold = conquered.gold();
|
||||
this.displayMessage(
|
||||
`Conquered ${conquered.displayName()} received ${renderNumber(
|
||||
|
||||
@@ -181,6 +181,7 @@ export interface AllianceView {
|
||||
other: PlayerID;
|
||||
createdAt: Tick;
|
||||
expiresAt: Tick;
|
||||
hasExtensionRequest: boolean;
|
||||
}
|
||||
|
||||
export interface AllianceRequestUpdate {
|
||||
|
||||
@@ -190,6 +190,8 @@ export class PlayerView {
|
||||
|
||||
private _territoryColor: Colord;
|
||||
private _borderColor: Colord;
|
||||
// Update here to include structure light and dark colors
|
||||
private _structureColors: { light: Colord; dark: Colord };
|
||||
private _defendedBorderColors: { light: Colord; dark: Colord };
|
||||
|
||||
constructor(
|
||||
@@ -235,6 +237,11 @@ export class PlayerView {
|
||||
this._territoryColor = defaultTerritoryColor;
|
||||
}
|
||||
|
||||
this._structureColors = this.game
|
||||
.config()
|
||||
.theme()
|
||||
.structureColors(this._territoryColor);
|
||||
|
||||
const maybeFocusedBorderColor =
|
||||
this.game.myClientID() === this.data.clientID
|
||||
? this.game.config().theme().focusedBorderColor()
|
||||
@@ -268,6 +275,10 @@ export class PlayerView {
|
||||
return isPrimary ? this._territoryColor : this._borderColor;
|
||||
}
|
||||
|
||||
structureColors(): { light: Colord; dark: Colord } {
|
||||
return this._structureColors;
|
||||
}
|
||||
|
||||
borderColor(tile?: TileRef, isDefended: boolean = false): Colord {
|
||||
if (tile === undefined || !isDefended) {
|
||||
return this._borderColor;
|
||||
|
||||
@@ -173,6 +173,10 @@ export class PlayerImpl implements Player {
|
||||
other: a.other(this).id(),
|
||||
createdAt: a.createdAt(),
|
||||
expiresAt: a.expiresAt(),
|
||||
hasExtensionRequest:
|
||||
a.expiresAt() <=
|
||||
this.mg.ticks() +
|
||||
this.mg.config().allianceExtensionPromptOffset(),
|
||||
}) satisfies AllianceView,
|
||||
),
|
||||
hasSpawned: this.hasSpawned(),
|
||||
@@ -791,8 +795,8 @@ export class PlayerImpl implements Player {
|
||||
return this._team === other.team();
|
||||
}
|
||||
|
||||
isFriendly(other: Player): boolean {
|
||||
if (other.isDisconnected()) {
|
||||
isFriendly(other: Player, treatAFKFriendly: boolean = false): boolean {
|
||||
if (other.isDisconnected() && !treatAFKFriendly) {
|
||||
return false;
|
||||
}
|
||||
return this.isOnSameTeam(other) || this.isAlliedWith(other);
|
||||
|
||||
@@ -148,6 +148,8 @@ export function bestShoreDeploymentSource(
|
||||
if (t === null) return false;
|
||||
|
||||
const candidates = candidateShoreTiles(gm, player, t);
|
||||
if (candidates.length === 0) return false;
|
||||
|
||||
const aStar = new MiniAStar(gm, gm.miniMap(), candidates, t, 1_000_000, 1);
|
||||
const result = aStar.compute();
|
||||
if (result !== PathFindResultType.Completed) {
|
||||
|
||||
@@ -111,6 +111,26 @@ describe("Attack", () => {
|
||||
expect(nuke.isActive()).toBe(false);
|
||||
expect(defender.units(UnitType.TransportShip)[0].troops()).toBeLessThan(90);
|
||||
});
|
||||
|
||||
test("Boat penalty on retreat Transport Ship arrival", async () => {
|
||||
const player_start_troops = defender.troops();
|
||||
const boat_troops = player_start_troops * 0.5;
|
||||
|
||||
sendBoat(game.ref(15, 8), game.ref(10, 5), boat_troops);
|
||||
|
||||
game.executeNextTick();
|
||||
|
||||
const ship = defender.units(UnitType.TransportShip)[0];
|
||||
expect(ship.troops()).toBe(boat_troops);
|
||||
expect(ship.isActive()).toBe(true);
|
||||
|
||||
ship.orderBoatRetreat();
|
||||
game.executeNextTick();
|
||||
|
||||
expect(ship.isActive()).toBe(false);
|
||||
expect(boat_troops).toBeLessThan(defender.troops());
|
||||
expect(defender.troops()).toBeLessThan(player_start_troops);
|
||||
});
|
||||
});
|
||||
|
||||
let playerA: Player;
|
||||
|
||||
+321
-1
@@ -1,12 +1,25 @@
|
||||
import { AttackExecution } from "../src/core/execution/AttackExecution";
|
||||
import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution";
|
||||
import { SpawnExecution } from "../src/core/execution/SpawnExecution";
|
||||
import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game";
|
||||
import { TransportShipExecution } from "../src/core/execution/TransportShipExecution";
|
||||
import { WarshipExecution } from "../src/core/execution/WarshipExecution";
|
||||
import {
|
||||
Game,
|
||||
GameMode,
|
||||
Player,
|
||||
PlayerInfo,
|
||||
PlayerType,
|
||||
UnitType,
|
||||
} from "../src/core/game/Game";
|
||||
import { toInt } from "../src/core/Util";
|
||||
import { setup } from "./util/Setup";
|
||||
import { UseRealAttackLogic } from "./util/TestConfig";
|
||||
import { executeTicks } from "./util/utils";
|
||||
|
||||
let game: Game;
|
||||
let player1: Player;
|
||||
let player2: Player;
|
||||
let enemy: Player;
|
||||
|
||||
describe("Disconnected", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -158,4 +171,311 @@ describe("Disconnected", () => {
|
||||
expect(player1.isDisconnected()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disconnected team member interactions", () => {
|
||||
const coastX = 7;
|
||||
|
||||
beforeEach(async () => {
|
||||
const player1Info = new PlayerInfo(
|
||||
"[CLAN]Player1",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_1_id",
|
||||
);
|
||||
const player2Info = new PlayerInfo(
|
||||
"[CLAN]Player2",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"player_2_id",
|
||||
);
|
||||
|
||||
game = await setup(
|
||||
"half_land_half_ocean",
|
||||
{
|
||||
infiniteGold: true,
|
||||
instantBuild: true,
|
||||
gameMode: GameMode.Team,
|
||||
playerTeams: 2, // ignore player2 "kicked" console warn
|
||||
},
|
||||
[player1Info, player2Info],
|
||||
undefined,
|
||||
UseRealAttackLogic, // don't use TestConfig's mock attackLogic
|
||||
);
|
||||
|
||||
game.addExecution(
|
||||
new SpawnExecution(player1Info, game.map().ref(coastX - 2, 1)),
|
||||
new SpawnExecution(player2Info, game.map().ref(coastX - 2, 4)),
|
||||
);
|
||||
|
||||
while (game.inSpawnPhase()) {
|
||||
game.executeNextTick();
|
||||
}
|
||||
|
||||
player1 = game.player(player1Info.id);
|
||||
player2 = game.player(player2Info.id);
|
||||
player2.markDisconnected(false);
|
||||
|
||||
expect(player1.team()).not.toBeNull();
|
||||
expect(player2.team()).not.toBeNull();
|
||||
expect(player1.isOnSameTeam(player2)).toBe(true);
|
||||
});
|
||||
|
||||
test("Team Warships should not attack disconnected team mate ships", () => {
|
||||
const warship = player1.buildUnit(
|
||||
UnitType.Warship,
|
||||
game.map().ref(coastX + 1, 10),
|
||||
{
|
||||
patrolTile: game.map().ref(coastX + 1, 10),
|
||||
},
|
||||
);
|
||||
game.addExecution(new WarshipExecution(warship));
|
||||
|
||||
const transportShip = player2.buildUnit(
|
||||
UnitType.TransportShip,
|
||||
game.map().ref(coastX + 1, 11),
|
||||
{
|
||||
troops: 100,
|
||||
},
|
||||
);
|
||||
|
||||
player2.markDisconnected(true);
|
||||
executeTicks(game, 10);
|
||||
|
||||
expect(warship.targetUnit()).toBe(undefined);
|
||||
expect(transportShip.isActive()).toBe(true);
|
||||
expect(transportShip.owner()).toBe(player2);
|
||||
});
|
||||
|
||||
test("Disconnected player Warship should not attack team members' ships", () => {
|
||||
const warship = player2.buildUnit(
|
||||
UnitType.Warship,
|
||||
game.map().ref(coastX + 1, 5),
|
||||
{
|
||||
patrolTile: game.map().ref(coastX + 1, 10),
|
||||
},
|
||||
);
|
||||
game.addExecution(new WarshipExecution(warship));
|
||||
|
||||
const transportShip = player1.buildUnit(
|
||||
UnitType.TransportShip,
|
||||
game.map().ref(coastX + 1, 6),
|
||||
{
|
||||
troops: 100,
|
||||
},
|
||||
);
|
||||
|
||||
player2.markDisconnected(true);
|
||||
executeTicks(game, 10);
|
||||
|
||||
expect(warship.targetUnit()).toBe(undefined);
|
||||
expect(transportShip.isActive()).toBe(true);
|
||||
expect(transportShip.owner()).toBe(player1);
|
||||
});
|
||||
|
||||
test("Player can attack disconnected team mate without troop loss", () => {
|
||||
player2.conquer(game.map().ref(coastX - 2, 2));
|
||||
player2.conquer(game.map().ref(coastX - 2, 3));
|
||||
player2.markDisconnected(true);
|
||||
|
||||
const troopsBeforeAttack = player1.troops();
|
||||
const startTroops = troopsBeforeAttack * 0.25;
|
||||
|
||||
game.addExecution(
|
||||
new AttackExecution(startTroops, player1, player2.id(), null),
|
||||
);
|
||||
|
||||
let expectedTotalGrowth = 0n;
|
||||
let afterTickZero = false;
|
||||
|
||||
while (player2.isAlive()) {
|
||||
if (afterTickZero) {
|
||||
// No growth on tick 0, troop additions start from tick 1
|
||||
const troopIncThisTick = game.config().troopIncreaseRate(player1);
|
||||
expectedTotalGrowth += toInt(troopIncThisTick);
|
||||
}
|
||||
|
||||
game.executeNextTick();
|
||||
afterTickZero = true;
|
||||
}
|
||||
|
||||
// Tick for retreat() in AttackExecution to add back startTtoops to owner troops
|
||||
const troopIncThisTick1 = game.config().troopIncreaseRate(player1);
|
||||
expectedTotalGrowth += toInt(troopIncThisTick1);
|
||||
|
||||
game.executeNextTick();
|
||||
|
||||
const expectedFinalTroops = Number(
|
||||
toInt(troopsBeforeAttack) + expectedTotalGrowth,
|
||||
);
|
||||
|
||||
// Verify no troop loss
|
||||
expect(player1.troops()).toBe(expectedFinalTroops);
|
||||
});
|
||||
|
||||
test("Conqueror gets conquered disconnected team member's transport- and warships", () => {
|
||||
const warship = player2.buildUnit(
|
||||
UnitType.Warship,
|
||||
game.map().ref(coastX + 1, 1),
|
||||
{
|
||||
patrolTile: game.map().ref(coastX + 1, 1),
|
||||
},
|
||||
);
|
||||
const transportShip = player2.buildUnit(
|
||||
UnitType.TransportShip,
|
||||
game.map().ref(coastX + 1, 3),
|
||||
{
|
||||
troops: 100,
|
||||
},
|
||||
);
|
||||
|
||||
player2.conquer(game.map().ref(coastX - 2, 1));
|
||||
player2.markDisconnected(true);
|
||||
|
||||
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
|
||||
|
||||
executeTicks(game, 10);
|
||||
|
||||
expect(player2.isAlive()).toBe(false);
|
||||
expect(warship.owner()).toBe(player1);
|
||||
expect(transportShip.owner()).toBe(player1);
|
||||
});
|
||||
|
||||
test("Captured transport ship landing attack should be in name of new owner", () => {
|
||||
player2.conquer(game.map().ref(coastX, 1));
|
||||
player2.conquer(game.map().ref(coastX - 1, 1));
|
||||
player2.conquer(game.map().ref(coastX, 2));
|
||||
|
||||
const enemyShoreTile = game.map().ref(coastX, 15);
|
||||
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
player2,
|
||||
null,
|
||||
enemyShoreTile,
|
||||
100,
|
||||
game.map().ref(coastX, 1),
|
||||
),
|
||||
);
|
||||
|
||||
executeTicks(game, 1);
|
||||
|
||||
expect(player2.isAlive()).toBe(true);
|
||||
const transportShip = player2.units(UnitType.TransportShip)[0];
|
||||
expect(player2.units(UnitType.TransportShip).length).toBe(1);
|
||||
|
||||
player2.markDisconnected(true);
|
||||
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
|
||||
|
||||
executeTicks(game, 10);
|
||||
|
||||
expect(player2.isAlive()).toBe(false);
|
||||
expect(transportShip.owner()).toBe(player1);
|
||||
|
||||
executeTicks(game, 30);
|
||||
|
||||
// Verify ship landed and tile ownership transferred to new ship owner
|
||||
expect(game.owner(enemyShoreTile)).toBe(player1);
|
||||
});
|
||||
|
||||
test("Captured transport ship should retreat to owner's shore tile", () => {
|
||||
player1.conquer(game.map().ref(coastX, 4));
|
||||
player2.conquer(game.map().ref(coastX, 1));
|
||||
|
||||
const enemyShoreTile = game.map().ref(coastX, 8);
|
||||
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
player2,
|
||||
null,
|
||||
enemyShoreTile,
|
||||
100,
|
||||
game.map().ref(coastX, 1),
|
||||
),
|
||||
);
|
||||
executeTicks(game, 1);
|
||||
|
||||
const transportShip = player2.units(UnitType.TransportShip)[0];
|
||||
expect(player2.units(UnitType.TransportShip).length).toBe(1);
|
||||
|
||||
expect(transportShip.targetTile()).toBe(enemyShoreTile);
|
||||
|
||||
player2.markDisconnected(true);
|
||||
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
|
||||
executeTicks(game, 10);
|
||||
|
||||
expect(player2.isAlive()).toBe(false);
|
||||
expect(transportShip.owner()).toBe(player1);
|
||||
|
||||
transportShip.orderBoatRetreat();
|
||||
executeTicks(game, 2);
|
||||
|
||||
expect(transportShip.targetTile()).not.toBe(enemyShoreTile);
|
||||
expect(game.owner(transportShip.targetTile()!)).toBe(player1);
|
||||
});
|
||||
|
||||
test("Retreating transport ship is deleted if new owner has no shore tiles", () => {
|
||||
player2.conquer(game.map().ref(coastX, 1));
|
||||
player2.conquer(game.map().ref(coastX - 6, 2));
|
||||
player1.conquer(game.map().ref(coastX - 6, 3));
|
||||
|
||||
const enemyShoreTile = game.map().ref(coastX, 15);
|
||||
|
||||
const boatTroops = 100;
|
||||
game.addExecution(
|
||||
new TransportShipExecution(
|
||||
player2,
|
||||
null,
|
||||
enemyShoreTile,
|
||||
boatTroops,
|
||||
game.map().ref(coastX, 1),
|
||||
),
|
||||
);
|
||||
executeTicks(game, 1);
|
||||
|
||||
const transportShip = player2.units(UnitType.TransportShip)[0];
|
||||
expect(player2.units(UnitType.TransportShip).length).toBe(1);
|
||||
|
||||
player2.markDisconnected(true);
|
||||
game.addExecution(new AttackExecution(1000, player1, player2.id(), null));
|
||||
executeTicks(game, 10);
|
||||
|
||||
expect(player2.isAlive()).toBe(false);
|
||||
expect(transportShip.owner()).toBe(player1);
|
||||
|
||||
// Make sure player1 has no shore tiles for the ship to retreat to anymore
|
||||
const enemyInfo = new PlayerInfo(
|
||||
"Enemy",
|
||||
PlayerType.Human,
|
||||
null,
|
||||
"enemy_id",
|
||||
);
|
||||
enemy = game.addPlayer(enemyInfo);
|
||||
|
||||
const shoreTiles = Array.from(player1.borderTiles()).filter((t) =>
|
||||
game.isShore(t),
|
||||
);
|
||||
shoreTiles.forEach((tile) => {
|
||||
enemy.conquer(tile);
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(player1.borderTiles()).filter((t) => game.isShore(t)).length,
|
||||
).toBe(0);
|
||||
|
||||
executeTicks(game, 1);
|
||||
|
||||
const troopIncPerTick = game.config().troopIncreaseRate(player1);
|
||||
const expectedTroopGrowth = toInt(troopIncPerTick * 1);
|
||||
const expectedFinalTroops = Number(
|
||||
toInt(player1.troops()) + expectedTroopGrowth,
|
||||
);
|
||||
|
||||
transportShip.orderBoatRetreat();
|
||||
executeTicks(game, 1);
|
||||
|
||||
expect(transportShip.isActive()).toBe(false);
|
||||
// Also test if boat troops were returned to player1 as new ship owner
|
||||
expect(player1.troops()).toBe(expectedFinalTroops + boatTroops);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { computeAllianceClipPath } from "../src/client/graphics/PlayerIcons";
|
||||
|
||||
describe("PlayerIcons", () => {
|
||||
describe("computeAllianceClipPath", () => {
|
||||
test("returns full visibility (20% top cut) when alliance time is at 100%", () => {
|
||||
const result = computeAllianceClipPath(1.0);
|
||||
// topCut = 20 + (1 - 1.0) * 80 * 0.78 = 20 + 0 = 20.00
|
||||
expect(result).toBe("inset(20.00% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns maximum cut (82.40% top cut) when alliance time is at 0%", () => {
|
||||
const result = computeAllianceClipPath(0.0);
|
||||
// topCut = 20 + (1 - 0.0) * 80 * 0.78 = 20 + 62.4 = 82.40
|
||||
expect(result).toBe("inset(82.40% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns 51.20% top cut when alliance time is at 50%", () => {
|
||||
const result = computeAllianceClipPath(0.5);
|
||||
// topCut = 20 + (1 - 0.5) * 80 * 0.78 = 20 + 31.2 = 51.20
|
||||
expect(result).toBe("inset(51.20% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns 27.80% top cut when alliance time is at 87.5%", () => {
|
||||
const result = computeAllianceClipPath(0.875);
|
||||
// topCut = 20 + (1 - 0.875) * 80 * 0.78 = 20 + 7.8 = 27.80
|
||||
expect(result).toBe("inset(27.80% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("returns 74.60% top cut when alliance time is at 12.5%", () => {
|
||||
const result = computeAllianceClipPath(0.125);
|
||||
// topCut = 20 + (1 - 0.125) * 80 * 0.78 = 20 + 54.6 = 74.60
|
||||
expect(result).toBe("inset(74.60% -2px 0 -2px)");
|
||||
});
|
||||
|
||||
test("includes -2px horizontal overscan to prevent subpixel gaps", () => {
|
||||
const result = computeAllianceClipPath(0.5);
|
||||
expect(result).toContain("-2px");
|
||||
expect(result.match(/-2px/g)).toHaveLength(2); // Should appear twice (left and right)
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// Mock BuildMenu to avoid importing lit and other ESM-heavy deps in this unit test
|
||||
jest.mock(
|
||||
"../src/client/graphics/layers/BuildMenu",
|
||||
() => ({
|
||||
BuildMenu: class {},
|
||||
flattenedBuildTable: [],
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
// Mock Utils to avoid touching DOM (document) during tests
|
||||
jest.mock("../src/client/Utils", () => ({
|
||||
translateText: (k: string) => k,
|
||||
getSvgAspectRatio: async () => 1,
|
||||
}));
|
||||
|
||||
import {
|
||||
COLORS,
|
||||
rootMenuElement,
|
||||
type MenuElementParams,
|
||||
} from "../src/client/graphics/layers/RadialMenuElements";
|
||||
|
||||
// Minimal stubs to satisfy types used in rootMenuElement.subMenu and allyBreak actions
|
||||
const makePlayer = (id: string) =>
|
||||
({
|
||||
id: () => id,
|
||||
isAlliedWith: (other: any) =>
|
||||
other && typeof other.id === "function" && other.id() !== id
|
||||
? true
|
||||
: true,
|
||||
}) as unknown as import("../src/core/game/GameView").PlayerView;
|
||||
|
||||
const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
|
||||
const myPlayer = (opts?.myPlayer as any) ?? makePlayer("p1");
|
||||
const selected = (opts?.selected as any) ?? makePlayer("p2");
|
||||
return {
|
||||
myPlayer,
|
||||
selected,
|
||||
tile: {} as any,
|
||||
playerActions: {
|
||||
canAttack: true,
|
||||
interaction: {
|
||||
canBreakAlliance: true,
|
||||
canSendAllianceRequest: false,
|
||||
canEmbargo: false,
|
||||
},
|
||||
} as any,
|
||||
game: {
|
||||
inSpawnPhase: () => false,
|
||||
owner: () => ({ isPlayer: () => false }),
|
||||
} as any,
|
||||
buildMenu: {
|
||||
canBuildOrUpgrade: () => false,
|
||||
cost: () => 0,
|
||||
count: () => 0,
|
||||
sendBuildOrUpgrade: () => {},
|
||||
} as any,
|
||||
emojiTable: {} as any,
|
||||
playerActionHandler: {
|
||||
handleBreakAlliance: jest.fn(),
|
||||
handleEmbargo: jest.fn(),
|
||||
handleDonateGold: jest.fn(),
|
||||
handleDonateTroops: jest.fn(),
|
||||
handleTargetPlayer: jest.fn(),
|
||||
} as any,
|
||||
playerPanel: {
|
||||
show: jest.fn(),
|
||||
} as any,
|
||||
chatIntegration: {
|
||||
createQuickChatMenu: jest.fn(() => []),
|
||||
} as any,
|
||||
eventBus: {} as any,
|
||||
closeMenu: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const findAllyBreak = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break");
|
||||
|
||||
describe("RadialMenuElements ally break", () => {
|
||||
test("shows break option with correct color when allied", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
expect(ally).toBeTruthy();
|
||||
expect(ally.name).toBe("break");
|
||||
expect(ally.color).toBe(COLORS.breakAlly);
|
||||
});
|
||||
|
||||
test("action calls handleBreakAlliance and closes menu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
|
||||
ally.action!(params);
|
||||
|
||||
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
|
||||
params.myPlayer,
|
||||
params.selected,
|
||||
);
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -25,6 +25,7 @@ export async function setup(
|
||||
_gameConfig: Partial<GameConfig> = {},
|
||||
humans: PlayerInfo[] = [],
|
||||
currentDir: string = __dirname,
|
||||
ConfigClass: typeof TestConfig = TestConfig,
|
||||
): Promise<Game> {
|
||||
// Suppress console.debug for tests.
|
||||
console.debug = () => {};
|
||||
@@ -70,7 +71,7 @@ export async function setup(
|
||||
randomSpawn: false,
|
||||
..._gameConfig,
|
||||
};
|
||||
const config = new TestConfig(
|
||||
const config = new ConfigClass(
|
||||
serverConfig,
|
||||
gameConfig,
|
||||
new UserSettings(),
|
||||
|
||||
@@ -85,3 +85,26 @@ export class TestConfig extends DefaultConfig {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
export class UseRealAttackLogic extends TestConfig {
|
||||
// Override to use DefaultConfig's real attackLogic
|
||||
attackLogic(
|
||||
gm: Game,
|
||||
attackTroops: number,
|
||||
attacker: Player,
|
||||
defender: Player | TerraNullius,
|
||||
tileToConquer: TileRef,
|
||||
): {
|
||||
attackerTroopLoss: number;
|
||||
defenderTroopLoss: number;
|
||||
tilesPerTickUsed: number;
|
||||
} {
|
||||
return DefaultConfig.prototype.attackLogic.call(
|
||||
this,
|
||||
gm,
|
||||
attackTroops,
|
||||
attacker,
|
||||
defender,
|
||||
tileToConquer,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user