Merge branch 'main' into team-names

This commit is contained in:
Mattia Migliorini
2026-03-09 12:55:28 +01:00
committed by GitHub
57 changed files with 1200 additions and 426 deletions
+27 -12
View File
@@ -120,12 +120,12 @@
</head>
<body
class="h-full select-none font-sans min-h-screen bg-cover bg-center bg-fixed transition-opacity duration-300 ease-in-out flex flex-row overflow-hidden"
class="h-full select-none font-sans min-h-screen bg-neutral-800 transition-opacity duration-300 ease-in-out flex flex-row overflow-hidden"
>
<div id="hex-grid" class="fixed inset-0 -z-50 pointer-events-none">
<div
id="background-layer"
class="absolute inset-0 bg-cover bg-center opacity-60 [filter:brightness(0.5)_saturate(1.4)] dark:[filter:sepia(0.2)_saturate(1.2)_hue-rotate(180deg)_brightness(0.4)]"
class="absolute inset-0 bg-cover bg-center opacity-30 [filter:brightness(1.0)] dark:[filter:sepia(0.2)_saturate(1.2)_hue-rotate(180deg)_brightness(0.9)]"
style="
background-image: url(&quot;/resources/images/background.webp&quot;);
"
@@ -134,14 +134,14 @@
class="absolute inset-0 bg-center bg-no-repeat bg-contain hidden lg:block"
style="
background-image: url(&quot;/resources/images/OpenFront.webp&quot;);
opacity: 0.25;
opacity: 0.5;
"
></div>
<div
class="absolute inset-0 bg-center bg-no-repeat bg-contain lg:hidden"
style="
background-image: url(&quot;/resources/images/OF.webp&quot;);
opacity: 0.25;
opacity: 0.5;
"
></div>
</div>
@@ -264,23 +264,39 @@
<!-- Game components -->
<div id="app"></div>
<!-- Bottom HUD: <sm=column, sm..lg=2col (HUD left | events right), lg+=3col grid centered -->
<div
class="fixed left-0 bottom-0 min-[1200px]:left-4 min-[1200px]:bottom-4 w-full flex flex-col sm:flex-row sm:items-end z-50 pointer-events-none"
class="fixed bottom-0 left-0 w-full z-[200] flex flex-col pointer-events-none sm:flex-row sm:items-end lg:grid lg:grid-cols-[1fr_460px_1fr] lg:items-end min-[1200px]:bottom-4 min-[1200px]:px-4"
style="
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
"
>
<!-- HUD: <sm contents (children join outer flex), sm+ flex-col 460px, lg+ col-2 -->
<div
class="contents sm:flex sm:flex-col sm:w-1/2 min-[1200px]:w-auto lg:max-w-[400px]"
class="contents sm:flex sm:flex-col sm:pointer-events-none w-full sm:w-[460px] lg:col-start-2 sm:z-10"
>
<attacks-display class="order-2 sm:order-none w-full"></attacks-display>
<control-panel class="order-4 sm:order-none w-full"></control-panel>
<attacks-display
class="w-full pointer-events-auto order-1 sm:order-none"
></attacks-display>
<div
class="pointer-events-auto bg-gray-800/70 backdrop-blur-xs sm:rounded-tr-lg lg:rounded-t-lg min-[1200px]:rounded-lg shadow-lg order-3 sm:order-none"
>
<control-panel class="w-full"></control-panel>
<unit-display class="hidden lg:block w-full"></unit-display>
</div>
</div>
<!-- events+chat: <sm between attacks and control (order-2), sm+ right side, lg+ col-3 -->
<div
class="contents sm:flex sm:flex-col sm:flex-1 min-[1200px]:w-auto min-[1200px]:fixed min-[1200px]:right-0 min-[1200px]:bottom-0 sm:items-end pointer-events-none"
class="flex flex-col pointer-events-none items-end order-2 sm:order-none sm:flex-1 lg:col-start-3 lg:self-end lg:justify-end min-[1200px]:mr-4"
>
<chat-display
class="order-1 sm:order-none w-full sm:w-auto"
class="w-full sm:w-auto pointer-events-auto"
></chat-display>
<events-display
class="order-3 sm:order-none w-full sm:w-auto"
class="w-full sm:w-auto pointer-events-auto"
></events-display>
</div>
</div>
@@ -290,7 +306,6 @@
<build-menu></build-menu>
<win-modal></win-modal>
<game-starting-modal></game-starting-modal>
<unit-display></unit-display>
<div
class="flex flex-col items-end fixed top-0 right-0 min-[1200px]:top-4 min-[1200px]:right-4 z-1000 gap-2"
>
Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

