Merge branch 'main' into patterned-territory
@@ -1,5 +1,5 @@
|
||||
# Use an official Node runtime as the base image
|
||||
FROM node:18 AS base
|
||||
FROM node:24-slim AS base
|
||||
|
||||
# Create dependency layer
|
||||
FROM base AS dependencies
|
||||
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 582 B |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 290 B |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 59 KiB |
@@ -33,50 +33,61 @@
|
||||
"action_reset_gfx": "Reset graphics",
|
||||
"ui_section": "Game UI",
|
||||
"ui_leaderboard": "Leaderboard",
|
||||
"ui_leaderboard_desc": "Shows the top players of the game and their names, % owned land and gold.",
|
||||
"ui_leaderboard_desc": "Shows the top players of the game and their names, % owned land, gold and troops. Using Show All shows all players in the game. If you don't want to see the leaderboard, click Hide.",
|
||||
"ui_control": "Control panel",
|
||||
"ui_control_desc": "The control panel contains the following elements:",
|
||||
"ui_pop": "Pop - The amount of units you have, your max population and the rate at which you gain them.",
|
||||
"ui_gold": "Gold - The amount of gold you have and the rate at which you gain it.",
|
||||
"ui_troops_workers": "Troops and Workers - The amount of allocated troops and workers. Troops are used to attack or defend against attacks. Workers are used to generate gold. You can adjust the number of troops and workers using the slider.",
|
||||
"ui_attack_ratio": "Attack ratio - The amount of troops that will be used when you attack. You can adjust the attack ratio using the slider. Having more attacking troops than defending troops will make you lose fewer troops in the attack, while having less will increase the damage dealt to your attacking troops. The effect doesn't go beyond ratios of 2:1.",
|
||||
"ui_events": "Event panel",
|
||||
"ui_events_desc": "The Event panel displays the latest events, requests and Quick Chat messages. Some examples are:",
|
||||
"ui_events_alliance": "Alliance - Alliance requests can be accepted or rejected. Allies can share resources and troops, but can't attack each other. Clicking Focus moves the view to the player who sent the request.",
|
||||
"ui_events_attack": "Attacks - Incoming attacks and your outgoing attacks are shown. Click the message to center the view on the attack, nuke or Boat (transport ship). You can retreat troops by clicking the red X button. This will cost the lives of 25% of your attacking troops. If you retrieve a Boat attack, the boat returns to its starting point and will attack there if the land has been captured since. Nukes can't be retreated once launched.",
|
||||
"ui_events_quickchat": "Quick Chat - You can see sent and recieved chat messages here. Send a message to a player by clicking the Quick Chat icon in their Info menu.",
|
||||
"ui_options": "Options",
|
||||
"ui_options_desc": "The following elements can be found inside:",
|
||||
"ui_playeroverlay": "Player info overlay",
|
||||
"ui_playeroverlay_desc": "When you hover over a country, the Player info overlay is displayed under Options. It shows the type of player: Human, Nation (smart bot), or Bot. A Nation's attitude towards you, ranging from Hostile to Friendly. And defending troops, gold, plus the number of Warships and various buildings the player has.",
|
||||
"option_pause": "Pause/Unpause the game - Only available in single player mode.",
|
||||
"option_timer": "Timer - Time passed since the start of the game.",
|
||||
"option_exit": "Exit button.",
|
||||
"option_settings": "Settings - Open the settings menu. Inside you can toggle the Alternate View, Dark Mode, Emojis and action on left click.",
|
||||
"option_settings": "Settings - Open the settings menu. Inside you can toggle the Alternate view, Emojis, Dark Mode, Ninja (anonymous/random names mode), and action on left click.",
|
||||
"radial_title": "Radial menu",
|
||||
"radial_desc": "Right clicking (or touch on mobile) opens the radial menu. From there you can:",
|
||||
"radial_build": "Open the build menu.",
|
||||
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
|
||||
"radial_build": "Open the Build menu.",
|
||||
"radial_info": "Open the Info menu.",
|
||||
"radial_boat": "Send a boat to attack at the selected location (only available if you have access to water).",
|
||||
"radial_boat": "Send a Boat (transport ship) to attack at the selected location. Only available if you have access to water.",
|
||||
"radial_close": "Close the menu.",
|
||||
"info_title": "Info menu",
|
||||
"info_enemy_desc": "Contains information such for the selected player name, gold, troops, and if the player is a traitor.Traitor is a player who betrayed and attacked a player who was in an alliance with them. The icons below represent the following interactions:",
|
||||
"info_enemy_desc": "Contains information such as the selected player's name, gold, troops, stopped trading with you, nukes sent to you, and if the player is a traitor. Stopped trading means you won't receive gold from them and they won't sent you gold via trade ships. Manually (if the player clicked \"Stop trading\", which lasts until you both click \"Start trading\") or automatically (if you betrayed your alliance, which lasts until you become allies again or after 5 minutes). Traitor displays Yes for 30 seconds when the player betrayed and attacked a player who was in an alliance with them. The icons below represent the following interactions:",
|
||||
"info_chat": "Send a quick chat message to the player. Select a Category, a Phrase, and if the phrase contains [P1] select a Player name to replace it with. Hit Send.",
|
||||
"info_target": "Place a target mark on the player, marking it for all allies, used to coordinate attacks.",
|
||||
"info_alliance": "Send an alliance request to the player. Allies can share resources and troops, but can't attack each other.",
|
||||
"info_emoji": "Send an emoji to the player.",
|
||||
"info_trade": "Use \"Stop trading\" to stop giving the player gold and receiving their gold via trade ships. If you both click \"Start trading\" it will start again.",
|
||||
"info_ally_panel": "Ally info panel",
|
||||
"info_ally_desc": "When you ally with a player, the following new icons become available:",
|
||||
"ally_betray": "Betray your ally, ending the alliance. You will now have a permanent icon stuck next to your name, unless the other nation was a traitor themselves. Attacks against you will incur less losses for the attacker until the end of the game, bots are less likely to ally with you and players will think twice before doing so.",
|
||||
"ally_betray": "Betray your ally, ending the alliance, halting trade, and weakening your defense. Trading between you is paused for 5 minutes (or until you become allies again) and others may stop trading too. And unless the other player was a traitor themselves, you'll be marked a traitor for 30 seconds. During this time an icon will be above your name and you will have a 50% defense debuff. Bots are less likely to ally with you and players will think twice before doing so.",
|
||||
"ally_donate": "Donate some of your troops to your ally. Used when they're low on troops and are being attacked, or when they need that extra power to crush an enemy.",
|
||||
"ally_donate_gold": "Donate some of your gold to your ally. Used when they're low on gold and need it for buildings, or when your team member is saving for that MIRV.",
|
||||
"build_menu_title": "Build menu",
|
||||
"build_menu_desc": "Build these or see how many of each you already build:",
|
||||
"build_name": "Name",
|
||||
"build_icon": "Icon",
|
||||
"build_desc": "Description",
|
||||
"build_city": "City",
|
||||
"build_city_desc": "Increases your max population. Useful when you can't expand your territory or you're about to hit your population limit.",
|
||||
"build_defense": "Defense Post",
|
||||
"build_defense_desc": "Increases defenses around nearby borders. Attacks from enemies are slower and have more casualties.",
|
||||
"build_defense_desc": "Increases defenses around nearby borders, which show a checkered pattern. Attacks from enemies are slower and have more casualties.",
|
||||
"build_port": "Port",
|
||||
"build_port_desc": "Automatically sends trade ships between ports of your country and other countries (except if you clicked \"stop trade\" on them or they clicked \"stop trade on you\"), giving gold to both sides. Allows building Battleships. Can only be built near water.",
|
||||
"build_port_desc": "Can only be built near water. Allows building Warships. Automatically sends trade ships between ports of your country and other countries (except when trade is stopped), giving gold to both sides. Trade stops automatically when you attack or are attacked by a player. It resumes after 5 minutes or if you become allies. You can manually toggle trading with \"Stop trading\" or \"Start trading\".",
|
||||
"build_warship": "Warship",
|
||||
"build_warship_desc": "Patrols in an area, capturing trade ships and destroying enemy Warships and Boats. Spawns from the nearest Port and patrols the area you first clicked to build it. You can control Warships by attack-clicking on them and then attack-clicking the new area you want them to move to.",
|
||||
"build_warship_desc": "Patrols in an area, capturing enemy trade ships and destroying their Boats (transport ships) and Warships. Spawns from the nearest Port and patrols the area you first clicked to build it. You can control Warships by attack-clicking on them (see action Attack under Hotkeys) and then attack-clicking the new area you want them to move to.",
|
||||
"build_silo": "Missile Silo",
|
||||
"build_silo_desc": "Allows launching missiles.",
|
||||
"build_sam": "SAM Launcher",
|
||||
"build_sam_desc": "Has a 75% chance to intercept enemy missiles in its 100 pixel range. The SAM has a 7.5 second cooldown and cannot intercept MIRVs.",
|
||||
"build_sam_desc": "Can intercept enemy missiles in its 100 pixel range. With a 100% hit chance for Atom Bomb, 80% for Hydrogen Bomb and 50% for individual MIRV Warheads. The SAM has a 7.5 second cooldown.",
|
||||
"build_atom": "Atom Bomb",
|
||||
"build_atom_desc": "Small explosive bomb that destroys territory, buildings, ships and boats. Spawns from the nearest Missile Silo and lands in the area you first clicked to build it.",
|
||||
"build_hydrogen": "Hydrogen Bomb",
|
||||
@@ -85,9 +96,11 @@
|
||||
"build_mirv_desc": "The most powerful bomb in the game. Splits up into smaller bombs that will cover a huge range of territory. Only damages the player that you first clicked on to build it. Spawns from the nearest Missile Silo and lands in the area you first clicked to build it.",
|
||||
"player_icons": "Player icons",
|
||||
"icon_desc": "Examples of some of the ingame icons you will encounter and what they mean:",
|
||||
"icon_crown": "Crown - This is the number 1 player in the leaderboard",
|
||||
"icon_traitor": "Crossed swords - Traitor. This player attacked an ally.",
|
||||
"icon_crown": "Crown - Number 1. This is the top player in the leaderboard.",
|
||||
"icon_traitor": "Broken shield - Traitor. This player attacked an ally.",
|
||||
"icon_ally": "Handshake - Ally. This player is your ally.",
|
||||
"icon_embargo": "Dollar stop sign - Embargo. This player has stopped trading with you automatically or manually.",
|
||||
"icon_request": "Envelope - Alliance request. This player has sent you an alliance request.",
|
||||
"info_enemy_panel": "Enemy info panel"
|
||||
},
|
||||
"single_modal": {
|
||||
@@ -129,7 +142,8 @@
|
||||
"deglaciatedantarctica": "Deglaciated Antarctica",
|
||||
"europeclassic": "Europe (classic)",
|
||||
"falklandislands": "Falkland Islands",
|
||||
"baikal": "Baikal"
|
||||
"baikal": "Baikal",
|
||||
"halkidiki": "Halkidiki"
|
||||
},
|
||||
"map_categories": {
|
||||
"continental": "Continental",
|
||||
@@ -213,6 +227,8 @@
|
||||
"dark_mode_desc": "Toggle the site’s appearance between light and dark themes",
|
||||
"emojis_label": "😊 Emojis",
|
||||
"emojis_desc": "Toggle whether emojis are shown in game",
|
||||
"special_effects_label": "💥 Special effects",
|
||||
"special_effects_desc": "Toggle special effects. Deactivate to improve performances",
|
||||
"anonymous_names_label": "🥷 Hidden Names",
|
||||
"anonymous_names_desc": "Hide real player names with random ones on your screen.",
|
||||
"left_click_label": "🖱️ Left Click to Open Menu",
|
||||
@@ -328,7 +344,7 @@
|
||||
"missile_silo": "Used to launch nukes",
|
||||
"sam_launcher": "Defends against incoming nukes",
|
||||
"warship": "Captures trade ships, destroys ships and boats",
|
||||
"port": "Sends trade ships to allies to generate gold",
|
||||
"port": "Sends trade ships to generate gold",
|
||||
"defense_post": "Increase defenses of nearby borders",
|
||||
"city": "Increase max population"
|
||||
},
|
||||
@@ -387,7 +403,8 @@
|
||||
"gold": "Gold",
|
||||
"troops": "Troops",
|
||||
"traitor": "Traitor",
|
||||
"embargo": "Embargo against you",
|
||||
"alliance_time_remaining": "Time Remaining",
|
||||
"embargo": "Stopped trading with you",
|
||||
"nuke": "Nukes sent by them to you",
|
||||
"start_trade": "Start trading",
|
||||
"stop_trade": "Stop trading",
|
||||
|
||||
@@ -213,6 +213,8 @@
|
||||
"dark_mode_desc": "Basculer l'apparence du site entre les thèmes clairs et sombres",
|
||||
"emojis_label": "😊 Émojis",
|
||||
"emojis_desc": "Afficher/Masquer les émoticônes dans le jeu",
|
||||
"special_effects_label": "💥 Effets spéciaux",
|
||||
"special_effects_desc": "Affiche les effets spéciaux - Désactivez pour améliorer les performances",
|
||||
"anonymous_names_label": "🥷 Noms masqués",
|
||||
"anonymous_names_desc": "Cacher le vrai nom des joueurs avec des noms aléatoires sur votre écran.",
|
||||
"left_click_label": "🖱️ Clic gauche pour ouvrir le menu",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "Halkidiki",
|
||||
"width": 2200,
|
||||
"height": 1760,
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1798, 984],
|
||||
"name": "Mount Athos",
|
||||
"strength": 2,
|
||||
"flag": "ha_ma"
|
||||
},
|
||||
{
|
||||
"coordinates": [921, 1445],
|
||||
"name": "Kassandra",
|
||||
"strength": 2,
|
||||
"flag": "gr"
|
||||
},
|
||||
{
|
||||
"coordinates": [1488, 1387],
|
||||
"name": "Sithonia",
|
||||
"strength": 2,
|
||||
"flag": "gr"
|
||||
},
|
||||
{
|
||||
"coordinates": [380, 459],
|
||||
"name": "Thessaloniki",
|
||||
"strength": 2,
|
||||
"flag": "gr"
|
||||
},
|
||||
{
|
||||
"coordinates": [867, 803],
|
||||
"name": "Polygyros",
|
||||
"strength": 2,
|
||||
"flag": "gr"
|
||||
},
|
||||
{
|
||||
"coordinates": [218, 81],
|
||||
"name": "Kilkis",
|
||||
"strength": 2,
|
||||
"flag": "gr"
|
||||
},
|
||||
{
|
||||
"coordinates": [1192, 163],
|
||||
"name": "Serres",
|
||||
"strength": 2,
|
||||
"flag": "gr"
|
||||
},
|
||||
{
|
||||
"coordinates": [1807, 204],
|
||||
"name": "Thrace",
|
||||
"strength": 2,
|
||||
"flag": "gr"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -86,7 +86,10 @@ export class HelpModal extends LitElement {
|
||||
<td>${translateText("help_modal.action_zoom")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">W</span> <span class="key">A</span> <span class="key">S</span> <span class="key">D</span></td>
|
||||
<td>
|
||||
<span class="key">W</span> <span class="key">A</span>
|
||||
<span class="key">S</span> <span class="key">D</span>
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_move_camera")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -112,7 +115,9 @@ export class HelpModal extends LitElement {
|
||||
<td>${translateText("help_modal.action_ratio_change")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="key">ALT</span> + <span class="key">R</span></td>
|
||||
<td>
|
||||
<span class="key">ALT</span> + <span class="key">R</span>
|
||||
</td>
|
||||
<td>${translateText("help_modal.action_reset_gfx")}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -126,13 +131,14 @@ export class HelpModal extends LitElement {
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-gray-300">
|
||||
<div class="text-gray-300 font-bold">
|
||||
${translateText("help_modal.ui_leaderboard")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/leaderboard.webp"
|
||||
src="/images/helpModal/leaderboard2.webp"
|
||||
alt="Leaderboard"
|
||||
title="Leaderboard"
|
||||
class="default-image"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -144,13 +150,14 @@ export class HelpModal extends LitElement {
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-col items-center w-full md:w-[80%]">
|
||||
<div class="text-gray-300">
|
||||
<div class="text-gray-300 font-bold">
|
||||
${translateText("help_modal.ui_control")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/controlPanel.webp"
|
||||
alt="Control panel"
|
||||
title="Control panel"
|
||||
class="default-image"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -172,13 +179,52 @@ export class HelpModal extends LitElement {
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-gray-300">
|
||||
<div class="text-gray-300 font-bold">
|
||||
${translateText("help_modal.ui_events")}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<img
|
||||
src="/images/helpModal/eventsPanel.webp"
|
||||
alt="Event panel"
|
||||
title="Event panel"
|
||||
class="default-image"
|
||||
/>
|
||||
<img
|
||||
src="/images/helpModal/eventsPanelAttack.webp"
|
||||
alt="Event panel"
|
||||
title="Event panel"
|
||||
class="default-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-4">${translateText("help_modal.ui_events_desc")}</p>
|
||||
<ul>
|
||||
<li class="mb-4">
|
||||
${translateText("help_modal.ui_events_alliance")}
|
||||
</li>
|
||||
<li class="mb-4">
|
||||
${translateText("help_modal.ui_events_attack")}
|
||||
</li>
|
||||
<li class="mb-4">
|
||||
${translateText("help_modal.ui_events_quickchat")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="mt-6 mb-4" />
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-gray-300 font-bold">
|
||||
${translateText("help_modal.ui_options")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/options.webp"
|
||||
src="/images/helpModal/options2.webp"
|
||||
alt="Options"
|
||||
title="Options"
|
||||
class="default-image"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -196,18 +242,46 @@ export class HelpModal extends LitElement {
|
||||
|
||||
<hr class="mt-6 mb-4" />
|
||||
|
||||
<div class="text-2xl font-bold text-center">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-gray-300 font-bold">
|
||||
${translateText("help_modal.ui_playeroverlay")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/playerInfoOverlay.webp"
|
||||
alt="Player info overlay"
|
||||
title="Player info overlay"
|
||||
class="default-image"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-4">
|
||||
${translateText("help_modal.ui_playeroverlay_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="mt-6 mb-4" />
|
||||
|
||||
<div class="text-2xl font-bold mb-4 text-center">
|
||||
${translateText("help_modal.radial_title")}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<img
|
||||
src="/images/helpModal/radialMenu.webp"
|
||||
alt="Radial menu"
|
||||
title="Radial menu"
|
||||
,
|
||||
class="radial-menu-image"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<img
|
||||
src="/images/helpModal/radialMenu2.webp"
|
||||
alt="Radial menu"
|
||||
title="Radial menu"
|
||||
class="default-image"
|
||||
/>
|
||||
<img
|
||||
src="/images/helpModal/radialMenuAlly.webp"
|
||||
alt="Radial menu ally"
|
||||
title="Radial menu ally"
|
||||
class="default-image"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-4">${translateText("help_modal.radial_desc")}</p>
|
||||
<ul>
|
||||
@@ -228,8 +302,12 @@ export class HelpModal extends LitElement {
|
||||
<span>${translateText("help_modal.radial_boat")}</span>
|
||||
</li>
|
||||
<li class="mb-4">
|
||||
<div class="inline-block icon cancel-icon"></div>
|
||||
<span>${translateText("help_modal.radial_close")}</span>
|
||||
<div class="inline-block icon alliance-icon"></div>
|
||||
<span>${translateText("help_modal.info_alliance")}</span>
|
||||
</li>
|
||||
<li class="mb-4">
|
||||
<div class="inline-block icon betray-icon"></div>
|
||||
<span>${translateText("help_modal.ally_betray")}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -238,24 +316,29 @@ export class HelpModal extends LitElement {
|
||||
<hr class="mt-6 mb-4" />
|
||||
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-center">
|
||||
<div class="text-2xl font-bold mb-4 text-center">
|
||||
${translateText("help_modal.info_title")}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4 mt-2">
|
||||
<div class="flex flex-col items-center w-full md:w-[80%]">
|
||||
<divclass="text-gray-300">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-col items-center w-full md:w-[62%]">
|
||||
<div class="text-gray-300 font-bold">
|
||||
${translateText("help_modal.info_enemy_panel")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/infoMenu.webp"
|
||||
src="/images/helpModal/infoMenu2.webp"
|
||||
alt="Enemy info panel"
|
||||
title="Enemy info panel"
|
||||
class="info-panel-img"
|
||||
/>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<p class="mb-4">${translateText("help_modal.info_enemy_desc")}</p>
|
||||
<ul>
|
||||
<li class="mb-4">
|
||||
<div class="inline-block icon chat-icon"></div>
|
||||
<span>${translateText("help_modal.info_chat")}</span>
|
||||
</li>
|
||||
<li class="mb-4">
|
||||
<div class="inline-block icon target-icon"></div>
|
||||
<span>${translateText("help_modal.info_target")}</span>
|
||||
@@ -264,10 +347,16 @@ export class HelpModal extends LitElement {
|
||||
<div class="inline-block icon alliance-icon"></div>
|
||||
<span>${translateText("help_modal.info_alliance")}</span>
|
||||
</li>
|
||||
<li>
|
||||
<li class="mb-4">
|
||||
<div class="inline-block icon emoji-icon"></div>
|
||||
<span>${translateText("help_modal.info_emoji")}</span>
|
||||
</li>
|
||||
<li class="mb-4">
|
||||
<div class="inline-block icon">
|
||||
<img src="/images/helpModal/stopTrading.webp" />
|
||||
</div>
|
||||
<span>${translateText("help_modal.info_trade")}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,13 +365,14 @@ export class HelpModal extends LitElement {
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-col items-center w-full md:w-[62%]">
|
||||
<div class="text-gray-300">
|
||||
<div class="text-gray-300 font-bold">
|
||||
${translateText("help_modal.info_ally_panel")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/infoMenuAlly.webp"
|
||||
src="/images/helpModal/infoMenu2Ally.webp"
|
||||
alt="Ally info panel"
|
||||
title="Ally info panel"
|
||||
class="info-panel-img"
|
||||
/>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
@@ -296,6 +386,10 @@ export class HelpModal extends LitElement {
|
||||
<div class="inline-block icon donate-icon"></div>
|
||||
<span>${translateText("help_modal.ally_donate")}</span>
|
||||
</li>
|
||||
<li class="mb-4">
|
||||
<div class="inline-block icon donate-gold-icon"></div>
|
||||
<span>${translateText("help_modal.ally_donate_gold")}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,6 +401,7 @@ export class HelpModal extends LitElement {
|
||||
<div class="text-2xl font-bold mb-4 text-center">
|
||||
${translateText("help_modal.build_menu_title")}
|
||||
</div>
|
||||
<p class="mb-4">${translateText("help_modal.build_menu_desc")}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -368,38 +463,92 @@ export class HelpModal extends LitElement {
|
||||
<hr class="mt-6 mb-4" />
|
||||
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-center">
|
||||
<div class="text-2xl mb-4 font-bold text-center">
|
||||
${translateText("help_modal.player_icons")}
|
||||
</div>
|
||||
<p>${translateText("help_modal.icon_desc")}</p>
|
||||
<div class="flex flex-col md:flex-row gap-4 mt-2">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-gray-300">
|
||||
<p class="mb-2">${translateText("help_modal.icon_desc")}</p>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4 mt-4">
|
||||
<div
|
||||
class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0"
|
||||
>
|
||||
<div
|
||||
class="text-gray-300 h-8 md:h-10 flex flex-col justify-start min-h-[3rem] w-full px-2 mb-1"
|
||||
>
|
||||
${translateText("help_modal.icon_crown")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/number1.webp"
|
||||
src="/images/helpModal/crown.webp"
|
||||
alt="Number 1 player"
|
||||
title="Number 1 player"
|
||||
class="player-icon-img w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-gray-300">
|
||||
<div
|
||||
class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0"
|
||||
>
|
||||
<div
|
||||
class="text-gray-300 h-8 md:h-10 flex flex-col justify-start min-h-[3rem] w-full px-2 mb-1"
|
||||
>
|
||||
${translateText("help_modal.icon_traitor")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/traitor.webp"
|
||||
src="/images/helpModal/traitor2.webp"
|
||||
alt="Traitor"
|
||||
title="Traitor"
|
||||
class="player-icon-img w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="text-gray-300">
|
||||
<div
|
||||
class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0"
|
||||
>
|
||||
<div
|
||||
class="text-gray-300 h-8 md:h-10 flex flex-col justify-start min-h-[3rem] w-full px-2 mb-1"
|
||||
>
|
||||
${translateText("help_modal.icon_ally")}
|
||||
</div>
|
||||
<img src="/images/helpModal/ally.webp" alt="Ally" title="Ally" />
|
||||
<img
|
||||
src="/images/helpModal/ally2.webp"
|
||||
alt="Ally"
|
||||
title="Ally"
|
||||
class="player-icon-img w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4 mt-4 md:justify-center">
|
||||
<div
|
||||
class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0"
|
||||
>
|
||||
<div
|
||||
class="text-gray-300 h-8 md:h-10 flex flex-col justify-start min-h-[3rem] w-full px-2 mb-1"
|
||||
>
|
||||
${translateText("help_modal.icon_embargo")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/embargo.webp"
|
||||
alt="Stopped trading"
|
||||
title="Stopped trading"
|
||||
class="player-icon-img w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col items-center w-full md:w-1/3 mb-2 md:mb-0"
|
||||
>
|
||||
<div
|
||||
class="text-gray-300 h-8 md:h-10 flex flex-col justify-start min-h-[3rem] w-full px-2 mb-1"
|
||||
>
|
||||
${translateText("help_modal.icon_request")}
|
||||
</div>
|
||||
<img
|
||||
src="/images/helpModal/allianceRequest.webp"
|
||||
alt="Alliance Request"
|
||||
title="Alliance Request"
|
||||
class="player-icon-img w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -150,6 +150,10 @@ export class LocalServer {
|
||||
return;
|
||||
}
|
||||
if (this.replayTurns.length > 0) {
|
||||
if (this.turns.length >= this.replayTurns.length) {
|
||||
this.endGame();
|
||||
return;
|
||||
}
|
||||
this.intents = this.replayTurns[this.turns.length].intents;
|
||||
}
|
||||
const pastTurn: Turn = {
|
||||
@@ -167,6 +171,9 @@ export class LocalServer {
|
||||
public endGame(saveFullGame: boolean = false) {
|
||||
consolex.log("local server ending game");
|
||||
clearInterval(this.turnCheckInterval);
|
||||
if (this.isReplay) {
|
||||
return;
|
||||
}
|
||||
const players: PlayerRecord[] = [
|
||||
{
|
||||
ip: null,
|
||||
|
||||
@@ -258,6 +258,13 @@ class Client {
|
||||
}
|
||||
const lobbyId = ctx.params.lobbyId;
|
||||
|
||||
if (lobbyId?.endsWith("#")) {
|
||||
// When the cookies button is pressed, '#' is added to the url
|
||||
// causing the page to attempt to rejoin the lobby during game play.
|
||||
console.error("Invalid lobby ID provided");
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinModal.open(lobbyId);
|
||||
|
||||
consolex.log(`joining lobby ${lobbyId}`);
|
||||
|
||||
@@ -31,7 +31,6 @@ export class SinglePlayerModal extends LitElement {
|
||||
@state() private selectedMap: GameMapType = GameMapType.World;
|
||||
@state() private selectedDifficulty: Difficulty = Difficulty.Medium;
|
||||
@state() private disableNPCs: boolean = false;
|
||||
@state() private disableNukes: boolean = false;
|
||||
@state() private bots: number = 400;
|
||||
@state() private infiniteGold: boolean = false;
|
||||
@state() private infiniteTroops: boolean = false;
|
||||
@@ -390,10 +389,6 @@ export class SinglePlayerModal extends LitElement {
|
||||
this.disableNPCs = Boolean((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
private handleDisableNukesChange(e: Event) {
|
||||
this.disableNukes = Boolean((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
private handleGameModeSelection(value: GameMode) {
|
||||
this.gameMode = value;
|
||||
}
|
||||
@@ -457,15 +452,16 @@ export class SinglePlayerModal extends LitElement {
|
||||
playerTeams: this.teamCount,
|
||||
difficulty: this.selectedDifficulty,
|
||||
disableNPCs: this.disableNPCs,
|
||||
disableNukes: this.disableNukes,
|
||||
bots: this.bots,
|
||||
infiniteGold: this.infiniteGold,
|
||||
infiniteTroops: this.infiniteTroops,
|
||||
instantBuild: this.instantBuild,
|
||||
disabledUnits: this.disabledUnits,
|
||||
disabledUnits: this.disabledUnits
|
||||
.map((u) => Object.values(UnitType).find((ut) => ut === u))
|
||||
.filter((ut): ut is UnitType => ut !== undefined),
|
||||
},
|
||||
},
|
||||
} as JoinLobbyEvent,
|
||||
} satisfies JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
|
||||
@@ -132,6 +132,10 @@ export class CancelAttackIntentEvent implements GameEvent {
|
||||
) {}
|
||||
}
|
||||
|
||||
export class CancelBoatIntentEvent implements GameEvent {
|
||||
constructor(public readonly unitID: number) {}
|
||||
}
|
||||
|
||||
export class SendSetTargetTroopRatioEvent implements GameEvent {
|
||||
constructor(public readonly ratio: number) {}
|
||||
}
|
||||
@@ -221,6 +225,10 @@ export class Transport {
|
||||
this.eventBus.on(CancelAttackIntentEvent, (e) =>
|
||||
this.onCancelAttackIntentEvent(e),
|
||||
);
|
||||
this.eventBus.on(CancelBoatIntentEvent, (e) =>
|
||||
this.onCancelBoatIntentEvent(e),
|
||||
);
|
||||
|
||||
this.eventBus.on(MoveWarshipIntentEvent, (e) => {
|
||||
this.onMoveWarshipEvent(e);
|
||||
});
|
||||
@@ -570,6 +578,14 @@ export class Transport {
|
||||
});
|
||||
}
|
||||
|
||||
private onCancelBoatIntentEvent(event: CancelBoatIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "cancel_boat",
|
||||
clientID: this.lobbyConfig.clientID,
|
||||
unitID: event.unitID,
|
||||
});
|
||||
}
|
||||
|
||||
private onMoveWarshipEvent(event: MoveWarshipIntentEvent) {
|
||||
this.sendIntent({
|
||||
type: "move_warship",
|
||||
|
||||
@@ -102,6 +102,15 @@ export class UserSettingModal extends LitElement {
|
||||
console.log("🤡 Emojis:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleFxLayer(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
|
||||
this.userSettings.set("settings.specialEffects", enabled);
|
||||
|
||||
console.log("💥 Special effects:", enabled ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
private toggleAnonymousNames(e: CustomEvent<{ checked: boolean }>) {
|
||||
const enabled = e.detail?.checked;
|
||||
if (typeof enabled !== "boolean") return;
|
||||
@@ -226,6 +235,15 @@ export class UserSettingModal extends LitElement {
|
||||
@change=${this.toggleEmojis}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 💥 Special effects -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.special_effects_label")}"
|
||||
description="${translateText("user_setting.special_effects_desc")}"
|
||||
id="special-effect-toggle"
|
||||
.checked=${this.userSettings.fxLayer()}
|
||||
@change=${this.toggleFxLayer}
|
||||
></setting-toggle>
|
||||
|
||||
<!-- 🖱️ Left Click Menu -->
|
||||
<setting-toggle
|
||||
label="${translateText("user_setting.left_click_label")}"
|
||||
|
||||
@@ -28,6 +28,7 @@ export const MapDescription: Record<keyof typeof GameMapType, string> = {
|
||||
DeglaciatedAntarctica: "Deglaciated Antarctica",
|
||||
FalklandIslands: "Falkland Islands",
|
||||
Baikal: "Baikal",
|
||||
Halkidiki: "Halkidiki",
|
||||
};
|
||||
|
||||
@customElement("map-display")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
export class AnimatedSprite {
|
||||
private frameHeight: number;
|
||||
private currentFrame: number = 0;
|
||||
private elapsedTime: number = 0;
|
||||
private active: boolean = true;
|
||||
|
||||
constructor(
|
||||
private image: CanvasImageSource,
|
||||
private frameWidth: number,
|
||||
private frameCount: number,
|
||||
private frameDuration: number, // in milliseconds
|
||||
private looping: boolean = true,
|
||||
private originX: number,
|
||||
private originY: number,
|
||||
) {
|
||||
if ("height" in image) {
|
||||
this.frameHeight = (image as HTMLImageElement | HTMLCanvasElement).height;
|
||||
} else {
|
||||
throw new Error("Image source must have a 'height' property.");
|
||||
}
|
||||
}
|
||||
|
||||
update(deltaTime: number) {
|
||||
if (!this.active) return;
|
||||
this.elapsedTime += deltaTime;
|
||||
if (this.elapsedTime >= this.frameDuration) {
|
||||
this.elapsedTime -= this.frameDuration;
|
||||
this.currentFrame++;
|
||||
|
||||
if (this.currentFrame >= this.frameCount) {
|
||||
if (this.looping) {
|
||||
this.currentFrame = 0;
|
||||
} else {
|
||||
this.currentFrame = this.frameCount - 1;
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
draw(ctx: CanvasRenderingContext2D, x: number, y: number) {
|
||||
const drawX = x - this.originX;
|
||||
const drawY = y - this.originY;
|
||||
|
||||
ctx.drawImage(
|
||||
this.image,
|
||||
this.currentFrame * this.frameWidth,
|
||||
0,
|
||||
this.frameWidth,
|
||||
this.frameHeight,
|
||||
drawX,
|
||||
drawY,
|
||||
this.frameWidth,
|
||||
this.frameHeight,
|
||||
);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentFrame = 0;
|
||||
this.elapsedTime = 0;
|
||||
}
|
||||
|
||||
setOrigin(xRatio: number, yRatio: number) {
|
||||
this.originX = xRatio;
|
||||
this.originY = yRatio;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import nuke from "../../../resources/sprites/nukeExplosion.png";
|
||||
import { AnimatedSprite } from "./AnimatedSprite";
|
||||
import { FxType } from "./fx/Fx";
|
||||
|
||||
type AnimatedSpriteConfig = {
|
||||
url: string;
|
||||
frameWidth: number;
|
||||
frameCount: number;
|
||||
frameDuration: number; // ms per frame
|
||||
looping?: boolean;
|
||||
originX: number;
|
||||
originY: number;
|
||||
};
|
||||
|
||||
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
|
||||
[FxType.Nuke]: {
|
||||
url: nuke,
|
||||
frameWidth: 60,
|
||||
frameCount: 9,
|
||||
frameDuration: 70,
|
||||
looping: false,
|
||||
originX: 30,
|
||||
originY: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const animatedSpriteImageMap: Map<FxType, CanvasImageSource> = new Map();
|
||||
|
||||
export const loadAllAnimatedSpriteImages = async (): Promise<void> => {
|
||||
const entries = Object.entries(ANIMATED_SPRITE_CONFIG);
|
||||
|
||||
await Promise.all(
|
||||
entries.map(async ([fxType, config]) => {
|
||||
const typedFxType = fxType as FxType;
|
||||
if (!config?.url) return;
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.src = config.url;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (e) => reject(e);
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext("2d")!.drawImage(img, 0, 0);
|
||||
|
||||
animatedSpriteImageMap.set(typedFxType, canvas);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load sprite for ${typedFxType}:`, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const createAnimatedSpriteForUnit = (
|
||||
fxType: FxType,
|
||||
): AnimatedSprite | null => {
|
||||
const config = ANIMATED_SPRITE_CONFIG[fxType];
|
||||
const image = animatedSpriteImageMap.get(fxType);
|
||||
if (!config || !image) return null;
|
||||
|
||||
return new AnimatedSprite(
|
||||
image,
|
||||
config.frameWidth,
|
||||
config.frameCount,
|
||||
config.frameDuration,
|
||||
config.looping ?? true,
|
||||
config.originX,
|
||||
config.originY,
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { ChatModal } from "./layers/ChatModal";
|
||||
import { ControlPanel } from "./layers/ControlPanel";
|
||||
import { EmojiTable } from "./layers/EmojiTable";
|
||||
import { EventsDisplay } from "./layers/EventsDisplay";
|
||||
import { FxLayer } from "./layers/FxLayer";
|
||||
import { Layer } from "./layers/Layer";
|
||||
import { Leaderboard } from "./layers/Leaderboard";
|
||||
import { MultiTabModal } from "./layers/MultiTabModal";
|
||||
@@ -165,6 +166,7 @@ export function createRenderer(
|
||||
new TerritoryLayer(game, eventBus),
|
||||
new StructureLayer(game, eventBus),
|
||||
new UnitLayer(game, eventBus, clientID, transformHandler),
|
||||
new FxLayer(game),
|
||||
new UILayer(game, eventBus, clientID, transformHandler),
|
||||
new NameLayer(game, transformHandler, clientID),
|
||||
eventsDisplay,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Fx {
|
||||
renderTick(duration: number, ctx: CanvasRenderingContext2D): boolean;
|
||||
}
|
||||
|
||||
export enum FxType {
|
||||
Nuke = "Nuke",
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { AnimatedSprite } from "../AnimatedSprite";
|
||||
import { createAnimatedSpriteForUnit } from "../AnimatedSpriteLoader";
|
||||
import { Fx, FxType } from "./Fx";
|
||||
|
||||
/**
|
||||
* Shockwave effect: draw a growing 1px white circle
|
||||
*/
|
||||
export class ShockwaveFx implements Fx {
|
||||
private lifeTime: number = 0;
|
||||
constructor(
|
||||
private x: number,
|
||||
private y: number,
|
||||
private duration: number,
|
||||
private maxRadius: number,
|
||||
) {}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
this.lifeTime += frameTime;
|
||||
if (this.lifeTime >= this.duration) {
|
||||
return false;
|
||||
}
|
||||
const t = this.lifeTime / this.duration;
|
||||
const radius = t * this.maxRadius;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = "rgba(255, 255, 255, " + (1 - t) + ")";
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Explosion effect: sprite animation of an explosion
|
||||
*/
|
||||
export class NukeExplosionFx implements Fx {
|
||||
private lifeTime: number = 0;
|
||||
private nukeExplosionSprite: AnimatedSprite | null;
|
||||
constructor(
|
||||
private x: number,
|
||||
private y: number,
|
||||
private duration: number,
|
||||
) {
|
||||
this.nukeExplosionSprite = createAnimatedSpriteForUnit(FxType.Nuke);
|
||||
}
|
||||
|
||||
renderTick(frameTime: number, ctx: CanvasRenderingContext2D): boolean {
|
||||
if (this.nukeExplosionSprite) {
|
||||
this.lifeTime += frameTime;
|
||||
if (this.lifeTime >= this.duration) {
|
||||
return false;
|
||||
}
|
||||
if (this.nukeExplosionSprite.isActive()) {
|
||||
this.nukeExplosionSprite.update(frameTime);
|
||||
this.nukeExplosionSprite.draw(ctx, this.x, this.y);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { ClientID } from "../../../core/Schemas";
|
||||
import {
|
||||
CancelAttackIntentEvent,
|
||||
CancelBoatIntentEvent,
|
||||
SendAllianceReplyIntentEvent,
|
||||
} from "../../Transport";
|
||||
import { Layer } from "./Layer";
|
||||
@@ -380,6 +381,12 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
this.eventBus.emit(new CancelAttackIntentEvent(myPlayer.id(), id));
|
||||
}
|
||||
|
||||
emitBoatCancelIntent(id: number) {
|
||||
const myPlayer = this.game.playerByClientID(this.clientID);
|
||||
if (!myPlayer) return;
|
||||
this.eventBus.emit(new CancelBoatIntentEvent(id));
|
||||
}
|
||||
|
||||
emitGoToPlayerEvent(attackerID: number) {
|
||||
const attacker = this.game.playerBySmallID(attackerID) as PlayerView;
|
||||
if (!attacker) return;
|
||||
@@ -572,25 +579,29 @@ export class EventsDisplay extends LitElement implements Layer {
|
||||
}
|
||||
|
||||
private renderBoats() {
|
||||
if (this.outgoingBoats.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
${this.outgoingBoats.length > 0
|
||||
? html`
|
||||
<tr class="border-t border-gray-700">
|
||||
<td
|
||||
class="lg:p-3 p-1 text-left text-blue-400 grid grid-cols-3 gap-2"
|
||||
>
|
||||
<td class="lg:p-3 p-1 text-left text-blue-400">
|
||||
${this.outgoingBoats.map(
|
||||
(boats) => html`
|
||||
(boat) => html`
|
||||
<button
|
||||
translate="no"
|
||||
@click=${() => this.emitGoToUnitEvent(boats)}
|
||||
@click=${() => this.emitGoToUnitEvent(boat)}
|
||||
>
|
||||
Boat: ${renderTroops(boats.troops())}
|
||||
Boat: ${renderTroops(boat.troops())}
|
||||
</button>
|
||||
${!boat.retreating()
|
||||
? html`<button
|
||||
${boat.retreating() ? "disabled" : ""}
|
||||
@click=${() => {
|
||||
this.emitBoatCancelIntent(boat.id());
|
||||
}}
|
||||
>
|
||||
❌
|
||||
</button>`
|
||||
: "(retreating...)"}
|
||||
`,
|
||||
)}
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { UnitType } from "../../../core/game/Game";
|
||||
import { GameUpdateType } from "../../../core/game/GameUpdates";
|
||||
import { GameView, UnitView } from "../../../core/game/GameView";
|
||||
import { loadAllAnimatedSpriteImages } from "../AnimatedSpriteLoader";
|
||||
import { Fx } from "../fx/Fx";
|
||||
import { NukeExplosionFx, ShockwaveFx } from "../fx/NukeFx";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
export class FxLayer implements Layer {
|
||||
private canvas: HTMLCanvasElement;
|
||||
private context: CanvasRenderingContext2D;
|
||||
|
||||
private lastRefresh: number = 0;
|
||||
private refreshRate: number = 10;
|
||||
|
||||
private allFx: Fx[] = [];
|
||||
|
||||
constructor(private game: GameView) {}
|
||||
|
||||
shouldTransform(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
tick() {
|
||||
this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
|
||||
?.forEach((unitView) => {
|
||||
if (unitView === undefined) return;
|
||||
this.onUnitEvent(unitView);
|
||||
});
|
||||
}
|
||||
|
||||
onUnitEvent(unit: UnitView) {
|
||||
switch (unit.type()) {
|
||||
case UnitType.AtomBomb:
|
||||
case UnitType.MIRVWarhead:
|
||||
this.handleNukeExplosion(unit, 70);
|
||||
break;
|
||||
case UnitType.HydrogenBomb:
|
||||
this.handleNukeExplosion(unit, 250);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
handleNukeExplosion(unit: UnitView, shockwaveRadius: number) {
|
||||
if (!unit.isActive()) {
|
||||
const x = this.game.x(unit.lastTile());
|
||||
const y = this.game.y(unit.lastTile());
|
||||
const nuke = new NukeExplosionFx(x, y, 1000);
|
||||
this.allFx.push(nuke as Fx);
|
||||
const shockwave = new ShockwaveFx(x, y, 1500, shockwaveRadius);
|
||||
this.allFx.push(shockwave as Fx);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.redraw();
|
||||
try {
|
||||
await loadAllAnimatedSpriteImages();
|
||||
console.log("FX sprites loaded successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to load FX sprites:", err);
|
||||
}
|
||||
}
|
||||
|
||||
redraw(): void {
|
||||
this.canvas = document.createElement("canvas");
|
||||
const context = this.canvas.getContext("2d");
|
||||
if (context === null) throw new Error("2d context not supported");
|
||||
this.context = context;
|
||||
this.context.imageSmoothingEnabled = false;
|
||||
this.canvas.width = this.game.width();
|
||||
this.canvas.height = this.game.height();
|
||||
}
|
||||
|
||||
renderLayer(context: CanvasRenderingContext2D) {
|
||||
const now = Date.now();
|
||||
if (this.game.config().userSettings()?.fxLayer()) {
|
||||
if (now > this.lastRefresh + this.refreshRate) {
|
||||
const delta = now - this.lastRefresh;
|
||||
this.renderAllFx(context, delta);
|
||||
this.lastRefresh = now;
|
||||
}
|
||||
context.drawImage(
|
||||
this.canvas,
|
||||
-this.game.width() / 2,
|
||||
-this.game.height() / 2,
|
||||
this.game.width(),
|
||||
this.game.height(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
renderAllFx(context: CanvasRenderingContext2D, delta: number) {
|
||||
if (this.allFx.length > 0) {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.renderContextFx(delta);
|
||||
}
|
||||
}
|
||||
|
||||
renderContextFx(duration: number) {
|
||||
for (let i = this.allFx.length - 1; i >= 0; i--) {
|
||||
if (!this.allFx[i].renderTick(duration, this.context)) {
|
||||
this.allFx.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { ClientID } from "../../../core/Schemas";
|
||||
import { Theme } from "../../../core/configuration/Config";
|
||||
import { AllPlayers, Cell, nukeTypes } from "../../../core/game/Game";
|
||||
import { GameView, PlayerView } from "../../../core/game/GameView";
|
||||
import { createCanvas, renderTroops } from "../../Utils";
|
||||
import { createCanvas, renderNumber, renderTroops } from "../../Utils";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
|
||||
@@ -214,20 +214,18 @@ export class NameLayer implements Layer {
|
||||
troopsDiv.style.marginTop = "-5%";
|
||||
element.appendChild(troopsDiv);
|
||||
|
||||
// TODO: enable this for new meta.
|
||||
|
||||
// const shieldDiv = document.createElement("div");
|
||||
// shieldDiv.classList.add("player-shield");
|
||||
// shieldDiv.style.zIndex = "3";
|
||||
// shieldDiv.style.marginTop = "-5%";
|
||||
// shieldDiv.style.display = "flex";
|
||||
// shieldDiv.style.alignItems = "center";
|
||||
// shieldDiv.style.gap = "0px";
|
||||
// shieldDiv.innerHTML = `
|
||||
// <img src="${this.shieldIconImage.src}" style="width: 16px; height: 16px;" />
|
||||
// <span style="color: black; font-size: 10px; margin-top: -2px;">0</span>
|
||||
// `;
|
||||
// element.appendChild(shieldDiv);
|
||||
const shieldDiv = document.createElement("div");
|
||||
shieldDiv.classList.add("player-shield");
|
||||
shieldDiv.style.zIndex = "3";
|
||||
shieldDiv.style.marginTop = "-5%";
|
||||
shieldDiv.style.display = "flex";
|
||||
shieldDiv.style.alignItems = "center";
|
||||
shieldDiv.style.gap = "0px";
|
||||
shieldDiv.innerHTML = `
|
||||
<img src="${this.shieldIconImage.src}" style="width: 16px; height: 16px;" />
|
||||
<span style="color: black; font-size: 10px; margin-top: -2px;">0</span>
|
||||
`;
|
||||
element.appendChild(shieldDiv);
|
||||
|
||||
// Start off invisible so it doesn't flash at 0,0
|
||||
element.style.display = "none";
|
||||
@@ -293,25 +291,23 @@ export class NameLayer implements Layer {
|
||||
troopsDiv.style.color = render.fontColor;
|
||||
troopsDiv.textContent = renderTroops(render.player.troops());
|
||||
|
||||
// TODO: enable this for new meta.
|
||||
|
||||
// const density = renderNumber(
|
||||
// render.player.troops() / render.player.numTilesOwned(),
|
||||
// );
|
||||
// const shieldDiv = render.element.querySelector(
|
||||
// ".player-shield",
|
||||
// ) as HTMLDivElement;
|
||||
// const shieldImg = shieldDiv.querySelector("img");
|
||||
// const shieldNumber = shieldDiv.querySelector("span");
|
||||
// if (shieldImg) {
|
||||
// shieldImg.style.width = `${render.fontSize * 0.8}px`;
|
||||
// shieldImg.style.height = `${render.fontSize * 0.8}px`;
|
||||
// }
|
||||
// if (shieldNumber) {
|
||||
// shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
|
||||
// shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
|
||||
// shieldNumber.textContent = density;
|
||||
// }
|
||||
const density = renderNumber(
|
||||
render.player.troops() / render.player.numTilesOwned(),
|
||||
);
|
||||
const shieldDiv = render.element.querySelector(
|
||||
".player-shield",
|
||||
) as HTMLDivElement;
|
||||
const shieldImg = shieldDiv.querySelector("img");
|
||||
const shieldNumber = shieldDiv.querySelector("span");
|
||||
if (shieldImg) {
|
||||
shieldImg.style.width = `${render.fontSize * 0.8}px`;
|
||||
shieldImg.style.height = `${render.fontSize * 0.8}px`;
|
||||
}
|
||||
if (shieldNumber) {
|
||||
shieldNumber.style.fontSize = `${render.fontSize * 0.6}px`;
|
||||
shieldNumber.style.marginTop = `${-render.fontSize * 0.1}px`;
|
||||
shieldNumber.textContent = density;
|
||||
}
|
||||
|
||||
// Handle icons
|
||||
const iconsDiv = render.element.querySelector(
|
||||
@@ -461,7 +457,7 @@ export class NameLayer implements Layer {
|
||||
);
|
||||
});
|
||||
const isMyPlayerTarget = nukesSentByOtherPlayer.find((unit) => {
|
||||
const detonationDst = unit.detonationDst();
|
||||
const detonationDst = unit.targetTile();
|
||||
if (detonationDst === undefined) return false;
|
||||
const targetId = this.game.owner(detonationDst).id();
|
||||
return myPlayer && targetId === myPlayer.id();
|
||||
|
||||
@@ -100,6 +100,11 @@ export class OptionsMenu extends LitElement implements Layer {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleSpecialEffectsButtonClick() {
|
||||
this.userSettings.toggleFxLayer();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private onToggleDarkModeButtonClick() {
|
||||
this.userSettings.toggleDarkMode();
|
||||
this.requestUpdate();
|
||||
@@ -197,6 +202,11 @@ export class OptionsMenu extends LitElement implements Layer {
|
||||
title: "Toggle Emojis",
|
||||
children: "🙂: " + (this.userSettings.emojis() ? "On" : "Off"),
|
||||
})}
|
||||
${button({
|
||||
onClick: this.onToggleSpecialEffectsButtonClick,
|
||||
title: "Toggle Special effects",
|
||||
children: "💥: " + (this.userSettings.fxLayer() ? "On" : "Off"),
|
||||
})}
|
||||
${button({
|
||||
onClick: this.onToggleDarkModeButtonClick,
|
||||
title: "Dark Mode",
|
||||
|
||||
@@ -45,6 +45,9 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
@state()
|
||||
private isVisible: boolean = false;
|
||||
|
||||
@state()
|
||||
private allianceExpiryText: string | null = null;
|
||||
|
||||
public show(actions: PlayerActions, tile: TileRef) {
|
||||
this.actions = actions;
|
||||
this.tile = tile;
|
||||
@@ -170,11 +173,38 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
const myPlayer = this.g.myPlayer();
|
||||
if (myPlayer !== null && myPlayer.isAlive()) {
|
||||
this.actions = await myPlayer.actions(this.tile);
|
||||
|
||||
if (this.actions?.interaction?.allianceCreatedAtTick !== undefined) {
|
||||
const createdAt = this.actions.interaction.allianceCreatedAtTick;
|
||||
const durationTicks = this.g.config().allianceDuration();
|
||||
const expiryTick = createdAt + durationTicks;
|
||||
const remainingTicks = expiryTick - this.g.ticks();
|
||||
|
||||
if (remainingTicks > 0) {
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
Math.floor(remainingTicks / 10),
|
||||
); // 10 ticks per second
|
||||
this.allianceExpiryText = this.formatDuration(remainingSeconds);
|
||||
}
|
||||
} else {
|
||||
this.allianceExpiryText = null;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private formatDuration(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return "0s";
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
let time = "";
|
||||
if (minutes > 0) time += `${minutes}m `;
|
||||
time += `${seconds}s`;
|
||||
return time.trim();
|
||||
}
|
||||
|
||||
getTotalNukesSent(otherId: PlayerID): number {
|
||||
const stats = this.g.player(otherId).stats();
|
||||
if (!stats) {
|
||||
@@ -225,196 +255,219 @@ export class PlayerPanel extends LitElement implements Layer {
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 pointer-events-auto"
|
||||
class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none overflow-auto"
|
||||
@contextmenu=${(e) => e.preventDefault()}
|
||||
@wheel=${(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-lg backdrop-blur-md relative"
|
||||
class="pointer-events-auto max-h-[90vh] overflow-y-auto min-w-[240px] w-auto px-4 py-2"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click=${this.handleClose}
|
||||
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
|
||||
<div
|
||||
class="bg-opacity-60 bg-gray-900 p-1 lg:p-2 rounded-lg backdrop-blur-md relative w-full mt-2"
|
||||
>
|
||||
<!-- Close button -->
|
||||
<button
|
||||
@click=${this.handleClose}
|
||||
class="absolute -top-2 -right-2 w-6 h-6 flex items-center justify-center
|
||||
bg-red-500 hover:bg-red-600 text-white rounded-full
|
||||
text-sm font-bold transition-colors"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-2 min-w-[240px]">
|
||||
<!-- Name section -->
|
||||
<div class="flex items-center gap-1 lg:gap-2">
|
||||
<div
|
||||
class="px-4 h-8 lg:h-10 flex items-center justify-center
|
||||
<div class="flex flex-col gap-2 min-w-[240px]">
|
||||
<!-- Name section -->
|
||||
<div class="flex items-center gap-1 lg:gap-2">
|
||||
<div
|
||||
class="px-4 h-8 lg:h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 text-opacity-90 text-white
|
||||
rounded text-sm lg:text-xl w-full"
|
||||
>
|
||||
${other?.name()}
|
||||
>
|
||||
${other?.name()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resources section -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<!-- Resources section -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Gold -->
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.gold")}
|
||||
</div>
|
||||
<div
|
||||
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
|
||||
translate="no"
|
||||
>
|
||||
${renderNumber(other.gold() || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Troops -->
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.troops")}
|
||||
</div>
|
||||
<div
|
||||
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
|
||||
translate="no"
|
||||
>
|
||||
${renderTroops(other.troops() || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attitude section -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Gold -->
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.gold")}
|
||||
${translateText("player_panel.traitor")}
|
||||
</div>
|
||||
<div
|
||||
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
|
||||
translate="no"
|
||||
>
|
||||
${renderNumber(other.gold() || 0)}
|
||||
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
|
||||
${other.isTraitor()
|
||||
? translateText("player_panel.yes")
|
||||
: translateText("player_panel.no")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embargo -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- Troops -->
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.troops")}
|
||||
${translateText("player_panel.embargo")}
|
||||
</div>
|
||||
<div
|
||||
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
|
||||
translate="no"
|
||||
>
|
||||
${renderTroops(other.troops() || 0)}
|
||||
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
|
||||
${other.hasEmbargoAgainst(myPlayer)
|
||||
? translateText("player_panel.yes")
|
||||
: translateText("player_panel.no")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attitude section -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.traitor")}
|
||||
${this.allianceExpiryText !== null
|
||||
? html`
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.alliance_time_remaining")}
|
||||
</div>
|
||||
<div
|
||||
class="bg-opacity-50 bg-gray-700 rounded p-2 text-white"
|
||||
>
|
||||
${this.allianceExpiryText}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
<!-- Stats -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.nuke")}
|
||||
</div>
|
||||
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
|
||||
${this.getTotalNukesSent(other.id())}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
|
||||
${other.isTraitor()
|
||||
? translateText("player_panel.yes")
|
||||
: translateText("player_panel.no")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embargo -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.embargo")}
|
||||
</div>
|
||||
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
|
||||
${other.hasEmbargoAgainst(myPlayer)
|
||||
? translateText("player_panel.yes")
|
||||
: translateText("player_panel.no")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-white text-opacity-80 text-sm px-2">
|
||||
${translateText("player_panel.nuke")}
|
||||
</div>
|
||||
<div class="bg-opacity-50 bg-gray-700 rounded p-2 text-white">
|
||||
${this.getTotalNukesSent(other.id())}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
@click=${(e) => this.handleChat(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
<!-- Action buttons -->
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
@click=${(e) => this.handleChat(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
|
||||
</button>
|
||||
${canTarget
|
||||
? html`<button
|
||||
@click=${(e) => this.handleTargetClick(e, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${targetIcon} alt="Target" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
${canBreakAlliance
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleBreakAllianceClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img
|
||||
src=${traitorIcon}
|
||||
alt="Break Alliance"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</button>`
|
||||
: ""}
|
||||
${canSendAllianceRequest
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleAllianceClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${allianceIcon} alt="Alliance" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
${canDonate
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleDonateTroopClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${donateTroopIcon} alt="Donate" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
${canDonate
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleDonateGoldClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${donateGoldIcon} alt="Donate" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
${canSendEmoji
|
||||
? html`<button
|
||||
@click=${(e) => this.handleEmojiClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${emojiIcon} alt="Emoji" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
</div>
|
||||
${canEmbargo && other !== myPlayer
|
||||
? html`<button
|
||||
@click=${(e) => this.handleEmbargoClick(e, myPlayer, other)}
|
||||
class="w-100 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
${translateText("player_panel.stop_trade")}
|
||||
</button>`
|
||||
: ""}
|
||||
${!canEmbargo && other !== myPlayer
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleStopEmbargoClick(e, myPlayer, other)}
|
||||
class="w-100 h-10 flex items-center justify-center
|
||||
<img src=${chatIcon} alt="Target" class="w-6 h-6" />
|
||||
</button>
|
||||
${canTarget
|
||||
? html`<button
|
||||
@click=${(e) => this.handleTargetClick(e, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${targetIcon} alt="Target" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
${canBreakAlliance
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleBreakAllianceClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img
|
||||
src=${traitorIcon}
|
||||
alt="Break Alliance"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</button>`
|
||||
: ""}
|
||||
${canSendAllianceRequest
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleAllianceClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${allianceIcon} alt="Alliance" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
${canDonate
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleDonateTroopClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img
|
||||
src=${donateTroopIcon}
|
||||
alt="Donate"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</button>`
|
||||
: ""}
|
||||
${canDonate
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleDonateGoldClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
${translateText("player_panel.start_trade")}
|
||||
</button>`
|
||||
: ""}
|
||||
>
|
||||
<img src=${donateGoldIcon} alt="Donate" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
${canSendEmoji
|
||||
? html`<button
|
||||
@click=${(e) => this.handleEmojiClick(e, myPlayer, other)}
|
||||
class="w-10 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
<img src=${emojiIcon} alt="Emoji" class="w-6 h-6" />
|
||||
</button>`
|
||||
: ""}
|
||||
</div>
|
||||
${canEmbargo && other !== myPlayer
|
||||
? html`<button
|
||||
@click=${(e) => this.handleEmbargoClick(e, myPlayer, other)}
|
||||
class="w-100 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
${translateText("player_panel.stop_trade")}
|
||||
</button>`
|
||||
: ""}
|
||||
${!canEmbargo && other !== myPlayer
|
||||
? html`<button
|
||||
@click=${(e) =>
|
||||
this.handleStopEmbargoClick(e, myPlayer, other)}
|
||||
class="w-100 h-10 flex items-center justify-center
|
||||
bg-opacity-50 bg-gray-700 hover:bg-opacity-70
|
||||
text-white rounded-lg transition-colors"
|
||||
>
|
||||
${translateText("player_panel.start_trade")}
|
||||
</button>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,26 +222,35 @@ export class UnitLayer implements Layer {
|
||||
const unitsToUpdate = this.game
|
||||
.updatesSinceLastTick()
|
||||
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
|
||||
?.forEach((unitView) => {
|
||||
if (unitView === undefined) return;
|
||||
const ready = isSpriteReady(unitView.type());
|
||||
if (ready) this.clearUnitCells(unitView);
|
||||
this.onUnitEvent(unitView);
|
||||
.filter((unit) => unit !== undefined);
|
||||
|
||||
if (unitsToUpdate) {
|
||||
// the clearing and drawing of unit sprites need to be done in 2 passes
|
||||
// otherwise the sprite of a unit can be drawn on top of another unit
|
||||
this.clearUnitsCells(unitsToUpdate);
|
||||
this.drawUnitsCells(unitsToUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
private clearUnitsCells(unitViews: UnitView[]) {
|
||||
unitViews
|
||||
.filter((unitView) => isSpriteReady(unitView.type()))
|
||||
.forEach((unitView) => {
|
||||
const sprite = getColoredSprite(unitView, this.theme);
|
||||
const clearsize = sprite.width + 1;
|
||||
const lastX = this.game.x(unitView.lastTile());
|
||||
const lastY = this.game.y(unitView.lastTile());
|
||||
this.context.clearRect(
|
||||
lastX - clearsize / 2,
|
||||
lastY - clearsize / 2,
|
||||
clearsize,
|
||||
clearsize,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private clearUnitCells(unit: UnitView) {
|
||||
const sprite = getColoredSprite(unit, this.theme);
|
||||
const clearsize = sprite.width + 1;
|
||||
|
||||
const lastX = this.game.x(unit.lastTile());
|
||||
const lastY = this.game.y(unit.lastTile());
|
||||
this.context.clearRect(
|
||||
lastX - clearsize / 2,
|
||||
lastY - clearsize / 2,
|
||||
clearsize,
|
||||
clearsize,
|
||||
);
|
||||
private drawUnitsCells(unitViews: UnitView[]) {
|
||||
unitViews.forEach((unitView) => this.onUnitEvent(unitView));
|
||||
}
|
||||
|
||||
private relationship(unit: UnitView): Relationship {
|
||||
@@ -291,7 +300,7 @@ export class UnitLayer implements Layer {
|
||||
}
|
||||
|
||||
private handleWarShipEvent(unit: UnitView) {
|
||||
if (unit.warshipTargetId()) {
|
||||
if (unit.targetUnitId()) {
|
||||
this.drawSprite(unit, colord({ r: 200, b: 0, g: 0 }));
|
||||
} else {
|
||||
this.drawSprite(unit);
|
||||
@@ -502,7 +511,7 @@ export class UnitLayer implements Layer {
|
||||
|
||||
if (this.alternateView) {
|
||||
let rel = this.relationship(unit);
|
||||
const dstPortId = unit.dstPortId();
|
||||
const dstPortId = unit.targetUnitId();
|
||||
if (unit.type() === UnitType.TradeShip && dstPortId !== undefined) {
|
||||
const target = this.game.unit(dstPortId)?.owner();
|
||||
const myPlayer = this.game.myPlayer();
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="l-header__highlightText">v22.0</div>
|
||||
<div class="l-header__highlightText">v23.0</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="bg-image"></div>
|
||||
|
||||
@@ -309,11 +309,6 @@ label.option-card:hover {
|
||||
background: rgba(74, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.radial-menu-image {
|
||||
width: 211px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
#helpModal table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
@@ -333,6 +328,28 @@ label.option-card:hover {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#helpModal .default-image {
|
||||
width: 12rem;
|
||||
max-width: 12rem;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#helpModal .info-panel-img {
|
||||
width: 12rem;
|
||||
max-width: 12rem;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#helpModal .player-icon-img {
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
#helpModal .icon {
|
||||
background-color: white;
|
||||
width: 32px;
|
||||
@@ -380,6 +397,10 @@ label.option-card:hover {
|
||||
mask: url("../../resources/images/MIRVIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .chat-icon {
|
||||
mask: url("../../resources/images/ChatIconWhite.svg") no-repeat center / cover;
|
||||
}
|
||||
|
||||
#helpModal .target-icon {
|
||||
mask: url("../../resources/images/TargetIcon.svg") no-repeat center / cover;
|
||||
}
|
||||
@@ -404,6 +425,11 @@ label.option-card:hover {
|
||||
center / cover;
|
||||
}
|
||||
|
||||
#helpModal .donate-gold-icon {
|
||||
mask: url("../../resources/images/DonateGoldIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
}
|
||||
|
||||
#helpModal .build-icon {
|
||||
mask: url("../../resources/images/BuildIconWhite.svg") no-repeat center /
|
||||
cover;
|
||||
|
||||
@@ -11,6 +11,7 @@ import europe from "../../../resources/maps/EuropeThumb.webp";
|
||||
import falklandislands from "../../../resources/maps/FalklandIslandsThumb.webp";
|
||||
import faroeislands from "../../../resources/maps/FaroeIslandsThumb.webp";
|
||||
import gatewayToTheAtlantic from "../../../resources/maps/GatewayToTheAtlanticThumb.webp";
|
||||
import halkidiki from "../../../resources/maps/HalkidikiThumb.webp";
|
||||
import iceland from "../../../resources/maps/IcelandThumb.webp";
|
||||
import japan from "../../../resources/maps/JapanThumb.webp";
|
||||
import knownworld from "../../../resources/maps/KnownWorldThumb.webp";
|
||||
@@ -72,6 +73,8 @@ export function getMapsImage(map: GameMapType): string {
|
||||
return falklandislands;
|
||||
case GameMapType.Baikal:
|
||||
return baikal;
|
||||
case GameMapType.Halkidiki:
|
||||
return halkidiki;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -203,6 +203,10 @@ export class GameRunner {
|
||||
canDonate: player.canDonate(other),
|
||||
canEmbargo: !player.hasEmbargoAgainst(other),
|
||||
};
|
||||
const alliance = player.allianceWith(other as Player);
|
||||
if (alliance) {
|
||||
actions.interaction.allianceCreatedAtTick = alliance.createdAt();
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
|
||||
@@ -108,14 +108,11 @@ export class PseudoRandom {
|
||||
* Returns a shuffled copy of the array using Fisher-Yates algorithm.
|
||||
*/
|
||||
shuffleArray<T>(array: T[]): T[] {
|
||||
// Create a copy to avoid modifying the original array
|
||||
const arrayCopy = [...array];
|
||||
|
||||
for (let i = arrayCopy.length - 1; i >= 0; i--) {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
const j = this.nextInt(0, i + 1);
|
||||
[arrayCopy[i], arrayCopy[j]] = [arrayCopy[j], arrayCopy[i]];
|
||||
[array[i], array[j]] = [array[j], array[i]];
|
||||
}
|
||||
|
||||
return arrayCopy;
|
||||
return array;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export type Intent =
|
||||
| AttackIntent
|
||||
| CancelAttackIntent
|
||||
| BoatAttackIntent
|
||||
| CancelBoatIntent
|
||||
| AllianceRequestIntent
|
||||
| AllianceRequestReplyIntent
|
||||
| BreakAllianceIntent
|
||||
@@ -37,6 +38,7 @@ export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||
export type SpawnIntent = z.infer<typeof SpawnIntentSchema>;
|
||||
export type BoatAttackIntent = z.infer<typeof BoatAttackIntentSchema>;
|
||||
export type CancelBoatIntent = z.infer<typeof CancelBoatIntentSchema>;
|
||||
export type AllianceRequestIntent = z.infer<typeof AllianceRequestIntentSchema>;
|
||||
export type AllianceRequestReplyIntent = z.infer<
|
||||
typeof AllianceRequestReplyIntentSchema
|
||||
@@ -133,7 +135,7 @@ export const TeamSchema = z.string();
|
||||
const SafeString = z
|
||||
.string()
|
||||
.regex(
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()-_+=\[\]{}|;:"'\/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|üÜ])*$/,
|
||||
/^([a-zA-Z0-9\s.,!?@#$%&*()\-_+=\[\]{}|;:"'\/\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff]|üÜ])*$/,
|
||||
)
|
||||
.max(1000);
|
||||
|
||||
@@ -196,6 +198,7 @@ const BaseIntentSchema = z.object({
|
||||
"cancel_attack",
|
||||
"spawn",
|
||||
"boat",
|
||||
"cancel_boat",
|
||||
"name",
|
||||
"targetPlayer",
|
||||
"emoji",
|
||||
@@ -295,6 +298,11 @@ export const CancelAttackIntentSchema = BaseIntentSchema.extend({
|
||||
attackID: z.string(),
|
||||
});
|
||||
|
||||
export const CancelBoatIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("cancel_boat"),
|
||||
unitID: z.number(),
|
||||
});
|
||||
|
||||
export const MoveWarshipIntentSchema = BaseIntentSchema.extend({
|
||||
type: z.literal("move_warship"),
|
||||
unitId: z.number(),
|
||||
@@ -319,6 +327,7 @@ const IntentSchema = z.union([
|
||||
CancelAttackIntentSchema,
|
||||
SpawnIntentSchema,
|
||||
BoatAttackIntentSchema,
|
||||
CancelBoatIntentSchema,
|
||||
AllianceRequestIntentSchema,
|
||||
AllianceRequestReplyIntentSchema,
|
||||
BreakAllianceIntentSchema,
|
||||
|
||||
@@ -125,7 +125,8 @@ export interface Config {
|
||||
defensePostRange(): number;
|
||||
SAMCooldown(): number;
|
||||
SiloCooldown(): number;
|
||||
defensePostDefenseBonus(): number;
|
||||
defensePostLossMultiplier(): number;
|
||||
defensePostSpeedMultiplier(): number;
|
||||
falloutDefenseModifier(percentOfFallout: number): number;
|
||||
difficultyModifier(difficulty: Difficulty): number;
|
||||
warshipPatrolRange(): number;
|
||||
|
||||
@@ -38,6 +38,12 @@ const JwksSchema = z.object({
|
||||
.min(1),
|
||||
});
|
||||
|
||||
const TERRAIN_EFFECTS = {
|
||||
[TerrainType.Plains]: { mag: 0.85, speed: 0.8 },
|
||||
[TerrainType.Highland]: { mag: 1, speed: 1 },
|
||||
[TerrainType.Mountain]: { mag: 1.2, speed: 1.3 },
|
||||
} as const;
|
||||
|
||||
export abstract class DefaultServerConfig implements ServerConfig {
|
||||
private publicKey: JWK;
|
||||
abstract jwtAudience(): string;
|
||||
@@ -57,47 +63,49 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
return this.publicKey;
|
||||
}
|
||||
otelEnabled(): boolean {
|
||||
return Boolean(
|
||||
this.otelEndpoint() && this.otelUsername() && this.otelPassword(),
|
||||
return (
|
||||
Boolean(this.otelEndpoint()) &&
|
||||
Boolean(this.otelUsername()) &&
|
||||
Boolean(this.otelPassword())
|
||||
);
|
||||
}
|
||||
otelEndpoint(): string {
|
||||
return process.env.OTEL_ENDPOINT ?? "undefined";
|
||||
return process.env.OTEL_ENDPOINT ?? "";
|
||||
}
|
||||
otelUsername(): string {
|
||||
return process.env.OTEL_USERNAME ?? "undefined";
|
||||
return process.env.OTEL_USERNAME ?? "";
|
||||
}
|
||||
otelPassword(): string {
|
||||
return process.env.OTEL_PASSWORD ?? "undefined";
|
||||
return process.env.OTEL_PASSWORD ?? "";
|
||||
}
|
||||
region(): string {
|
||||
if (this.env() === GameEnv.Dev) {
|
||||
return "dev";
|
||||
}
|
||||
return process.env.REGION ?? "undefined";
|
||||
return process.env.REGION ?? "";
|
||||
}
|
||||
gitCommit(): string {
|
||||
return process.env.GIT_COMMIT ?? "undefined";
|
||||
return process.env.GIT_COMMIT ?? "";
|
||||
}
|
||||
r2Endpoint(): string {
|
||||
return `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`;
|
||||
}
|
||||
r2AccessKey(): string {
|
||||
return process.env.R2_ACCESS_KEY ?? "undefined";
|
||||
return process.env.R2_ACCESS_KEY ?? "";
|
||||
}
|
||||
r2SecretKey(): string {
|
||||
return process.env.R2_SECRET_KEY ?? "undefined";
|
||||
return process.env.R2_SECRET_KEY ?? "";
|
||||
}
|
||||
|
||||
r2Bucket(): string {
|
||||
return process.env.R2_BUCKET ?? "undefined";
|
||||
return process.env.R2_BUCKET ?? "";
|
||||
}
|
||||
|
||||
adminHeader(): string {
|
||||
return "x-admin-key";
|
||||
}
|
||||
adminToken(): string {
|
||||
return process.env.ADMIN_TOKEN ?? "undefined";
|
||||
return process.env.ADMIN_TOKEN ?? "dummy-admin-token";
|
||||
}
|
||||
abstract numWorkers(): number;
|
||||
abstract env(): GameEnv;
|
||||
@@ -131,6 +139,7 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
GameMapType.Asia,
|
||||
GameMapType.FalklandIslands,
|
||||
GameMapType.Baikal,
|
||||
GameMapType.Halkidiki,
|
||||
].includes(map)
|
||||
) {
|
||||
return Math.random() < 0.3 ? 50 : 25;
|
||||
@@ -246,8 +255,8 @@ export class DefaultConfig implements Config {
|
||||
|
||||
falloutDefenseModifier(falloutRatio: number): number {
|
||||
// falloutRatio is between 0 and 1
|
||||
// So defense modifier is between [5, 2.5]
|
||||
return 5 - falloutRatio * 2;
|
||||
// So defense modifier is between [3, 1]
|
||||
return 3 - falloutRatio * 2;
|
||||
}
|
||||
SAMCooldown(): number {
|
||||
return 75;
|
||||
@@ -257,10 +266,13 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
|
||||
defensePostRange(): number {
|
||||
return 30;
|
||||
return 40;
|
||||
}
|
||||
defensePostDefenseBonus(): number {
|
||||
return 5;
|
||||
defensePostLossMultiplier(): number {
|
||||
return 6;
|
||||
}
|
||||
defensePostSpeedMultiplier(): number {
|
||||
return 3;
|
||||
}
|
||||
playerTeams(): number | typeof Duos {
|
||||
return this._gameConfig.playerTeams ?? 0;
|
||||
@@ -490,34 +502,27 @@ export class DefaultConfig implements Config {
|
||||
defenderTroopLoss: number;
|
||||
tilesPerTickUsed: number;
|
||||
} {
|
||||
let mag = 0;
|
||||
let speed = 0;
|
||||
const type = gm.terrainType(tileToConquer);
|
||||
switch (type) {
|
||||
case TerrainType.Plains:
|
||||
mag = 85;
|
||||
speed = 16.5;
|
||||
break;
|
||||
case TerrainType.Highland:
|
||||
mag = 100;
|
||||
speed = 20;
|
||||
break;
|
||||
case TerrainType.Mountain:
|
||||
mag = 120;
|
||||
speed = 25;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`terrain type ${type} not supported`);
|
||||
const mod = TERRAIN_EFFECTS[type];
|
||||
if (!mod) {
|
||||
throw new Error(`terrain type ${type} not supported`);
|
||||
}
|
||||
if (defender.isPlayer()) {
|
||||
let mag = mod.mag;
|
||||
let speed = mod.speed;
|
||||
|
||||
const attackerType = attacker.type();
|
||||
const defenderIsPlayer = defender.isPlayer();
|
||||
const defenderType = defenderIsPlayer ? defender.type() : null;
|
||||
|
||||
if (defenderIsPlayer) {
|
||||
for (const dp of gm.nearbyUnits(
|
||||
tileToConquer,
|
||||
gm.config().defensePostRange(),
|
||||
UnitType.DefensePost,
|
||||
)) {
|
||||
if (dp.unit.owner() === defender) {
|
||||
mag *= this.defensePostDefenseBonus();
|
||||
speed *= this.defensePostDefenseBonus();
|
||||
mag *= this.defensePostLossMultiplier();
|
||||
speed *= this.defensePostSpeedMultiplier();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -529,55 +534,50 @@ export class DefaultConfig implements Config {
|
||||
speed *= this.falloutDefenseModifier(falloutRatio);
|
||||
}
|
||||
|
||||
if (attacker.isPlayer() && defender.isPlayer()) {
|
||||
if (attacker.isPlayer() && defenderIsPlayer) {
|
||||
if (
|
||||
attacker.type() === PlayerType.Human &&
|
||||
defender.type() === PlayerType.Bot
|
||||
attackerType === PlayerType.Human &&
|
||||
defenderType === PlayerType.Bot
|
||||
) {
|
||||
mag *= 0.8;
|
||||
}
|
||||
if (
|
||||
attacker.type() === PlayerType.FakeHuman &&
|
||||
defender.type() === PlayerType.Bot
|
||||
attackerType === PlayerType.FakeHuman &&
|
||||
defenderType === PlayerType.Bot
|
||||
) {
|
||||
mag *= 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
let largeLossModifier = 1;
|
||||
if (attacker.numTilesOwned() > 100_000) {
|
||||
largeLossModifier = Math.sqrt(100_000 / attacker.numTilesOwned());
|
||||
if (attackerType === PlayerType.Bot) {
|
||||
speed *= 4; // slow bot attacks
|
||||
}
|
||||
let largeSpeedMalus = 1;
|
||||
if (attacker.numTilesOwned() > 75_000) {
|
||||
// sqrt is only exponent 1/2 which doesn't slow enough huge players
|
||||
largeSpeedMalus = (75_000 / attacker.numTilesOwned()) ** 0.6;
|
||||
}
|
||||
|
||||
if (defender.isPlayer()) {
|
||||
if (defenderIsPlayer) {
|
||||
const defenderTroops = defender.troops();
|
||||
const defenderTiles = defender.numTilesOwned();
|
||||
const defenderDensity = defenderTroops / defenderTiles;
|
||||
const attackRatio = defenderTroops / attackTroops;
|
||||
const traitorDebuff = defender.isTraitor()
|
||||
? this.traitorDefenseDebuff()
|
||||
: 1;
|
||||
const baseTroopLoss = 16;
|
||||
const baseTileCost = 23;
|
||||
const attackStandardSize = 10_000;
|
||||
return {
|
||||
attackerTroopLoss:
|
||||
within(defender.troops() / attackTroops, 0.6, 2) *
|
||||
mag *
|
||||
0.8 *
|
||||
largeLossModifier *
|
||||
(defender.isTraitor() ? this.traitorDefenseDebuff() : 1),
|
||||
defenderTroopLoss: defender.troops() / defender.numTilesOwned(),
|
||||
mag * (baseTroopLoss + defenderDensity * traitorDebuff),
|
||||
defenderTroopLoss: defenderDensity,
|
||||
tilesPerTickUsed:
|
||||
within(defender.troops() / (5 * attackTroops), 0.2, 1.5) *
|
||||
baseTileCost *
|
||||
within(defenderDensity, 3, 100) ** 0.2 *
|
||||
(attackStandardSize / attackTroops) ** 0.1 *
|
||||
speed *
|
||||
largeSpeedMalus,
|
||||
within(attackRatio, 0.1, 20) ** 0.4,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
attackerTroopLoss:
|
||||
attacker.type() === PlayerType.Bot ? mag / 10 : mag / 5,
|
||||
attackerTroopLoss: 16 * mag,
|
||||
defenderTroopLoss: 0,
|
||||
tilesPerTickUsed: within(
|
||||
(2000 * Math.max(10, speed)) / attackTroops,
|
||||
5,
|
||||
100,
|
||||
),
|
||||
tilesPerTickUsed: 31 * speed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -589,13 +589,9 @@ export class DefaultConfig implements Config {
|
||||
numAdjacentTilesWithEnemy: number,
|
||||
): number {
|
||||
if (defender.isPlayer()) {
|
||||
return (
|
||||
within(((5 * attackTroops) / defender.troops()) * 2, 0.01, 0.5) *
|
||||
numAdjacentTilesWithEnemy *
|
||||
3
|
||||
);
|
||||
return 10 * numAdjacentTilesWithEnemy;
|
||||
} else {
|
||||
return numAdjacentTilesWithEnemy * 2;
|
||||
return 12 * numAdjacentTilesWithEnemy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,28 +621,28 @@ export class DefaultConfig implements Config {
|
||||
|
||||
startManpower(playerInfo: PlayerInfo): number {
|
||||
if (playerInfo.playerType === PlayerType.Bot) {
|
||||
return 10_000;
|
||||
return 6_000;
|
||||
}
|
||||
if (playerInfo.playerType === PlayerType.FakeHuman) {
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return 2_500 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 2_500 + 1000 * (playerInfo?.nation?.strength ?? 1);
|
||||
case Difficulty.Medium:
|
||||
return 5_000 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 6_000 + 2000 * (playerInfo?.nation?.strength ?? 1);
|
||||
case Difficulty.Hard:
|
||||
return 20_000 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 20_000 + 4000 * (playerInfo?.nation?.strength ?? 1);
|
||||
case Difficulty.Impossible:
|
||||
return 50_000 * (playerInfo?.nation?.strength ?? 1);
|
||||
return 50_000 + 8000 * (playerInfo?.nation?.strength ?? 1);
|
||||
}
|
||||
}
|
||||
return this.infiniteTroops() ? 1_000_000 : 25_000;
|
||||
return this.infiniteTroops() ? 1_000_000 : 20_000;
|
||||
}
|
||||
|
||||
maxPopulation(player: Player | PlayerView): number {
|
||||
const maxPop =
|
||||
player.type() === PlayerType.Human && this.infiniteTroops()
|
||||
? 1_000_000_000
|
||||
: 2 * (Math.pow(player.numTilesOwned(), 0.6) * 1000 + 50000) +
|
||||
: 1 * (player.numTilesOwned() * 30 + 50000) +
|
||||
player.units(UnitType.City).length * this.cityPopulationIncrease();
|
||||
|
||||
if (player.type() === PlayerType.Bot) {
|
||||
@@ -659,22 +655,26 @@ export class DefaultConfig implements Config {
|
||||
|
||||
switch (this._gameConfig.difficulty) {
|
||||
case Difficulty.Easy:
|
||||
return maxPop * 0.5;
|
||||
return maxPop * 0.4;
|
||||
case Difficulty.Medium:
|
||||
return maxPop * 1;
|
||||
return maxPop * 0.8;
|
||||
case Difficulty.Hard:
|
||||
return maxPop * 1.5;
|
||||
return maxPop * 1.4;
|
||||
case Difficulty.Impossible:
|
||||
return maxPop * 2;
|
||||
return maxPop * 1.8;
|
||||
}
|
||||
}
|
||||
|
||||
populationIncreaseRate(player: Player): number {
|
||||
const max = this.maxPopulation(player);
|
||||
|
||||
let toAdd = 10 + Math.pow(player.population(), 0.73) / 4;
|
||||
|
||||
const ratio = 1 - player.population() / max;
|
||||
//population grows proportional to current population with growth decreasing as it approaches max
|
||||
// smaller countries recieve a boost to pop growth to speed up early game
|
||||
const baseAdditionRate = 10;
|
||||
const basePopGrowthRate = 1300 / max + 1 / 140;
|
||||
const reproductionPop = 0.8 * player.troops() + 1.2 * player.workers();
|
||||
let toAdd = baseAdditionRate + basePopGrowthRate * reproductionPop;
|
||||
const totalPop = player.totalPopulation();
|
||||
const ratio = 1 - totalPop / max;
|
||||
toAdd *= ratio;
|
||||
|
||||
if (player.type() === PlayerType.Bot) {
|
||||
@@ -698,15 +698,15 @@ export class DefaultConfig implements Config {
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(player.population() + toAdd, max) - player.population();
|
||||
return Math.min(totalPop + toAdd, max) - totalPop;
|
||||
}
|
||||
|
||||
goldAdditionRate(player: Player): number {
|
||||
return Math.sqrt(player.workers() * player.numTilesOwned()) / 200;
|
||||
return 0.045 * player.workers() ** 0.7;
|
||||
}
|
||||
|
||||
troopAdjustmentRate(player: Player): number {
|
||||
const maxDiff = this.maxPopulation(player) / 1000;
|
||||
const maxDiff = this.maxPopulation(player) / 500;
|
||||
const target = player.population() * player.targetTroopRatio();
|
||||
const diff = target - player.troops();
|
||||
if (Math.abs(diff) < maxDiff) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { consolex } from "../Consolex";
|
||||
import { Execution, Game, Player, PlayerID, UnitType } from "../game/Game";
|
||||
|
||||
export class BoatRetreatExecution implements Execution {
|
||||
private active = true;
|
||||
private player: Player | undefined;
|
||||
constructor(
|
||||
private playerID: PlayerID,
|
||||
private unitID: number,
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
if (!mg.hasPlayer(this.playerID)) {
|
||||
console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
this.player = mg.player(this.playerID);
|
||||
}
|
||||
|
||||
tick(ticks: number): void {
|
||||
if (!this.player) {
|
||||
console.warn(`BoatRetreatExecution: Player ${this.playerID} not found`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const unit = this.player
|
||||
.units()
|
||||
.find(
|
||||
(unit) =>
|
||||
unit.id() === this.unitID && unit.type() === UnitType.TransportShip,
|
||||
);
|
||||
|
||||
if (!unit) {
|
||||
consolex.warn(`Didn't find outgoing boat with id ${this.unitID}`);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
unit.orderBoatRetreat();
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
owner(): Player {
|
||||
if (this.player === undefined) {
|
||||
throw new Error("Not initialized");
|
||||
}
|
||||
return this.player;
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
activeDuringSpawnPhase(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { AllianceRequestExecution } from "./alliance/AllianceRequestExecution";
|
||||
import { AllianceRequestReplyExecution } from "./alliance/AllianceRequestReplyExecution";
|
||||
import { BreakAllianceExecution } from "./alliance/BreakAllianceExecution";
|
||||
import { AttackExecution } from "./AttackExecution";
|
||||
import { BoatRetreatExecution } from "./BoatRetreatExecution";
|
||||
import { BotSpawner } from "./BotSpawner";
|
||||
import { ConstructionExecution } from "./ConstructionExecution";
|
||||
import { DonateGoldExecution } from "./DonateGoldExecution";
|
||||
@@ -59,6 +60,8 @@ export class Executor {
|
||||
}
|
||||
case "cancel_attack":
|
||||
return new RetreatExecution(playerID, intent.attackID);
|
||||
case "cancel_boat":
|
||||
return new BoatRetreatExecution(playerID, intent.unitID);
|
||||
case "move_warship":
|
||||
return new MoveWarshipExecution(intent.unitId, intent.tile);
|
||||
case "spawn":
|
||||
|
||||
@@ -169,10 +169,8 @@ export class MirvExecution implements Execution {
|
||||
if (this.mg.owner(tile) !== this.targetPlayer) {
|
||||
continue;
|
||||
}
|
||||
for (const t of taken) {
|
||||
if (this.mg.manhattanDist(tile, t) < 25) {
|
||||
continue;
|
||||
}
|
||||
if (this.proximityCheck(tile, taken)) {
|
||||
continue;
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
@@ -180,6 +178,15 @@ export class MirvExecution implements Execution {
|
||||
return null;
|
||||
}
|
||||
|
||||
private proximityCheck(tile: TileRef, taken: TileRef[]): boolean {
|
||||
for (const t of taken) {
|
||||
if (this.mg.manhattanDist(tile, t) < 25) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
owner(): Player {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
@@ -53,11 +53,8 @@ export class MissileSiloExecution implements Execution {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
this.silo.isCooldown() &&
|
||||
this.silo.ticksLeftInCooldown(this.mg.config().SiloCooldown()) === 0
|
||||
) {
|
||||
this.silo.setCooldown(false);
|
||||
if (this.silo.ticksLeftInCooldown() === 0) {
|
||||
this.silo.touch();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export class MoveWarshipExecution implements Execution {
|
||||
console.log("MoveWarshipExecution: warship is already dead");
|
||||
return;
|
||||
}
|
||||
warship.setMoveTarget(this.position);
|
||||
warship.setTargetTile(this.position);
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ export class NukeExecution implements Execution {
|
||||
.units(UnitType.MissileSilo)
|
||||
.find((silo) => silo.tile() === spawn);
|
||||
if (silo) {
|
||||
silo.setCooldown(true);
|
||||
silo.launch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export class RetreatExecution implements Execution {
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
if (!mg.hasPlayer(this.playerID)) {
|
||||
console.warn(`RetreatExecution: player ${this.player.id()} not found`);
|
||||
console.warn(`RetreatExecution: player ${this.playerID} not found`);
|
||||
return;
|
||||
}
|
||||
this.mg = mg;
|
||||
|
||||
@@ -135,10 +135,10 @@ export class SAMLauncherExecution implements Execution {
|
||||
unit.owner() !== this.player && !this.player.isFriendly(unit.owner()),
|
||||
)
|
||||
.filter((unit) => {
|
||||
const dst = unit.detonationDst();
|
||||
const dst = unit.targetTile();
|
||||
return (
|
||||
this.sam !== null &&
|
||||
dst !== null &&
|
||||
dst !== undefined &&
|
||||
this.mg.manhattanDist(dst, this.sam.tile()) <
|
||||
this.MIRVWarheadProtectionRadius
|
||||
);
|
||||
@@ -149,19 +149,17 @@ export class SAMLauncherExecution implements Execution {
|
||||
target = this.getSingleTarget();
|
||||
}
|
||||
|
||||
if (
|
||||
this.sam.isCooldown() &&
|
||||
this.sam.ticksLeftInCooldown(this.mg.config().SAMCooldown()) === 0
|
||||
) {
|
||||
this.sam.setCooldown(false);
|
||||
if (this.sam.ticksLeftInCooldown() === 0) {
|
||||
// Touch SAM to update sprite to show not in cooldown.
|
||||
this.sam.touch();
|
||||
}
|
||||
|
||||
const isSingleTarget = target && !target.targetedBySAM();
|
||||
if (
|
||||
(isSingleTarget || mirvWarheadTargets.length > 0) &&
|
||||
!this.sam.isCooldown()
|
||||
!this.sam.isInCooldown()
|
||||
) {
|
||||
this.sam.setCooldown(true);
|
||||
this.sam.launch();
|
||||
const type =
|
||||
mirvWarheadTargets.length > 0 ? UnitType.MIRVWarhead : target?.type();
|
||||
if (type === undefined) throw new Error("Unknown unit type");
|
||||
|
||||
@@ -94,7 +94,7 @@ export class TradeShipExecution implements Execution {
|
||||
return;
|
||||
} else {
|
||||
this._dstPort = ports[0];
|
||||
this.tradeShip.setDstPort(this._dstPort);
|
||||
this.tradeShip.setTargetUnit(this._dstPort);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export class TradeShipExecution implements Execution {
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
// Fire unit event to rerender.
|
||||
this.tradeShip.move(this.tradeShip.tile());
|
||||
this.tradeShip.touch();
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
this._dstPort.cachePut(this.tradeShip.tile(), result.tile);
|
||||
|
||||
@@ -165,6 +165,10 @@ 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
|
||||
}
|
||||
|
||||
const result = this.pathFinder.nextTile(this.boat.tile(), this.dst);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
|
||||
@@ -19,12 +19,12 @@ export class WarshipExecution implements Execution {
|
||||
private _owner: Player;
|
||||
private active = true;
|
||||
private warship: Unit | null = null;
|
||||
private mg: Game | null = null;
|
||||
private mg: Game;
|
||||
|
||||
private target: Unit | null = null;
|
||||
private target: Unit | undefined = undefined;
|
||||
private pathfinder: PathFinder | null = null;
|
||||
|
||||
private patrolTile: TileRef | null = null;
|
||||
private patrolTile: TileRef | undefined;
|
||||
|
||||
private lastShellAttack = 0;
|
||||
private alreadySentShell = new Set<Unit>();
|
||||
@@ -35,6 +35,7 @@ export class WarshipExecution implements Execution {
|
||||
) {}
|
||||
|
||||
init(mg: Game, ticks: number): void {
|
||||
this.mg = mg;
|
||||
if (!mg.hasPlayer(this.playerID)) {
|
||||
console.log(`WarshipExecution: player ${this.playerID} not found`);
|
||||
this.active = false;
|
||||
@@ -42,7 +43,6 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
this.pathfinder = PathFinder.Mini(mg, 5000);
|
||||
this._owner = mg.player(this.playerID);
|
||||
this.mg = mg;
|
||||
this.patrolTile = this.patrolCenterTile;
|
||||
this.random = new PseudoRandom(mg.ticks());
|
||||
}
|
||||
@@ -56,14 +56,14 @@ export class WarshipExecution implements Execution {
|
||||
const result = this.pathfinder.nextTile(this.warship.tile(), target);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.warship.setMoveTarget(null);
|
||||
this.warship.move(this.warship.tile());
|
||||
this.warship.setTargetTile(undefined);
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.move(this.warship.tile());
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.log(`path not found to target`);
|
||||
@@ -72,7 +72,11 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
|
||||
private shoot() {
|
||||
if (this.mg === null || this.warship === null || this.target === null) {
|
||||
if (
|
||||
this.mg === null ||
|
||||
this.warship === null ||
|
||||
this.target === undefined
|
||||
) {
|
||||
throw new Error("Warship not initialized");
|
||||
}
|
||||
const shellAttackRate = this.mg.config().warshipShellAttackRate();
|
||||
@@ -89,7 +93,7 @@ export class WarshipExecution implements Execution {
|
||||
if (!this.target.hasHealth()) {
|
||||
// Don't send multiple shells to target that can be oneshotted
|
||||
this.alreadySentShell.add(this.target);
|
||||
this.target = null;
|
||||
this.target = undefined;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -99,9 +103,17 @@ export class WarshipExecution implements Execution {
|
||||
if (this.warship === null || this.pathfinder === null) {
|
||||
throw new Error("Warship not initialized");
|
||||
}
|
||||
if (this.patrolTile === null) return;
|
||||
this.warship.setWarshipTarget(this.target);
|
||||
if (this.target === null || this.target.type() !== UnitType.TradeShip) {
|
||||
if (this.patrolTile === undefined) {
|
||||
this.patrolTile = this.randomTile();
|
||||
if (this.patrolTile === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.warship.setTargetUnit(this.target);
|
||||
if (
|
||||
this.target === undefined ||
|
||||
this.target.type() !== UnitType.TradeShip
|
||||
) {
|
||||
// Patrol unless we are hunting down a tradeship
|
||||
const result = this.pathfinder.nextTile(
|
||||
this.warship.tile(),
|
||||
@@ -109,18 +121,18 @@ export class WarshipExecution implements Execution {
|
||||
);
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this.patrolTile = this.randomTile();
|
||||
this.warship.move(this.warship.tile());
|
||||
this.patrolTile = undefined;
|
||||
this.warship.touch();
|
||||
break;
|
||||
case PathFindResultType.NextTile:
|
||||
this.warship.move(result.tile);
|
||||
break;
|
||||
case PathFindResultType.Pending:
|
||||
this.warship.move(this.warship.tile());
|
||||
this.warship.touch();
|
||||
return;
|
||||
case PathFindResultType.PathNotFound:
|
||||
consolex.log(`path not found to patrol tile`);
|
||||
this.patrolTile = this.randomTile();
|
||||
this.patrolTile = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -129,7 +141,13 @@ export class WarshipExecution implements Execution {
|
||||
tick(ticks: number): void {
|
||||
if (this.pathfinder === null) throw new Error("Warship not initialized");
|
||||
if (this.warship === null) {
|
||||
if (this.patrolTile === null) return;
|
||||
if (this.patrolTile === undefined) {
|
||||
console.log(
|
||||
`WarshipExecution: no patrol tile for ${this._owner.name()}`,
|
||||
);
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
const spawn = this._owner.canBuild(UnitType.Warship, this.patrolTile);
|
||||
if (spawn === false) {
|
||||
this.active = false;
|
||||
@@ -142,13 +160,12 @@ export class WarshipExecution implements Execution {
|
||||
this.active = false;
|
||||
return;
|
||||
}
|
||||
if (this.target !== null && !this.target.isActive()) {
|
||||
this.target = null;
|
||||
if (this.target !== undefined && !this.target.isActive()) {
|
||||
this.target = undefined;
|
||||
}
|
||||
const hasPort = this._owner.units(UnitType.Port).length > 0;
|
||||
if (this.mg === null) throw new Error("Game not initialized");
|
||||
const warship = this.warship;
|
||||
if (warship === null) throw new Error("Warship not initialized");
|
||||
if (warship === undefined) throw new Error("Warship not initialized");
|
||||
const ships = this.mg
|
||||
.nearbyUnits(
|
||||
this.warship.tile(),
|
||||
@@ -164,68 +181,67 @@ export class WarshipExecution implements Execution {
|
||||
(unit.type() !== UnitType.TradeShip ||
|
||||
(hasPort &&
|
||||
this.warship !== null &&
|
||||
unit.dstPort()?.owner() !== this.warship.owner() &&
|
||||
!unit.dstPort()?.owner().isFriendly(this.warship.owner()) &&
|
||||
unit.targetUnit()?.owner() !== this.warship.owner() &&
|
||||
!unit.targetUnit()?.owner().isFriendly(this.warship.owner()) &&
|
||||
unit.isSafeFromPirates() !== true)),
|
||||
);
|
||||
|
||||
this.target =
|
||||
ships.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
this.target = ships.sort((a, b) => {
|
||||
const { unit: unitA, distSquared: distA } = a;
|
||||
const { unit: unitB, distSquared: distB } = b;
|
||||
|
||||
// Prioritize Warships
|
||||
if (
|
||||
unitA.type() === UnitType.Warship &&
|
||||
unitB.type() !== UnitType.Warship
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.Warship &&
|
||||
unitB.type() === UnitType.Warship
|
||||
)
|
||||
return 1;
|
||||
// Prioritize Warships
|
||||
if (
|
||||
unitA.type() === UnitType.Warship &&
|
||||
unitB.type() !== UnitType.Warship
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.Warship &&
|
||||
unitB.type() === UnitType.Warship
|
||||
)
|
||||
return 1;
|
||||
|
||||
// Then favor Transport Ships over Trade Ships
|
||||
if (
|
||||
unitA.type() === UnitType.TransportShip &&
|
||||
unitB.type() !== UnitType.TransportShip
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.TransportShip &&
|
||||
unitB.type() === UnitType.TransportShip
|
||||
)
|
||||
return 1;
|
||||
// Then favor Transport Ships over Trade Ships
|
||||
if (
|
||||
unitA.type() === UnitType.TransportShip &&
|
||||
unitB.type() !== UnitType.TransportShip
|
||||
)
|
||||
return -1;
|
||||
if (
|
||||
unitA.type() !== UnitType.TransportShip &&
|
||||
unitB.type() === UnitType.TransportShip
|
||||
)
|
||||
return 1;
|
||||
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit ?? null;
|
||||
// If both are the same type, sort by distance (lower `distSquared` means closer)
|
||||
return distA - distB;
|
||||
})[0]?.unit;
|
||||
|
||||
const moveTarget = this.warship.moveTarget();
|
||||
const moveTarget = this.warship.targetTile();
|
||||
if (moveTarget) {
|
||||
this.goToMoveTarget(moveTarget);
|
||||
// If we have a "move target" then we cannot target trade ships as it
|
||||
// requires moving.
|
||||
if (this.target && this.target.type() === UnitType.TradeShip) {
|
||||
this.target = null;
|
||||
this.target = undefined;
|
||||
}
|
||||
} else if (!this.target || this.target.type() !== UnitType.TradeShip) {
|
||||
this.patrol();
|
||||
}
|
||||
|
||||
if (
|
||||
this.target === null ||
|
||||
this.target === undefined ||
|
||||
!this.target.isActive() ||
|
||||
this.target.owner() === this._owner ||
|
||||
this.target.isSafeFromPirates() === true
|
||||
) {
|
||||
// In case another warship captured or destroyed target, or the target escaped into safe waters
|
||||
this.target = null;
|
||||
this.target = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
this.warship.setWarshipTarget(this.target);
|
||||
this.warship.setTargetUnit(this.target);
|
||||
|
||||
// If we have a move target we do not want to go after trading ships
|
||||
if (!this.target) {
|
||||
@@ -247,7 +263,7 @@ export class WarshipExecution implements Execution {
|
||||
switch (result.type) {
|
||||
case PathFindResultType.Completed:
|
||||
this._owner.captureUnit(this.target);
|
||||
this.target = null;
|
||||
this.target = undefined;
|
||||
this.warship.move(this.warship.tile());
|
||||
return;
|
||||
case PathFindResultType.NextTile:
|
||||
@@ -271,13 +287,13 @@ export class WarshipExecution implements Execution {
|
||||
return false;
|
||||
}
|
||||
|
||||
randomTile(): TileRef {
|
||||
randomTile(allowShoreline: boolean = false): TileRef | undefined {
|
||||
if (this.mg === null) {
|
||||
throw new Error("Warship not initialized");
|
||||
}
|
||||
let warshipPatrolRange = this.mg.config().warshipPatrolRange();
|
||||
const maxAttemptBeforeExpand: number = warshipPatrolRange * 2;
|
||||
let attemptCount: number = 0;
|
||||
const maxAttemptBeforeExpand: number = 500;
|
||||
let attempts: number = 0;
|
||||
let expandCount: number = 0;
|
||||
while (expandCount < 3) {
|
||||
const x =
|
||||
@@ -290,11 +306,14 @@ export class WarshipExecution implements Execution {
|
||||
continue;
|
||||
}
|
||||
const tile = this.mg.ref(x, y);
|
||||
if (!this.mg.isOcean(tile) || this.mg.isShoreline(tile)) {
|
||||
attemptCount++;
|
||||
if (attemptCount === maxAttemptBeforeExpand) {
|
||||
if (
|
||||
!this.mg.isOcean(tile) ||
|
||||
(!allowShoreline && this.mg.isShoreline(tile))
|
||||
) {
|
||||
attempts++;
|
||||
if (attempts === maxAttemptBeforeExpand) {
|
||||
expandCount++;
|
||||
attemptCount = 0;
|
||||
attempts = 0;
|
||||
warshipPatrolRange =
|
||||
warshipPatrolRange + Math.floor(warshipPatrolRange / 2);
|
||||
}
|
||||
@@ -302,6 +321,13 @@ export class WarshipExecution implements Execution {
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
throw new Error("unreachable");
|
||||
console.warn(
|
||||
`Failed to find random tile for warship for ${this._owner.name()}`,
|
||||
);
|
||||
if (!allowShoreline) {
|
||||
// If we failed to find a tile on the ocean, try again but allow shoreline
|
||||
return this.randomTile(true);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export enum GameMapType {
|
||||
DeglaciatedAntarctica = "Deglaciated Antarctica",
|
||||
FalklandIslands = "Falkland Islands",
|
||||
Baikal = "Baikal",
|
||||
Halkidiki = "Halkidiki",
|
||||
}
|
||||
|
||||
export const mapCategories: Record<string, GameMapType[]> = {
|
||||
@@ -101,6 +102,7 @@ export const mapCategories: Record<string, GameMapType[]> = {
|
||||
GameMapType.FaroeIslands,
|
||||
GameMapType.FalklandIslands,
|
||||
GameMapType.Baikal,
|
||||
GameMapType.Halkidiki,
|
||||
],
|
||||
fantasy: [
|
||||
GameMapType.Pangaea,
|
||||
@@ -323,57 +325,59 @@ export class PlayerInfo {
|
||||
}
|
||||
|
||||
export interface Unit {
|
||||
id(): number;
|
||||
hash(): number;
|
||||
|
||||
// Properties
|
||||
// Common properties.
|
||||
id(): number;
|
||||
type(): UnitType;
|
||||
troops(): number;
|
||||
owner(): Player;
|
||||
info(): UnitInfo;
|
||||
|
||||
// Location
|
||||
delete(displayerMessage?: boolean): void;
|
||||
tile(): TileRef;
|
||||
lastTile(): TileRef;
|
||||
move(tile: TileRef): void;
|
||||
|
||||
// State
|
||||
isActive(): boolean;
|
||||
hasHealth(): boolean;
|
||||
health(): number;
|
||||
modifyHealth(delta: number): void;
|
||||
|
||||
setWarshipTarget(target: Unit | null): void; // warship only
|
||||
warshipTarget(): Unit | null;
|
||||
|
||||
setOwner(owner: Player): void;
|
||||
setCooldown(triggerCooldown: boolean): void;
|
||||
ticksLeftInCooldown(cooldownDuration: number): Tick;
|
||||
isCooldown(): boolean;
|
||||
setDstPort(dstPort: Unit): void;
|
||||
dstPort(): Unit | null; // Only for trade ships
|
||||
setSafeFromPirates(): void; // Only for trade ships
|
||||
isSafeFromPirates(): boolean; // Only for trade ships
|
||||
detonationDst(): TileRef | null; // Only for nukes
|
||||
|
||||
setMoveTarget(cell: TileRef | null): void;
|
||||
moveTarget(): TileRef | null;
|
||||
touch(): void;
|
||||
toUpdate(): UnitUpdate;
|
||||
|
||||
// Targeting
|
||||
setTargetTile(cell: TileRef | undefined): void;
|
||||
targetTile(): TileRef | undefined;
|
||||
setTargetUnit(unit: Unit | undefined): void;
|
||||
targetUnit(): Unit | undefined;
|
||||
setTargetedBySAM(targeted: boolean): void;
|
||||
targetedBySAM(): boolean;
|
||||
|
||||
// Mutations
|
||||
setTroops(troops: number): void;
|
||||
delete(displayerMessage?: boolean): void;
|
||||
// Health
|
||||
hasHealth(): boolean;
|
||||
retreating(): boolean;
|
||||
orderBoatRetreat(): void;
|
||||
health(): number;
|
||||
modifyHealth(delta: number): void;
|
||||
|
||||
// Only for Construction type
|
||||
// Troops
|
||||
setTroops(troops: number): void;
|
||||
troops(): number;
|
||||
|
||||
// --- UNIT SPECIFIC ---
|
||||
|
||||
// SAMs & Missile Silos
|
||||
launch(): void;
|
||||
ticksLeftInCooldown(): Tick | undefined;
|
||||
isInCooldown(): boolean;
|
||||
|
||||
// Trade Ships
|
||||
setSafeFromPirates(): void; // Only for trade ships
|
||||
isSafeFromPirates(): boolean; // Only for trade ships
|
||||
|
||||
// Construction
|
||||
constructionType(): UnitType | null;
|
||||
setConstructionType(type: UnitType): void;
|
||||
|
||||
// Updates
|
||||
toUpdate(): UnitUpdate;
|
||||
|
||||
cachePut(from: TileRef, to: TileRef): void; // ports only
|
||||
cacheGet(from: TileRef): TileRef | undefined; // ports only
|
||||
// Ports
|
||||
cachePut(from: TileRef, to: TileRef): void;
|
||||
cacheGet(from: TileRef): TileRef | undefined;
|
||||
}
|
||||
|
||||
export interface TerraNullius {
|
||||
@@ -421,6 +425,7 @@ export interface Player {
|
||||
// Resources & Population
|
||||
gold(): Gold;
|
||||
population(): number;
|
||||
totalPopulation(): number;
|
||||
workers(): number;
|
||||
troops(): number;
|
||||
targetTroopRatio(): number;
|
||||
@@ -612,6 +617,7 @@ export interface PlayerInteraction {
|
||||
canTarget: boolean;
|
||||
canDonate: boolean;
|
||||
canEmbargo: boolean;
|
||||
allianceCreatedAtTick?: Tick;
|
||||
}
|
||||
|
||||
export interface EmojiMessage {
|
||||
|
||||
@@ -73,9 +73,9 @@ export interface UnitUpdate {
|
||||
pos: TileRef;
|
||||
lastPos: TileRef;
|
||||
isActive: boolean;
|
||||
dstPortId?: number; // Only for trade ships
|
||||
detonationDst?: TileRef; // Only for nukes
|
||||
warshipTargetId?: number;
|
||||
retreating: boolean;
|
||||
targetUnitId?: number; // Only for trade ships
|
||||
targetTile?: TileRef; // Only for nukes
|
||||
health?: number;
|
||||
constructionType?: UnitType;
|
||||
ticksLeftInCooldown?: Tick;
|
||||
@@ -105,6 +105,7 @@ export interface PlayerUpdate {
|
||||
tilesOwned: number;
|
||||
gold: number;
|
||||
population: number;
|
||||
totalPopulation: number;
|
||||
workers: number;
|
||||
troops: number;
|
||||
targetTroopRatio: number;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
GameUpdates,
|
||||
Gold,
|
||||
NameViewData,
|
||||
nukeTypes,
|
||||
Player,
|
||||
PlayerActions,
|
||||
PlayerBorderTiles,
|
||||
@@ -79,11 +78,17 @@ export class UnitView {
|
||||
troops(): number {
|
||||
return this.data.troops;
|
||||
}
|
||||
retreating(): boolean {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw Error("Must be a transport ship");
|
||||
}
|
||||
return this.data.retreating;
|
||||
}
|
||||
tile(): TileRef {
|
||||
return this.data.pos;
|
||||
}
|
||||
owner(): PlayerView {
|
||||
return this.gameView.playerBySmallID(this.data.ownerID) as PlayerView;
|
||||
return this.gameView.playerBySmallID(this.data.ownerID)! as PlayerView;
|
||||
}
|
||||
isActive(): boolean {
|
||||
return this.data.isActive;
|
||||
@@ -97,20 +102,11 @@ export class UnitView {
|
||||
constructionType(): UnitType | undefined {
|
||||
return this.data.constructionType;
|
||||
}
|
||||
dstPortId(): number | undefined {
|
||||
return this.data.dstPortId;
|
||||
targetUnitId(): number | undefined {
|
||||
return this.data.targetUnitId;
|
||||
}
|
||||
detonationDst(): TileRef | undefined {
|
||||
if (!nukeTypes.includes(this.type())) {
|
||||
throw Error("Must be a nuke");
|
||||
}
|
||||
return this.data.detonationDst;
|
||||
}
|
||||
warshipTargetId(): number | undefined {
|
||||
if (this.type() !== UnitType.Warship) {
|
||||
throw Error("Must be a warship");
|
||||
}
|
||||
return this.data.warshipTargetId;
|
||||
targetTile(): TileRef | undefined {
|
||||
return this.data.targetTile;
|
||||
}
|
||||
ticksLeftInCooldown(): Tick | undefined {
|
||||
return this.data.ticksLeftInCooldown;
|
||||
@@ -122,7 +118,7 @@ export class UnitView {
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
public anonymousName: string;
|
||||
public anonymousName: string | null = null;
|
||||
|
||||
constructor(
|
||||
private game: GameView,
|
||||
@@ -132,8 +128,10 @@ export class PlayerView {
|
||||
if (data.clientID === game.myClientID()) {
|
||||
this.anonymousName = this.data.name;
|
||||
} else {
|
||||
this.anonymousName =
|
||||
createRandomName(this.data.name, this.data.playerType) ?? "";
|
||||
this.anonymousName = createRandomName(
|
||||
this.data.name,
|
||||
this.data.playerType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +224,9 @@ export class PlayerView {
|
||||
population(): number {
|
||||
return this.data.population;
|
||||
}
|
||||
totalPopulation(): number {
|
||||
return this.data.totalPopulation;
|
||||
}
|
||||
workers(): number {
|
||||
return this.data.workers;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export class PlayerImpl implements Player {
|
||||
tilesOwned: this.numTilesOwned(),
|
||||
gold: Number(this._gold),
|
||||
population: this.population(),
|
||||
totalPopulation: this.totalPopulation(),
|
||||
workers: this.workers(),
|
||||
troops: this.troops(),
|
||||
targetTroopRatio: this.targetTroopRatio(),
|
||||
@@ -655,6 +656,21 @@ export class PlayerImpl implements Player {
|
||||
population(): number {
|
||||
return Number(this._troops + this._workers);
|
||||
}
|
||||
totalPopulation(): number {
|
||||
return this.population() + this.attackingTroops();
|
||||
}
|
||||
private attackingTroops(): number {
|
||||
const landAttackTroops = this._outgoingAttacks
|
||||
.filter((a) => a.isActive())
|
||||
.reduce((sum, a) => sum + a.troops(), 0);
|
||||
|
||||
const boatTroops = this.units(UnitType.TransportShip)
|
||||
.map((u) => u.troops())
|
||||
.reduce((sum, n) => sum + n, 0);
|
||||
|
||||
return landAttackTroops + boatTroops;
|
||||
}
|
||||
|
||||
workers(): number {
|
||||
return Math.max(1, Number(this._workers));
|
||||
}
|
||||
@@ -803,7 +819,7 @@ export class PlayerImpl implements Player {
|
||||
// only get missilesilos that are not on cooldown
|
||||
const spawns = this.units(UnitType.MissileSilo)
|
||||
.filter((silo) => {
|
||||
return !silo.isCooldown();
|
||||
return !silo.isInCooldown();
|
||||
})
|
||||
.sort(distSortUnit(this.mg, tile));
|
||||
if (spawns.length === 0) {
|
||||
@@ -916,7 +932,7 @@ export class PlayerImpl implements Player {
|
||||
hash(): number {
|
||||
return (
|
||||
simpleHash(this.id()) * (this.population() + this.numTilesOwned()) +
|
||||
this._units.reduce((acc, unit) => acc + (unit as UnitImpl).hash(), 0)
|
||||
this._units.reduce((acc, unit) => acc + unit.hash(), 0)
|
||||
);
|
||||
}
|
||||
toString(): string {
|
||||
|
||||
@@ -46,6 +46,7 @@ const MAP_FILE_NAMES: Record<GameMapType, string> = {
|
||||
[GameMapType.EuropeClassic]: "EuropeClassic",
|
||||
[GameMapType.FalklandIslands]: "FalklandIslands",
|
||||
[GameMapType.Baikal]: "Baikal",
|
||||
[GameMapType.Halkidiki]: "Halkidiki",
|
||||
};
|
||||
|
||||
class GameMapLoader {
|
||||
|
||||
@@ -14,20 +14,17 @@ import { PlayerImpl } from "./PlayerImpl";
|
||||
|
||||
export class UnitImpl implements Unit {
|
||||
private _active = true;
|
||||
private _targetTile: TileRef | undefined;
|
||||
private _targetUnit: Unit | undefined;
|
||||
private _health: bigint;
|
||||
private _lastTile: TileRef;
|
||||
private _moveTarget: TileRef | null = null;
|
||||
private _retreating: boolean = false;
|
||||
private _targetedBySAM = false;
|
||||
private _safeFromPiratesCooldown: number; // Only for trade ships
|
||||
private _lastSetSafeFromPirates: number; // Only for trade ships
|
||||
private _constructionType: UnitType | undefined;
|
||||
private _lastOwner: PlayerImpl | null = null;
|
||||
private _troops: number;
|
||||
private _cooldownTick: Tick | null = null;
|
||||
private _dstPort: Unit | undefined = undefined; // Only for trade ships
|
||||
private _detonationDst: TileRef | undefined = undefined; // Only for nukes
|
||||
private _warshipTarget: Unit | undefined = undefined;
|
||||
private _cooldownDuration: number | undefined = undefined;
|
||||
private _cooldownStartTick: Tick | null = null;
|
||||
private _pathCache: Map<TileRef, TileRef> = new Map();
|
||||
|
||||
constructor(
|
||||
@@ -40,19 +37,22 @@ export class UnitImpl implements Unit {
|
||||
) {
|
||||
this._lastTile = _tile;
|
||||
this._health = toInt(this.mg.unitInfo(_type).maxHealth ?? 1);
|
||||
this._safeFromPiratesCooldown = this.mg
|
||||
.config()
|
||||
.safeFromPiratesCooldownMax();
|
||||
|
||||
this._troops = "troops" in params ? (params.troops ?? 0) : 0;
|
||||
this._dstPort = "dstPort" in params ? params.dstPort : undefined;
|
||||
this._cooldownDuration =
|
||||
"cooldownDuration" in params ? params.cooldownDuration : undefined;
|
||||
this._lastSetSafeFromPirates =
|
||||
"lastSetSafeFromPirates" in params
|
||||
? (params.lastSetSafeFromPirates ?? 0)
|
||||
: 0;
|
||||
}
|
||||
touch(): void {
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
setTileTarget(tile: TileRef | undefined): void {
|
||||
this._targetTile = tile;
|
||||
}
|
||||
tileTarget(): TileRef | undefined {
|
||||
return this._targetTile;
|
||||
}
|
||||
|
||||
cachePut(from: TileRef, to: TileRef): void {
|
||||
this._pathCache.set(from, to);
|
||||
@@ -66,13 +66,6 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
|
||||
toUpdate(): UnitUpdate {
|
||||
const warshipTarget = this.warshipTarget();
|
||||
const dstPort = this.dstPort();
|
||||
if (this._lastTile === null) throw new Error("null _lastTile");
|
||||
const ticksLeftInCooldown =
|
||||
this._cooldownDuration !== undefined
|
||||
? this.ticksLeftInCooldown(this._cooldownDuration)
|
||||
: undefined;
|
||||
return {
|
||||
type: GameUpdateType.Unit,
|
||||
unitType: this._type,
|
||||
@@ -81,14 +74,14 @@ export class UnitImpl implements Unit {
|
||||
ownerID: this._owner.smallID(),
|
||||
lastOwnerID: this._lastOwner?.smallID(),
|
||||
isActive: this._active,
|
||||
retreating: this._retreating,
|
||||
pos: this._tile,
|
||||
lastPos: this._lastTile,
|
||||
health: this.hasHealth() ? Number(this._health) : undefined,
|
||||
constructionType: this._constructionType,
|
||||
dstPortId: dstPort?.id() ?? undefined,
|
||||
warshipTargetId: warshipTarget?.id() ?? undefined,
|
||||
detonationDst: this.detonationDst() ?? undefined,
|
||||
ticksLeftInCooldown,
|
||||
targetUnitId: this._targetUnit?.id() ?? undefined,
|
||||
targetTile: this.targetTile() ?? undefined,
|
||||
ticksLeftInCooldown: this.ticksLeftInCooldown() ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +90,6 @@ export class UnitImpl implements Unit {
|
||||
}
|
||||
|
||||
lastTile(): TileRef {
|
||||
if (this._lastTile === null) throw new Error("null _lastTile");
|
||||
return this._lastTile;
|
||||
}
|
||||
|
||||
@@ -111,6 +103,7 @@ export class UnitImpl implements Unit {
|
||||
this.mg.addUnit(this);
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
setTroops(troops: number): void {
|
||||
this._troops = troops;
|
||||
}
|
||||
@@ -180,6 +173,17 @@ export class UnitImpl implements Unit {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
retreating(): boolean {
|
||||
return this._retreating;
|
||||
}
|
||||
|
||||
orderBoatRetreat() {
|
||||
if (this.type() !== UnitType.TransportShip) {
|
||||
throw new Error(`Cannot retreat ${this.type()}`);
|
||||
}
|
||||
this._retreating = true;
|
||||
}
|
||||
|
||||
constructionType(): UnitType | null {
|
||||
if (this.type() !== UnitType.Construction) {
|
||||
throw new Error(`Cannot get construction type on ${this.type()}`);
|
||||
@@ -203,52 +207,47 @@ export class UnitImpl implements Unit {
|
||||
return `Unit:${this._type},owner:${this.owner().name()}`;
|
||||
}
|
||||
|
||||
setWarshipTarget(target: Unit) {
|
||||
this._warshipTarget = target;
|
||||
launch(): void {
|
||||
this._cooldownStartTick = this.mg.ticks();
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
}
|
||||
|
||||
warshipTarget(): Unit | null {
|
||||
return this._warshipTarget ?? null;
|
||||
}
|
||||
|
||||
detonationDst(): TileRef | null {
|
||||
return this._detonationDst ?? null;
|
||||
}
|
||||
|
||||
dstPort(): Unit | null {
|
||||
return this._dstPort ?? null;
|
||||
}
|
||||
|
||||
// set the cooldown to the current tick or remove it
|
||||
setCooldown(triggerCooldown: boolean): void {
|
||||
if (triggerCooldown) {
|
||||
this._cooldownTick = this.mg.ticks();
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
ticksLeftInCooldown(): Tick | undefined {
|
||||
let cooldownDuration = 0;
|
||||
if (this.type() === UnitType.SAMLauncher) {
|
||||
cooldownDuration = this.mg.config().SAMCooldown();
|
||||
} else if (this.type() === UnitType.MissileSilo) {
|
||||
cooldownDuration = this.mg.config().SiloCooldown();
|
||||
} else {
|
||||
this._cooldownTick = null;
|
||||
this.mg.addUpdate(this.toUpdate());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!this._cooldownStartTick) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return cooldownDuration - (this.mg.ticks() - this._cooldownStartTick);
|
||||
}
|
||||
|
||||
ticksLeftInCooldown(cooldownDuration: number): Tick {
|
||||
const cooldownTick = this._cooldownTick ?? 0;
|
||||
return Math.max(0, cooldownDuration - (this.mg.ticks() - cooldownTick));
|
||||
isInCooldown(): boolean {
|
||||
const ticksLeft = this.ticksLeftInCooldown();
|
||||
return ticksLeft !== undefined && ticksLeft > 0;
|
||||
}
|
||||
|
||||
isCooldown(): boolean {
|
||||
return this._cooldownTick ? true : false;
|
||||
setTargetTile(targetTile: TileRef | undefined) {
|
||||
this._targetTile = targetTile;
|
||||
}
|
||||
|
||||
setDstPort(dstPort: Unit): void {
|
||||
this._dstPort = dstPort;
|
||||
targetTile(): TileRef | undefined {
|
||||
return this._targetTile;
|
||||
}
|
||||
|
||||
setMoveTarget(moveTarget: TileRef) {
|
||||
this._moveTarget = moveTarget;
|
||||
setTargetUnit(target: Unit | undefined): void {
|
||||
this._targetUnit = target;
|
||||
}
|
||||
|
||||
moveTarget(): TileRef | null {
|
||||
return this._moveTarget;
|
||||
targetUnit(): Unit | undefined {
|
||||
return this._targetUnit;
|
||||
}
|
||||
|
||||
setTargetedBySAM(targeted: boolean): void {
|
||||
@@ -266,7 +265,7 @@ export class UnitImpl implements Unit {
|
||||
isSafeFromPirates(): boolean {
|
||||
return (
|
||||
this.mg.ticks() - this._lastSetSafeFromPirates <
|
||||
this._safeFromPiratesCooldown
|
||||
this.mg.config().safeFromPiratesCooldownMax()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ export class UserSettings {
|
||||
return this.get("settings.anonymousNames", false);
|
||||
}
|
||||
|
||||
fxLayer() {
|
||||
return this.get("settings.specialEffects", true);
|
||||
}
|
||||
|
||||
darkMode() {
|
||||
return this.get("settings.darkMode", false);
|
||||
}
|
||||
@@ -51,6 +55,10 @@ export class UserSettings {
|
||||
this.set("settings.anonymousNames", !this.anonymousNames());
|
||||
}
|
||||
|
||||
toggleFxLayer() {
|
||||
this.set("settings.specialEffects", !this.fxLayer());
|
||||
}
|
||||
|
||||
toggleDarkMode() {
|
||||
this.set("settings.darkMode", !this.darkMode());
|
||||
if (this.darkMode()) {
|
||||
|
||||
@@ -32,9 +32,9 @@ export async function generateMap(
|
||||
const stream = Readable.from(imageBuffer);
|
||||
const img = await decodePNGFromStream(stream);
|
||||
|
||||
console.log(`Processing Map: ${name}`);
|
||||
console.log("Image loaded successfully");
|
||||
console.log("Image dimensions:", img.width, "x", img.height);
|
||||
console.debug(
|
||||
`Processing Map: ${name}, dimensions: ${img.width}x${img.height}`,
|
||||
);
|
||||
|
||||
const terrain: Terrain[][] = Array(img.width)
|
||||
.fill(null)
|
||||
@@ -98,7 +98,7 @@ async function createMiniMap(tm: Terrain[][]): Promise<Terrain[][]> {
|
||||
}
|
||||
|
||||
function processShore(map: Terrain[][]): Coord[] {
|
||||
console.log("Identifying shorelines");
|
||||
console.debug("Identifying shorelines");
|
||||
const shorelineWaters: Coord[] = [];
|
||||
for (let x = 0; x < map.length; x++) {
|
||||
for (let y = 0; y < map[0].length; y++) {
|
||||
@@ -120,7 +120,7 @@ function processShore(map: Terrain[][]): Coord[] {
|
||||
}
|
||||
|
||||
function processDistToLand(shorelineWaters: Coord[], map: Terrain[][]) {
|
||||
console.log(
|
||||
console.debug(
|
||||
"Setting Water tiles magnitude = Manhattan distance from nearest land",
|
||||
);
|
||||
|
||||
@@ -178,7 +178,7 @@ function neighbors(x: number, y: number, map: Terrain[][]): Terrain[] {
|
||||
}
|
||||
|
||||
function processWater(map: Terrain[][], removeSmall: boolean) {
|
||||
console.log("Processing water bodies");
|
||||
console.debug("Processing water bodies");
|
||||
const visited = new Set<string>();
|
||||
const waterBodies: { coords: Coord[]; size: number }[] = [];
|
||||
|
||||
@@ -209,11 +209,11 @@ function processWater(map: Terrain[][], removeSmall: boolean) {
|
||||
for (const coord of largestWaterBody.coords) {
|
||||
map[coord.x][coord.y].ocean = true;
|
||||
}
|
||||
console.log(`Identified ocean with ${largestWaterBody.size} water tiles`);
|
||||
console.debug(`Identified ocean with ${largestWaterBody.size} water tiles`);
|
||||
|
||||
if (removeSmall) {
|
||||
// Assess size of the other water bodies and remove those smaller than min_lake_size
|
||||
console.log("Searching for small water bodies for removal");
|
||||
console.debug("Searching for small water bodies for removal");
|
||||
for (let w = 1; w < waterBodies.length; w++) {
|
||||
if (waterBodies[w].size < min_lake_size) {
|
||||
smallLakes++;
|
||||
@@ -223,7 +223,7 @@ function processWater(map: Terrain[][], removeSmall: boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
console.debug(
|
||||
`Identified and removed ${smallLakes} bodies of water smaller than ${min_lake_size} tiles`,
|
||||
);
|
||||
}
|
||||
@@ -233,7 +233,7 @@ function processWater(map: Terrain[][], removeSmall: boolean) {
|
||||
//Adjust water tile magnitudes to reflect distance from land
|
||||
processDistToLand(shorelineWaters, map);
|
||||
} else {
|
||||
console.log("No water bodies found in the map");
|
||||
console.debug("No water bodies found in the map");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@ function removeSmallIslands(map: Terrain[][], removeSmall: boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
console.debug(
|
||||
`Identified and removed ${smallIslands} islands smaller than ${min_island_size} tiles`,
|
||||
);
|
||||
}
|
||||
@@ -348,7 +348,7 @@ function logBinaryAsBits(data: Uint8Array, length: number = 8) {
|
||||
const bits = Array.from(data.slice(0, length))
|
||||
.map((b) => b.toString(2).padStart(8, "0"))
|
||||
.join(" ");
|
||||
console.log(`Binary data (bits):`, bits);
|
||||
console.debug(`Binary data (bits):`, bits);
|
||||
}
|
||||
|
||||
function getNeighborCoords(x: number, y: number, map: Terrain[][]): Coord[] {
|
||||
@@ -372,7 +372,7 @@ async function createMapThumbnail(
|
||||
map: Terrain[][],
|
||||
quality: number = 0.5,
|
||||
): Promise<Bitmap> {
|
||||
console.log("creating thumbnail");
|
||||
console.debug("creating thumbnail");
|
||||
|
||||
const srcWidth = map.length;
|
||||
const srcHeight = map[0].length;
|
||||
|
||||
@@ -27,6 +27,7 @@ const maps = [
|
||||
"DeglaciatedAntarctica",
|
||||
"FalklandIslands",
|
||||
"Baikal",
|
||||
"Halkidiki",
|
||||
];
|
||||
|
||||
const removeSmall = true;
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Turn,
|
||||
} from "../core/Schemas";
|
||||
import { createGameRecord } from "../core/Util";
|
||||
import { ServerConfig } from "../core/configuration/Config";
|
||||
import { GameEnv, ServerConfig } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import { archive } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
@@ -132,6 +132,25 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.config.env() === GameEnv.Prod) {
|
||||
// Prevent multiple clients from using the same account in prod
|
||||
const conflicting = this.activeClients.find(
|
||||
(c) =>
|
||||
c.persistentID === client.persistentID &&
|
||||
c.clientID !== client.clientID,
|
||||
);
|
||||
if (conflicting !== undefined) {
|
||||
this.log.error("client ids do not match", {
|
||||
clientID: client.clientID,
|
||||
clientIP: ipAnonymize(client.ip),
|
||||
clientPersistentID: client.persistentID,
|
||||
existingIP: ipAnonymize(conflicting.ip),
|
||||
existingPersistentID: conflicting.persistentID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale client if this is a reconnect
|
||||
const existing = this.activeClients.find(
|
||||
(c) => c.clientID === client.clientID,
|
||||
@@ -148,10 +167,10 @@ export class GameServer {
|
||||
return;
|
||||
}
|
||||
existing.ws.removeAllListeners("message");
|
||||
this.activeClients = this.activeClients.filter(
|
||||
(c) => c.clientID !== client.clientID,
|
||||
);
|
||||
this.activeClients = this.activeClients.filter((c) => c !== existing);
|
||||
}
|
||||
|
||||
// Client connection accepted
|
||||
this.activeClients.push(client);
|
||||
client.lastPing = Date.now();
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const frequency = {
|
||||
FaroeIslands: 1,
|
||||
FalklandIslands: 1,
|
||||
Baikal: 1,
|
||||
Halkidiki: 1,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
|
||||
@@ -196,7 +196,7 @@ app.post(
|
||||
async function fetchLobbies(): Promise<number> {
|
||||
const fetchPromises: Promise<GameInfo | null>[] = [];
|
||||
|
||||
for (const gameID of publicLobbyIDs) {
|
||||
for (const gameID of new Set(publicLobbyIDs)) {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
const port = config.workerPort(gameID);
|
||||
@@ -211,6 +211,7 @@ async function fetchLobbies(): Promise<number> {
|
||||
.catch((error) => {
|
||||
log.error(`Error fetching game ${gameID}:`, error);
|
||||
// Return null or a placeholder if fetch fails
|
||||
publicLobbyIDs.delete(gameID);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -74,17 +74,19 @@ describe("MissileSilo", () => {
|
||||
});
|
||||
|
||||
test("missilesilo should cooldown as long as configured", async () => {
|
||||
expect(attacker.units(UnitType.MissileSilo)[0].isCooldown()).toBeFalsy();
|
||||
expect(attacker.units(UnitType.MissileSilo)[0].isInCooldown()).toBeFalsy();
|
||||
// send the nuke far enough away so it doesnt destroy the silo
|
||||
attackerBuildsNuke(null, game.ref(50, 50));
|
||||
expect(attacker.units(UnitType.AtomBomb)).toHaveLength(1);
|
||||
|
||||
for (let i = 0; i < game.config().SiloCooldown() - 1; i++) {
|
||||
for (let i = 0; i < game.config().SiloCooldown() - 2; i++) {
|
||||
game.executeNextTick();
|
||||
expect(attacker.units(UnitType.MissileSilo)[0].isCooldown()).toBeTruthy();
|
||||
expect(
|
||||
attacker.units(UnitType.MissileSilo)[0].isInCooldown(),
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
game.executeNextTick();
|
||||
expect(attacker.units(UnitType.MissileSilo)[0].isCooldown()).toBeFalsy();
|
||||
expect(attacker.units(UnitType.MissileSilo)[0].isInCooldown()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("SAM", () => {
|
||||
test("sam should cooldown as long as configured", async () => {
|
||||
const sam = defender.buildUnit(UnitType.SAMLauncher, game.ref(1, 1), {});
|
||||
game.addExecution(new SAMLauncherExecution(defender.id(), null, sam));
|
||||
expect(sam.isCooldown()).toBeFalsy();
|
||||
expect(sam.isInCooldown()).toBeFalsy();
|
||||
const nuke = attacker.buildUnit(UnitType.AtomBomb, game.ref(1, 2), {
|
||||
detonationDst: game.ref(1, 2),
|
||||
});
|
||||
@@ -88,14 +88,14 @@ describe("SAM", () => {
|
||||
executeTicks(game, 3);
|
||||
|
||||
expect(nuke.isActive()).toBeFalsy();
|
||||
for (let i = 0; i < game.config().SAMCooldown() - 2; i++) {
|
||||
for (let i = 0; i < game.config().SAMCooldown() - 3; i++) {
|
||||
game.executeNextTick();
|
||||
expect(sam.isCooldown()).toBeTruthy();
|
||||
expect(sam.isInCooldown()).toBeTruthy();
|
||||
}
|
||||
|
||||
executeTicks(game, 2);
|
||||
|
||||
expect(sam.isCooldown()).toBeFalsy();
|
||||
expect(sam.isInCooldown()).toBeFalsy();
|
||||
});
|
||||
|
||||
test("two sams should not target twice same nuke", async () => {
|
||||
@@ -112,6 +112,6 @@ describe("SAM", () => {
|
||||
executeTicks(game, 3);
|
||||
|
||||
expect(nuke.isActive()).toBeFalsy();
|
||||
expect([sam1, sam2].filter((s) => s.isCooldown())).toHaveLength(1);
|
||||
expect([sam1, sam2].filter((s) => s.isInCooldown())).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ export async function setup(
|
||||
_gameConfig: Partial<GameConfig> = {},
|
||||
humans: PlayerInfo[] = [],
|
||||
): Promise<Game> {
|
||||
// Suppress console.debug for tests.
|
||||
console.debug = () => {};
|
||||
|
||||
// Load the specified map
|
||||
const mapPath = path.join(__dirname, "..", "testdata", `${mapName}.png`);
|
||||
const imageBuffer = await fs.readFile(mapPath);
|
||||
|
||||