mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-26 00:14:36 +00:00
Merge branch 'main' into embeddedurlfix
This commit is contained in:
@@ -199,6 +199,11 @@
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></user-setting>
|
||||
<troubleshooting-modal
|
||||
id="page-troubleshooting"
|
||||
inline
|
||||
class="hidden w-full h-full page-content"
|
||||
></troubleshooting-modal>
|
||||
<stats-modal
|
||||
id="page-stats"
|
||||
inline
|
||||
|
||||
@@ -98,8 +98,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [404, 1146],
|
||||
"name": "Fermanagh",
|
||||
"flag": "gb-nir"
|
||||
"name": "Fermanagh"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1038, 993],
|
||||
"name": "Kingdom of the Two Sicilies",
|
||||
"flag": "custom:Kingdom of the Two Sicilies"
|
||||
"name": "Kingdom of the Two Sicilies"
|
||||
},
|
||||
{
|
||||
"coordinates": [370, 1137],
|
||||
@@ -18,8 +17,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [625, 534],
|
||||
"name": "Tuscany",
|
||||
"flag": "custom:Tuscany"
|
||||
"name": "Tuscany"
|
||||
},
|
||||
{
|
||||
"coordinates": [595, 190],
|
||||
@@ -28,13 +26,11 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [469, 386],
|
||||
"name": "Modena",
|
||||
"flag": "custom:Modena"
|
||||
"name": "Modena"
|
||||
},
|
||||
{
|
||||
"coordinates": [391, 254],
|
||||
"name": "Parma",
|
||||
"flag": "custom:Parma"
|
||||
"name": "Parma"
|
||||
},
|
||||
{
|
||||
"coordinates": [361, 68],
|
||||
@@ -43,8 +39,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [278, 774],
|
||||
"name": "Kingdom of Sardinia",
|
||||
"flag": "custom:Kingdom of Sardinia"
|
||||
"name": "Kingdom of Sardinia"
|
||||
},
|
||||
{
|
||||
"coordinates": [29, 266],
|
||||
@@ -58,8 +53,7 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [1238, 349],
|
||||
"name": "Ottoman Empire",
|
||||
"flag": "custom:Ottoman Empire2"
|
||||
"name": "Ottoman Empire"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [800, 430],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Laval"
|
||||
},
|
||||
{
|
||||
"coordinates": [1110, 930],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Royal Mount park"
|
||||
},
|
||||
{
|
||||
"coordinates": [1220, 1360],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Hochelaga Archipelago"
|
||||
},
|
||||
{
|
||||
@@ -23,42 +23,42 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [1400, 1000],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Saint-Lambert"
|
||||
},
|
||||
{
|
||||
"coordinates": [500, 130],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Blainville"
|
||||
},
|
||||
{
|
||||
"coordinates": [350, 650],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Saint-Eustache"
|
||||
},
|
||||
{
|
||||
"coordinates": [200, 1350],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Perrot Island"
|
||||
},
|
||||
{
|
||||
"coordinates": [25, 950],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Kanesatake Lands"
|
||||
},
|
||||
{
|
||||
"coordinates": [50, 450],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Mirabel"
|
||||
},
|
||||
{
|
||||
"coordinates": [650, 1450],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Chateauguay"
|
||||
},
|
||||
{
|
||||
"coordinates": [1330, 300],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Pointe-aux-Trembles"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1941, 1031],
|
||||
"name": "Rif",
|
||||
"flag": "Rif"
|
||||
"name": "Rif"
|
||||
},
|
||||
{
|
||||
"coordinates": [2733, 1190],
|
||||
@@ -28,13 +27,11 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [1271, 1393],
|
||||
"name": "Shilha",
|
||||
"flag": "Shilha"
|
||||
"name": "Shilha"
|
||||
},
|
||||
{
|
||||
"coordinates": [1555, 258],
|
||||
"name": "Andalusia",
|
||||
"flag": "Andalusia"
|
||||
"name": "Andalusia"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<path fill="#fff" d="M90.9 17.1c-3.9-3.9-10.2-3.9-14.1 0L37.5 56.4 23.2 42.1c-3.9-3.9-10.2-3.9-14.1 0s-3.9 10.2 0 14.1l21.4 21.4c1.9 1.9 4.4 2.9 7.1 2.9s5.1-1 7.1-2.9l46.4-46.4c3.8-3.9 3.8-10.2-.2-14.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 278 B |
@@ -7,6 +7,7 @@
|
||||
},
|
||||
"common": {
|
||||
"close": "Close",
|
||||
"copy": "Copy",
|
||||
"back": "Back",
|
||||
"available": "Available",
|
||||
"preset_max": "Max",
|
||||
@@ -51,12 +52,43 @@
|
||||
"account": "Account",
|
||||
"help": "Help",
|
||||
"menu": "Menu",
|
||||
"troubleshooting": "Troubleshooting",
|
||||
"go_to_troubleshooting": "Go to our troubleshooting page",
|
||||
"pick_pattern": "Pick a pattern!"
|
||||
},
|
||||
"news": {
|
||||
"github_link": "on GitHub",
|
||||
"title": "Release Notes"
|
||||
},
|
||||
"troubleshooting": {
|
||||
"title": "Troubleshooting",
|
||||
"loading": "Loading...",
|
||||
"environment": "Environment",
|
||||
"rendering": "Rendering",
|
||||
"power": "Power",
|
||||
"browser": "Browser",
|
||||
"platform": "Platform",
|
||||
"copied_to_clipboard": "Info copied to the clipboard! Feel free to share it on our Discord if you need help.",
|
||||
"os": "OS",
|
||||
"device_pixel_ratio": "Device Pixel Ratio",
|
||||
"chromium_tip": "OpenFront runs best on Chromium-based browsers.",
|
||||
"hardware_acceleration_tip": "Make sure hardware acceleration is enabled in your browser settings for optimal performance.",
|
||||
"renderer": "Renderer",
|
||||
"max_texture_size": "Max Texture Size",
|
||||
"high_precision_shaders": "High Precision Shaders",
|
||||
"gpu": "GPU",
|
||||
"unavailable": "Unavailable",
|
||||
"gpu_tip": "Verify that this is the dedicated GPU, if one is available.",
|
||||
"battery": "Battery",
|
||||
"charging": "Charging",
|
||||
"battery_level": "Battery Level",
|
||||
"power_saving_tip": "Make sure that your browser is not set to power saving mode.",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"unknown": "Unknown",
|
||||
"software_rendering": "Software rendering",
|
||||
"canvas_2d_no_gpu": "Canvas 2D (no GPU)"
|
||||
},
|
||||
"help_modal": {
|
||||
"hotkeys": "Hotkeys",
|
||||
"table_key": "Key",
|
||||
@@ -141,6 +173,7 @@
|
||||
"build_mirv": "MIRV",
|
||||
"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",
|
||||
"troubleshooting_desc": "If you experience performance issues, crashes, or other problems while playing OpenFront, please visit our Troubleshooting page for help diagnosing and fixing common issues:",
|
||||
"icon_desc": "Examples of some of the ingame icons you will encounter and what they mean:",
|
||||
"icon_crown": "Crown - Number 1. This is the top player in the leaderboard.",
|
||||
"icon_traitor": "Broken shield - Traitor. This player attacked an ally.",
|
||||
@@ -166,6 +199,7 @@
|
||||
"infinite_gold": "Infinite gold",
|
||||
"infinite_troops": "Infinite troops",
|
||||
"compact_map": "Compact Map",
|
||||
"crowded": "Crowded",
|
||||
"max_timer": "Game length (minutes)",
|
||||
"max_timer_placeholder": "Mins",
|
||||
"max_timer_invalid": "Please enter a valid max timer value (1-120 minutes)",
|
||||
@@ -246,6 +280,8 @@
|
||||
},
|
||||
"map": {
|
||||
"map": "Map",
|
||||
"featured": "Featured",
|
||||
"all": "All",
|
||||
"world": "World",
|
||||
"giantworldmap": "Giant World Map",
|
||||
"europe": "Europe",
|
||||
@@ -296,6 +332,7 @@
|
||||
"amazonriver": "Amazon River"
|
||||
},
|
||||
"map_categories": {
|
||||
"featured": "Featured",
|
||||
"continental": "Continental",
|
||||
"regional": "Regional",
|
||||
"fantasy": "Other",
|
||||
@@ -421,6 +458,7 @@
|
||||
"public_game_modifier": {
|
||||
"random_spawn": "Random Spawn",
|
||||
"compact_map": "Compact Map",
|
||||
"crowded": "Crowded",
|
||||
"starting_gold": "5M Starting Gold"
|
||||
},
|
||||
"select_lang": {
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [404, 1146],
|
||||
"flag": "gb-nir",
|
||||
"name": "Fermanagh"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1038, 993],
|
||||
"flag": "custom:Kingdom of the Two Sicilies",
|
||||
"name": "Kingdom of the Two Sicilies"
|
||||
},
|
||||
{
|
||||
@@ -33,7 +32,6 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [625, 534],
|
||||
"flag": "custom:Tuscany",
|
||||
"name": "Tuscany"
|
||||
},
|
||||
{
|
||||
@@ -43,12 +41,10 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [469, 386],
|
||||
"flag": "custom:Modena",
|
||||
"name": "Modena"
|
||||
},
|
||||
{
|
||||
"coordinates": [391, 254],
|
||||
"flag": "custom:Parma",
|
||||
"name": "Parma"
|
||||
},
|
||||
{
|
||||
@@ -58,7 +54,6 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [278, 774],
|
||||
"flag": "custom:Kingdom of Sardinia",
|
||||
"name": "Kingdom of Sardinia"
|
||||
},
|
||||
{
|
||||
@@ -73,7 +68,6 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [1238, 349],
|
||||
"flag": "custom:Ottoman Empire2",
|
||||
"name": "Ottoman Empire"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -18,17 +18,17 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [800, 430],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Laval"
|
||||
},
|
||||
{
|
||||
"coordinates": [1110, 930],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Royal Mount park"
|
||||
},
|
||||
{
|
||||
"coordinates": [1220, 1360],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Hochelaga Archipelago"
|
||||
},
|
||||
{
|
||||
@@ -38,42 +38,42 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [1400, 1000],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Saint-Lambert"
|
||||
},
|
||||
{
|
||||
"coordinates": [500, 130],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Blainville"
|
||||
},
|
||||
{
|
||||
"coordinates": [350, 650],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Saint-Eustache"
|
||||
},
|
||||
{
|
||||
"coordinates": [200, 1350],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Perrot Island"
|
||||
},
|
||||
{
|
||||
"coordinates": [25, 950],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Kanesatake Lands"
|
||||
},
|
||||
{
|
||||
"coordinates": [50, 450],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Mirabel"
|
||||
},
|
||||
{
|
||||
"coordinates": [650, 1450],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Chateauguay"
|
||||
},
|
||||
{
|
||||
"coordinates": [1330, 300],
|
||||
"flag": "quebec",
|
||||
"flag": "Quebec",
|
||||
"name": "Pointe-aux-Trembles"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"nations": [
|
||||
{
|
||||
"coordinates": [1941, 1031],
|
||||
"flag": "Rif",
|
||||
"name": "Rif"
|
||||
},
|
||||
{
|
||||
@@ -43,12 +42,10 @@
|
||||
},
|
||||
{
|
||||
"coordinates": [1271, 1393],
|
||||
"flag": "Shilha",
|
||||
"name": "Shilha"
|
||||
},
|
||||
{
|
||||
"coordinates": [1555, 258],
|
||||
"flag": "Andalusia",
|
||||
"name": "Andalusia"
|
||||
}
|
||||
]
|
||||
|
||||
+63
-2
@@ -3,8 +3,8 @@ import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/Maps";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { TroubleshootingModal } from "./TroubleshootingModal";
|
||||
|
||||
@customElement("help-modal")
|
||||
export class HelpModal extends BaseModal {
|
||||
@@ -104,7 +104,7 @@ export class HelpModal extends BaseModal {
|
||||
: ""}"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("main.instructions"),
|
||||
title: translateText("main.help"),
|
||||
onBack: this.close,
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
@@ -120,6 +120,53 @@ export class HelpModal extends BaseModal {
|
||||
[&_p]:text-gray-300 [&_p]:mb-3 [&_strong]:text-white [&_strong]:font-bold
|
||||
scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"
|
||||
>
|
||||
<!-- Troubleshooting Section -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="text-blue-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M2 20 L12 0 L22 20 L2 20"></path>
|
||||
<line x1="12" y1="8" x2="12" y2="14"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-xl font-bold uppercase tracking-widest text-white/90"
|
||||
>
|
||||
${translateText("main.troubleshooting")}
|
||||
</h3>
|
||||
<div
|
||||
class="flex-1 h-px bg-gradient-to-r from-blue-500/50 to-transparent"
|
||||
></div>
|
||||
</div>
|
||||
<section>
|
||||
<div class="w-full flex flex-col items-center">
|
||||
<p class="mb-6 text-white/70 text-sm">
|
||||
${translateText("help_modal.troubleshooting_desc")}
|
||||
</p>
|
||||
<button
|
||||
id="troubleshooting-button"
|
||||
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
data-page="page-troubleshooting"
|
||||
@click="${this.openTroubleshooting}"
|
||||
data-i18n="main.go_to_troubleshooting"
|
||||
>
|
||||
<span
|
||||
class="relative z-10 text-2xl"
|
||||
data-i18n="main.go_to_troubleshooting"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Hotkeys Section -->
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="text-blue-400">
|
||||
@@ -1137,6 +1184,20 @@ export class HelpModal extends BaseModal {
|
||||
`;
|
||||
}
|
||||
|
||||
openTroubleshooting() {
|
||||
const troubleshootingModal = document.querySelector(
|
||||
"troubleshooting-modal",
|
||||
) as TroubleshootingModal;
|
||||
if (
|
||||
!troubleshootingModal ||
|
||||
!(troubleshootingModal instanceof TroubleshootingModal)
|
||||
) {
|
||||
console.warn("Troubleshooting modal element not found");
|
||||
return;
|
||||
}
|
||||
troubleshootingModal.open();
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.keybinds = this.getKeybinds();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Quads,
|
||||
Trios,
|
||||
UnitType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import {
|
||||
ClientInfo,
|
||||
@@ -28,7 +27,7 @@ import "./components/CopyButton";
|
||||
import "./components/Difficulties";
|
||||
import "./components/FluentSlider";
|
||||
import "./components/LobbyPlayerView";
|
||||
import "./components/Maps";
|
||||
import "./components/map/MapPicker";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { crazyGamesSDK } from "./CrazyGamesSDK";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
@@ -38,7 +37,6 @@ import {
|
||||
renderToggleInputCardInput,
|
||||
} from "./utilities/RenderToggleInputCard";
|
||||
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
|
||||
import randomMap from "/images/RandomMap.webp?url";
|
||||
@customElement("host-lobby-modal")
|
||||
export class HostLobbyModal extends BaseModal {
|
||||
@state() private selectedMap: GameMapType = GameMapType.World;
|
||||
@@ -209,80 +207,14 @@ export class HostLobbyModal extends BaseModal {
|
||||
${translateText("map.map")}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="space-y-8">
|
||||
<!-- Use the imported mapCategories -->
|
||||
${Object.entries(mapCategories).map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h4>
|
||||
<div
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
${maps.map((mapValue) => {
|
||||
const mapKey = Object.entries(GameMapType).find(
|
||||
([, v]) => v === mapValue,
|
||||
)?.[0];
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
class="cursor-pointer transition-transform duration-200 active:scale-95"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === mapValue}
|
||||
.translation=${translateText(
|
||||
`map.${mapKey?.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
<!-- Random Map Card -->
|
||||
<div class="w-full pt-4 border-t border-white/5">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText("map_categories.special")}
|
||||
</h4>
|
||||
<div
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<button
|
||||
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
|
||||
.useRandomMap
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
|
||||
@click=${this.handleSelectRandomMap}
|
||||
>
|
||||
<div
|
||||
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
|
||||
>
|
||||
<img
|
||||
src=${randomMap}
|
||||
alt=${translateText("map.random")}
|
||||
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 text-center border-t border-white/5">
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
|
||||
>
|
||||
${translateText("map.random")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<map-picker
|
||||
.selectedMap=${this.selectedMap}
|
||||
.useRandomMap=${this.useRandomMap}
|
||||
.randomMapDivider=${true}
|
||||
.onSelectMap=${(mapValue: GameMapType) =>
|
||||
this.handleMapSelection(mapValue)}
|
||||
.onSelectRandom=${() => this.handleSelectRandomMap()}
|
||||
></map-picker>
|
||||
</div>
|
||||
|
||||
<!-- Difficulty Selection -->
|
||||
@@ -653,6 +585,7 @@ export class HostLobbyModal extends BaseModal {
|
||||
.gameMode=${this.gameMode}
|
||||
.clients=${this.clients}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.currentClientID=${this.lobbyCreatorClientID}
|
||||
.teamCount=${this.teamCount}
|
||||
.nationCount=${this.nationCount}
|
||||
.disableNations=${this.disableNations}
|
||||
|
||||
@@ -28,6 +28,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
|
||||
@state() private gameConfig: GameConfig | null = null;
|
||||
@state() private lobbyCreatorClientID: string | null = null;
|
||||
@state() private currentLobbyId: string = "";
|
||||
@state() private currentClientID: string = "";
|
||||
@state() private nationCount: number = 0;
|
||||
|
||||
private playersInterval: NodeJS.Timeout | null = null;
|
||||
@@ -101,6 +102,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
|
||||
.gameMode=${this.gameConfig?.gameMode ?? GameMode.FFA}
|
||||
.clients=${this.players}
|
||||
.lobbyCreatorClientID=${this.lobbyCreatorClientID}
|
||||
.currentClientID=${this.currentClientID}
|
||||
.teamCount=${this.gameConfig?.playerTeams ?? 2}
|
||||
.nationCount=${this.nationCount}
|
||||
.disableNations=${this.gameConfig?.disableNations ?? false}
|
||||
@@ -290,6 +292,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
|
||||
this.hasJoined = false;
|
||||
this.message = "";
|
||||
this.currentLobbyId = "";
|
||||
this.currentClientID = "";
|
||||
this.nationCount = 0;
|
||||
|
||||
this.leaveLobbyOnClose = true;
|
||||
@@ -418,6 +421,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
|
||||
this.showMessage(translateText("private_lobby.joined_waiting"));
|
||||
this.message = "";
|
||||
this.hasJoined = true;
|
||||
this.currentClientID = generateID();
|
||||
|
||||
// If the modal closes as part of joining the game, do not leave the lobby
|
||||
this.leaveLobbyOnClose = false;
|
||||
@@ -426,7 +430,7 @@ export class JoinPrivateLobbyModal extends BaseModal {
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
clientID: generateID(),
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
@@ -477,12 +481,13 @@ export class JoinPrivateLobbyModal extends BaseModal {
|
||||
return "version_mismatch";
|
||||
}
|
||||
|
||||
this.currentClientID = generateID();
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("join-lobby", {
|
||||
detail: {
|
||||
gameID: lobbyId,
|
||||
gameRecord: parsed.data,
|
||||
clientID: generateID(),
|
||||
clientID: this.currentClientID,
|
||||
} as JoinLobbyEvent,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
|
||||
@@ -20,7 +20,13 @@ import {
|
||||
import { getPersistentID } from "./Auth";
|
||||
import { LobbyConfig } from "./ClientGameRunner";
|
||||
import { ReplaySpeedChangeEvent } from "./InputHandler";
|
||||
import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier";
|
||||
import {
|
||||
defaultReplaySpeedMultiplier,
|
||||
ReplaySpeedMultiplier,
|
||||
} from "./utilities/ReplaySpeedMultiplier";
|
||||
|
||||
// build a small backlog so MAX can catch up.
|
||||
const MAX_REPLAY_BACKLOG_TURNS = 60;
|
||||
|
||||
export class LocalServer {
|
||||
// All turns from the game record on replay.
|
||||
@@ -64,9 +70,16 @@ export class LocalServer {
|
||||
const turnIntervalMs =
|
||||
this.lobbyConfig.serverConfig.turnIntervalMs() *
|
||||
this.replaySpeedMultiplier;
|
||||
const backlog = Math.max(0, this.turns.length - this.turnsExecuted);
|
||||
const allowReplayBacklog =
|
||||
this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest &&
|
||||
this.lobbyConfig.gameRecord !== undefined;
|
||||
const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0;
|
||||
|
||||
const canQueueNextTurn =
|
||||
backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog);
|
||||
if (
|
||||
this.turnsExecuted === this.turns.length &&
|
||||
canQueueNextTurn &&
|
||||
Date.now() > this.turnStartTime + turnIntervalMs
|
||||
) {
|
||||
this.turnStartTime = Date.now();
|
||||
|
||||
@@ -813,6 +813,7 @@ class Client {
|
||||
"game-top-bar",
|
||||
"help-modal",
|
||||
"user-setting",
|
||||
"troubleshooting-modal",
|
||||
"territory-patterns-modal",
|
||||
"language-modal",
|
||||
"news-modal",
|
||||
|
||||
@@ -65,7 +65,7 @@ export class NewsModal extends BaseModal {
|
||||
protected onOpen(): void {
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
fetch(changelog)
|
||||
fetch(`${changelog}?v=${encodeURIComponent(version.trim())}`)
|
||||
.then((response) => (response.ok ? response.text() : "Failed to load"))
|
||||
.then((markdown) =>
|
||||
markdown
|
||||
|
||||
@@ -374,6 +374,9 @@ export class PublicLobby extends LitElement {
|
||||
if (publicGameModifiers.isCompact) {
|
||||
labels.push(translateText("public_game_modifier.compact_map"));
|
||||
}
|
||||
if (publicGameModifiers.isCrowded) {
|
||||
labels.push(translateText("public_game_modifier.crowded"));
|
||||
}
|
||||
if (publicGameModifiers.startingGold) {
|
||||
labels.push(translateText("public_game_modifier.starting_gold"));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Quads,
|
||||
Trios,
|
||||
UnitType,
|
||||
mapCategories,
|
||||
} from "../core/game/Game";
|
||||
import { UserSettings } from "../core/game/UserSettings";
|
||||
import { TeamCountConfig } from "../core/Schemas";
|
||||
@@ -24,7 +23,7 @@ import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Difficulties";
|
||||
import "./components/FluentSlider";
|
||||
import "./components/Maps";
|
||||
import "./components/map/MapPicker";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { fetchCosmetics } from "./Cosmetics";
|
||||
import { FlagInput } from "./FlagInput";
|
||||
@@ -35,7 +34,6 @@ import {
|
||||
renderToggleInputCardInput,
|
||||
} from "./utilities/RenderToggleInputCard";
|
||||
import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions";
|
||||
import randomMap from "/images/RandomMap.webp?url";
|
||||
|
||||
@customElement("single-player-modal")
|
||||
export class SinglePlayerModal extends BaseModal {
|
||||
@@ -197,84 +195,15 @@ export class SinglePlayerModal extends BaseModal {
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
${Object.entries(mapCategories).map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h4>
|
||||
<div
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
${maps.map((mapValue) => {
|
||||
const mapKey = Object.keys(GameMapType).find(
|
||||
(key) =>
|
||||
GameMapType[key as keyof typeof GameMapType] ===
|
||||
mapValue,
|
||||
);
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
class="cursor-pointer transition-transform duration-200 active:scale-95"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap &&
|
||||
this.selectedMap === mapValue}
|
||||
.showMedals=${this.showAchievements}
|
||||
.wins=${this.mapWins.get(mapValue) ?? new Set()}
|
||||
.translation=${translateText(
|
||||
`map.${mapKey?.toLowerCase()}`,
|
||||
)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
|
||||
<!-- Random Map Card -->
|
||||
<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText("map_categories.special")}
|
||||
</h4>
|
||||
<div
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
|
||||
>
|
||||
<button
|
||||
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
|
||||
.useRandomMap
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
|
||||
@click=${this.handleSelectRandomMap}
|
||||
>
|
||||
<div
|
||||
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
|
||||
>
|
||||
<img
|
||||
src=${randomMap}
|
||||
alt=${translateText("map.random")}
|
||||
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 text-center border-t border-white/5">
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
|
||||
>
|
||||
${translateText("map.random")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<map-picker
|
||||
.selectedMap=${this.selectedMap}
|
||||
.useRandomMap=${this.useRandomMap}
|
||||
.showMedals=${this.showAchievements}
|
||||
.mapWins=${this.mapWins}
|
||||
.onSelectMap=${(mapValue: GameMapType) =>
|
||||
this.handleMapSelection(mapValue)}
|
||||
.onSelectRandom=${() => this.handleSelectRandomMap()}
|
||||
></map-picker>
|
||||
</div>
|
||||
|
||||
<!-- Difficulty Selection -->
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { translateText } from "./Utils";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import {
|
||||
collectGraphicsDiagnostics,
|
||||
GraphicsDiagnostics,
|
||||
} from "./utilities/Diagnostic";
|
||||
import infoIcon from "/images/InfoIcon.svg?url";
|
||||
|
||||
@customElement("troubleshooting-modal")
|
||||
export class TroubleshootingModal extends BaseModal {
|
||||
@property({ type: String }) markdown = "Loading...";
|
||||
|
||||
@property({ type: Object })
|
||||
diagnostics?: GraphicsDiagnostics;
|
||||
|
||||
@property({ type: Boolean }) loading = true;
|
||||
|
||||
private initialized: boolean = false;
|
||||
|
||||
private async loadDiagnostics() {
|
||||
const canvas = document.createElement("canvas");
|
||||
this.diagnostics = await collectGraphicsDiagnostics(canvas);
|
||||
this.loading = false;
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
render() {
|
||||
const content = html`
|
||||
<div
|
||||
class="h-full select-text flex flex-col ${this.inline
|
||||
? "bg-black/60 backdrop-blur-md rounded-2xl border border-white/10"
|
||||
: ""}"
|
||||
>
|
||||
${modalHeader({
|
||||
titleContent: html` <div
|
||||
class="w-full flex flex-col sm:flex-row justify-between gap-2"
|
||||
>
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl md:text-3xl font-bold uppercase tracking-widest break-words hyphens-auto"
|
||||
>
|
||||
<a
|
||||
class="hover:text-blue-200 text-blue-400 cursor-pointer"
|
||||
@click=${this.close}
|
||||
>${translateText("main.help")}</a
|
||||
>
|
||||
/ ${translateText("troubleshooting.title")}
|
||||
</span>
|
||||
<button
|
||||
class="hover:bg-white/5 px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest bg-blue-500/20 text-blue-400 border border-blue-500/30 shadow-[0_0_15px_rgba(59,130,246,0.2)]"
|
||||
@click=${this.copyDiagnostics}
|
||||
>
|
||||
${translateText("common.copy")}
|
||||
</button>
|
||||
</div>`,
|
||||
onBack: this.close,
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.loading
|
||||
? ""
|
||||
: html`
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-1 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent mr-1"
|
||||
>
|
||||
${this.section(
|
||||
"",
|
||||
html`${this.infoTip(
|
||||
translateText("troubleshooting.hardware_acceleration_tip"),
|
||||
true,
|
||||
)}`,
|
||||
)}
|
||||
${this.section(
|
||||
translateText("troubleshooting.environment"),
|
||||
html`
|
||||
${this.row(
|
||||
translateText("troubleshooting.browser"),
|
||||
this.diagnostics!.browser.engine,
|
||||
)}
|
||||
${this.row(
|
||||
translateText("troubleshooting.platform"),
|
||||
this.diagnostics!.browser.platform,
|
||||
)}
|
||||
${this.row(
|
||||
translateText("troubleshooting.os"),
|
||||
this.diagnostics!.browser.os,
|
||||
)}
|
||||
${this.row(
|
||||
translateText("troubleshooting.device_pixel_ratio"),
|
||||
this.diagnostics!.browser.dpr,
|
||||
)}
|
||||
${this.infoTip(
|
||||
translateText("troubleshooting.chromium_tip"),
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
${this.section(
|
||||
translateText("troubleshooting.rendering"),
|
||||
html`
|
||||
${this.row(
|
||||
translateText("troubleshooting.renderer"),
|
||||
this.describeRenderer(this.diagnostics!.rendering),
|
||||
)}
|
||||
${this.row(
|
||||
translateText("troubleshooting.max_texture_size"),
|
||||
this.diagnostics!.rendering.maxTextureSize ??
|
||||
translateText("troubleshooting.unknown"),
|
||||
)}
|
||||
${this.row(
|
||||
translateText("troubleshooting.high_precision_shaders"),
|
||||
this.diagnostics!.rendering.shaderHighp === true
|
||||
? translateText("troubleshooting.yes")
|
||||
: translateText("troubleshooting.no"),
|
||||
)}${this.row(
|
||||
translateText("troubleshooting.gpu"),
|
||||
!this.diagnostics!.rendering.gpu ||
|
||||
this.diagnostics!.rendering.gpu.unavailable
|
||||
? translateText("troubleshooting.unavailable")
|
||||
: `${this.diagnostics!.rendering.gpu.vendor} — ${this.diagnostics!.rendering.gpu.renderer}`,
|
||||
)}
|
||||
${this.infoTip(translateText("troubleshooting.gpu_tip"))}
|
||||
`,
|
||||
)}
|
||||
${this.section(
|
||||
translateText("troubleshooting.power"),
|
||||
html`
|
||||
${this.diagnostics!.power.unavailable
|
||||
? this.row(
|
||||
translateText("troubleshooting.battery"),
|
||||
translateText("troubleshooting.unavailable"),
|
||||
)
|
||||
: html`
|
||||
${this.row(
|
||||
translateText("troubleshooting.charging"),
|
||||
this.diagnostics!.power.charging
|
||||
? translateText("troubleshooting.yes")
|
||||
: translateText("troubleshooting.no"),
|
||||
)}
|
||||
${this.row(
|
||||
translateText("troubleshooting.battery_level"),
|
||||
this.diagnostics!.power.level,
|
||||
)}
|
||||
`}
|
||||
${this.infoTip(
|
||||
translateText("troubleshooting.power_saving_tip"),
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
title=${translateText("troubleshooting.title")}
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private infoTip(text: string, warning?: boolean): unknown {
|
||||
return html`
|
||||
<div
|
||||
class="mt-2 ${warning
|
||||
? "bg-orange-500/10"
|
||||
: "bg-white/10"} flex gap-2 text-white py-1 px-3 rounded-sm border-1 ${warning
|
||||
? "border-orange-400"
|
||||
: "border-white/40"}"
|
||||
>
|
||||
<img src="${infoIcon}" class="w-4" />
|
||||
${text}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
this.loadDiagnostics();
|
||||
}
|
||||
}
|
||||
|
||||
private section(title: string, content: unknown) {
|
||||
return html`
|
||||
<div class="px-4 py-3">
|
||||
<h4
|
||||
class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400"
|
||||
>
|
||||
${title}
|
||||
</h4>
|
||||
<div class="space-y-1">${content}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private row(label: string, value: unknown) {
|
||||
return html`
|
||||
<div class="flex justify-between gap-4 text-sm">
|
||||
<span class="text-slate-400">${label}</span>
|
||||
<span class="text-right text-white max-w-100">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async copyDiagnostics() {
|
||||
if (!this.diagnostics) return;
|
||||
const formatted =
|
||||
"```json\n" + JSON.stringify(this.diagnostics, null, 2) + "\n```";
|
||||
await navigator.clipboard.writeText(formatted);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("show-message", {
|
||||
detail: {
|
||||
message: html`${translateText("troubleshooting.copied_to_clipboard")}`,
|
||||
type: "info",
|
||||
duration: 3000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private describeRenderer(rendering: any): string {
|
||||
if (rendering.gpu?.software) {
|
||||
return translateText("troubleshooting.software_rendering");
|
||||
}
|
||||
if (rendering.type === "Canvas2D") {
|
||||
return translateText("troubleshooting.canvas_2d_no_gpu");
|
||||
}
|
||||
return `${rendering.type}`;
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.unregisterEscapeHandler();
|
||||
|
||||
if (this.inline) {
|
||||
this.style.pointerEvents = "none";
|
||||
if (window.showPage) {
|
||||
window.showPage?.("page-help");
|
||||
}
|
||||
} else {
|
||||
this.modalEl?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
} from "../../core/game/Game";
|
||||
import { getCompactMapNationCount } from "../../core/game/NationCreation";
|
||||
import { assignTeamsLobbyPreview } from "../../core/game/TeamAssignment";
|
||||
import { UserSettings } from "../../core/game/UserSettings";
|
||||
import { ClientInfo, TeamCountConfig } from "../../core/Schemas";
|
||||
import { createRandomName } from "../../core/Util";
|
||||
import { translateText } from "../Utils";
|
||||
|
||||
export interface TeamPreviewData {
|
||||
@@ -30,6 +32,7 @@ export class LobbyTeamView extends LitElement {
|
||||
@state() private teamPreview: TeamPreviewData[] = [];
|
||||
@state() private teamMaxSize: number = 0;
|
||||
@property({ type: String }) lobbyCreatorClientID: string = "";
|
||||
@property({ type: String }) currentClientID: string = "";
|
||||
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
|
||||
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
|
||||
@property({ type: Number }) nationCount: number = 0;
|
||||
@@ -38,6 +41,7 @@ export class LobbyTeamView extends LitElement {
|
||||
|
||||
private theme: PastelTheme = new PastelTheme();
|
||||
@state() private showTeamColors: boolean = false;
|
||||
private userSettings: UserSettings = new UserSettings();
|
||||
|
||||
willUpdate(changedProperties: Map<string, any>) {
|
||||
// Recompute team preview when relevant properties change
|
||||
@@ -108,12 +112,14 @@ export class LobbyTeamView extends LitElement {
|
||||
${repeat(
|
||||
this.clients,
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<div
|
||||
(client) => {
|
||||
const displayName = this.displayUsername(client);
|
||||
return html`<div
|
||||
class="px-2 py-1 rounded-sm bg-gray-700/70 mb-1 text-xs text-white"
|
||||
>
|
||||
${client.username}
|
||||
</div>`,
|
||||
${displayName}
|
||||
</div>`;
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-3 md:gap-4 md:pr-1">
|
||||
@@ -151,9 +157,10 @@ export class LobbyTeamView extends LitElement {
|
||||
return html`${repeat(
|
||||
this.clients,
|
||||
(c) => c.clientID ?? c.username,
|
||||
(client) =>
|
||||
html`<span class="player-tag">
|
||||
<span class="text-white">${client.username}</span>
|
||||
(client) => {
|
||||
const displayName = this.displayUsername(client);
|
||||
return html`<span class="player-tag">
|
||||
<span class="text-white">${displayName}</span>
|
||||
${client.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="host-badge"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
@@ -163,13 +170,14 @@ export class LobbyTeamView extends LitElement {
|
||||
class="remove-player-btn"
|
||||
@click=${() => this.onKickPlayer?.(client.clientID)}
|
||||
aria-label=${translateText("host_modal.remove_player", {
|
||||
username: client.username,
|
||||
username: displayName,
|
||||
})}
|
||||
>
|
||||
×
|
||||
</button>`
|
||||
: html``}
|
||||
</span>`,
|
||||
</span>`;
|
||||
},
|
||||
)} `;
|
||||
}
|
||||
|
||||
@@ -207,11 +215,12 @@ export class LobbyTeamView extends LitElement {
|
||||
: repeat(
|
||||
preview.players,
|
||||
(p) => p.clientID ?? p.username,
|
||||
(p) =>
|
||||
html` <div
|
||||
(p) => {
|
||||
const displayName = this.displayUsername(p);
|
||||
return html` <div
|
||||
class="bg-gray-700/70 px-2 py-1 rounded-sm text-xs flex items-center justify-between"
|
||||
>
|
||||
<span class="truncate text-white">${p.username}</span>
|
||||
<span class="truncate text-white">${displayName}</span>
|
||||
${p.clientID === this.lobbyCreatorClientID
|
||||
? html`<span class="ml-2 text-[11px] text-green-300"
|
||||
>(${translateText("host_modal.host_badge")})</span
|
||||
@@ -223,14 +232,15 @@ export class LobbyTeamView extends LitElement {
|
||||
aria-label=${translateText(
|
||||
"host_modal.remove_player",
|
||||
{
|
||||
username: p.username,
|
||||
username: displayName,
|
||||
},
|
||||
)}
|
||||
>
|
||||
×
|
||||
</button>`
|
||||
: html``}
|
||||
</div>`,
|
||||
</div>`;
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,4 +363,18 @@ export class LobbyTeamView extends LitElement {
|
||||
}
|
||||
return getCompactMapNationCount(this.nationCount, this.isCompactMap);
|
||||
}
|
||||
|
||||
private displayUsername(client: ClientInfo): string {
|
||||
if (!this.userSettings.anonymousNames()) {
|
||||
return client.username;
|
||||
}
|
||||
|
||||
if (this.currentClientID && client.clientID === this.currentClientID) {
|
||||
return client.username;
|
||||
}
|
||||
|
||||
return (
|
||||
createRandomName(client.username, PlayerType.Human) ?? client.username
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { Difficulty, GameMapType } from "../../core/game/Game";
|
||||
import { terrainMapFileLoader } from "../TerrainMapFileLoader";
|
||||
import { translateText } from "../Utils";
|
||||
import { Difficulty, GameMapType } from "../../../core/game/Game";
|
||||
import { terrainMapFileLoader } from "../../TerrainMapFileLoader";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
@customElement("map-display")
|
||||
export class MapDisplay extends LitElement {
|
||||
@@ -0,0 +1,183 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapType,
|
||||
mapCategories,
|
||||
} from "../../../core/game/Game";
|
||||
import { translateText } from "../../Utils";
|
||||
import "./MapDisplay";
|
||||
import randomMap from "/images/RandomMap.webp?url";
|
||||
|
||||
const featuredMaps: GameMapType[] = [
|
||||
GameMapType.World,
|
||||
GameMapType.Europe,
|
||||
GameMapType.NorthAmerica,
|
||||
GameMapType.SouthAmerica,
|
||||
GameMapType.Asia,
|
||||
GameMapType.Africa,
|
||||
GameMapType.Japan,
|
||||
];
|
||||
|
||||
@customElement("map-picker")
|
||||
export class MapPicker extends LitElement {
|
||||
@property({ type: String }) selectedMap: GameMapType = GameMapType.World;
|
||||
@property({ type: Boolean }) useRandomMap = false;
|
||||
@property({ type: Boolean }) showMedals = false;
|
||||
@property({ type: Boolean }) randomMapDivider = false;
|
||||
@property({ attribute: false }) mapWins: Map<GameMapType, Set<Difficulty>> =
|
||||
new Map();
|
||||
@property({ attribute: false }) onSelectMap?: (map: GameMapType) => void;
|
||||
@property({ attribute: false }) onSelectRandom?: () => void;
|
||||
@state() private showAllMaps = false;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private handleMapSelection(mapValue: GameMapType) {
|
||||
this.onSelectMap?.(mapValue);
|
||||
}
|
||||
|
||||
private handleSelectRandomMap = () => {
|
||||
this.onSelectRandom?.();
|
||||
};
|
||||
|
||||
private getWins(mapValue: GameMapType): Set<Difficulty> {
|
||||
return this.mapWins?.get(mapValue) ?? new Set();
|
||||
}
|
||||
|
||||
private renderMapCard(mapValue: GameMapType) {
|
||||
const mapKey = Object.entries(GameMapType).find(
|
||||
([_, value]) => value === mapValue,
|
||||
)?.[0];
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.handleMapSelection(mapValue)}
|
||||
class="cursor-pointer transition-transform duration-200 active:scale-95"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${!this.useRandomMap && this.selectedMap === mapValue}
|
||||
.showMedals=${this.showMedals}
|
||||
.wins=${this.getWins(mapValue)}
|
||||
.translation=${translateText(`map.${mapKey?.toLowerCase()}`)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAllMaps() {
|
||||
const mapCategoryEntries = Object.entries(mapCategories);
|
||||
return html`<div class="space-y-8">
|
||||
${mapCategoryEntries.map(
|
||||
([categoryKey, maps]) => html`
|
||||
<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
${maps.map((mapValue) => this.renderMapCard(mapValue))}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderFeaturedMaps() {
|
||||
let featuredMapList = featuredMaps;
|
||||
if (!featuredMapList.includes(this.selectedMap)) {
|
||||
featuredMapList = [this.selectedMap, ...featuredMaps];
|
||||
}
|
||||
return html`<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText("map_categories.featured")}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
${featuredMapList.map((mapValue) => this.renderMapCard(mapValue))}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="space-y-8">
|
||||
<div class="w-full">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="${translateText("map.map")}"
|
||||
class="grid grid-cols-2 gap-2 rounded-xl border border-white/10 bg-black/20 p-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${!this.showAllMaps}
|
||||
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
|
||||
.showAllMaps
|
||||
? "text-white/60 hover:text-white"
|
||||
: "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"}"
|
||||
@click=${() => (this.showAllMaps = false)}
|
||||
>
|
||||
${translateText("map.featured")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected=${this.showAllMaps}
|
||||
class="px-3 py-2 rounded-lg text-xs font-bold uppercase tracking-wider transition-all ${this
|
||||
.showAllMaps
|
||||
? "bg-blue-500/20 text-blue-100 shadow-[0_0_12px_rgba(59,130,246,0.2)]"
|
||||
: "text-white/60 hover:text-white"}"
|
||||
@click=${() => (this.showAllMaps = true)}
|
||||
>
|
||||
${translateText("map.all")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${this.showAllMaps ? this.renderAllMaps() : this.renderFeaturedMaps()}
|
||||
<div
|
||||
class="w-full ${this.randomMapDivider
|
||||
? "pt-4 border-t border-white/5"
|
||||
: ""}"
|
||||
>
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText("map_categories.special")}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
class="relative group rounded-xl border transition-all duration-200 overflow-hidden flex flex-col items-stretch ${this
|
||||
.useRandomMap
|
||||
? "bg-blue-500/20 border-blue-500/50 shadow-[0_0_15px_rgba(59,130,246,0.3)]"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"}"
|
||||
@click=${this.handleSelectRandomMap}
|
||||
>
|
||||
<div
|
||||
class="aspect-[2/1] w-full relative overflow-hidden bg-black/20"
|
||||
>
|
||||
<img
|
||||
src=${randomMap}
|
||||
alt=${translateText("map.random")}
|
||||
class="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 text-center border-t border-white/5">
|
||||
<div
|
||||
class="text-xs font-bold text-white uppercase tracking-wider break-words hyphens-auto"
|
||||
>
|
||||
${translateText("map.random")}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -907,9 +907,12 @@ export class RadialMenu implements Layer {
|
||||
.select(".center-button-hitbox")
|
||||
.style("cursor", enabled ? "pointer" : "not-allowed");
|
||||
|
||||
// Use default color for back button, otherwise use the current center button color
|
||||
const buttonColor =
|
||||
state === "back" ? this.defaultCenterButtonColor : this.centerButtonColor;
|
||||
centerButton
|
||||
.select(".center-button-visible")
|
||||
.attr("fill", enabled ? this.centerButtonColor : "#999999");
|
||||
.attr("fill", enabled ? buttonColor : "#999999");
|
||||
|
||||
centerButton
|
||||
.select(".center-button-icon")
|
||||
|
||||
@@ -17,6 +17,7 @@ import allianceIcon from "/images/AllianceIconWhite.svg?url";
|
||||
import boatIcon from "/images/BoatIconWhite.svg?url";
|
||||
import buildIcon from "/images/BuildIconWhite.svg?url";
|
||||
import chatIcon from "/images/ChatIconWhite.svg?url";
|
||||
import checkmarkIcon from "/images/CheckmarkIconWhite.svg?url";
|
||||
import donateGoldIcon from "/images/DonateGoldIconWhite.svg?url";
|
||||
import donateTroopIcon from "/images/DonateTroopIconWhite.svg?url";
|
||||
import emojiIcon from "/images/EmojiIconWhite.svg?url";
|
||||
@@ -218,6 +219,15 @@ const allyBreakElement: MenuElement = {
|
||||
!!params.playerActions?.interaction?.canBreakAlliance,
|
||||
color: COLORS.breakAlly,
|
||||
icon: traitorIcon,
|
||||
subMenu: () => [allyBreakCancelElement, allyBreakConfirmElement],
|
||||
};
|
||||
|
||||
const allyBreakConfirmElement: MenuElement = {
|
||||
id: "ally_break_confirm",
|
||||
name: "confirm",
|
||||
disabled: () => false,
|
||||
color: COLORS.breakAlly,
|
||||
icon: checkmarkIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.playerActionHandler.handleBreakAlliance(
|
||||
params.myPlayer,
|
||||
@@ -227,6 +237,17 @@ const allyBreakElement: MenuElement = {
|
||||
},
|
||||
};
|
||||
|
||||
const allyBreakCancelElement: MenuElement = {
|
||||
id: "ally_break_cancel",
|
||||
name: "cancel",
|
||||
disabled: () => false,
|
||||
color: COLORS.info,
|
||||
icon: xIcon,
|
||||
action: (params: MenuElementParams) => {
|
||||
params.closeMenu();
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const allyDonateGoldElement: MenuElement = {
|
||||
id: "ally_donate_gold",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
export type RendererType = "Canvas2D" | "WebGL1" | "WebGL2";
|
||||
|
||||
export interface BrowserInfo {
|
||||
engine: string;
|
||||
platform: string;
|
||||
os: string;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export interface GraphicsDiagnostics {
|
||||
browser: BrowserInfo;
|
||||
rendering: RenderingInfo;
|
||||
power: PowerInfo;
|
||||
}
|
||||
|
||||
export interface GPUInfo {
|
||||
vendor?: string;
|
||||
renderer?: string;
|
||||
software?: boolean;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
export interface RenderingInfo {
|
||||
type: RendererType;
|
||||
antialias?: boolean;
|
||||
maxTextureSize?: number;
|
||||
shaderHighp?: boolean;
|
||||
gpu?: GPUInfo;
|
||||
}
|
||||
|
||||
export interface PerformanceInfo {
|
||||
fps: number;
|
||||
worstFrameMs: number;
|
||||
jankPercent: number;
|
||||
throttlingLikely: boolean;
|
||||
}
|
||||
|
||||
export interface PowerInfo {
|
||||
charging?: boolean;
|
||||
level?: string;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
export async function collectGraphicsDiagnostics(
|
||||
canvas: HTMLCanvasElement,
|
||||
): Promise<GraphicsDiagnostics> {
|
||||
/* ---------- Browser / OS ---------- */
|
||||
|
||||
const uaData = (navigator as any).userAgentData;
|
||||
|
||||
const os = uaData?.platform ?? detectOS(navigator.userAgent);
|
||||
|
||||
const browser: BrowserInfo = {
|
||||
engine: uaData?.brands
|
||||
? uaData.brands.map((b: any) => b.brand).join(", ")
|
||||
: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
os,
|
||||
dpr: window.devicePixelRatio,
|
||||
};
|
||||
|
||||
/* ---------- Rendering ---------- */
|
||||
|
||||
let gl: WebGLRenderingContext | WebGL2RenderingContext | null = null;
|
||||
let type: RendererType = "Canvas2D";
|
||||
|
||||
gl =
|
||||
canvas.getContext("webgl2", { antialias: true }) ??
|
||||
canvas.getContext("webgl", { antialias: true });
|
||||
|
||||
if (gl) {
|
||||
const isWebGL2 =
|
||||
typeof WebGL2RenderingContext !== "undefined" &&
|
||||
gl instanceof WebGL2RenderingContext;
|
||||
type = isWebGL2 ? "WebGL2" : "WebGL1";
|
||||
}
|
||||
|
||||
const rendering: RenderingInfo = { type };
|
||||
|
||||
if (gl) {
|
||||
rendering.antialias = gl.getContextAttributes()?.antialias ?? false;
|
||||
rendering.maxTextureSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
||||
|
||||
const precision = gl.getShaderPrecisionFormat(
|
||||
gl.FRAGMENT_SHADER,
|
||||
gl.HIGH_FLOAT,
|
||||
);
|
||||
rendering.shaderHighp = precision !== null && precision.precision > 0;
|
||||
|
||||
const debugInfo = gl.getExtension("WEBGL_debug_renderer_info");
|
||||
|
||||
if (debugInfo) {
|
||||
const renderer = gl.getParameter(
|
||||
(debugInfo as any).UNMASKED_RENDERER_WEBGL,
|
||||
) as string;
|
||||
|
||||
const vendor = gl.getParameter(
|
||||
(debugInfo as any).UNMASKED_VENDOR_WEBGL,
|
||||
) as string;
|
||||
rendering.gpu = {
|
||||
vendor,
|
||||
renderer,
|
||||
software: /swiftshader|llvmpipe|software/i.test(renderer),
|
||||
};
|
||||
} else {
|
||||
rendering.gpu = { unavailable: true };
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Power ---------- */
|
||||
|
||||
let power: PowerInfo = {};
|
||||
|
||||
if ("getBattery" in navigator) {
|
||||
try {
|
||||
const battery = await (navigator as any).getBattery();
|
||||
power = {
|
||||
charging: battery.charging,
|
||||
level: Math.round(battery.level * 100) + "%",
|
||||
};
|
||||
} catch {
|
||||
power = { unavailable: true };
|
||||
}
|
||||
} else {
|
||||
power = { unavailable: true };
|
||||
}
|
||||
return {
|
||||
browser,
|
||||
rendering,
|
||||
power,
|
||||
};
|
||||
}
|
||||
|
||||
function detectOS(ua: string): string {
|
||||
if (/windows nt/i.test(ua)) return "Windows";
|
||||
if (/mac os x/i.test(ua)) return "macOS";
|
||||
if (/android/i.test(ua)) return "Android";
|
||||
if (/iphone|ipad|ipod/i.test(ua)) return "iOS";
|
||||
if (/linux/i.test(ua)) return "Linux";
|
||||
return "Unknown";
|
||||
}
|
||||
+10
-4
@@ -112,12 +112,12 @@ export class GameRunner {
|
||||
this.turns.push(turn);
|
||||
}
|
||||
|
||||
public executeNextTick() {
|
||||
public executeNextTick(): boolean {
|
||||
if (this.isExecuting) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (this.currTurn >= this.turns.length) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
this.isExecuting = true;
|
||||
|
||||
@@ -144,7 +144,8 @@ export class GameRunner {
|
||||
} else {
|
||||
console.error("Game tick error:", error);
|
||||
}
|
||||
return;
|
||||
this.isExecuting = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
|
||||
@@ -177,6 +178,11 @@ export class GameRunner {
|
||||
tickExecutionDuration: tickExecutionDuration,
|
||||
});
|
||||
this.isExecuting = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public pendingTurns(): number {
|
||||
return Math.max(0, this.turns.length - this.currTurn);
|
||||
}
|
||||
|
||||
public playerActions(
|
||||
|
||||
@@ -219,6 +219,7 @@ export const GameConfigSchema = z.object({
|
||||
.object({
|
||||
isCompact: z.boolean(),
|
||||
isRandomSpawn: z.boolean(),
|
||||
isCrowded: z.boolean(),
|
||||
startingGold: z.number().int().min(0).optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
@@ -211,6 +211,7 @@ export enum GameMapSize {
|
||||
export interface PublicGameModifiers {
|
||||
isCompact: boolean;
|
||||
isRandomSpawn: boolean;
|
||||
isCrowded: boolean;
|
||||
startingGold?: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
const ctx: Worker = self as any;
|
||||
let gameRunner: Promise<GameRunner> | null = null;
|
||||
const mapLoader = new FetchGameMapLoader(`/maps`, version);
|
||||
const MAX_TICKS_PER_HEARTBEAT = 4;
|
||||
|
||||
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
|
||||
// skip if ErrorUpdate
|
||||
@@ -36,9 +37,19 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
|
||||
const message = e.data;
|
||||
|
||||
switch (message.type) {
|
||||
case "heartbeat":
|
||||
(await gameRunner)?.executeNextTick();
|
||||
case "heartbeat": {
|
||||
const gr = await gameRunner;
|
||||
if (!gr) {
|
||||
break;
|
||||
}
|
||||
const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT);
|
||||
for (let i = 0; i < ticksToRun; i++) {
|
||||
if (!gr.executeNextTick()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "init":
|
||||
try {
|
||||
gameRunner = createGameRunner(
|
||||
|
||||
@@ -97,7 +97,7 @@ export class MapPlaylist {
|
||||
|
||||
const modifiers = this.getRandomPublicGameModifiers();
|
||||
const { startingGold } = modifiers;
|
||||
let { isCompact, isRandomSpawn } = modifiers;
|
||||
let { isCompact, isRandomSpawn, isCrowded } = modifiers;
|
||||
|
||||
// Duos, Trios, and Quads should not get random spawn (as it defeats the purpose)
|
||||
if (
|
||||
@@ -108,8 +108,8 @@ export class MapPlaylist {
|
||||
isRandomSpawn = false;
|
||||
}
|
||||
|
||||
// Maps with smallest player count < 50 don't support compact map in team games
|
||||
// The smallest player count is the 3rd number in the player counts array
|
||||
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
|
||||
// (not enough players after 75% player reduction for compact maps)
|
||||
if (
|
||||
mode === GameMode.Team &&
|
||||
!(await this.supportsCompactMapForTeams(map))
|
||||
@@ -117,15 +117,34 @@ export class MapPlaylist {
|
||||
isCompact = false;
|
||||
}
|
||||
|
||||
// Crowded modifier: if the map's biggest player count (first number of calculateMapPlayerCounts) is 60 or lower (small maps),
|
||||
// set player count to 125 (or 60 if compact map is also enabled)
|
||||
let crowdedMaxPlayers: number | undefined;
|
||||
if (isCrowded) {
|
||||
crowdedMaxPlayers = await this.getCrowdedMaxPlayers(map, isCompact);
|
||||
if (crowdedMaxPlayers === undefined) {
|
||||
isCrowded = false;
|
||||
} else {
|
||||
crowdedMaxPlayers = this.adjustForTeams(crowdedMaxPlayers, playerTeams);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the default public game config (from your GameManager)
|
||||
return {
|
||||
donateGold: mode === GameMode.Team,
|
||||
donateTroops: mode === GameMode.Team,
|
||||
gameMap: map,
|
||||
maxPlayers: await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact),
|
||||
maxPlayers:
|
||||
crowdedMaxPlayers ??
|
||||
(await this.lobbyMaxPlayers(map, mode, playerTeams, isCompact)),
|
||||
gameType: GameType.Public,
|
||||
gameMapSize: isCompact ? GameMapSize.Compact : GameMapSize.Normal,
|
||||
publicGameModifiers: { isCompact, isRandomSpawn, startingGold },
|
||||
publicGameModifiers: {
|
||||
isCompact,
|
||||
isRandomSpawn,
|
||||
isCrowded,
|
||||
startingGold,
|
||||
},
|
||||
startingGold,
|
||||
difficulty:
|
||||
playerTeams === HumansVsNations ? Difficulty.Medium : Difficulty.Easy,
|
||||
@@ -171,7 +190,7 @@ export class MapPlaylist {
|
||||
disableNations: true,
|
||||
gameMode: GameMode.FFA,
|
||||
bots: 100,
|
||||
spawnImmunityDuration: 5 * 10,
|
||||
spawnImmunityDuration: 30 * 10,
|
||||
disabledUnits: [],
|
||||
} satisfies GameConfig;
|
||||
}
|
||||
@@ -209,18 +228,31 @@ export class MapPlaylist {
|
||||
return {
|
||||
isRandomSpawn: Math.random() < 0.1, // 10% chance
|
||||
isCompact: Math.random() < 0.05, // 5% chance
|
||||
isCrowded: Math.random() < 0.05, // 5% chance
|
||||
startingGold: Math.random() < 0.05 ? 5_000_000 : undefined, // 5% chance
|
||||
};
|
||||
}
|
||||
|
||||
// Maps with smallest player count (third number of calculateMapPlayerCounts) < 50 don't support compact map in team games
|
||||
// (not enough players after 75% player reduction for compact maps)
|
||||
private async supportsCompactMapForTeams(map: GameMapType): Promise<boolean> {
|
||||
// Maps with smallest player count < 50 don't support compact map in team games
|
||||
// The smallest player count is the 3rd number in the player counts array
|
||||
const landTiles = await getMapLandTiles(map);
|
||||
const [, , smallest] = this.calculateMapPlayerCounts(landTiles);
|
||||
return smallest >= 50;
|
||||
}
|
||||
|
||||
private async getCrowdedMaxPlayers(
|
||||
map: GameMapType,
|
||||
isCompact: boolean,
|
||||
): Promise<number | undefined> {
|
||||
const landTiles = await getMapLandTiles(map);
|
||||
const [firstPlayerCount] = this.calculateMapPlayerCounts(landTiles);
|
||||
if (firstPlayerCount <= 60) {
|
||||
return isCompact ? 60 : 125;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async lobbyMaxPlayers(
|
||||
map: GameMapType,
|
||||
mode: GameMode,
|
||||
@@ -236,7 +268,15 @@ export class MapPlaylist {
|
||||
if (isCompactMap) {
|
||||
p = Math.max(3, Math.floor(p * 0.25));
|
||||
}
|
||||
if (numPlayerTeams === undefined) return p;
|
||||
return this.adjustForTeams(p, numPlayerTeams);
|
||||
}
|
||||
|
||||
private adjustForTeams(
|
||||
playerCount: number,
|
||||
numPlayerTeams: TeamCountConfig | undefined,
|
||||
): number {
|
||||
if (numPlayerTeams === undefined) return playerCount;
|
||||
let p = playerCount;
|
||||
switch (numPlayerTeams) {
|
||||
case Duos:
|
||||
p -= p % 2;
|
||||
@@ -292,6 +332,8 @@ export class MapPlaylist {
|
||||
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const team1: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const team2: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa3: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
|
||||
this.mapsPlaylist = [];
|
||||
for (let i = 0; i < maps.length; i++) {
|
||||
@@ -306,6 +348,14 @@ export class MapPlaylist {
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.disableTeams) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, team2, GameMode.Team)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa3, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from "fs";
|
||||
import { globSync } from "glob";
|
||||
import path from "path";
|
||||
|
||||
type Nation = {
|
||||
flag?: string;
|
||||
};
|
||||
|
||||
type Manifest = {
|
||||
nations?: Nation[];
|
||||
};
|
||||
|
||||
describe("Map manifests: nation flags exist", () => {
|
||||
test("All nations' flags reference existing SVG files", () => {
|
||||
const manifestPaths = globSync("resources/maps/**/manifest.json");
|
||||
|
||||
expect(manifestPaths.length).toBeGreaterThan(0);
|
||||
|
||||
const flagDir = path.join(__dirname, "../resources/flags");
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const manifestPath of manifestPaths) {
|
||||
try {
|
||||
const raw = fs.readFileSync(manifestPath, "utf8");
|
||||
const manifest = JSON.parse(raw) as Manifest;
|
||||
|
||||
(manifest.nations ?? []).forEach((nation, idx) => {
|
||||
const flag = nation?.flag;
|
||||
if (flag === undefined || flag === null) return;
|
||||
if (typeof flag !== "string") {
|
||||
errors.push(
|
||||
`${manifestPath} -> nations[${idx}].flag is not a string`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flag.trim().length === 0) return;
|
||||
if (flag.startsWith("!")) return;
|
||||
|
||||
const svgFile = flag.endsWith(".svg") ? flag : `${flag}.svg`;
|
||||
const flagPath = path.join(flagDir, svgFile);
|
||||
if (!fs.existsSync(flagPath)) {
|
||||
errors.push(
|
||||
`${manifestPath} -> nations[${idx}].flag "${flag}" does not exist in resources/flags`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push(
|
||||
`Failed to parse ${manifestPath}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
"Map manifest flag file violations:\n" + errors.join("\n"),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,12 @@ const makeParams = (opts?: Partial<MenuElementParams>): MenuElementParams => {
|
||||
const findAllyBreak = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break");
|
||||
|
||||
const findAllyBreakConfirm = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break_confirm");
|
||||
|
||||
const findAllyBreakCancel = (items: any[]) =>
|
||||
items.find((i) => i && i.id === "ally_break_cancel");
|
||||
|
||||
describe("RadialMenuElements ally break", () => {
|
||||
test("shows break option with correct color when allied", () => {
|
||||
const params = makeParams();
|
||||
@@ -85,12 +91,29 @@ describe("RadialMenuElements ally break", () => {
|
||||
expect(ally.color).toBe(COLORS.breakAlly);
|
||||
});
|
||||
|
||||
test("action calls handleBreakAlliance and closes menu", () => {
|
||||
test("break option opens confirmation submenu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
|
||||
ally.action!(params);
|
||||
expect(ally.subMenu).toBeDefined();
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
expect(subMenuItems.length).toBe(2);
|
||||
|
||||
const confirmItem = findAllyBreakConfirm(subMenuItems);
|
||||
const cancelItem = findAllyBreakCancel(subMenuItems);
|
||||
expect(confirmItem).toBeTruthy();
|
||||
expect(cancelItem).toBeTruthy();
|
||||
});
|
||||
|
||||
test("confirm action calls handleBreakAlliance and closes menu", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
const confirmItem = findAllyBreakConfirm(subMenuItems)!;
|
||||
|
||||
confirmItem.action!(params);
|
||||
|
||||
expect(params.playerActionHandler.handleBreakAlliance).toHaveBeenCalledWith(
|
||||
params.myPlayer,
|
||||
@@ -98,4 +121,19 @@ describe("RadialMenuElements ally break", () => {
|
||||
);
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("cancel action closes menu without breaking alliance", () => {
|
||||
const params = makeParams();
|
||||
const items = rootMenuElement.subMenu!(params);
|
||||
const ally = findAllyBreak(items)!;
|
||||
const subMenuItems = ally.subMenu!(params);
|
||||
const cancelItem = findAllyBreakCancel(subMenuItems)!;
|
||||
|
||||
cancelItem.action!(params);
|
||||
|
||||
expect(
|
||||
params.playerActionHandler.handleBreakAlliance,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(params.closeMenu).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export class TestServerConfig implements ServerConfig {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
getRandomPublicGameModifiers(): PublicGameModifiers {
|
||||
return { isCompact: false, isRandomSpawn: false };
|
||||
return { isCompact: false, isRandomSpawn: false, isCrowded: false };
|
||||
}
|
||||
async supportsCompactMapForTeams(): Promise<boolean> {
|
||||
throw new Error("Method not implemented.");
|
||||
|
||||
Reference in New Issue
Block a user