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
+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 }[] = [