@@ -0,0 +1,81 @@
{
"name": "aegean",
"nations": [
{
"coordinates": [786, 1860],
"name": "Crete"
},
{
"coordinates": [1554, 1530],
"name": "Rhodes"
},
{
"coordinates": [1051, 539],
"name": "Lesbos"
},
{
"coordinates": [1070, 820],
"name": "Chios"
},
{
"coordinates": [1235, 1023],
"name": "Samos"
},
{
"coordinates": [1193, 301],
"name": "Troy"
},
{
"coordinates": [1446, 954],
"name": "Ephesus"
},
{
"coordinates": [1515, 1223],
"name": "Miletus"
},
{
"coordinates": [824, 305],
"name": "Lemnos"
},
{
"coordinates": [1312, 37],
"name": "Thrace"
},
{
"coordinates": [1473, 509],
"name": "Achaemenid Empire",
"flag": "Achaemenid Empire"
},
{
"coordinates": [702, 40],
"name": "Thasos"
},
{
"coordinates": [832, 1253],
"name": "Cyclades"
},
{
"coordinates": [479, 943],
"name": "Athens",
"flag": "Athens"
},
{
"coordinates": [110, 1157],
"name": "Sparta",
"flag": "Sparta"
},
{
"coordinates": [348, 56],
"name": "Macedonia",
"flag": "Macedonia"
},
{
"coordinates": [175, 456],
"name": "Thessaly"
},
{
"coordinates": [71, 742],
"name": "Aetolia"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

@@ -0,0 +1,110 @@
{
"name": "SanFrancisco",
"nations": [
{
"coordinates": [996, 990],
"name": "San Francisco",
"flag": "California"
},
{
"coordinates": [1177, 1389],
"name": "San Mateo",
"flag": "California"
},
{
"coordinates": [1323, 1598],
"name": "Palo Alto",
"flag": "California"
},
{
"coordinates": [1618, 1653],
"name": "San Jose",
"flag": "California"
},
{
"coordinates": [1756, 1467],
"name": "Fremont",
"flag": "California"
},
{
"coordinates": [1537, 1173],
"name": "Hayward",
"flag": "California"
},
{
"coordinates": [1308, 873],
"name": "Oakland",
"flag": "California"
},
{
"coordinates": [1230, 514],
"name": "Richmond",
"flag": "California"
},
{
"coordinates": [1635, 517],
"name": "Concord",
"flag": "California"
},
{
"coordinates": [1253, 778],
"name": "Berkeley",
"flag": "California"
},
{
"coordinates": [1749, 87],
"name": "Fairfield",
"flag": "California"
},
{
"coordinates": [1348, 279],
"name": "Vallejo",
"flag": "California"
},
{
"coordinates": [1122, 18],
"name": "Napa",
"flag": "California"
},
{
"coordinates": [757, 152],
"name": "Novato",
"flag": "California"
},
{
"coordinates": [868, 539],
"name": "San Rafael",
"flag": "California"
},
{
"coordinates": [930, 1233],
"name": "Daly City",
"flag": "California"
},
{
"coordinates": [1955, 521],
"name": "Pittsburg",
"flag": "California"
},
{
"coordinates": [1647, 849],
"name": "Dublin",
"flag": "California"
},
{
"coordinates": [503, 575],
"name": "Bolinas",
"flag": "California"
},
{
"coordinates": [1882, 1082],
"name": "Livermore",
"flag": "California"
},
{
"coordinates": [215, 26],
"name": "Bodega Bay",
"flag": "California"
}
]
}
+2
View File
@@ -76,6 +76,8 @@ var maps = []struct {
{Name: "hawaii"},
{Name: "niledelta"},
{Name: "arctic"},
{Name: "sanfrancisco"},
{Name: "aegean"},
{Name: "big_plains", IsTest: true},
{Name: "half_land_half_ocean", IsTest: true},
{Name: "ocean_and_land", IsTest: true},
+7 -4
View File
@@ -21,7 +21,7 @@
"colord": "^2.9.3",
"colorjs.io": "^0.5.2",
"compression": "^1.8.1",
"dompurify": "^3.1.7",
"dompurify": "^3.3.2",
"dotenv": "^16.5.0",
"ejs": "^3.1.10",
"express": "^4.22.1",
@@ -7034,10 +7034,13 @@
}
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
+1 -1
View File
@@ -104,7 +104,7 @@
"colord": "^2.9.3",
"colorjs.io": "^0.5.2",
"compression": "^1.8.1",
"dompurify": "^3.1.7",
"dompurify": "^3.3.2",
"dotenv": "^16.5.0",
"ejs": "^3.1.10",
"express": "^4.22.1",
+4
View File
@@ -237,6 +237,10 @@
{
"key": "stop_trading",
"requiresPlayer": true
},
{
"key": "stop_trading_all",
"requiresPlayer": false
}
]
}
+2 -1
View File
@@ -643,7 +643,8 @@
{
"code": "Empire of Japan",
"continent": "Asia",
"name": "Empire of Japan"
"name": "Empire of Japan",
"restricted": true
},
{
"code": "Empire of Japan1",
+5 -2
View File
@@ -337,7 +337,9 @@
"hawaii": "Hawaii",
"alps": "Alps",
"niledelta": "Nile Delta",
"arctic": "Arctic"
"arctic": "Arctic",
"sanfrancisco": "San Francisco",
"aegean": "Aegean"
},
"map_categories": {
"featured": "Featured",
@@ -686,7 +688,8 @@
"mirv_ready": "[P1] has enough gold to launch a MIRV!",
"snowballing": "[P1] is snowballing too fast!",
"cheating": "[P1] is cheating!",
"stop_trading": "Stop trading with [P1]!"
"stop_trading": "Stop trading with [P1]!",
"stop_trading_all": "Please stop trading with all!"
}
},
"build_menu": {
+96
View File
@@ -0,0 +1,96 @@
{
"map": {
"height": 2000,
"num_land_tiles": 1044110,
"width": 1600
},
"map16x": {
"height": 500,
"num_land_tiles": 60226,
"width": 400
},
"map4x": {
"height": 1000,
"num_land_tiles": 253795,
"width": 800
},
"name": "aegean",
"nations": [
{
"coordinates": [786, 1860],
"name": "Crete"
},
{
"coordinates": [1554, 1530],
"name": "Rhodes"
},
{
"coordinates": [1051, 539],
"name": "Lesbos"
},
{
"coordinates": [1070, 820],
"name": "Chios"
},
{
"coordinates": [1235, 1023],
"name": "Samos"
},
{
"coordinates": [1193, 301],
"name": "Troy"
},
{
"coordinates": [1446, 954],
"name": "Ephesus"
},
{
"coordinates": [1515, 1223],
"name": "Miletus"
},
{
"coordinates": [824, 305],
"name": "Lemnos"
},
{
"coordinates": [1312, 37],
"name": "Thrace"
},
{
"coordinates": [1473, 509],
"flag": "Achaemenid Empire",
"name": "Achaemenid Empire"
},
{
"coordinates": [702, 40],
"name": "Thasos"
},
{
"coordinates": [832, 1253],
"name": "Cyclades"
},
{
"coordinates": [479, 943],
"flag": "Athens",
"name": "Athens"
},
{
"coordinates": [110, 1157],
"flag": "Sparta",
"name": "Sparta"
},
{
"coordinates": [348, 56],
"flag": "Macedonia",
"name": "Macedonia"
},
{
"coordinates": [175, 456],
"name": "Thessaly"
},
{
"coordinates": [71, 742],
"name": "Aetolia"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+125
View File
@@ -0,0 +1,125 @@
{
"map": {
"height": 1700,
"num_land_tiles": 1887961,
"width": 2000
},
"map16x": {
"height": 425,
"num_land_tiles": 112964,
"width": 500
},
"map4x": {
"height": 850,
"num_land_tiles": 465078,
"width": 1000
},
"name": "SanFrancisco",
"nations": [
{
"coordinates": [996, 990],
"flag": "California",
"name": "San Francisco"
},
{
"coordinates": [1177, 1389],
"flag": "California",
"name": "San Mateo"
},
{
"coordinates": [1323, 1598],
"flag": "California",
"name": "Palo Alto"
},
{
"coordinates": [1618, 1653],
"flag": "California",
"name": "San Jose"
},
{
"coordinates": [1756, 1467],
"flag": "California",
"name": "Fremont"
},
{
"coordinates": [1537, 1173],
"flag": "California",
"name": "Hayward"
},
{
"coordinates": [1308, 873],
"flag": "California",
"name": "Oakland"
},
{
"coordinates": [1230, 514],
"flag": "California",
"name": "Richmond"
},
{
"coordinates": [1635, 517],
"flag": "California",
"name": "Concord"
},
{
"coordinates": [1253, 778],
"flag": "California",
"name": "Berkeley"
},
{
"coordinates": [1749, 87],
"flag": "California",
"name": "Fairfield"
},
{
"coordinates": [1348, 279],
"flag": "California",
"name": "Vallejo"
},
{
"coordinates": [1122, 18],
"flag": "California",
"name": "Napa"
},
{
"coordinates": [757, 152],
"flag": "California",
"name": "Novato"
},
{
"coordinates": [868, 539],
"flag": "California",
"name": "San Rafael"
},
{
"coordinates": [930, 1233],
"flag": "California",
"name": "Daly City"
},
{
"coordinates": [1955, 521],
"flag": "California",
"name": "Pittsburg"
},
{
"coordinates": [1647, 849],
"flag": "California",
"name": "Dublin"
},
{
"coordinates": [503, 575],
"flag": "California",
"name": "Bolinas"
},
{
"coordinates": [1882, 1082],
"flag": "California",
"name": "Livermore"
},
{
"coordinates": [215, 26],
"flag": "California",
"name": "Bodega Bay"
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+7 -3
View File
@@ -194,7 +194,7 @@ export function joinLobby(
async function createClientGame(
lobbyConfig: LobbyConfig,
clientID: ClientID,
clientID: ClientID | undefined,
eventBus: EventBus,
transport: Transport,
userSettings: UserSettings,
@@ -267,7 +267,7 @@ export class ClientGameRunner {
constructor(
private lobby: LobbyConfig,
private clientID: ClientID,
private clientID: ClientID | undefined,
private eventBus: EventBus,
private renderer: GameRenderer,
private input: InputHandler,
@@ -294,7 +294,7 @@ export class ClientGameRunner {
}
private async saveGame(update: WinUpdate) {
if (this.myPlayer === null) {
if (!this.clientID) {
return;
}
const players: PlayerRecord[] = [
@@ -544,6 +544,7 @@ export class ClientGameRunner {
return;
}
if (this.myPlayer === null) {
if (!this.clientID) return;
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
@@ -578,6 +579,7 @@ export class ClientGameRunner {
const tile = this.gameView.ref(cell.x, cell.y);
if (this.myPlayer === null) {
if (!this.clientID) return;
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
@@ -639,6 +641,7 @@ export class ClientGameRunner {
}
if (this.myPlayer === null) {
if (!this.clientID) return;
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
@@ -664,6 +667,7 @@ export class ClientGameRunner {
}
if (this.myPlayer === null) {
if (!this.clientID) return;
const myPlayer = this.gameView.playerByClientID(this.clientID);
if (myPlayer === null) return;
this.myPlayer = myPlayer;
+1 -1
View File
@@ -94,7 +94,7 @@ export class FlagInput extends LitElement {
></span>
${showSelect
? html`<span
class="text-[10px] font-black text-white uppercase leading-none break-words w-full text-center px-1"
class="text-[10px] font-medium tracking-wider text-white uppercase leading-none break-words w-full text-center px-1"
>
${translateText("flag_input.title")}
</span>`
+13 -14
View File
@@ -23,7 +23,7 @@ import {
translateText,
} from "./Utils";
const CARD_BG = "bg-[color-mix(in_oklab,var(--frenchBlue)_70%,black)]";
const CARD_BG = "bg-sky-950";
@customElement("game-mode-selector")
export class GameModeSelector extends LitElement {
@@ -119,7 +119,7 @@ export class GameModeSelector extends LitElement {
const special = this.lobbies?.games?.["special"]?.[0];
return html`
<div class="flex flex-col gap-4 w-[84%] sm:w-full mx-auto pb-4 sm:pb-0">
<div class="flex flex-col gap-4 w-full px-4 sm:px-0 mx-auto pb-4 sm:pb-0">
<!-- Solo: mobile only, top -->
<div class="sm:hidden h-14">
${this.renderSmallActionCard(
@@ -133,17 +133,17 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
)}
</div>
<!-- Game cards grid -->
@@ -204,17 +204,17 @@ export class GameModeSelector extends LitElement {
${this.renderSmallActionCard(
translateText("main.create"),
this.openHostLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
)}
${this.renderSmallActionCard(
translateText("mode_selector.ranked_title"),
this.openRankedMenu,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
)}
${this.renderSmallActionCard(
translateText("main.join"),
this.openJoinLobby,
"bg-[color-mix(in_oklab,var(--frenchBlue)_75%,black)]",
"bg-slate-700 hover:bg-slate-600 active:bg-slate-800",
)}
</div>
</div>
@@ -255,7 +255,7 @@ export class GameModeSelector extends LitElement {
return html`
<button
@click=${onClick}
class="flex items-center justify-center w-full h-full rounded-xl ${bgClass} border-0 transition-transform hover:scale-[1.02] active:scale-[0.98] text-sm lg:text-base font-bold text-white uppercase tracking-wider text-center"
class="flex items-center justify-center w-full h-full rounded-lg ${bgClass} transition-colors text-sm lg:text-base font-medium text-white uppercase tracking-wider text-center"
>
${title}
</button>
@@ -306,8 +306,7 @@ export class GameModeSelector extends LitElement {
return html`
<button
@click=${() => this.validateAndJoin(lobby)}
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]"
style="background-color: color-mix(in oklab, var(--frenchBlue) 75%, black)"
class="group relative w-full h-44 sm:h-full text-white uppercase rounded-2xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] bg-sky-950"
>
<!-- Image clipped separately so overflow-hidden doesn't block absolute children -->
<div
@@ -329,11 +328,11 @@ export class GameModeSelector extends LitElement {
class="absolute inset-x-2 top-2 flex items-start justify-between gap-2"
>
${modifierLabels.length > 0
? html`<div class="flex flex-col items-start gap-1">
? html`<div class="flex flex-col items-start gap-1 mt-[2px]">
${modifierLabels.map(
(label) =>
html`<span
class="px-2 py-0.5 rounded text-xs font-bold uppercase tracking-widest bg-teal-600 text-white shadow-[0_0_6px_rgba(13,148,136,0.35)]"
class="px-2 py-1 rounded text-xs font-bold uppercase tracking-widest bg-sky-600 text-white shadow-[0_0_6px_rgba(14,165,233,0.35)]"
>${label}</span
>`,
)}
@@ -343,7 +342,7 @@ export class GameModeSelector extends LitElement {
<span
class="text-xs font-bold tracking-widest ${timeDisplayUppercase
? "uppercase"
: "normal-case"} bg-sky-600 px-2.5 py-1 rounded"
: "normal-case"} bg-sky-600 text-white px-2 py-1 rounded"
>${timeDisplay}</span
>
</div>
+9 -5
View File
@@ -20,24 +20,28 @@ export class GameStartingModal extends LitElement {
: "opacity-0 invisible"}"
></div>
<div
class="fixed top-1/2 left-1/2 bg-zinc-800/70 p-6 rounded-xl z-[9999] shadow-[0_0_20px_rgba(0,0,0,0.5)] backdrop-blur-[5px] text-white w-[300px] text-center transition-all duration-300 -translate-x-1/2 ${isVisible
class="fixed top-1/2 left-1/2 bg-zinc-900/90 backdrop-blur-md border border-white/10 p-6 rounded-2xl z-[9999] shadow-2xl text-white w-[400px] text-center transition-all duration-300 -translate-x-1/2 ${isVisible
? "opacity-100 visible -translate-y-1/2"
: "opacity-0 invisible -translate-y-[48%]"}"
>
<div class="text-xl mt-5 mb-2.5 px-0">
<div
class="text-base font-medium tracking-wider uppercase text-white/40 mb-3"
>
© OpenFront and Contributors
</div>
<a
href="https://github.com/openfrontio/OpenFrontIO/blob/main/CREDITS.md"
target="_blank"
rel="noopener noreferrer"
class="block mt-2.5 mb-4 text-xl text-blue-400 no-underline transition-colors duration-200 hover:text-blue-300 hover:underline"
class="block mb-4 text-lg font-medium tracking-wider uppercase text-sky-400 no-underline transition-colors duration-200 hover:text-sky-300"
>${translateText("game_starting_modal.credits")}</a
>
<p class="my-0.5 text-sm">
<p class="text-base text-white/40 mb-4">
${translateText("game_starting_modal.code_license")}
</p>
<p class="text-base my-5 bg-black/30 p-2.5 rounded">
<p
class="text-xl font-medium tracking-wider text-white bg-white/5 border border-white/10 px-4 py-3 rounded-xl"
>
${translateText("game_starting_modal.title")}
</p>
</div>
+4 -4
View File
@@ -120,8 +120,8 @@ export class GutterAds extends LitElement {
return html`
<!-- Left Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
style="left: calc(50% - 10.5cm - 208px); top: calc(50% + 10px);"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% - var(--half-content) - 208px); top: calc(50% + 10px);"
>
<div
id="${this.leftContainerId}"
@@ -131,8 +131,8 @@ export class GutterAds extends LitElement {
<!-- Right Gutter Ad -->
<div
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center"
style="left: calc(50% + 10.5cm + 48px); top: calc(50% + 10px);"
class="hidden xl:flex fixed transform -translate-y-1/2 w-[160px] min-h-[600px] z-40 pointer-events-auto items-center justify-center xl:[--half-content:10.5cm] 2xl:[--half-content:12.5cm]"
style="left: calc(50% + var(--half-content) + 48px); top: calc(50% + 10px);"
>
<div
id="${this.rightContainerId}"
+1 -1
View File
@@ -328,7 +328,7 @@ export class HostLobbyModal extends BaseModal {
<!-- Player List / footer -->
<div class="p-6 pt-4 border-t border-white/10 bg-black/20 shrink-0">
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
@click=${this.startGame}
?disabled=${this.clients.length < 2}
>
+79 -47
View File
@@ -92,6 +92,8 @@ export class GhostStructureChangedEvent implements GameEvent {
constructor(public readonly ghostStructure: PlayerBuildableUnitType | null) {}
}
export class ConfirmGhostStructureEvent implements GameEvent {}
export class SwapRocketDirectionEvent implements GameEvent {
constructor(public readonly rocketDirectionUp: boolean) {}
}
@@ -339,6 +341,14 @@ export class InputHandler {
this.setGhostStructure(null);
}
if (
(e.code === "Enter" || e.code === "NumpadEnter") &&
this.uiState.ghostStructure !== null
) {
e.preventDefault();
this.eventBus.emit(new ConfirmGhostStructureEvent());
}
if (
[
this.keybinds.moveUp,
@@ -410,54 +420,11 @@ export class InputHandler {
this.eventBus.emit(new CenterCameraEvent());
}
if (e.code === this.keybinds.buildCity) {
// Two-phase build keybind matching: exact code match first, then digit/Numpad alias.
const matchedBuild = this.resolveBuildKeybind(e.code);
if (matchedBuild !== null) {
e.preventDefault();
this.setGhostStructure(UnitType.City);
}
if (e.code === this.keybinds.buildFactory) {
e.preventDefault();
this.setGhostStructure(UnitType.Factory);
}
if (e.code === this.keybinds.buildPort) {
e.preventDefault();
this.setGhostStructure(UnitType.Port);
}
if (e.code === this.keybinds.buildDefensePost) {
e.preventDefault();
this.setGhostStructure(UnitType.DefensePost);
}
if (e.code === this.keybinds.buildMissileSilo) {
e.preventDefault();
this.setGhostStructure(UnitType.MissileSilo);
}
if (e.code === this.keybinds.buildSamLauncher) {
e.preventDefault();
this.setGhostStructure(UnitType.SAMLauncher);
}
if (e.code === this.keybinds.buildAtomBomb) {
e.preventDefault();
this.setGhostStructure(UnitType.AtomBomb);
}
if (e.code === this.keybinds.buildHydrogenBomb) {
e.preventDefault();
this.setGhostStructure(UnitType.HydrogenBomb);
}
if (e.code === this.keybinds.buildWarship) {
e.preventDefault();
this.setGhostStructure(UnitType.Warship);
}
if (e.code === this.keybinds.buildMIRV) {
e.preventDefault();
this.setGhostStructure(UnitType.MIRV);
this.setGhostStructure(matchedBuild);
}
if (e.code === this.keybinds.swapDirection) {
@@ -616,6 +583,71 @@ export class InputHandler {
this.eventBus.emit(new GhostStructureChangedEvent(ghostStructure));
}
/**
* Extracts the digit character from KeyboardEvent.code.
* Codes look like "Digit0".."Digit9" (6 chars, digit at index 5) and
* "Numpad0".."Numpad9" (7 chars, digit at index 6). Returns null if not a digit key.
*/
private digitFromKeyCode(code: string): string | null {
if (
code?.length === 6 &&
code.startsWith("Digit") &&
/^[0-9]$/.test(code[5])
)
return code[5];
if (
code?.length === 7 &&
code.startsWith("Numpad") &&
/^[0-9]$/.test(code[6])
)
return code[6];
return null;
}
/** Strict equality only: used for first-pass exact KeyboardEvent.code match. */
private buildKeybindMatches(code: string, keybindValue: string): boolean {
return code === keybindValue;
}
/** Digit/Numpad alias match: used only when no exact match was found. */
private buildKeybindMatchesDigit(
code: string,
keybindValue: string,
): boolean {
const digit = this.digitFromKeyCode(code);
const bindDigit = this.digitFromKeyCode(keybindValue);
return digit !== null && bindDigit !== null && digit === bindDigit;
}
/**
* Resolves a keyup code to a build action: exact code match first, then digit/Numpad alias.
* Returns the UnitType to set as ghost, or null if no build keybind matched.
*/
private resolveBuildKeybind(code: string): PlayerBuildableUnitType | null {
const buildKeybinds: ReadonlyArray<{
key: string;
type: PlayerBuildableUnitType;
}> = [
{ key: "buildCity", type: UnitType.City },
{ key: "buildFactory", type: UnitType.Factory },
{ key: "buildPort", type: UnitType.Port },
{ key: "buildDefensePost", type: UnitType.DefensePost },
{ key: "buildMissileSilo", type: UnitType.MissileSilo },
{ key: "buildSamLauncher", type: UnitType.SAMLauncher },
{ key: "buildAtomBomb", type: UnitType.AtomBomb },
{ key: "buildHydrogenBomb", type: UnitType.HydrogenBomb },
{ key: "buildWarship", type: UnitType.Warship },
{ key: "buildMIRV", type: UnitType.MIRV },
];
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatches(code, this.keybinds[key])) return type;
}
for (const { key, type } of buildKeybinds) {
if (this.buildKeybindMatchesDigit(code, this.keybinds[key])) return type;
}
return null;
}
private getPinchDistance(): number {
const pointerEvents = Array.from(this.pointers.values());
const dx = pointerEvents[0].clientX - pointerEvents[1].clientX;
+3 -1
View File
@@ -130,6 +130,8 @@ export class JoinLobbyModal extends BaseModal {
.lobbyCreatorClientID=${hostClientID}
.currentClientID=${this.currentClientID}
.teamCount=${this.gameConfig?.playerTeams ?? 2}
.isPublicGame=${this.gameConfig?.gameType ===
GameType.Public}
.nationCount=${nationsConfigToSlider(
this.gameConfig?.nations ?? "default",
this.nationCount,
@@ -146,7 +148,7 @@ export class JoinLobbyModal extends BaseModal {
class="p-6 lg:p-6 border-t border-white/10 bg-black/20 shrink-0"
>
<button
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 disabled:opacity-50 disabled:cursor-not-allowed rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0 disabled:transform-none"
disabled
>
${translateText("private_lobby.joined_waiting")}
+2 -1
View File
@@ -354,7 +354,8 @@ export class LangSelector extends LitElement {
>
<img
id="lang-flag"
class="object-contain pointer-events-none"
class="object-contain pointer-events-none transition-all"
style="filter: grayscale(1) sepia(1) saturate(3) hue-rotate(190deg) brightness(0.85)"
style="width: 28px; height: 28px;"
src="/flags/${currentLang.svg}.svg"
alt="flag"
+3 -2
View File
@@ -113,7 +113,8 @@ export class LocalServer {
gameStartInfo: this.lobbyConfig.gameStartInfo,
turns: [],
lobbyCreatedAt: this.lobbyConfig.gameStartInfo.lobbyCreatedAt,
myClientID: this.clientID,
// Don't send myClientID for replays — viewer has no player identity.
myClientID: this.lobbyConfig.gameRecord ? undefined : this.clientID,
} satisfies ServerStartGameMessage);
}
@@ -127,7 +128,7 @@ export class LocalServer {
gameStartInfo: this.lobbyConfig.gameStartInfo!,
turns: this.turns,
lobbyCreatedAt: this.lobbyConfig.gameStartInfo!.lobbyCreatedAt,
myClientID: this.clientID,
myClientID: this.lobbyConfig.gameRecord ? undefined : this.clientID,
} satisfies ServerStartGameMessage);
}
if (clientMsg.type === "intent") {
+1 -1
View File
@@ -344,7 +344,7 @@ export class SinglePlayerModal extends BaseModal {
: null}
<button
@click=${this.startGame}
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-blue-600 hover:bg-blue-500 rounded-xl transition-all shadow-lg shadow-blue-900/20 hover:shadow-blue-900/40 hover:-translate-y-0.5 active:translate-y-0"
class="w-full py-4 text-sm font-bold text-white uppercase tracking-widest bg-sky-600 hover:bg-sky-500 rounded-xl transition-all shadow-lg shadow-sky-900/20 hover:shadow-sky-900/40 hover:-translate-y-0.5 active:translate-y-0"
>
${translateText("single_modal.start")}
</button>
+2 -2
View File
@@ -78,7 +78,7 @@ export class UsernameInput extends LitElement {
@input=${this.handleClanTagChange}
placeholder="${translateText("username.tag")}"
maxlength="5"
class="w-[6rem] text-xl font-bold text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
class="w-[6rem] text-xl font-medium tracking-wider text-center uppercase shrink-0 bg-transparent text-white placeholder-white/70 focus:placeholder-transparent border-0 border-b border-white/40 focus:outline-none focus:border-white/60"
/>
<input
type="text"
@@ -86,7 +86,7 @@ export class UsernameInput extends LitElement {
@input=${this.handleUsernameChange}
placeholder="${translateText("username.enter_username")}"
maxlength="${MAX_USERNAME_LENGTH}"
class="flex-1 min-w-0 border-0 text-2xl font-bold text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
class="flex-1 min-w-0 border-0 text-2xl font-medium tracking-wider text-left text-white placeholder-white/70 focus:outline-none focus:ring-0 overflow-x-auto whitespace-nowrap text-ellipsis pr-2 bg-transparent"
/>
</div>
${this.validationError
+6 -3
View File
@@ -36,9 +36,12 @@ export function getGameModeLabel(gameConfig: GameConfig): string {
// Humans vs Nations
if (playerTeams === HumansVsNations) {
return translateText("public_lobby.teams_hvn_detailed", {
num: maxPlayers ?? 0,
});
if (maxPlayers) {
return translateText("public_lobby.teams_hvn_detailed", {
num: maxPlayers,
});
}
return translateText("public_lobby.teams_hvn");
}
// Named team types (Duos, Trios, Quads)
+6 -6
View File
@@ -102,7 +102,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-play"
? "active"
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-play"
data-i18n="main.play"
></button>
@@ -111,7 +111,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-news"
? "active"
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-news"
data-i18n="main.news"
@click=${this._notifications.onNewsClick}
@@ -131,7 +131,7 @@ export class DesktopNavBar extends LitElement {
<button
class="nav-menu-item ${currentPage === "page-item-store"
? "active"
: ""} text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
: ""} text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-item-store"
data-i18n="main.store"
@click=${this._notifications.onStoreClick}
@@ -148,18 +148,18 @@ export class DesktopNavBar extends LitElement {
: ""}
</div>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-settings"
data-i18n="main.settings"
></button>
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-leaderboard"
data-i18n="main.leaderboard"
></button>
<div class="relative">
<button
class="nav-menu-item text-white/70 hover:text-blue-500 font-bold tracking-widest uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
class="nav-menu-item text-white/70 hover:text-blue-500 font-medium tracking-wider uppercase cursor-pointer transition-colors [&.active]:text-blue-500"
data-page="page-help"
data-i18n="main.help"
@click=${this._notifications.onHelpClick}
+24 -8
View File
@@ -35,11 +35,24 @@ export class LobbyTeamView extends LitElement {
@property({ attribute: "team-count" }) teamCount: TeamCountConfig = 2;
@property({ type: Function }) onKickPlayer?: (clientID: string) => void;
@property({ type: Number }) nationCount: number = 0;
@property({ type: Boolean }) isPublicGame: boolean = false;
private theme: PastelTheme = new PastelTheme();
@state() private showTeamColors: boolean = false;
private userSettings: UserSettings = new UserSettings();
/**
* For public HumansVsNations games, nation count always matches human count
* (server enforces this in NationCreation). For private games, the host
* controls the nation count via the slider.
*/
private get effectiveNationCount(): number {
if (this.isPublicGame && this.teamCount === HumansVsNations) {
return this.clients.length;
}
return this.nationCount;
}
willUpdate(changedProperties: Map<string, any>) {
// Recompute team preview when relevant properties change
// clients is updated from WebSocket lobby_info events
@@ -47,7 +60,8 @@ export class LobbyTeamView extends LitElement {
changedProperties.has("gameMode") ||
changedProperties.has("clients") ||
changedProperties.has("teamCount") ||
changedProperties.has("nationCount")
changedProperties.has("nationCount") ||
changedProperties.has("isPublicGame")
) {
const teamsList = this.getTeamList();
this.computeTeamPreview(teamsList);
@@ -67,8 +81,8 @@ export class LobbyTeamView extends LitElement {
? translateText("host_modal.player")
: translateText("host_modal.players")}
<span style="margin: 0 8px;"></span>
${this.nationCount}
${this.nationCount === 1
${this.effectiveNationCount}
${this.effectiveNationCount === 1
? translateText("host_modal.nation_player")
: translateText("host_modal.nation_players")}
</div>
@@ -179,12 +193,12 @@ export class LobbyTeamView extends LitElement {
private renderTeamCard(preview: TeamPreviewData, isEmpty: boolean = false) {
const displayCount =
preview.team === ColoredTeams.Nations
? this.nationCount
? this.effectiveNationCount
: preview.players.length;
const maxTeamSize =
preview.team === ColoredTeams.Nations
? this.nationCount
? this.effectiveNationCount
: this.teamMaxSize;
const teamLabel = getTranslatedPlayerTeamLabel(preview.team);
@@ -245,7 +259,7 @@ export class LobbyTeamView extends LitElement {
private getTeamList(): Team[] {
if (this.gameMode !== GameMode.Team) return [];
const playerCount = this.clients.length + this.nationCount;
const playerCount = this.clients.length + this.effectiveNationCount;
const config = this.teamCount;
if (config === HumansVsNations) {
@@ -309,7 +323,7 @@ export class LobbyTeamView extends LitElement {
const assignment = assignTeamsLobbyPreview(
players,
teams,
this.nationCount,
this.effectiveNationCount,
);
const buckets = new Map<Team, ClientInfo[]>();
for (const t of teams) buckets.set(t, []);
@@ -333,7 +347,9 @@ export class LobbyTeamView extends LitElement {
// Fallback: divide players across teams; guard against 0 and empty lobbies
this.teamMaxSize = Math.max(
1,
Math.ceil((this.clients.length + this.nationCount) / teams.length),
Math.ceil(
(this.clients.length + this.effectiveNationCount) / teams.length,
),
);
}
this.teamPreview = teams.map((t) => ({
+1 -1
View File
@@ -22,7 +22,7 @@ export class MainLayout extends LitElement {
class="relative [.in-game_&]:hidden flex flex-col flex-1 overflow-hidden w-full px-0 lg:px-[clamp(1.5rem,3vw,3rem)] pt-0 lg:pt-[clamp(0.75rem,1.5vw,1.5rem)] pb-0 lg:pb-[clamp(0.75rem,1.5vw,1.5rem)]"
>
<div
class="w-full lg:max-w-[20cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden"
class="w-full lg:max-w-[20cm] 2xl:max-w-[24cm] mx-auto flex flex-col flex-1 gap-0 lg:gap-[clamp(1.5rem,3vw,3rem)] overflow-y-auto overflow-x-hidden sm:px-4 lg:px-0"
>
${this._initialChildren}
</div>
+1 -1
View File
@@ -100,7 +100,7 @@ export class PlayPage extends LitElement {
</div>
<div
class="w-full pb-4 lg:pb-0 flex flex-col gap-4 lg:grid lg:grid-cols-[2fr_1fr] lg:gap-4"
class="w-full pb-4 lg:pb-0 flex flex-col gap-4 sm:-mx-4 sm:w-[calc(100%+2rem)] lg:mx-0 lg:w-full lg:grid lg:grid-cols-[2fr_1fr] lg:gap-4"
>
<!-- Mobile: spacer for fixed top bar -->
<div
@@ -14,7 +14,7 @@ export class OButton extends LitElement {
@property({ type: Boolean }) fill = false;
@property({ type: Boolean }) submit = false;
private static readonly BASE_CLASS =
"bg-blue-600 hover:bg-blue-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
"bg-sky-600 hover:bg-sky-700 text-white font-bold uppercase tracking-wider px-4 py-3 rounded-xl transition-all duration-300 transform hover:-translate-y-px outline-none border border-transparent text-center text-base lg:text-lg whitespace-normal break-words leading-tight overflow-hidden relative";
createRenderRoot() {
return this;
+11 -3
View File
@@ -1,22 +1,30 @@
export class AnimatedSprite {
private frameHeight: number;
private frameWidth: 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 = false,
private originX: number,
private originY: number,
) {
if ("height" in image) {
if (frameCount <= 0) {
throw new Error("Animated sprite should at least have one frame");
}
if ("height" in image && "width" in image) {
this.frameHeight = (image as HTMLImageElement | HTMLCanvasElement).height;
this.frameWidth = Math.floor(
(image as HTMLImageElement | HTMLCanvasElement).width / frameCount,
);
} else {
throw new Error("Image source must have a 'height' property.");
throw new Error(
"Image source must have 'width' and 'height' properties.",
);
}
}
+1 -16
View File
@@ -18,7 +18,6 @@ import { colorizeCanvas } from "./SpriteLoader";
type AnimatedSpriteConfig = {
url: string;
frameWidth: number;
frameCount: number;
frameDuration: number; // ms per frame
looping?: boolean;
@@ -29,7 +28,6 @@ type AnimatedSpriteConfig = {
const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
[FxType.MiniFire]: {
url: miniFire,
frameWidth: 7,
frameCount: 6,
frameDuration: 100,
looping: true,
@@ -38,7 +36,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.MiniSmoke]: {
url: miniSmoke,
frameWidth: 11,
frameCount: 4,
frameDuration: 120,
looping: true,
@@ -47,7 +44,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.MiniBigSmoke]: {
url: miniBigSmoke,
frameWidth: 24,
frameCount: 5,
frameDuration: 120,
looping: true,
@@ -56,8 +52,7 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.MiniSmokeAndFire]: {
url: miniSmokeAndFire,
frameWidth: 24,
frameCount: 5,
frameCount: 6,
frameDuration: 120,
looping: true,
originX: 9,
@@ -65,7 +60,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.MiniExplosion]: {
url: miniExplosion,
frameWidth: 13,
frameCount: 4,
frameDuration: 70,
looping: false,
@@ -74,7 +68,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.Dust]: {
url: dust,
frameWidth: 9,
frameCount: 3,
frameDuration: 100,
looping: false,
@@ -83,7 +76,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.UnitExplosion]: {
url: unitExplosion,
frameWidth: 19,
frameCount: 4,
frameDuration: 70,
looping: false,
@@ -92,7 +84,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.BuildingExplosion]: {
url: buildingExplosion,
frameWidth: 17,
frameCount: 10,
frameDuration: 70,
looping: false,
@@ -101,7 +92,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.SinkingShip]: {
url: sinkingShip,
frameWidth: 16,
frameCount: 14,
frameDuration: 90,
looping: false,
@@ -110,7 +100,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.Nuke]: {
url: nuke,
frameWidth: 60,
frameCount: 9,
frameDuration: 70,
looping: false,
@@ -119,7 +108,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.SAMExplosion]: {
url: SAMExplosion,
frameWidth: 48,
frameCount: 9,
frameDuration: 70,
looping: false,
@@ -128,7 +116,6 @@ const ANIMATED_SPRITE_CONFIG: Partial<Record<FxType, AnimatedSpriteConfig>> = {
},
[FxType.Conquest]: {
url: conquestSword,
frameWidth: 21,
frameCount: 10,
frameDuration: 90,
looping: false,
@@ -181,7 +168,6 @@ export class AnimatedSpriteLoader {
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
@@ -229,7 +215,6 @@ export class AnimatedSpriteLoader {
return new AnimatedSprite(
image,
config.frameWidth,
config.frameCount,
config.frameDuration,
config.looping ?? true,
+7 -7
View File
@@ -221,7 +221,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
@@ -254,7 +254,7 @@ export class AttacksDisplay extends LitElement implements Layer {
/>`,
onClick: () => this.handleRetaliate(attack),
className:
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 rounded-lg px-1.5 py-1 border border-red-700/50",
"ml-auto inline-flex items-center justify-center cursor-pointer bg-red-900/50 hover:bg-red-800/70 sm:rounded-lg px-1.5 py-1 border border-red-700/50",
translate: false,
})
: ""}
@@ -269,7 +269,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingAttacks.map(
(attack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
@@ -311,7 +311,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingLandAttacks.map(
(landAttack) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`<img
@@ -367,7 +367,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.outgoingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
@@ -403,7 +403,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return this.incomingBoats.map(
(boat) => html`
<div
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs min-[1200px]:rounded-lg sm:rounded-r-lg px-1.5 py-0.5 overflow-hidden"
class="flex items-center gap-0.5 w-full bg-gray-800/70 backdrop-blur-xs sm:rounded-lg px-1.5 py-0.5 overflow-hidden"
>
${this.renderButton({
content: html`${this.renderBoatIcon(boat)}
@@ -441,7 +441,7 @@ export class AttacksDisplay extends LitElement implements Layer {
return html`
<div
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 sm:grid-cols-1 gap-1 text-white text-sm lg:text-base"
class="w-full mb-1 mt-1 sm:mt-0 pointer-events-auto grid grid-cols-2 gap-1 text-white text-sm lg:text-base"
>
${this.renderOutgoingAttacks()} ${this.renderOutgoingLandAttacks()}
${this.renderBoats()} ${this.renderIncomingAttacks()}
+162 -172
View File
@@ -40,9 +40,6 @@ export class ControlPanel extends LitElement implements Layer {
@state()
private _attackingTroops: number = 0;
@state()
private _touchDragging = false;
private _troopRateIsIncreasing: boolean = true;
private _lastTroopIncreaseRate: number;
@@ -127,73 +124,13 @@ export class ControlPanel extends LitElement implements Layer {
this.requestUpdate();
}
private _outsideTouchHandler: ((ev: Event) => void) | null = null;
private handleAttackTouchStart(e: TouchEvent) {
e.preventDefault();
e.stopPropagation();
if (this._touchDragging) {
this.closeAttackBar();
return;
}
this._touchDragging = true;
setTimeout(() => {
this._outsideTouchHandler = () => {
this.closeAttackBar();
};
document.addEventListener("touchstart", this._outsideTouchHandler);
}, 0);
}
private closeAttackBar() {
this._touchDragging = false;
if (this._outsideTouchHandler) {
document.removeEventListener("touchstart", this._outsideTouchHandler);
this._outsideTouchHandler = null;
}
}
private handleBarTouch(e: TouchEvent) {
e.preventDefault();
e.stopPropagation();
this.setRatioFromTouch(e.touches[0]);
const onMove = (ev: TouchEvent) => {
ev.preventDefault();
this.setRatioFromTouch(ev.touches[0]);
};
const onEnd = () => {
document.removeEventListener("touchmove", onMove);
document.removeEventListener("touchend", onEnd);
};
document.addEventListener("touchmove", onMove, { passive: false });
document.addEventListener("touchend", onEnd);
}
private setRatioFromTouch(touch: Touch) {
const barEl = this.querySelector(".attack-drag-bar");
if (!barEl) return;
const rect = barEl.getBoundingClientRect();
const ratio = (rect.bottom - touch.clientY) / (rect.bottom - rect.top);
this.attackRatio =
Math.round(Math.max(1, Math.min(100, ratio * 100))) / 100;
this.onAttackRatioChange(this.attackRatio);
}
private handleRatioSliderInput(e: Event) {
const value = Number((e.target as HTMLInputElement).value);
this.attackRatio = value / 100;
this.onAttackRatioChange(this.attackRatio);
}
private renderTroopBar() {
private calculateTroopBar(): { greenPercent: number; orangePercent: number } {
const base = Math.max(this._maxTroops, 1);
const greenPercentRaw = (this._troops / base) * 100;
const orangePercentRaw = (this._attackingTroops / base) * 100;
@@ -204,9 +141,14 @@ export class ControlPanel extends LitElement implements Layer {
Math.min(100 - greenPercent, orangePercentRaw),
);
return { greenPercent, orangePercent };
}
private renderMobileTroopBar() {
const { greenPercent, orangePercent } = this.calculateTroopBar();
return html`
<div
class="w-full h-6 lg:h-8 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="h-full flex">
${greenPercent > 0
@@ -223,7 +165,7 @@ export class ControlPanel extends LitElement implements Layer {
: ""}
</div>
<div
class="absolute inset-0 flex items-center justify-between px-1.5 lg:px-2 text-xs lg:text-sm font-bold leading-none pointer-events-none"
class="absolute inset-0 flex items-center justify-between px-1.5 text-xs font-bold leading-none pointer-events-none"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
@@ -243,10 +185,10 @@ export class ControlPanel extends LitElement implements Layer {
aria-hidden="true"
width="12"
height="12"
class="lg:w-4 lg:h-4 brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
class="brightness-0 invert drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
/>
<span
class="text-[10px] lg:text-xs font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
class="text-[10px] font-bold drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)] ${this
._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
@@ -257,127 +199,175 @@ export class ControlPanel extends LitElement implements Layer {
`;
}
render() {
private renderDesktopTroopBar() {
const { greenPercent, orangePercent } = this.calculateTroopBar();
return html`
<div
class="relative pointer-events-auto ${this._isVisible
? "relative z-[60] w-full lg:max-w-[400px] text-sm lg:text-base bg-gray-800/70 p-1.5 pr-2 lg:p-5 shadow-lg sm:rounded-tr-lg min-[1200px]:rounded-lg backdrop-blur-xs"
: "hidden"}"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
class="w-full h-6 border border-gray-600 rounded-md bg-gray-900/60 overflow-hidden relative"
>
<div class="flex gap-2 lg:gap-3 items-center">
<!-- Gold: 1/4 -->
<div
class="flex items-center justify-center p-1 lg:p-1.5 lg:gap-1 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs lg:text-sm w-1/5 lg:w-auto shrink-0"
translate="no"
>
<img
src=${goldCoinIcon}
width="13"
height="13"
class="lg:w-4 lg:h-4"
/>
<span class="px-0.5">${renderNumber(this._gold)}</span>
</div>
<!-- Troop bar: 2/4 -->
<div class="w-3/5 lg:flex-1">${this.renderTroopBar()}</div>
<!-- Attack ratio: 1/4 -->
<div
class="relative w-1/5 shrink-0 flex items-center justify-center gap-1 cursor-pointer lg:hidden"
@touchstart=${(e: TouchEvent) => this.handleAttackTouchStart(e)}
>
<div class="flex flex-col items-center w-10 shrink-0">
<div
class="flex items-center gap-0.5 text-white text-xs font-bold tabular-nums"
translate="no"
>
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="10"
height="10"
class="brightness-0 invert sepia saturate-[10000%] hue-rotate-[0deg]"
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
/>
${(this.attackRatio * 100).toFixed(0)}%
</div>
<div class="text-[10px] text-red-400 tabular-nums" translate="no">
(${renderTroops(
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})
</div>
</div>
<!-- Small red vertical bar indicator -->
<div class="shrink-0">
<div
class="w-1.5 h-8 bg-white/20 rounded-full relative overflow-hidden"
>
<div
class="absolute bottom-0 w-full bg-red-500 rounded-full transition-all duration-200"
style="height: ${this.attackRatio * 100}%"
></div>
</div>
</div>
</div>
<div class="h-full flex">
${greenPercent > 0
? html`<div
class="h-full bg-green-500 transition-[width] duration-200"
style="width: ${greenPercent}%;"
></div>`
: ""}
${orangePercent > 0
? html`<div
class="h-full bg-orange-400 transition-[width] duration-200"
style="width: ${orangePercent}%;"
></div>`
: ""}
</div>
${this._touchDragging
? html`
<div
class="absolute bottom-full right-0 flex flex-col items-center pointer-events-auto z-[10000] bg-gray-800/70 backdrop-blur-xs rounded-tl-lg sm:rounded-lg p-2 w-12"
style="height: 50vh;"
@touchstart=${(e: TouchEvent) => this.handleBarTouch(e)}
>
<span class="text-red-400 text-sm font-bold mb-1" translate="no"
>${(this.attackRatio * 100).toFixed(0)}%</span
>
<div
class="attack-drag-bar flex-1 w-3 bg-white/20 rounded-full relative overflow-hidden"
>
<div
class="absolute bottom-0 w-full bg-red-500 rounded-full"
style="height: ${this.attackRatio * 100}%"
></div>
</div>
</div>
`
: ""}
<!-- Attack ratio bar (desktop, always visible) -->
<div class="hidden lg:block mt-2">
<div
class="flex items-center justify-between text-sm font-bold mb-1"
translate="no"
<div
class="absolute inset-0 flex items-center justify-start px-1.5 text-xs font-bold leading-none pointer-events-none gap-0.5"
translate="no"
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._troops)}</span
>
<span class="text-white flex items-center gap-1"
><img
src=${swordIcon}
alt=""
aria-hidden="true"
width="14"
height="14"
style="filter: brightness(0) saturate(100%) invert(36%) sepia(95%) saturate(5500%) hue-rotate(350deg) brightness(95%) contrast(95%);"
/>Attack Ratio</span
>
<span class="text-white tabular-nums"
>${(this.attackRatio * 100).toFixed(0)}%
(${renderTroops(
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})</span
>
</div>
<span class="text-white/60 drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>/</span
>
<span class="text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.8)]"
>${renderTroops(this._maxTroops)}</span
>
</div>
</div>
`;
}
private renderDesktop() {
return html`
<!-- Row 1: troop rate | troop bar | gold -->
<div class="flex gap-1.5 items-center mb-1.5">
<!-- Troop rate -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md font-bold text-xs p-1 w-[5.5rem] ${this
._troopRateIsIncreasing
? "border-green-400"
: "border-orange-400"}"
translate="no"
>
<img
src=${soldierIcon}
alt=""
aria-hidden="true"
width="13"
height="13"
class="shrink-0"
style="filter: ${this._troopRateIsIncreasing
? "brightness(0) saturate(100%) invert(74%) sepia(44%) saturate(500%) hue-rotate(83deg) brightness(103%)"
: "brightness(0) saturate(100%) invert(65%) sepia(60%) saturate(600%) hue-rotate(330deg) brightness(105%)"}"
/>
<span
class="text-xs font-bold tabular-nums ${this._troopRateIsIncreasing
? "text-green-400"
: "text-orange-400"}"
>+${renderTroops(this.troopRate)}/s</span
>
</div>
<!-- Troop bar -->
<div class="flex-1">${this.renderDesktopTroopBar()}</div>
<!-- Gold -->
<div
class="flex items-center gap-1 shrink-0 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs p-1 w-[4.5rem]"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" class="shrink-0" />
<span class="tabular-nums">${renderNumber(this._gold)}</span>
</div>
</div>
<!-- Row 2: attack ratio | slider -->
<div class="flex items-center gap-2" translate="no">
<div
class="flex items-center gap-1 shrink-0 border border-gray-600 rounded-md p-1 text-xs font-bold text-white cursor-pointer w-[7rem]"
>
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="12"
height="12"
style="filter: brightness(0) invert(1);"
/>
<span
>${(this.attackRatio * 100).toFixed(0)}%
(${renderTroops(
(this.game?.myPlayer()?.troops() ?? 0) * this.attackRatio,
)})</span
>
</div>
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
class="flex-1 h-2 accent-blue-500 cursor-pointer"
/>
</div>
`;
}
private renderMobile() {
return html`
<div class="flex gap-2 items-center">
<!-- Gold -->
<div
class="flex items-center justify-center p-1 gap-0.5 border rounded-md border-yellow-400 font-bold text-yellow-400 text-xs w-1/5 shrink-0"
translate="no"
>
<img src=${goldCoinIcon} width="13" height="13" />
<span class="px-0.5">${renderNumber(this._gold)}</span>
</div>
<!-- Troop bar -->
<div class="w-[40%] shrink-0 flex items-center">
${this.renderMobileTroopBar()}
</div>
<!-- Sword + % label -->
<div class="flex flex-col items-center shrink-0 gap-0.5" translate="no">
<img
src=${swordIcon}
alt=""
aria-hidden="true"
width="10"
height="10"
style="filter: brightness(0) invert(1);"
/>
<span class="text-white text-xs font-bold tabular-nums"
>${(this.attackRatio * 100).toFixed(0)}%</span
>
</div>
<!-- Attack ratio slider -->
<div class="flex-1" translate="no">
<input
type="range"
min="1"
max="100"
.value=${String(Math.round(this.attackRatio * 100))}
@input=${(e: Event) => this.handleRatioSliderInput(e)}
class="w-full h-2 accent-red-500 cursor-pointer"
class="w-full h-1.5 accent-blue-500 cursor-pointer"
/>
</div>
</div>
`;
}
render() {
return html`
<div
class="relative pointer-events-auto ${this._isVisible
? "relative w-full text-sm px-2 py-1.5"
: "hidden"}"
@contextmenu=${(e: MouseEvent) => e.preventDefault()}
>
<div class="lg:hidden">${this.renderMobile()}</div>
<div class="hidden lg:block">${this.renderDesktop()}</div>
</div>
`;
}
createRenderRoot() {
return this; // Disable shadow DOM to allow Tailwind styles
}
+4 -6
View File
@@ -794,9 +794,7 @@ export class EventsDisplay extends LitElement implements Layer {
<!-- Events Toggle (when hidden) -->
${this._hidden
? html`
<div
class="relative w-fit min-[1200px]:bottom-4 min-[1200px]:right-4 z-50"
>
<div class="relative w-fit z-50">
${this.renderButton({
content: html`
<span class="flex items-center gap-2">
@@ -811,18 +809,18 @@ export class EventsDisplay extends LitElement implements Layer {
`,
onClick: this.toggleHidden,
className:
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg max-sm:rounded-tr-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
"text-white cursor-pointer pointer-events-auto w-fit p-2 lg:p-3 min-[1200px]:rounded-lg sm:rounded-tl-lg bg-gray-800/70 backdrop-blur-xs",
})}
</div>
`
: html`
<!-- Main Events Display -->
<div
class="relative w-full min-[1200px]:bottom-4 min-[1200px]:right-4 z-50 min-[1200px]:w-96 backdrop-blur-sm"
class="relative w-full z-50 min-[1200px]:w-96 backdrop-blur-sm"
>
<!-- Button Bar -->
<div
class="w-full p-2 lg:p-3 bg-gray-800/70 min-[1200px]:rounded-t-lg sm:rounded-tl-lg"
class="w-full p-2 lg:p-3 bg-gray-800/70 sm:rounded-tl-lg min-[1200px]:rounded-t-lg"
>
<div class="flex justify-between items-center gap-3">
<div class="flex gap-4">
+2 -1
View File
@@ -31,7 +31,8 @@ export class MultiTabModal extends LitElement implements Layer {
if (
this.game.inSpawnPhase() ||
this.game.config().gameConfig().gameType === GameType.Singleplayer ||
this.game.config().serverConfig().env() === GameEnv.Dev
this.game.config().serverConfig().env() === GameEnv.Dev ||
this.game.config().isReplay()
) {
return;
}
@@ -17,6 +17,7 @@ import { TileRef } from "../../../core/game/GameMap";
import { GameUpdateType } from "../../../core/game/GameUpdates";
import { GameView, UnitView } from "../../../core/game/GameView";
import {
ConfirmGhostStructureEvent,
GhostStructureChangedEvent,
MouseMoveEvent,
MouseUpEvent,
@@ -43,6 +44,11 @@ import {
} from "./StructureDrawingUtils";
import bitmapFont from "/fonts/round_6x6_modified.xml?url";
/** True for nuke types (AtomBomb, HydrogenBomb): ghost is preserved after placement so user can place multiple or keep selection (Enter/key confirm). */
export function shouldPreserveGhostAfterBuild(unitType: UnitType): boolean {
return unitType === UnitType.AtomBomb || unitType === UnitType.HydrogenBomb;
}
extend([a11yPlugin]);
class StructureRenderInfo {
@@ -92,6 +98,7 @@ export class StructureIconsLayer implements Layer {
> = new Map(Structures.types.map((type) => [type, { visible: true }]));
private lastGhostQueryAt: number;
private visibilityStateDirty = true;
private pendingConfirm: MouseUpEvent | null = null;
private hasHiddenStructure = false;
potentialUpgrade: StructureRenderInfo | undefined;
@@ -171,7 +178,12 @@ export class StructureIconsLayer implements Layer {
);
this.eventBus.on(MouseMoveEvent, (e) => this.moveGhost(e));
this.eventBus.on(MouseUpEvent, (e) => this.createStructure(e));
this.eventBus.on(MouseUpEvent, (e) => this.requestConfirmStructure(e));
this.eventBus.on(ConfirmGhostStructureEvent, () =>
this.requestConfirmStructure(
new MouseUpEvent(this.mousePos.x, this.mousePos.y),
),
);
window.addEventListener("resize", () => this.resizeCanvas());
await this.setupRenderer();
@@ -307,7 +319,10 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.container.filters = [];
}
if (!this.ghostUnit) return;
if (!this.ghostUnit) {
this.pendingConfirm = null;
return;
}
const unit = buildables.find(
(u) => u.type === this.ghostUnit!.buildableUnit.type,
@@ -322,6 +337,7 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.container.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(255, 0, 0, 1)" }),
];
this.pendingConfirm = null;
return;
}
@@ -369,6 +385,14 @@ export class StructureIconsLayer implements Layer {
: Math.min(1, scale / ICON_SCALE_FACTOR_ZOOMED_OUT);
this.ghostUnit.container.scale.set(s);
this.ghostUnit.range?.scale.set(this.transformHandler.scale);
if (this.pendingConfirm !== null) {
const ev = this.pendingConfirm;
this.pendingConfirm = null;
if (this.isGhostReadyForConfirm()) {
this.createStructure(ev);
}
}
});
}
@@ -399,6 +423,30 @@ export class StructureIconsLayer implements Layer {
.fill({ color: 0x000000, alpha: 0.65 });
}
/**
* True when the ghost exists and buildableUnit has been refreshed (canBuild or canUpgrade set).
* Used to avoid running createStructure before renderGhost's async buildables() has updated the ghost.
*/
private isGhostReadyForConfirm(): boolean {
if (!this.ghostUnit) return false;
const bu = this.ghostUnit.buildableUnit;
return bu.canBuild !== false || bu.canUpgrade !== false;
}
/**
* Request confirm (place/upgrade): run createStructure now if ghost is ready, otherwise defer until
* renderGhost's buildables() callback has updated the ghost. Shared by Enter (ConfirmGhostStructureEvent)
* and mouse click (MouseUpEvent) so numpad-select-then-confirm works.
*/
private requestConfirmStructure(e: MouseUpEvent): void {
if (!this.ghostUnit && !this.uiState.ghostStructure) return;
if (this.isGhostReadyForConfirm()) {
this.createStructure(e);
} else {
this.pendingConfirm = e;
}
}
private createStructure(e: MouseUpEvent) {
if (!this.ghostUnit) return;
if (
@@ -420,6 +468,7 @@ export class StructureIconsLayer implements Layer {
this.ghostUnit.buildableUnit.type,
),
);
this.removeGhostStructure();
} else if (this.ghostUnit.buildableUnit.canBuild) {
const unitType = this.ghostUnit.buildableUnit.type;
const rocketDirectionUp =
@@ -433,8 +482,12 @@ export class StructureIconsLayer implements Layer {
rocketDirectionUp,
),
);
if (!shouldPreserveGhostAfterBuild(unitType)) {
this.removeGhostStructure();
}
} else {
this.removeGhostStructure();
}
this.removeGhostStructure();
}
private moveGhost(e: MouseMoveEvent) {
@@ -489,6 +542,7 @@ export class StructureIconsLayer implements Layer {
}
private clearGhostStructure() {
this.pendingConfirm = null;
if (this.ghostUnit) {
this.ghostUnit.container.destroy();
this.ghostUnit.range?.destroy();
+82 -86
View File
@@ -126,86 +126,80 @@ export class UnitDisplay extends LitElement implements Layer {
}
return html`
<div
class="hidden min-[1200px]:flex fixed bottom-4 left-1/2 transform -translate-x-1/2 z-[1100] 2xl:flex-row xl:flex-col min-[1200px]:flex-col 2xl:gap-5 xl:gap-2 min-[1200px]:gap-2 justify-center items-center"
>
<div class="bg-gray-800/70 backdrop-blur-xs rounded-lg p-0.5">
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1 w-fit">
${this.renderUnitItem(
cityIcon,
this._cities,
UnitType.City,
"city",
this.keybinds["buildCity"]?.key ?? "1",
)}
${this.renderUnitItem(
factoryIcon,
this._factories,
UnitType.Factory,
"factory",
this.keybinds["buildFactory"]?.key ?? "2",
)}
${this.renderUnitItem(
portIcon,
this._port,
UnitType.Port,
"port",
this.keybinds["buildPort"]?.key ?? "3",
)}
${this.renderUnitItem(
defensePostIcon,
this._defensePost,
UnitType.DefensePost,
"defense_post",
this.keybinds["buildDefensePost"]?.key ?? "4",
)}
${this.renderUnitItem(
missileSiloIcon,
this._missileSilo,
UnitType.MissileSilo,
"missile_silo",
this.keybinds["buildMissileSilo"]?.key ?? "5",
)}
${this.renderUnitItem(
samLauncherIcon,
this._samLauncher,
UnitType.SAMLauncher,
"sam_launcher",
this.keybinds["buildSamLauncher"]?.key ?? "6",
)}
</div>
</div>
<div class="bg-gray-800/70 backdrop-blur-xs rounded-lg p-0.5 w-fit">
<div class="grid grid-rows-1 auto-cols-max grid-flow-col gap-1">
${this.renderUnitItem(
warshipIcon,
this._warships,
UnitType.Warship,
"warship",
this.keybinds["buildWarship"]?.key ?? "7",
)}
${this.renderUnitItem(
atomBombIcon,
null,
UnitType.AtomBomb,
"atom_bomb",
this.keybinds["buildAtomBomb"]?.key ?? "8",
)}
${this.renderUnitItem(
hydrogenBombIcon,
null,
UnitType.HydrogenBomb,
"hydrogen_bomb",
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
)}
${this.renderUnitItem(
mirvIcon,
null,
UnitType.MIRV,
"mirv",
this.keybinds["buildMIRV"]?.key ?? "0",
)}
</div>
<div class="border-t border-white/10 p-0.5 w-full">
<div
class="grid grid-rows-1 auto-cols-max grid-flow-col gap-0.5 w-fit mx-auto"
>
${this.renderUnitItem(
cityIcon,
this._cities,
UnitType.City,
"city",
this.keybinds["buildCity"]?.key ?? "1",
)}
${this.renderUnitItem(
factoryIcon,
this._factories,
UnitType.Factory,
"factory",
this.keybinds["buildFactory"]?.key ?? "2",
)}
${this.renderUnitItem(
portIcon,
this._port,
UnitType.Port,
"port",
this.keybinds["buildPort"]?.key ?? "3",
)}
${this.renderUnitItem(
defensePostIcon,
this._defensePost,
UnitType.DefensePost,
"defense_post",
this.keybinds["buildDefensePost"]?.key ?? "4",
)}
${this.renderUnitItem(
missileSiloIcon,
this._missileSilo,
UnitType.MissileSilo,
"missile_silo",
this.keybinds["buildMissileSilo"]?.key ?? "5",
)}
${this.renderUnitItem(
samLauncherIcon,
this._samLauncher,
UnitType.SAMLauncher,
"sam_launcher",
this.keybinds["buildSamLauncher"]?.key ?? "6",
)}
${this.renderUnitItem(
warshipIcon,
this._warships,
UnitType.Warship,
"warship",
this.keybinds["buildWarship"]?.key ?? "7",
)}
${this.renderUnitItem(
atomBombIcon,
null,
UnitType.AtomBomb,
"atom_bomb",
this.keybinds["buildAtomBomb"]?.key ?? "8",
)}
${this.renderUnitItem(
hydrogenBombIcon,
null,
UnitType.HydrogenBomb,
"hydrogen_bomb",
this.keybinds["buildHydrogenBomb"]?.key ?? "9",
)}
${this.renderUnitItem(
mirvIcon,
null,
UnitType.MIRV,
"mirv",
this.keybinds["buildMIRV"]?.key ?? "0",
)}
</div>
</div>
`;
@@ -243,7 +237,7 @@ export class UnitDisplay extends LitElement implements Layer {
${hovered
? html`
<div
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-20 shadow-lg pointer-events-none"
class="absolute -top-[250%] left-1/2 -translate-x-1/2 text-gray-200 text-center w-max text-xs bg-gray-800/90 backdrop-blur-xs rounded-sm p-1 z-[100] shadow-lg pointer-events-none"
>
<div class="font-bold text-sm mb-1">
${translateText(
@@ -265,7 +259,7 @@ export class UnitDisplay extends LitElement implements Layer {
<div
class="${this.canBuild(unitType)
? ""
: "opacity-40"} border border-slate-500 rounded-sm pr-2 pb-1 flex items-center gap-2 cursor-pointer
: "opacity-40"} border border-slate-500 rounded-sm px-0.5 pb-0.5 flex items-center gap-0.5 cursor-pointer
${selected ? "hover:bg-gray-400/10" : "hover:bg-gray-800"}
rounded-sm text-white ${selected ? "bg-slate-400/20" : ""}"
@click=${() => {
@@ -299,12 +293,14 @@ export class UnitDisplay extends LitElement implements Layer {
@mouseleave=${() =>
this.eventBus?.emit(new ToggleStructureEvent(null))}
>
${html`<div class="ml-1 text-xs relative -top-1.5 text-gray-400">
${html`<div class="ml-0.5 text-[10px] relative -top-1 text-gray-400">
${displayHotkey}
</div>`}
<div class="flex items-center gap-1 pt-1">
<img src=${icon} alt=${structureKey} class="align-middle size-6" />
${number !== null ? renderNumber(number) : null}
<div class="flex items-center gap-0.5 pt-0.5">
<img src=${icon} alt=${structureKey} class="align-middle size-5" />
${number !== null
? html`<span class="text-xs">${renderNumber(number)}</span>`
: null}
</div>
</div>
</div>
+1 -1
View File
@@ -33,7 +33,7 @@ import { simpleHash } from "./Util";
export async function createGameRunner(
gameStart: GameStartInfo,
clientID: ClientID,
clientID: ClientID | undefined,
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
): Promise<GameRunner> {
+3 -2
View File
@@ -552,8 +552,9 @@ export const ServerStartGameMessageSchema = z.object({
turns: TurnSchema.array(),
gameStartInfo: GameStartInfoSchema,
lobbyCreatedAt: z.number(),
// The clientID assigned to this connection by the server
myClientID: ID,
// The clientID assigned to this connection by the server.
// Absent for replays where the viewer has no player identity.
myClientID: ID.optional(),
});
export const ServerDesyncSchema = z.object({
+1 -1
View File
@@ -36,7 +36,7 @@ export class Executor {
constructor(
private mg: Game,
private gameID: GameID,
private clientID: ClientID,
private clientID: ClientID | undefined,
) {
// Add one to avoid id collisions with bots.
this.random = new PseudoRandom(simpleHash(gameID) + 1);
+4
View File
@@ -137,6 +137,8 @@ export enum GameMapType {
Alps = "Alps",
NileDelta = "Nile Delta",
Arctic = "Arctic",
SanFrancisco = "San Francisco",
Aegean = "Aegean",
}
export type GameMapName = keyof typeof GameMapType;
@@ -186,6 +188,8 @@ export const mapCategories: Record<string, GameMapType[]> = {
GameMapType.Alps,
GameMapType.NileDelta,
GameMapType.Arctic,
GameMapType.SanFrancisco,
GameMapType.Aegean,
],
fantasy: [
GameMapType.Pangaea,
+5 -3
View File
@@ -657,7 +657,7 @@ export class GameView implements GameMap {
public worker: WorkerClient,
private _config: Config,
private _mapData: TerrainMapData,
private _myClientID: ClientID,
private _myClientID: ClientID | undefined,
private _myUsername: string,
private _gameID: GameID,
private humans: Player[],
@@ -785,7 +785,9 @@ export class GameView implements GameMap {
}
});
this._myPlayer ??= this.playerByClientID(this._myClientID);
if (this._myClientID) {
this._myPlayer ??= this.playerByClientID(this._myClientID);
}
for (const unit of this._units.values()) {
unit._wasUpdated = false;
@@ -1103,7 +1105,7 @@ export class GameView implements GameMap {
);
}
myClientID(): ClientID {
myClientID(): ClientID | undefined {
return this._myClientID;
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class WorkerClient {
constructor(
private gameStartInfo: GameStartInfo,
private clientID: ClientID,
private clientID: ClientID | undefined,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url), {
type: "module",
+1 -1
View File
@@ -39,7 +39,7 @@ interface BaseWorkerMessage {
export interface InitMessage extends BaseWorkerMessage {
type: "init";
gameStartInfo: GameStartInfo;
clientID: ClientID;
clientID: ClientID | undefined;
}
export interface TurnMessage extends BaseWorkerMessage {
+2
View File
@@ -80,6 +80,8 @@ const frequency: Partial<Record<GameMapName, number>> = {
Alps: 4,
NileDelta: 4,
Arctic: 6,
SanFrancisco: 3,
Aegean: 6,
};
const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
+188 -1
View File
@@ -1,5 +1,11 @@
import { AutoUpgradeEvent, InputHandler } from "../src/client/InputHandler";
import {
AutoUpgradeEvent,
ConfirmGhostStructureEvent,
InputHandler,
} from "../src/client/InputHandler";
import { UIState } from "../src/client/graphics/UIState";
import { EventBus } from "../src/core/EventBus";
import { UnitType } from "../src/core/game/Game";
class MockPointerEvent {
button: number;
@@ -462,4 +468,185 @@ describe("InputHandler AutoUpgrade", () => {
spy.mockRestore();
});
});
describe("Enter key confirm ghost structure", () => {
let uiState: UIState;
beforeEach(() => {
localStorage.removeItem("settings.keybinds");
uiState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler.initialize();
});
test("emits ConfirmGhostStructureEvent on Enter when ghost structure is set", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
uiState.ghostStructure = UnitType.City;
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" }));
expect(mockEmit).toHaveBeenCalledWith(
expect.any(ConfirmGhostStructureEvent),
);
});
test("emits ConfirmGhostStructureEvent on NumpadEnter when ghost structure is set", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
uiState.ghostStructure = UnitType.Factory;
window.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadEnter" }),
);
expect(mockEmit).toHaveBeenCalledWith(
expect.any(ConfirmGhostStructureEvent),
);
});
test("does not emit ConfirmGhostStructureEvent on Enter when no ghost structure", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
expect(uiState.ghostStructure).toBeNull();
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" }));
const confirmCalls = mockEmit.mock.calls.filter(
(call) => call[0] instanceof ConfirmGhostStructureEvent,
);
expect(confirmCalls).toHaveLength(0);
});
});
describe("Numpad number keys for build keybinds", () => {
beforeEach(() => {
localStorage.removeItem("settings.keybinds");
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler.initialize();
});
test("Numpad1 sets ghost structure to City when buildCity is Digit1", () => {
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
});
test("Numpad5 sets ghost structure to MissileSilo when buildMissileSilo is Digit5", () => {
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad5", key: "5" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MissileSilo);
});
test("Numpad0 sets ghost structure to MIRV when buildMIRV is Digit0", () => {
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad0", key: "0" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MIRV);
});
});
describe("Build keybind two-phase matching (exact code first, then digit/Numpad alias)", () => {
beforeEach(() => {
localStorage.removeItem("settings.keybinds");
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler.initialize();
});
test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => {
localStorage.setItem(
"settings.keybinds",
JSON.stringify({
buildCity: "Digit1",
buildFactory: "Numpad1",
}),
);
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
});
test("exact code match wins: Numpad1 sets Factory when buildCity=Digit1 and buildFactory=Numpad1", () => {
localStorage.setItem(
"settings.keybinds",
JSON.stringify({
buildCity: "Digit1",
buildFactory: "Numpad1",
}),
);
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.Factory);
});
test("digit alias used when no exact match: Numpad1 sets City when only buildCity=Digit1", () => {
localStorage.setItem(
"settings.keybinds",
JSON.stringify({ buildCity: "Digit1" }),
);
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(uiState, mockCanvas, eventBus);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
});
});
});
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { shouldPreserveGhostAfterBuild } from "../../../../src/client/graphics/layers/StructureIconsLayer";
import { UnitType } from "../../../../src/core/game/Game";
/**
* Tests for StructureIconsLayer edge cases mentioned in comments:
* - Locked nuke / AtomBomb / HydrogenBomb: when confirming placement (Enter or key),
* the ghost is preserved so the user can place multiple nukes or keep the nuke
* selected. Other structure types clear the ghost after placement.
*/
describe("StructureIconsLayer ghost preservation (locked nuke / Enter confirm)", () => {
describe("shouldPreserveGhostAfterBuild", () => {
test("returns true for AtomBomb so ghost is not cleared after placement", () => {
expect(shouldPreserveGhostAfterBuild(UnitType.AtomBomb)).toBe(true);
});
test("returns true for HydrogenBomb so ghost is not cleared after placement", () => {
expect(shouldPreserveGhostAfterBuild(UnitType.HydrogenBomb)).toBe(true);
});
test("returns false for City so ghost is cleared after placement", () => {
expect(shouldPreserveGhostAfterBuild(UnitType.City)).toBe(false);
});
test("returns false for Factory so ghost is cleared after placement", () => {
expect(shouldPreserveGhostAfterBuild(UnitType.Factory)).toBe(false);
});
test("returns false for other buildable types (Port, DefensePost, MissileSilo, SAMLauncher, Warship, MIRV)", () => {
expect(shouldPreserveGhostAfterBuild(UnitType.Port)).toBe(false);
expect(shouldPreserveGhostAfterBuild(UnitType.DefensePost)).toBe(false);
expect(shouldPreserveGhostAfterBuild(UnitType.MissileSilo)).toBe(false);
expect(shouldPreserveGhostAfterBuild(UnitType.SAMLauncher)).toBe(false);
expect(shouldPreserveGhostAfterBuild(UnitType.Warship)).toBe(false);
expect(shouldPreserveGhostAfterBuild(UnitType.MIRV)).toBe(false);
});
});
});