mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:10:42 +00:00
Merge branch 'v31'
This commit is contained in:
+43
-74
@@ -60,8 +60,20 @@ export class ClanModal extends BaseModal {
|
||||
} | null = null;
|
||||
|
||||
render() {
|
||||
const content = this.renderInner();
|
||||
if (this.inline) return content;
|
||||
const onListView = this.view === "list" && !this.selectedClanTag;
|
||||
const tabs = onListView
|
||||
? [
|
||||
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
|
||||
{ key: "browse", label: translateText("clan_modal.browse") },
|
||||
]
|
||||
: [];
|
||||
const header = onListView
|
||||
? modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})
|
||||
: null;
|
||||
return html`
|
||||
<o-modal
|
||||
id="clan-modal"
|
||||
@@ -69,12 +81,26 @@ export class ClanModal extends BaseModal {
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
hideHeader
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) => this.handleTabChange(key as Tab)}
|
||||
>
|
||||
${content}
|
||||
${header ? html`<div slot="header">${header}</div>` : ""}
|
||||
${this.renderInner()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private handleTabChange(tab: Tab) {
|
||||
this.activeTab = tab;
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
if (tab === "my-clans") {
|
||||
this.loadMyClans();
|
||||
}
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.loadMyClans();
|
||||
}
|
||||
@@ -131,16 +157,7 @@ export class ClanModal extends BaseModal {
|
||||
|
||||
private renderInner() {
|
||||
if (this.loading) {
|
||||
return html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.renderLoadingSpinner()}
|
||||
</div>
|
||||
`;
|
||||
return this.renderLoadingSpinner();
|
||||
}
|
||||
|
||||
if (this.view === "my-requests") {
|
||||
@@ -289,30 +306,20 @@ export class ClanModal extends BaseModal {
|
||||
></clan-detail-view>`;
|
||||
}
|
||||
|
||||
// List view (tabs + my clans / browse)
|
||||
// List view (my clans / browse) — header + tabs are rendered by o-modal
|
||||
return html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
title: translateText("clan_modal.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
})}
|
||||
${this.renderTabs()}
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar mr-1">
|
||||
${this.activeTab === "my-clans"
|
||||
? this.renderMyClans()
|
||||
: html`<clan-browse-view
|
||||
.myClanRoles=${this.myClanRoles}
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
.cachedState=${this.browseCache}
|
||||
@browse-updated=${(e: CustomEvent<BrowseState>) => {
|
||||
this.browseCache = e.detail;
|
||||
}}
|
||||
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
|
||||
this.openDetail(e.detail.tag)}
|
||||
></clan-browse-view>`}
|
||||
</div>
|
||||
</div>
|
||||
${this.activeTab === "my-clans"
|
||||
? this.renderMyClans()
|
||||
: html`<clan-browse-view
|
||||
.myClanRoles=${this.myClanRoles}
|
||||
.myPendingRequests=${this.myPendingRequests}
|
||||
.cachedState=${this.browseCache}
|
||||
@browse-updated=${(e: CustomEvent<BrowseState>) => {
|
||||
this.browseCache = e.detail;
|
||||
}}
|
||||
@clan-select=${(e: CustomEvent<{ tag: string }>) =>
|
||||
this.openDetail(e.detail.tag)}
|
||||
></clan-browse-view>`}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -321,44 +328,6 @@ export class ClanModal extends BaseModal {
|
||||
this.view = "detail";
|
||||
}
|
||||
|
||||
private renderTabs() {
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: "my-clans", label: translateText("clan_modal.my_clans") },
|
||||
{ key: "browse", label: translateText("clan_modal.browse") },
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="flex border-b border-white/10 px-4 lg:px-6 gap-1">
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<button
|
||||
@click=${() => {
|
||||
this.activeTab = tab.key;
|
||||
this.view = "list";
|
||||
this.selectedClan = null;
|
||||
this.selectedClanTag = "";
|
||||
if (tab.key === "my-clans") {
|
||||
this.loadMyClans();
|
||||
}
|
||||
}}
|
||||
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative
|
||||
${this.activeTab === tab.key
|
||||
? "text-aquarius"
|
||||
: "text-white/40 hover:text-white/70"}"
|
||||
>
|
||||
${tab.label}
|
||||
${this.activeTab === tab.key
|
||||
? html`<div
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
|
||||
></div>`
|
||||
: ""}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderMyClans() {
|
||||
const hasClans = this.myClans.length > 0;
|
||||
const hasRequests = this.myPendingRequests.length > 0;
|
||||
|
||||
@@ -73,7 +73,7 @@ export class FlagInput extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
id="flag-input"
|
||||
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
|
||||
class="flag-btn p-0 m-0 border-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
|
||||
title=${buttonTitle}
|
||||
@click=${this.onInputClick}
|
||||
>
|
||||
|
||||
@@ -120,7 +120,7 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 hover:scale-y-105 hover:scale-x-[1.01]",
|
||||
)}
|
||||
</div>
|
||||
<!-- Create/ranked/join: mobile only, below solo -->
|
||||
@@ -128,19 +128,19 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
${!crazyGamesSDK.isOnCrazyGames()
|
||||
? this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)
|
||||
: html`<div class="invisible"></div>`}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
</div>
|
||||
<!-- iOS Add to Home Screen banner -->
|
||||
@@ -192,7 +192,7 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.solo"),
|
||||
this.openSinglePlayerModal,
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80",
|
||||
"bg-malibu-blue hover:bg-aquarius active:bg-malibu-blue/80 hover:scale-y-105 hover:scale-x-[1.01]",
|
||||
)}
|
||||
</div>
|
||||
<!-- Bottom row: create + ranked + join (desktop only) -->
|
||||
@@ -200,19 +200,19 @@ export class GameModeSelector extends LitElement {
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.create"),
|
||||
this.openHostLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
${!crazyGamesSDK.isOnCrazyGames()
|
||||
? this.renderSmallActionCard(
|
||||
translateText("mode_selector.ranked_title"),
|
||||
this.openRankedMenu,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)
|
||||
: html`<div class="invisible"></div>`}
|
||||
${this.renderSmallActionCard(
|
||||
translateText("main.join"),
|
||||
this.openJoinLobby,
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-lobby-card-hover)]",
|
||||
"bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:scale-105 hover:shadow-[var(--shadow-action-card-hover)]",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +210,6 @@ export class LangSelector extends LitElement {
|
||||
"join-lobby-modal",
|
||||
"emoji-table",
|
||||
"leader-board",
|
||||
"leaderboard-tabs",
|
||||
"leaderboard-player-list",
|
||||
"leaderboard-clan-table",
|
||||
"build-menu",
|
||||
|
||||
@@ -5,7 +5,6 @@ import "./components/leaderboard/LeaderboardClanTable";
|
||||
import type { LeaderboardClanTable } from "./components/leaderboard/LeaderboardClanTable";
|
||||
import "./components/leaderboard/LeaderboardPlayerList";
|
||||
import type { LeaderboardPlayerList } from "./components/leaderboard/LeaderboardPlayerList";
|
||||
import "./components/leaderboard/LeaderboardTabs";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { translateText } from "./Utils";
|
||||
|
||||
@@ -81,46 +80,13 @@ export class LeaderboardModal extends BaseModal {
|
||||
>(${translateText("leaderboard_modal.refresh_time")})</span
|
||||
>`;
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${modalHeader({
|
||||
titleContent: html`
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
|
||||
>
|
||||
${translateText("leaderboard_modal.title")}
|
||||
</span>
|
||||
${this.activeTab === "clans" ? dateRange : ""}
|
||||
${this.activeTab === "players" ? refreshTime : ""}
|
||||
</div>
|
||||
`,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<leaderboard-tabs
|
||||
.activeTab=${this.activeTab}
|
||||
@tab-change=${(event: CustomEvent<"players" | "clans">) =>
|
||||
this.handleTabChange(event.detail)}
|
||||
></leaderboard-tabs>
|
||||
<div class="flex-1 min-h-0">
|
||||
<leaderboard-player-list
|
||||
class=${this.activeTab === "players" ? "h-full" : "hidden"}
|
||||
></leaderboard-player-list>
|
||||
<leaderboard-clan-table
|
||||
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
|
||||
@date-range-change=${(
|
||||
event: CustomEvent<{ start: string; end: string }>,
|
||||
) => this.handleClanDateRangeChange(event)}
|
||||
></leaderboard-clan-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) return content;
|
||||
const tabs = [
|
||||
{
|
||||
key: "players",
|
||||
label: translateText("leaderboard_modal.ranked_tab"),
|
||||
},
|
||||
{ key: "clans", label: translateText("leaderboard_modal.clans_tab") },
|
||||
];
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
@@ -128,8 +94,39 @@ export class LeaderboardModal extends BaseModal {
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) =>
|
||||
this.handleTabChange(key as "players" | "clans")}
|
||||
>
|
||||
${content}
|
||||
<div slot="header">
|
||||
${modalHeader({
|
||||
titleContent: html`
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
class="text-white text-xl sm:text-2xl font-bold uppercase tracking-widest"
|
||||
>
|
||||
${translateText("leaderboard_modal.title")}
|
||||
</span>
|
||||
${this.activeTab === "clans" ? dateRange : ""}
|
||||
${this.activeTab === "players" ? refreshTime : ""}
|
||||
</div>
|
||||
`,
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.close"),
|
||||
})}
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 h-full">
|
||||
<leaderboard-player-list
|
||||
class=${this.activeTab === "players" ? "h-full" : "hidden"}
|
||||
></leaderboard-player-list>
|
||||
<leaderboard-clan-table
|
||||
class=${this.activeTab === "clans" ? "h-full" : "hidden"}
|
||||
@date-range-change=${(
|
||||
event: CustomEvent<{ start: string; end: string }>,
|
||||
) => this.handleClanDateRangeChange(event)}
|
||||
></leaderboard-clan-table>
|
||||
</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class PatternInput extends LitElement {
|
||||
return html`
|
||||
<button
|
||||
id="pattern-input"
|
||||
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] rounded-lg overflow-hidden"
|
||||
class="pattern-btn m-0 p-0 w-full h-full flex cursor-pointer justify-center items-center focus:outline-none focus:ring-0 transition-all duration-200 hover:scale-105 bg-surface hover:brightness-[1.08] active:brightness-[0.95] hover:shadow-[var(--shadow-action-card-hover)] rounded-lg overflow-hidden"
|
||||
title=${buttonTitle}
|
||||
@click=${this.onInputClick}
|
||||
>
|
||||
|
||||
+23
-53
@@ -41,43 +41,12 @@ export class StoreModal extends BaseModal {
|
||||
}
|
||||
|
||||
private renderHeader(): TemplateResult {
|
||||
return html`
|
||||
${modalHeader({
|
||||
title: translateText("store.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
||||
})}
|
||||
<div class="flex items-center gap-2 justify-center pt-2">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "packs"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "packs")}
|
||||
>
|
||||
${translateText("store.packs")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "patterns"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "patterns")}
|
||||
>
|
||||
${translateText("store.patterns")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "flags"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "flags")}
|
||||
>
|
||||
${translateText("store.flags")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return modalHeader({
|
||||
title: translateText("store.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: html`<not-logged-in-warning></not-logged-in-warning>`,
|
||||
});
|
||||
}
|
||||
|
||||
private renderPatternGrid(): TemplateResult {
|
||||
@@ -188,22 +157,18 @@ export class StoreModal extends BaseModal {
|
||||
render() {
|
||||
if (!this.isActive && !this.inline) return html``;
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
${this.renderHeader()}
|
||||
<div class="overflow-y-auto pr-2 custom-scrollbar mr-1">
|
||||
${this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.activeTab === "flags"
|
||||
? this.renderFlagGrid()
|
||||
: this.renderPackGrid()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const tabs = [
|
||||
{ key: "packs", label: translateText("store.packs") },
|
||||
{ key: "patterns", label: translateText("store.patterns") },
|
||||
{ key: "flags", label: translateText("store.flags") },
|
||||
];
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
const grid =
|
||||
this.activeTab === "patterns"
|
||||
? this.renderPatternGrid()
|
||||
: this.activeTab === "flags"
|
||||
? this.renderFlagGrid()
|
||||
: this.renderPackGrid();
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
@@ -212,8 +177,13 @@ export class StoreModal extends BaseModal {
|
||||
?inline=${this.inline}
|
||||
?hideHeader=${true}
|
||||
?hideCloseButton=${true}
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) =>
|
||||
(this.activeTab = key as "patterns" | "flags" | "packs")}
|
||||
>
|
||||
${content}
|
||||
<div slot="header">${this.renderHeader()}</div>
|
||||
${grid}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -319,51 +319,10 @@ export class UserSettingModal extends BaseModal {
|
||||
? this.renderBasicSettings()
|
||||
: this.renderKeybindSettings();
|
||||
|
||||
const content = html`
|
||||
<div class="${this.modalContainerClass}">
|
||||
<div
|
||||
class="relative flex flex-col border-b border-white/10 lg:pb-4 shrink-0"
|
||||
>
|
||||
${modalHeader({
|
||||
title: translateText("user_setting.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
showDivider: true,
|
||||
})}
|
||||
|
||||
<div class="hidden lg:flex items-center gap-2 justify-center mt-4">
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "basic"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "basic")}
|
||||
>
|
||||
${translateText("user_setting.tab_basic")}
|
||||
</button>
|
||||
<button
|
||||
class="px-6 py-2 text-xs font-bold transition-all duration-200 rounded-lg uppercase tracking-widest ${this
|
||||
.activeTab === "keybinds"
|
||||
? "bg-malibu-blue/20 text-aquarius border border-malibu-blue/30 shadow-[var(--shadow-malibu-blue)]"
|
||||
: "text-white/40 hover:text-white hover:bg-white/5 border border-transparent"}"
|
||||
@click=${() => (this.activeTab = "keybinds")}
|
||||
>
|
||||
${translateText("user_setting.tab_keybinds")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pt-6 flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent px-6 pb-6 mr-1"
|
||||
>
|
||||
<div class="flex flex-col gap-2">${activeContent}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
const tabs = [
|
||||
{ key: "basic", label: translateText("user_setting.tab_basic") },
|
||||
{ key: "keybinds", label: translateText("user_setting.tab_keybinds") },
|
||||
];
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
@@ -371,8 +330,20 @@ export class UserSettingModal extends BaseModal {
|
||||
?inline=${this.inline}
|
||||
hideCloseButton
|
||||
hideHeader
|
||||
.tabs=${tabs}
|
||||
.activeTab=${this.activeTab}
|
||||
.onTabChange=${(key: string) =>
|
||||
(this.activeTab = key as "basic" | "keybinds")}
|
||||
>
|
||||
${content}
|
||||
<div slot="header">
|
||||
${modalHeader({
|
||||
title: translateText("user_setting.title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
showDivider: true,
|
||||
})}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">${activeContent}</div>
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { LitElement, html, unsafeCSS } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import tailwindStyles from "../../styles.css?inline";
|
||||
|
||||
export type OModalTab = { key: string; label: string };
|
||||
|
||||
@customElement("o-modal")
|
||||
export class OModal extends LitElement {
|
||||
static styles = [unsafeCSS(tailwindStyles)];
|
||||
@@ -28,6 +30,15 @@ export class OModal extends LitElement {
|
||||
@property({ type: String })
|
||||
public maxWidth = "";
|
||||
|
||||
@property({ type: Array })
|
||||
public tabs: OModalTab[] = [];
|
||||
|
||||
@property({ type: String })
|
||||
public activeTab = "";
|
||||
|
||||
@property({ attribute: false })
|
||||
public onTabChange?: (key: string) => void;
|
||||
|
||||
public onClose?: () => void;
|
||||
|
||||
public open() {
|
||||
@@ -60,7 +71,48 @@ export class OModal extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private handleTabClick(key: string) {
|
||||
this.onTabChange?.(key);
|
||||
}
|
||||
|
||||
private renderTabs() {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex justify-center border-b border-white/10 px-4 lg:px-6 gap-1 shrink-0"
|
||||
>
|
||||
${this.tabs.map((tab) => {
|
||||
const active = this.activeTab === tab.key;
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-key=${tab.key}
|
||||
aria-selected=${active}
|
||||
class="px-4 py-3 text-sm font-bold uppercase tracking-wider transition-all relative cursor-pointer ${active
|
||||
? "text-aquarius"
|
||||
: "text-white/40 hover:text-white/70"}"
|
||||
@click=${() => this.handleTabClick(tab.key)}
|
||||
>
|
||||
${tab.label}
|
||||
${active
|
||||
? html`<div
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-malibu-blue"
|
||||
></div>`
|
||||
: ""}
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const shouldRender = this.isModalOpen || this.inline;
|
||||
if (!shouldRender) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const backdropClass = this.inline
|
||||
? "relative z-10 w-full h-full flex items-stretch bg-transparent"
|
||||
: "fixed inset-0 z-[9999] bg-black/60 flex items-center justify-center overflow-hidden";
|
||||
@@ -73,42 +125,45 @@ export class OModal extends LitElement {
|
||||
const wrapperStyle =
|
||||
!this.inline && this.maxWidth ? `max-width: ${this.maxWidth};` : "";
|
||||
|
||||
const hasTabs = this.tabs.length > 0;
|
||||
const sectionClass = hasTabs
|
||||
? "relative flex-1 min-h-0 flex flex-col text-white bg-black/70 backdrop-blur-xl lg:rounded-2xl lg:border border-white/10 overflow-hidden"
|
||||
: "relative flex-1 min-h-0 flex flex-col text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-hidden";
|
||||
|
||||
return html`
|
||||
${this.isModalOpen
|
||||
? html`
|
||||
<aside
|
||||
class="${backdropClass}"
|
||||
@click=${this.inline ? null : () => this.close()}
|
||||
>
|
||||
<div
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
class="${wrapperClass}"
|
||||
style="${wrapperStyle}"
|
||||
<aside
|
||||
class="${backdropClass}"
|
||||
@click=${this.inline ? null : () => this.close()}
|
||||
>
|
||||
<div
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
class="${wrapperClass}"
|
||||
style="${wrapperStyle}"
|
||||
>
|
||||
${this.inline || this.hideCloseButton
|
||||
? html``
|
||||
: html`<div
|
||||
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
|
||||
@click=${() => this.close()}
|
||||
>
|
||||
${this.inline || this.hideCloseButton
|
||||
? html``
|
||||
: html`<div
|
||||
class="absolute top-5 right-5 z-10 text-white cursor-pointer"
|
||||
@click=${() => this.close()}
|
||||
>
|
||||
✕
|
||||
</div>`}
|
||||
${!this.hideHeader && this.title
|
||||
? html`<div
|
||||
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
|
||||
>
|
||||
${this.title}
|
||||
</div>`
|
||||
: html``}
|
||||
<section
|
||||
class="relative flex-1 min-h-0 p-0 lg:p-[1.4rem] text-white bg-[#23232382] backdrop-blur-md lg:rounded-lg overflow-y-auto"
|
||||
>
|
||||
<slot></slot>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
`
|
||||
: html``}
|
||||
✕
|
||||
</div>`}
|
||||
${!this.hideHeader && this.title
|
||||
? html`<div
|
||||
class="px-[1.4rem] py-[1rem] text-2xl font-bold text-white"
|
||||
>
|
||||
${this.title}
|
||||
</div>`
|
||||
: html``}
|
||||
<section class="${sectionClass}">
|
||||
<slot name="header"></slot>
|
||||
${hasTabs ? this.renderTabs() : html``}
|
||||
<div class="flex-1 min-h-0 overflow-y-auto p-0 lg:p-[1.4rem]">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { translateText } from "../../Utils";
|
||||
|
||||
export type LeaderboardTab = "players" | "clans";
|
||||
|
||||
@customElement("leaderboard-tabs")
|
||||
export class LeaderboardTabs extends LitElement {
|
||||
@property({ type: String }) activeTab: LeaderboardTab = "players";
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private baseTabClass =
|
||||
"px-6 py-2 rounded-full text-sm font-bold uppercase tracking-wider transition-all cursor-pointer select-none";
|
||||
private activeTabClass = "bg-blue-600 text-white";
|
||||
private inactiveTabClass =
|
||||
"text-white/40 hover:text-white/60 hover:bg-white/5";
|
||||
|
||||
private getTabClass(active: boolean) {
|
||||
return [
|
||||
this.baseTabClass,
|
||||
active ? this.activeTabClass : this.inactiveTabClass,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@state()
|
||||
private playerClass = this.getTabClass(this.activeTab === "players");
|
||||
@state()
|
||||
private clanClass = this.getTabClass(this.activeTab === "clans");
|
||||
|
||||
private handleTabChange(tab: LeaderboardTab) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<LeaderboardTab>("tab-change", {
|
||||
detail: tab,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.playerClass = this.getTabClass(tab === "players");
|
||||
this.clanClass = this.getTabClass(tab === "clans");
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div
|
||||
role="tablist"
|
||||
class="flex gap-2 p-1 bg-white/5 rounded-full border border-white/10 mb-4 w-fit mx-auto mt-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="${this.playerClass}"
|
||||
@click=${() => this.handleTabChange("players")}
|
||||
id="player-leaderboard-tab"
|
||||
aria-selected=${this.activeTab === "players"}
|
||||
>
|
||||
${translateText("leaderboard_modal.ranked_tab")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="${this.clanClass}"
|
||||
@click=${() => this.handleTabChange("clans")}
|
||||
id="clan-leaderboard-tab"
|
||||
aria-selected=${this.activeTab === "clans"}
|
||||
>
|
||||
${translateText("leaderboard_modal.clans_tab")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,33 @@
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { Cell } from "../../../core/game/Game";
|
||||
import { Cell, PlayerType } from "../../../core/game/Game";
|
||||
import { GameView } from "../../../core/game/GameView";
|
||||
import { UserSettings } from "../../../core/game/UserSettings";
|
||||
import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { renderTroops } from "../../Utils";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
||||
|
||||
// Match AttacksDisplay: aquarius for outgoing, red-400 for incoming.
|
||||
const OUTGOING_COLOR = "var(--color-aquarius)";
|
||||
const INCOMING_COLOR = "var(--color-red-400)";
|
||||
|
||||
// At/above this zoom, the label stays at its full screen size. Below it the
|
||||
// label shrinks linearly with zoom-out, floored so it never disappears.
|
||||
const LABEL_FULL_SIZE_ZOOM = 1.5;
|
||||
const LABEL_MIN_SCREEN_SCALE = 0.5;
|
||||
const OUTGOING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)";
|
||||
const INCOMING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)";
|
||||
// At/above this zoom the label is rendered at full size; below it shrinks
|
||||
// linearly toward LABEL_MIN_RENDERED_SIZE as zoom→0.
|
||||
const LABEL_FULL_SIZE_ZOOM = 4.0;
|
||||
const LABEL_MIN_RENDERED_SIZE = 0.63;
|
||||
// Overall size multiplier applied to the rendered label.
|
||||
const LABEL_SIZE_MULTIPLIER = 1.0;
|
||||
|
||||
// Vertical strength bar to the left of the icon: grows in height as the
|
||||
// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the
|
||||
// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops.
|
||||
const BAR_FULL_HEIGHT_RATIO = 2;
|
||||
const BAR_MAX_HEIGHT_PX = 13;
|
||||
|
||||
// Element scale factor that, combined with the container's `scale(zoom)`,
|
||||
// yields the desired on-screen label size: constant screen size when zoomed
|
||||
// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a
|
||||
// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears.
|
||||
// Counter-scale against the container's `scale(zoom)`. At/above
|
||||
// LABEL_FULL_SIZE_ZOOM the rendered size is capped at LABEL_SIZE_MULTIPLIER;
|
||||
// below it the rendered size shrinks linearly toward
|
||||
// LABEL_SIZE_MULTIPLIER * LABEL_MIN_RENDERED_SIZE as zoom→0.
|
||||
export function computeLabelScale(zoom: number): number {
|
||||
const netScale = Math.max(
|
||||
LABEL_MIN_SCREEN_SCALE,
|
||||
Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM),
|
||||
);
|
||||
return netScale / zoom;
|
||||
}
|
||||
|
||||
// Fraction (0–1) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means
|
||||
// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more
|
||||
// of the opposing troops.
|
||||
export function computeBarStrength(
|
||||
attackerTroops: number,
|
||||
opposingTroops: number,
|
||||
): number {
|
||||
if (opposingTroops <= 0) return 1;
|
||||
return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO);
|
||||
const t = Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM);
|
||||
const renderedSize =
|
||||
LABEL_SIZE_MULTIPLIER *
|
||||
(LABEL_MIN_RENDERED_SIZE + (1 - LABEL_MIN_RENDERED_SIZE) * t);
|
||||
return renderedSize / zoom;
|
||||
}
|
||||
|
||||
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
|
||||
@@ -70,7 +49,6 @@ interface AttackLabel {
|
||||
positions: (Cell | null)[];
|
||||
isIncoming: boolean;
|
||||
attackerTroops: number;
|
||||
barStrength: number;
|
||||
}
|
||||
|
||||
export class AttackingTroopsOverlay implements Layer {
|
||||
@@ -144,7 +122,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
|
||||
const activeIDs = new Set<string>();
|
||||
|
||||
// Outgoing: cyan bar widens as our attack outnumbers the defender.
|
||||
// Outgoing: only label attacks targeting another player.
|
||||
for (const attack of myPlayer.outgoingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
if (!attack.targetID) {
|
||||
@@ -156,20 +134,22 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
const barStrength = computeBarStrength(attack.troops, defender.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, false, barStrength);
|
||||
this.ensureLabel(attack.id, attack.troops, false);
|
||||
}
|
||||
|
||||
// Incoming: red bar widens as the attacker outnumbers the player.
|
||||
// Incoming: only label attacks coming from another player; skip tribes.
|
||||
for (const attack of myPlayer.incomingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID);
|
||||
if (!attacker || !attacker.isPlayer()) {
|
||||
if (
|
||||
!attacker ||
|
||||
!attacker.isPlayer() ||
|
||||
attacker.type() === PlayerType.Bot
|
||||
) {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
const barStrength = computeBarStrength(attack.troops, myPlayer.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, true, barStrength);
|
||||
this.ensureLabel(attack.id, attack.troops, true);
|
||||
}
|
||||
|
||||
for (const [id] of this.labels) {
|
||||
@@ -202,7 +182,6 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
attackID: string,
|
||||
attackerTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
) {
|
||||
let label = this.labels.get(attackID);
|
||||
if (!label) {
|
||||
@@ -211,15 +190,13 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
positions: [],
|
||||
isIncoming,
|
||||
attackerTroops,
|
||||
barStrength,
|
||||
};
|
||||
this.labels.set(attackID, label);
|
||||
} else {
|
||||
label.attackerTroops = attackerTroops;
|
||||
label.barStrength = barStrength;
|
||||
}
|
||||
for (const el of label.elements) {
|
||||
this.updateLabelContent(el, attackerTroops, barStrength);
|
||||
this.updateLabelContent(el, attackerTroops);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,6 +212,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
|
||||
// Hoist the per-frame label scale once; zoom is constant within a frame.
|
||||
const scale = this.labelScale();
|
||||
const innerTransform = `scale(${scale})`;
|
||||
for (const label of this.labels.values()) {
|
||||
for (let i = 0; i < label.elements.length; i++) {
|
||||
const el = label.elements[i];
|
||||
@@ -245,15 +223,17 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
continue;
|
||||
}
|
||||
|
||||
el.style.display = "inline-flex";
|
||||
// Centre the label on its world position; counter-scale keeps the
|
||||
// label at constant screen size while zoomed in, then it shrinks
|
||||
// (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM.
|
||||
const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`;
|
||||
if (this.lastTransform.get(el) !== transform) {
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
el.style.display = "";
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
// Outer: world position only — the 0.25s transition smooths cluster
|
||||
// shifts. Inner: scale only — applied without transition so zoom is
|
||||
// instant.
|
||||
const outerTransform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%)`;
|
||||
if (this.lastTransform.get(el) !== outerTransform) {
|
||||
el.style.transform = outerTransform;
|
||||
this.lastTransform.set(el, outerTransform);
|
||||
}
|
||||
inner.style.transform = innerTransform;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -262,11 +242,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
// Add elements for new clusters.
|
||||
while (lbl.elements.length < positions.length) {
|
||||
lbl.elements.push(
|
||||
this.createLabelElement(
|
||||
lbl.attackerTroops,
|
||||
lbl.isIncoming,
|
||||
lbl.barStrength,
|
||||
),
|
||||
this.createLabelElement(lbl.attackerTroops, lbl.isIncoming),
|
||||
);
|
||||
lbl.positions.push(null);
|
||||
}
|
||||
@@ -286,9 +262,9 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
|
||||
const el = lbl.elements[i];
|
||||
el.style.transition = "none";
|
||||
const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`;
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
const outerTransform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%)`;
|
||||
el.style.transform = outerTransform;
|
||||
this.lastTransform.set(el, outerTransform);
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
});
|
||||
@@ -297,73 +273,48 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
}
|
||||
}
|
||||
|
||||
// Outer wraps position+transition (animates cluster moves). Inner holds the
|
||||
// scale (instant on zoom) plus all visual chrome. Splitting them keeps the
|
||||
// 0.25s transition off zoom changes.
|
||||
private createLabelTemplate(): HTMLDivElement {
|
||||
const el = document.createElement("div");
|
||||
el.style.position = "absolute";
|
||||
el.style.display = "none";
|
||||
el.style.alignItems = "center";
|
||||
el.style.gap = "3px";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.fontSize = "14px";
|
||||
el.style.fontWeight = "bold";
|
||||
el.style.padding = "2px 5px";
|
||||
el.style.borderRadius = "3px";
|
||||
el.style.backgroundColor = "rgba(0,0,0,0.85)";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.lineHeight = "1.3";
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
el.style.width = "max-content";
|
||||
const outer = document.createElement("div");
|
||||
outer.style.position = "absolute";
|
||||
outer.style.display = "none";
|
||||
outer.style.pointerEvents = "none";
|
||||
outer.style.transition = "transform 0.25s linear";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.style.width = "2px";
|
||||
bar.style.borderRadius = "1px";
|
||||
bar.style.alignSelf = "flex-end";
|
||||
bar.style.transition = "height 0.25s linear";
|
||||
el.appendChild(bar);
|
||||
const inner = document.createElement("div");
|
||||
inner.style.whiteSpace = "nowrap";
|
||||
inner.style.fontSize = "17px";
|
||||
inner.style.fontWeight = "bold";
|
||||
inner.style.lineHeight = "1.3";
|
||||
inner.style.width = "max-content";
|
||||
// No background — let the territory border show through. Stacked black
|
||||
// text-shadows form a soft dark glow so the number stays readable over
|
||||
// any terrain.
|
||||
inner.style.textShadow =
|
||||
"0 0 2px rgba(0,0,0,1), 0 0 3px rgba(0,0,0,0.85), 0 0 5px rgba(0,0,0,0.5)";
|
||||
outer.appendChild(inner);
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.style.width = "13px";
|
||||
icon.style.height = "13px";
|
||||
el.appendChild(icon);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.minWidth = "25px";
|
||||
el.appendChild(span);
|
||||
|
||||
return el;
|
||||
return outer;
|
||||
}
|
||||
|
||||
private createLabelElement(
|
||||
attackerTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
): HTMLDivElement {
|
||||
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
|
||||
el.style.fontFamily = this.game.config().theme().font();
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const icon = el.children[1] as HTMLImageElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
icon.src = soldierIcon;
|
||||
icon.style.filter = isIncoming
|
||||
? INCOMING_ICON_FILTER
|
||||
: OUTGOING_ICON_FILTER;
|
||||
span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
inner.style.fontFamily = this.game.config().theme().font();
|
||||
inner.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
inner.textContent = renderTroops(attackerTroops);
|
||||
this.container.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
private updateLabelContent(
|
||||
el: HTMLDivElement,
|
||||
attackerTroops: number,
|
||||
barStrength: number,
|
||||
) {
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
private updateLabelContent(el: HTMLDivElement, attackerTroops: number) {
|
||||
const inner = el.children[0] as HTMLDivElement;
|
||||
inner.textContent = renderTroops(attackerTroops);
|
||||
}
|
||||
|
||||
private removeLabel(attackID: string) {
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
--shadow-malibu-blue-ring-sm: 0 0 0 4px rgba(0, 132, 209, 0.2);
|
||||
--shadow-malibu-blue-ring-lg: 0 0 0 6px rgba(0, 132, 209, 0.3);
|
||||
--shadow-lobby-card-hover: 0 0 0 2px #0084d1, 0 0 20px rgba(0, 132, 209, 0.5);
|
||||
--shadow-action-card-hover:
|
||||
0 0 0 1px #0084d1, 0 0 12px rgba(0, 132, 209, 0.35);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -13,45 +13,10 @@ import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
|
||||
import { ClientID, GameStartInfo, Turn } from "../Schemas";
|
||||
import { generateID } from "../Util";
|
||||
import { WorkerMessage } from "./WorkerMessages";
|
||||
// ?worker&url returns the worker bundle's URL as a string. We load it via a
|
||||
// same-origin Blob trampoline because browsers refuse cross-origin
|
||||
// `new Worker(url)` even with valid CORS+CORP. A Blob URL is same-origin to
|
||||
// the page so the constructor accepts it, and dynamic `import()` inside the
|
||||
// Blob IS CORS-checked and can fetch the real worker module from the CDN.
|
||||
// R2 must serve the worker bundle with `Access-Control-Allow-Origin`.
|
||||
import workerUrl from "./Worker.worker.ts?worker&url";
|
||||
|
||||
function createGameWorker(): Worker {
|
||||
const cdnBase = getCdnBase().replace(/\/+$/, "");
|
||||
// Same-origin path (dev, or any deploy without CDN_BASE set): construct the
|
||||
// worker directly. The Blob trampoline below is only needed for cross-origin
|
||||
// loads — browsers refuse `new Worker(url)` cross-origin even with valid
|
||||
// CORS+CORP, and Vite's dev server doesn't serve `?worker&url` URLs as
|
||||
// regular ES modules so the trampoline's dynamic `import()` would hang.
|
||||
if (!cdnBase) {
|
||||
return new Worker(workerUrl, { type: "module" });
|
||||
}
|
||||
const fullUrl = `${cdnBase}${workerUrl}`;
|
||||
// Buffer-and-replay: the worker's port enables when the trampoline script
|
||||
// starts, so any messages posted before the imported module attaches its
|
||||
// `message` handler would dispatch to no listener and be dropped. Capture
|
||||
// them here, then re-dispatch after the import resolves.
|
||||
const trampoline = `
|
||||
const buffered = [];
|
||||
const buffer = (e) => buffered.push(e);
|
||||
self.addEventListener("message", buffer);
|
||||
import(${JSON.stringify(fullUrl)}).then(() => {
|
||||
self.removeEventListener("message", buffer);
|
||||
for (const e of buffered) self.dispatchEvent(new MessageEvent("message", { data: e.data }));
|
||||
}).catch((e) => self.postMessage({ type: "trampoline_error", message: String((e && e.message) || e) }));
|
||||
`;
|
||||
const blobUrl = URL.createObjectURL(
|
||||
new Blob([trampoline], { type: "application/javascript" }),
|
||||
);
|
||||
const worker = new Worker(blobUrl, { type: "module" });
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return worker;
|
||||
}
|
||||
// Inlined into the main bundle as a same-origin Blob, sidestepping the
|
||||
// cross-origin `new Worker(url)` restriction that would otherwise apply when
|
||||
// the worker bundle is served from the CDN.
|
||||
import GameWorker from "./Worker.worker.ts?worker&inline";
|
||||
|
||||
export class WorkerClient {
|
||||
private worker: Worker;
|
||||
@@ -65,7 +30,7 @@ export class WorkerClient {
|
||||
private gameStartInfo: GameStartInfo,
|
||||
private clientID: ClientID | undefined,
|
||||
) {
|
||||
this.worker = createGameWorker();
|
||||
this.worker = new GameWorker();
|
||||
this.messageHandlers = new Map();
|
||||
|
||||
// Set up global message handler
|
||||
@@ -112,21 +77,8 @@ export class WorkerClient {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageId = generateID();
|
||||
|
||||
const onTrampolineError = (event: MessageEvent) => {
|
||||
if (event.data?.type !== "trampoline_error") return;
|
||||
this.worker.removeEventListener("message", onTrampolineError);
|
||||
this.messageHandlers.delete(messageId);
|
||||
reject(
|
||||
new Error(
|
||||
`Worker trampoline import failed: ${event.data.message ?? "unknown error"}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
this.worker.addEventListener("message", onTrampolineError);
|
||||
|
||||
this.messageHandlers.set(messageId, (message) => {
|
||||
if (message.type === "initialized") {
|
||||
this.worker.removeEventListener("message", onTrampolineError);
|
||||
this.isInitialized = true;
|
||||
resolve();
|
||||
}
|
||||
@@ -140,15 +92,12 @@ export class WorkerClient {
|
||||
cdnBase: getCdnBase(),
|
||||
});
|
||||
|
||||
// Backstop for the worker hanging after a successful import (the
|
||||
// trampoline_error path handles the cross-origin / CORS load failure).
|
||||
setTimeout(() => {
|
||||
if (!this.isInitialized) {
|
||||
this.worker.removeEventListener("message", onTrampolineError);
|
||||
this.messageHandlers.delete(messageId);
|
||||
reject(new Error("Worker initialization timeout"));
|
||||
}
|
||||
}, 20000);
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,7 @@ export type WorkerMessageType =
|
||||
| "attack_clustered_positions"
|
||||
| "attack_clustered_positions_result"
|
||||
| "transport_ship_spawn"
|
||||
| "transport_ship_spawn_result"
|
||||
| "trampoline_error";
|
||||
| "transport_ship_spawn_result";
|
||||
|
||||
// Base interface for all messages
|
||||
interface BaseWorkerMessage {
|
||||
@@ -138,15 +137,6 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
|
||||
result: TileRef | false;
|
||||
}
|
||||
|
||||
// Posted by the Blob trampoline (see WorkerClient.createGameWorker) when the
|
||||
// dynamic import of the real worker module fails. The real worker module
|
||||
// never loaded, so no other message will ever arrive — initialize() must
|
||||
// reject on this rather than wait out its timeout.
|
||||
export interface TrampolineErrorMessage extends BaseWorkerMessage {
|
||||
type: "trampoline_error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Union types for type safety
|
||||
export type MainThreadMessage =
|
||||
| InitMessage
|
||||
@@ -169,5 +159,4 @@ export type WorkerMessage =
|
||||
| PlayerProfileResultMessage
|
||||
| PlayerBorderTilesResultMessage
|
||||
| AttackClusteredPositionsResultMessage
|
||||
| TransportShipSpawnResultMessage
|
||||
| TrampolineErrorMessage;
|
||||
| TransportShipSpawnResultMessage;
|
||||
|
||||
@@ -3,8 +3,7 @@ import { ClientID } from "../core/Schemas";
|
||||
|
||||
const INTENTS_PER_SECOND = 10;
|
||||
const INTENTS_PER_MINUTE = 150;
|
||||
const MAX_INTENT_SIZE = 500;
|
||||
const MAX_CONFIG_INTENT_SIZE = 2000;
|
||||
const MAX_INTENT_SIZE = 2000;
|
||||
const TOTAL_BYTES = 2 * 1024 * 1024; // 2MB per client
|
||||
export type RateLimitResult = "ok" | "limit" | "kick";
|
||||
|
||||
@@ -17,30 +16,19 @@ interface ClientBucket {
|
||||
export class ClientMsgRateLimiter {
|
||||
private buckets = new Map<ClientID, ClientBucket>();
|
||||
|
||||
check(
|
||||
clientID: ClientID,
|
||||
type: string,
|
||||
bytes: number,
|
||||
intentType?: string,
|
||||
): RateLimitResult {
|
||||
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
|
||||
const bucket = this.getOrCreate(clientID);
|
||||
bucket.totalBytes += bytes;
|
||||
|
||||
if (bucket.totalBytes >= TOTAL_BYTES) return "kick";
|
||||
|
||||
if (type === "intent") {
|
||||
// Config updates are lobby-only and not stored in turn history,
|
||||
// so they can be larger than regular intents.
|
||||
const maxSize =
|
||||
intentType === "update_game_config"
|
||||
? MAX_CONFIG_INTENT_SIZE
|
||||
: MAX_INTENT_SIZE;
|
||||
// Intents are stored in turn history for the duration of the game, so
|
||||
// oversized intents would accumulate and fill up server RAM.
|
||||
// Intents are also sent to all players, so it increase outgoing
|
||||
// data.
|
||||
// Intents should never be larger than MAX_INTENT_SIZE, so we assume the client is malicious.
|
||||
if (bytes > maxSize) {
|
||||
if (bytes > MAX_INTENT_SIZE) {
|
||||
return "kick";
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -349,13 +349,10 @@ export class GameServer {
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
const bytes = Buffer.byteLength(message, "utf8");
|
||||
const intentType =
|
||||
clientMsg.type === "intent" ? clientMsg.intent.type : undefined;
|
||||
const rateResult = this.intentRateLimiter.check(
|
||||
client.clientID,
|
||||
clientMsg.type,
|
||||
bytes,
|
||||
intentType,
|
||||
);
|
||||
if (rateResult === "kick") {
|
||||
this.log.warn(`Client rate limit exceeded, kicking`, {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
pattern,
|
||||
resolveConfusablesTransformer,
|
||||
resolveLeetSpeakTransformer,
|
||||
skipNonAlphabeticTransformer,
|
||||
toAsciiLowerCaseTransformer,
|
||||
} from "obscenity";
|
||||
import countries from "resources/countries.json";
|
||||
@@ -71,15 +72,21 @@ export function createMatcher(bannedWords: string[]): RegExpMatcher {
|
||||
];
|
||||
// substringMatcher: literal patterns, no collapse — catches "niggertesting" as a substring
|
||||
// collapseMatcher: deduped patterns + collapse transformer — catches "niiiigger", "hiiitler"
|
||||
// skipNonAlphabeticTransformer is applied last to catch punctuation-separated bypasses
|
||||
// like "n.i.g.g.e.r".
|
||||
const substringMatcher = new RegExpMatcher({
|
||||
...buildDataset(bannedWords, false),
|
||||
blacklistMatcherTransformers: baseTransformers,
|
||||
blacklistMatcherTransformers: [
|
||||
...baseTransformers,
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
const collapseMatcher = new RegExpMatcher({
|
||||
...buildDataset(bannedWords, true),
|
||||
blacklistMatcherTransformers: [
|
||||
...baseTransformers,
|
||||
collapseDuplicatesTransformer(),
|
||||
skipNonAlphabeticTransformer(),
|
||||
],
|
||||
});
|
||||
return {
|
||||
|
||||
+16
-5
@@ -114,11 +114,9 @@ describe("UsernameCensor", () => {
|
||||
expect(matcher.hasMatch("MyChairName")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects banned words with underscores/dots/numbers mixed in", () => {
|
||||
// These should NOT bypass the filter (skipNonAlphabetic was intentionally removed)
|
||||
// Words separated by non-alpha chars are treated as separate tokens
|
||||
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(false); // dots break the word
|
||||
expect(matcher.hasMatch("hi_tler")).toBe(false); // underscore breaks it
|
||||
test("detects banned words with non-alphabetic characters mixed in", () => {
|
||||
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
|
||||
expect(matcher.hasMatch("hi_tler")).toBe(true);
|
||||
});
|
||||
|
||||
test("allows clean usernames", () => {
|
||||
@@ -141,6 +139,19 @@ describe("UsernameCensor", () => {
|
||||
expect(matcher.hasMatch("kkklover")).toBe(true);
|
||||
expect(matcher.hasMatch("ilovekkkboys")).toBe(true);
|
||||
});
|
||||
|
||||
test("catches slurs separated by periods (bypass attempt)", () => {
|
||||
expect(matcher.hasMatch("n.i.g.g.e.r")).toBe(true);
|
||||
expect(matcher.hasMatch("N.I.G.G.E.R")).toBe(true);
|
||||
expect(matcher.hasMatch("n.i.g.g.a")).toBe(true);
|
||||
expect(matcher.hasMatch("h.i.t.l.e.r")).toBe(true);
|
||||
expect(matcher.hasMatch("hello n.i.g.g.e.r world")).toBe(true);
|
||||
});
|
||||
|
||||
test("censor replaces period-separated slur usernames", () => {
|
||||
const result = checker.censor("n.i.g.g.e.r", null);
|
||||
expect(shadowNames).toContain(result.username);
|
||||
});
|
||||
});
|
||||
|
||||
describe("censor", () => {
|
||||
|
||||
@@ -101,6 +101,7 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
import "../../src/client/components/baseComponents/Modal";
|
||||
import { LeaderboardModal } from "../../src/client/LeaderboardModal";
|
||||
|
||||
describe("LeaderboardModal", () => {
|
||||
@@ -334,7 +335,14 @@ describe("LeaderboardModal", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const tab = modal.querySelector("#clan-leaderboard-tab");
|
||||
modal.inline = true;
|
||||
await modal.updateComplete;
|
||||
const oModal = modal.querySelector("o-modal");
|
||||
await (oModal as unknown as { updateComplete: Promise<unknown> })
|
||||
.updateComplete;
|
||||
const tab = oModal!.shadowRoot!.querySelector(
|
||||
'button[role="tab"][data-key="clans"]',
|
||||
);
|
||||
expect(tab).toBeTruthy();
|
||||
|
||||
tab!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
@@ -1,66 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
alignClusterOrder,
|
||||
computeBarStrength,
|
||||
computeLabelScale,
|
||||
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
|
||||
import { Cell } from "../../../../src/core/game/Game";
|
||||
|
||||
describe("computeLabelScale", () => {
|
||||
test("counter-scales the zoom when above the full-size threshold", () => {
|
||||
// zoom = 2 → label rendered at 1/2 to stay at full screen size.
|
||||
expect(computeLabelScale(2)).toBeCloseTo(0.5);
|
||||
// LABEL_FULL_SIZE_ZOOM = 4, LABEL_MIN_RENDERED_SIZE = 0.63,
|
||||
// LABEL_SIZE_MULTIPLIER = 1.0. Rendered size at zoom z:
|
||||
// 1.0 * (0.63 + 0.37 * min(1, z/4)).
|
||||
test("at the full-size threshold, rendered size is capped at the multiplier", () => {
|
||||
// zoom = 4 → rendered = 1.0 → scale = 1.0 / 4.
|
||||
expect(computeLabelScale(4)).toBeCloseTo(1.0 / 4);
|
||||
});
|
||||
|
||||
test("counter-scales exactly at the full-size threshold", () => {
|
||||
// zoom = 1.5 → label rendered at 1/1.5 ≈ 0.6667.
|
||||
expect(computeLabelScale(1.5)).toBeCloseTo(1 / 1.5);
|
||||
test("above the threshold, rendered size stays capped (counter-scales zoom)", () => {
|
||||
// zoom = 8 → rendered still 1.0 → scale = 1.0 / 8.
|
||||
expect(computeLabelScale(8)).toBeCloseTo(1.0 / 8);
|
||||
});
|
||||
|
||||
test("rides the world transform between the floor and the threshold", () => {
|
||||
// Below the threshold, netScale = zoom / 1.5, so the factor is constant 1/1.5.
|
||||
expect(computeLabelScale(1)).toBeCloseTo(1 / 1.5);
|
||||
expect(computeLabelScale(0.9)).toBeCloseTo(1 / 1.5);
|
||||
test("at zoom = 0+, rendered size approaches the floor", () => {
|
||||
// As zoom→0, t→0, rendered → 1.0 * 0.63 (the floor).
|
||||
// At zoom = 0.001, rendered ≈ floor, so scale ≈ floor / zoom = huge.
|
||||
const scale = computeLabelScale(0.001);
|
||||
const floorRendered = 1.0 * 0.63;
|
||||
// Within 1% of the floor-divided-by-zoom value.
|
||||
expect(scale).toBeGreaterThan((floorRendered / 0.001) * 0.99);
|
||||
expect(scale).toBeLessThan((floorRendered / 0.001) * 1.01);
|
||||
});
|
||||
|
||||
test("floor engages exactly at zoom = 0.75 (LABEL_MIN_SCREEN_SCALE * LABEL_FULL_SIZE_ZOOM)", () => {
|
||||
expect(computeLabelScale(0.75)).toBeCloseTo(1 / 1.5);
|
||||
});
|
||||
|
||||
test("grows in screen space when zoomed out past the floor", () => {
|
||||
// zoom = 0.5 → netScale clamped to 0.5, factor = 0.5 / 0.5 = 1.
|
||||
expect(computeLabelScale(0.5)).toBeCloseTo(1);
|
||||
// zoom = 0.25 → factor = 0.5 / 0.25 = 2.
|
||||
expect(computeLabelScale(0.25)).toBeCloseTo(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeBarStrength", () => {
|
||||
test("equal troops sit at the midpoint", () => {
|
||||
// 1000 vs 1000 → ratio 1, divided by full-height ratio of 2 → 0.5.
|
||||
expect(computeBarStrength(1000, 1000)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
test("attacker with no troops yields a zero-height bar", () => {
|
||||
expect(computeBarStrength(0, 1000)).toBe(0);
|
||||
});
|
||||
|
||||
test("scales linearly between zero and the full-height threshold", () => {
|
||||
// 500 vs 1000 → ratio 0.5 → 0.25.
|
||||
expect(computeBarStrength(500, 1000)).toBeCloseTo(0.25);
|
||||
// 1500 vs 1000 → ratio 1.5 → 0.75.
|
||||
expect(computeBarStrength(1500, 1000)).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
test("clamps at full height when attacker has 2× the opposition", () => {
|
||||
expect(computeBarStrength(2000, 1000)).toBeCloseTo(1);
|
||||
expect(computeBarStrength(10_000, 1000)).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
test("returns full height when the opposing side has no troops", () => {
|
||||
// Avoids division-by-zero: an undefended target is maximum strength.
|
||||
expect(computeBarStrength(500, 0)).toBe(1);
|
||||
expect(computeBarStrength(0, 0)).toBe(1);
|
||||
test("interpolates linearly between floor and full-size threshold", () => {
|
||||
// zoom = 2 → t = 0.5 → rendered = 1.0 * (0.63 + 0.185) = 0.815.
|
||||
expect(computeLabelScale(2)).toBeCloseTo(0.815 / 2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,16 @@ describe("ClientMsgRateLimiter", () => {
|
||||
}
|
||||
expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
|
||||
it("allows intents up to MAX_INTENT_SIZE", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", 2000)).toBe("ok");
|
||||
});
|
||||
|
||||
it("kicks intents exceeding MAX_INTENT_SIZE", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", 2001)).toBe("kick");
|
||||
});
|
||||
});
|
||||
|
||||
describe("non-intent messages", () => {
|
||||
|
||||
Reference in New Issue
Block a user